Nobody has started this yet — be first.
Business impact
A marketing team asking "how did this link perform during last month's campaign (March 1-15)" cannot get that answer today -- days only ever means "the last N days counting back from right now." Anyone needing a fixed historical window (a monthly report, a post-campaign retro, a compliance audit for a specific date range) has to either export everything and filter client-side, or ask an engineer to run a one-off DB query.
Problem
url_analytics reads days (clamped to 1-30, default 7) and computes since = datetime.utcnow() - timedelta(days=days). There is no handling for any other query parameter -- Flask/Werkzeug silently ignores unrecognized query args, so ?from=2026-03-01&to=2026-03-15 today is indistinguishable from no query string at all.
Current behavior
GET /api/urls/<slug>/analytics?from=2026-08-05&to=2026-08-15 does not include a click that falls inside that range in clicks_per_day -- the endpoint silently falls back to "the last 7 days" instead.
Expected behavior
GET /api/urls/<slug>/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD scopes clicks_per_day (and ideally top_referrers/top_user_agents, for consistency) to exactly that inclusive date range, taking priority over days when both are present. days alone should keep working exactly as before for callers that do not need a specific historical range.
Steps to reproduce
Why this matters
This adds a second, mutually-exclusive way to scope the same query -- worth deciding explicitly what happens if a caller sends days and from/to together (this ticket's suggested approach: from/to wins), and how to parse/validate the date strings (reject a malformed date with a clear 400 rather than crashing on datetime.strptime).
Suggested approach
Look at how days is currently read via request.args.get(...) and clamped, and apply the same "parse defensively, fall back sanely" pattern to from/to. The existing since/click-filtering logic in clicks_per_day's query just needs an upper bound added (ClickEvent.timestamp <= to) alongside the existing lower bound, when an explicit range is given instead of days.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_12_custom_date_range.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/custom-date-range-analyticsFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/custom-date-range-analyticsSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign indocker-entrypoint.shcurl -X POST http://localhost:5000/api/urls \
-H "Content-Type: application/json" \
-d '{"target_url": "https://anthropic.com", "custom_slug": "claude"}'
# -> {"slug": "claude", "short_url": "http://localhost:5000/claude", "expires_at": null}
curl -i http://localhost:5000/claude # 302 redirect, records a click
make seed # or: docker compose exec web flask seed-db
If something is already listening on 5432/6379 locally, uncomment POSTGRES_HOST_PORT / REDIS_HOST_PORT in .env to remap the host side — the app always talks to db/redis over the compose network regardless. Run production-style (gunicorn, no dev server, no bind mount) with docker compose -f docker-compose.prod.yml up --build. GET /healthz checks both Postgres and Redis connectivity.
You do not need Docker running at all to work the tickets — the test suite uses TestingConfig (SQLite in-memory via StaticPool) and fakeredis instead of a real Postgres/Redis, so everything runs against a plain virtualenv:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest practice_tickets/tests/test_ticket_01_base62_roundtrip.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 20, clean pass/fail summary
./practice_tickets/run_tickets.sh 05 13 16 # just a few, by number
All 20 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. .venv/bin/pytest tests/ (via make test) runs the project's own main suite — some ticket bugs (TICKET-05, 07, 08, 09, 14 among others) also break assertions in that suite, since they are real bugs in shared code paths; fixing the ticket fixes those too, not a separate task.
Level 2
Implement a feature
Extend the system within its own patterns.