+++ title = "Filter quotes by date range (before/after with year/month/day granularity)" priority = 5 status = "done" ticket_type = "feature" dependencies = [] +++ ## Feature Extend `GET /api/quotes` with date range filtering. Users can specify a "before" and/or "after" bound, with year/month/day granularity for each. Quotes without a date (`date IS NULL`) are excluded when a date filter is active. --- ## Query parameter design Six optional query params are added: | Param | Type | Description | |---|---|---| | `date_after_year` | u16 | Only include quotes dated on or after this year | | `date_after_month` | u8 (1–12) | Narrows after-bound to this month | | `date_after_day` | u8 (1–31) | Narrows after-bound to this day | | `date_before_year` | u16 | Only include quotes dated on or before this year | | `date_before_month` | u8 (1–12) | Narrows before-bound to this month | | `date_before_day` | u8 (1–31) | Narrows before-bound to this day | The handler constructs two partial ISO strings from these fields before calling `list_quotes`: - **after bound** (`>=`): `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` — string comparison anchors to the start of the specified period (e.g., `2020` means ≥ `2020-01-01` in text comparison, but actually `>= '2020'` which works correctly since any 2020 date starts with `2020`). - **before bound** (`<=`): `YYYY-MM-DD` where missing month defaults to `12` and missing day defaults to `31` — so `date_before_year=2020` means `<= '2020-12-31'`. **Validation rules:** - Month must be 1–12 if provided (without its year, it is an error). - Day must be 1–31 if provided (without its year+month, it is an error). - Return `400 Bad Request` for invalid combinations. --- ## Part 1 — API: `handlers/mod.rs` **Extend `ListParams`:** ```rust #[derive(Debug, Deserialize)] struct ListParams { #[serde(default = "default_page")] page: u32, author: Option, tag: Option, // Date range filter date_after_year: Option, date_after_month: Option, date_after_day: Option, date_before_year: Option, date_before_month: Option, date_before_day: Option, } ``` **In `list_handler`**, construct the `date_after` and `date_before` strings before calling `repo.list_quotes`: ```rust fn build_date_bound(year: Option, month: Option, day: Option) -> Option { match (year, month, day) { (None, _, _) => None, (Some(y), None, _) => Some(format!("{y:04}")), (Some(y), Some(m), None) => Some(format!("{y:04}-{m:02}")), (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), } } ``` Return `400` if month is present without year, or day is present without year+month. --- ## Part 2 — Repository trait: `db/mod.rs` Extend `list_quotes` signature: ```rust async fn list_quotes( &self, page: u32, author: Option<&str>, tag: Option<&str>, date_after: Option<&str>, // new date_before: Option<&str>, // new ) -> Result; ``` --- ## Part 3 — Native implementation: `db/native.rs` In `NativeRepository::list_quotes`, build the WHERE clause dynamically. Current query filters on `author` and `tag` via subquery. Extend it: ```sql -- Additional clauses appended when filters are present: AND q.date IS NOT NULL AND q.date >= ? -- when date_after is Some AND q.date <= ? -- when date_before is Some ``` Use string comparison — ISO YYYY-MM-DD format sorts lexicographically, so `>=`/`<=` on the `date` TEXT column is correct. Prefix matching (e.g. `date >= '2020'` with `date = '2020-06-15'`) works because `'2020-06-15' >= '2020'` is true in SQLite string comparison. --- ## Part 4 — D1 implementation: `db/d1.rs` Apply the same WHERE clause extensions to the D1 query builder. --- ## Part 5 — UI: Browse page File: `src/bin/ui/pages/browse.rs` Add date filter controls to the filter panel alongside author and tag filters: - "After:" year input (``), optional month select (1–12), optional day input. - "Before:" same. On filter apply, include the populated fields as query params. When fields are empty, omit them. --- ## Part 6 — Mock repo in tests: `handlers/mod.rs` Update `MockRepo::list_quotes` to accept the two new `date_after` / `date_before` params (ignore them in the mock — just extend the signature). --- ## Part 7 — OpenAPI spec: `api/openapi.yaml` Add the six new query parameters to the `GET /api/quotes` operation with descriptions and `schema: {type: integer}`. --- ## Files touched - `src/bin/api/handlers/mod.rs` — `ListParams` + `list_handler` + `MockRepo` - `src/bin/api/db/mod.rs` — `QuoteRepository::list_quotes` signature - `src/bin/api/db/native.rs` — SQL query extension - `src/bin/api/db/d1.rs` — SQL query extension - `src/bin/ui/pages/browse.rs` — date filter UI - `api/openapi.yaml` — new query params ## Validation ```sh # From quotesdb/ root cargo fmt && cargo check && cargo clippy && cargo test trunk build redocly lint api/openapi.yaml ``` Test manually: - `GET /api/quotes?date_after_year=1900&date_before_year=2000` — should return only quotes with dates in the 20th century. - `GET /api/quotes?date_after_year=2020&date_after_month=6` — on or after June 2020. - `GET /api/quotes?date_after_month=3` — should return 400 (month without year). ## Commit scope `feat(quotesdb): date range filter for quotes list`