You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

162 lines
5.4 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

+++
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 (112) | Narrows after-bound to this month |
| `date_after_day` | u8 (131) | Narrows after-bound to this day |
| `date_before_year` | u16 | Only include quotes dated on or before this year |
| `date_before_month` | u8 (112) | Narrows before-bound to this month |
| `date_before_day` | u8 (131) | 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 112 if provided (without its year, it is an error).
- Day must be 131 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<String>,
tag: Option<String>,
// Date range filter
date_after_year: Option<u16>,
date_after_month: Option<u8>,
date_after_day: Option<u8>,
date_before_year: Option<u16>,
date_before_month: Option<u8>,
date_before_day: Option<u8>,
}
```
**In `list_handler`**, construct the `date_after` and `date_before` strings before calling `repo.list_quotes`:
```rust
fn build_date_bound(year: Option<u16>, month: Option<u8>, day: Option<u8>) -> Option<String> {
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<ListResult, DbError>;
```
---
## 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 (`<input type="number" min="0" max="9999">`), optional month select (112), 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`