From fb93483f5c7d253815a9196ef5edb1359b264f12 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 10:02:29 -0800 Subject: [PATCH] feat(quotesdb): date range filter for quotes list Add 6 optional query parameters to GET /api/quotes: date_after_year/month/day and date_before_year/month/day Changes: - QuoteRepository::list_quotes gains date_after and date_before params - NativeRepository and D1Repository build ISO date prefix WHERE clauses; quotes with NULL date are excluded when any bound is set - list_handler validates component ordering (month requires year, etc.) and returns 400 on invalid combinations - build_date_bound helper converts y/m/d components to ISO prefix strings - UI api::list_quotes and browse page gain From/To year filter inputs - author page call updated to pass None for the new date params - openapi.yaml extended with 6 new query parameter entries - 6 new integration tests covering after, before, range, and 400 cases - 1 new native DB unit test covering all filter combinations Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/api/openapi.yaml | 48 ++++++ quotesdb/src/bin/api/db/d1.rs | 21 ++- quotesdb/src/bin/api/db/mod.rs | 6 +- quotesdb/src/bin/api/db/native.rs | 101 ++++++++++- quotesdb/src/bin/api/handlers/mod.rs | 244 ++++++++++++++++++++++++++- quotesdb/src/bin/ui/api.rs | 10 ++ quotesdb/src/bin/ui/pages/author.rs | 2 +- quotesdb/src/bin/ui/pages/browse.rs | 138 +++++++++++---- 8 files changed, 528 insertions(+), 42 deletions(-) diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index 5ea483d..5e09a62 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -236,6 +236,54 @@ paths: required: false schema: type: string + - name: date_after_year + in: query + description: Only include quotes dated on or after this year. + required: false + schema: + type: integer + minimum: 0 + maximum: 9999 + - name: date_after_month + in: query + description: Narrows after-bound to this month (1–12). Requires date_after_year. + required: false + schema: + type: integer + minimum: 1 + maximum: 12 + - name: date_after_day + in: query + description: Narrows after-bound to this day (1–31). Requires date_after_year and date_after_month. + required: false + schema: + type: integer + minimum: 1 + maximum: 31 + - name: date_before_year + in: query + description: Only include quotes dated on or before this year. + required: false + schema: + type: integer + minimum: 0 + maximum: 9999 + - name: date_before_month + in: query + description: Narrows before-bound to this month (1–12). Requires date_before_year. + required: false + schema: + type: integer + minimum: 1 + maximum: 12 + - name: date_before_day + in: query + description: Narrows before-bound to this day (1–31). Requires date_before_year and date_before_month. + required: false + schema: + type: integer + minimum: 1 + maximum: 31 responses: "200": description: Paginated list of quotes. diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 70af19e..c64703a 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -124,9 +124,12 @@ impl QuoteRepository for D1Repository { Ok(()) } - /// List quotes with optional author/tag filters and 1-based pagination. + /// List quotes with optional author/tag/date filters and 1-based pagination. /// /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. + /// `date_after` and `date_before` are ISO date prefix strings compared via + /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` + /// are excluded when either bound is set. /// Tags for each returned quote are fetched in a second query per quote to /// avoid duplicate rows from a JOIN. async fn list_quotes( @@ -134,6 +137,8 @@ impl QuoteRepository for D1Repository { page: u32, author: Option<&str>, tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, ) -> Result { const PAGE_SIZE: u32 = 10; let page = page.max(1); @@ -155,6 +160,20 @@ impl QuoteRepository for D1Repository { binds.push(JsValue::from_str(t)); param_idx += 1; } + // Exclude NULL dates when any date bound is active + if date_after.is_some() || date_before.is_some() { + conditions.push("q.date IS NOT NULL".to_owned()); + } + if let Some(da) = date_after { + conditions.push(format!("q.date >= ?{param_idx}")); + binds.push(JsValue::from_str(da)); + param_idx += 1; + } + if let Some(db) = date_before { + conditions.push(format!("q.date <= ?{param_idx}")); + binds.push(JsValue::from_str(db)); + param_idx += 1; + } let where_clause = if conditions.is_empty() { String::new() diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index 3ee3133..baf91f4 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -92,12 +92,16 @@ pub trait QuoteRepository { /// List quotes with optional filtering and pagination. /// /// Page numbers are 1-based. Returns an empty `quotes` vec when `page` - /// is beyond the last page. + /// is beyond the last page. `date_after` and `date_before` are ISO date + /// prefix strings (e.g. `"2020"`, `"2020-06"`, `"2020-06-15"`); the DB + /// layer uses `>=` / `<=` comparisons against the stored `date` column. async fn list_quotes( &self, page: u32, author: Option<&str>, tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, ) -> Result; /// Retrieve a single quote by its ID. diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index dfcd801..a4b4547 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -75,9 +75,12 @@ impl QuoteRepository for NativeRepository { .map_err(|e| DbError::Internal(e.to_string())) } - /// List quotes with optional author/tag filters and 1-based pagination. + /// List quotes with optional author/tag/date filters and 1-based pagination. /// /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. + /// `date_after` and `date_before` are ISO date prefix strings compared via + /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` + /// are excluded when either bound is set. /// Tags for each returned quote are fetched in a second query per quote to /// avoid duplicate rows from a JOIN. async fn list_quotes( @@ -85,10 +88,14 @@ impl QuoteRepository for NativeRepository { page: u32, author: Option<&str>, tag: Option<&str>, + date_after: Option<&str>, + date_before: Option<&str>, ) -> Result { let page = page.max(1); let author = author.map(|s| s.to_owned()); let tag = tag.map(|s| s.to_owned()); + let date_after = date_after.map(|s| s.to_owned()); + let date_before = date_before.map(|s| s.to_owned()); self.conn .call(move |conn| { @@ -103,6 +110,16 @@ impl QuoteRepository for NativeRepository { conditions .push("q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?)".to_owned()); } + // Exclude NULL dates when any date bound is active + if date_after.is_some() || date_before.is_some() { + conditions.push("q.date IS NOT NULL".to_owned()); + } + if date_after.is_some() { + conditions.push("q.date >= ?".to_owned()); + } + if date_before.is_some() { + conditions.push("q.date <= ?".to_owned()); + } let where_clause = if conditions.is_empty() { String::new() } else { @@ -117,6 +134,12 @@ impl QuoteRepository for NativeRepository { if let Some(ref t) = tag { params.push(Box::new(t.clone())); } + if let Some(ref da) = date_after { + params.push(Box::new(da.clone())); + } + if let Some(ref db) = date_before { + params.push(Box::new(db.clone())); + } // ── Count total matching rows ────────────────────────────── let count_sql = format!("SELECT COUNT(*) FROM quotes q {where_clause}"); @@ -141,7 +164,7 @@ impl QuoteRepository for NativeRepository { LIMIT ? OFFSET ?" ); - // Re-collect bound params (page + limit/offset appended) + // Re-collect bound params (limit/offset appended at end) let mut params2: Vec> = Vec::new(); if let Some(ref a) = author { params2.push(Box::new(a.clone())); @@ -149,6 +172,12 @@ impl QuoteRepository for NativeRepository { if let Some(ref t) = tag { params2.push(Box::new(t.clone())); } + if let Some(ref da) = date_after { + params2.push(Box::new(da.clone())); + } + if let Some(ref db) = date_before { + params2.push(Box::new(db.clone())); + } params2.push(Box::new(PAGE_SIZE)); params2.push(Box::new(offset)); @@ -541,12 +570,12 @@ mod tests { .await .unwrap(); } - let page1 = repo.list_quotes(1, None, None).await.unwrap(); + let page1 = repo.list_quotes(1, None, None, None, None).await.unwrap(); assert_eq!(page1.quotes.len(), 10); assert_eq!(page1.total_count, 15); assert_eq!(page1.total_pages, 2); - let page2 = repo.list_quotes(2, None, None).await.unwrap(); + let page2 = repo.list_quotes(2, None, None, None, None).await.unwrap(); assert_eq!(page2.quotes.len(), 5); } @@ -558,7 +587,10 @@ mod tests { .await .unwrap(); } - let result = repo.list_quotes(1, Some("alice"), None).await.unwrap(); + let result = repo + .list_quotes(1, Some("alice"), None, None, None) + .await + .unwrap(); // COLLATE NOCASE should match "Alice" and "alice" assert_eq!(result.total_count, 2); } @@ -580,11 +612,68 @@ mod tests { .await .unwrap(); - let result = repo.list_quotes(1, None, Some("rust")).await.unwrap(); + let result = repo + .list_quotes(1, None, Some("rust"), None, None) + .await + .unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.quotes[0].text, "Tagged"); } + #[tokio::test] + async fn test_list_quotes_date_filter() { + let repo = in_memory_repo().await; + // Insert quotes with specific dates and one without a date + for (text, date) in &[ + ("Old quote", Some("1990-01-01")), + ("Mid quote", Some("2000-06-15")), + ("New quote", Some("2020-12-31")), + ("No date quote", None), + ] { + repo.create_quote(CreateQuoteInput { + text: text.to_string(), + author: "Author".to_owned(), + source: None, + date: date.map(|d| d.to_owned()), + tags: vec![], + auth_code: None, + }) + .await + .unwrap(); + } + + // date_after only — should match 2000 and 2020 + let result = repo + .list_quotes(1, None, None, Some("2000"), None) + .await + .unwrap(); + assert_eq!(result.total_count, 2); + + // date_before only — should match 1990 and 2000 + let result = repo + .list_quotes(1, None, None, None, Some("2000-12-31")) + .await + .unwrap(); + assert_eq!(result.total_count, 2); + + // both bounds — should match only 2000 + let result = repo + .list_quotes(1, None, None, Some("2000"), Some("2010")) + .await + .unwrap(); + assert_eq!(result.total_count, 1); + assert_eq!(result.quotes[0].text, "Mid quote"); + + // No date quotes are excluded when a bound is active + let result_all = repo.list_quotes(1, None, None, None, None).await.unwrap(); + assert_eq!(result_all.total_count, 4); // includes "No date quote" + let result_bounded = repo + .list_quotes(1, None, None, Some("1900"), None) + .await + .unwrap(); + assert_eq!(result_bounded.total_count, 3); // "No date quote" excluded + } + #[tokio::test] async fn test_random_quote_empty() { let repo = in_memory_repo().await; diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index e787218..ffe881f 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -82,12 +82,65 @@ struct ListParams { author: Option, /// Filter by tag. tag: Option, + /// Only include quotes dated on or after this year. + date_after_year: Option, + /// Narrows after-bound to this month (1–12). Requires `date_after_year`. + date_after_month: Option, + /// Narrows after-bound to this day (1–31). Requires `date_after_year` and `date_after_month`. + date_after_day: Option, + /// Only include quotes dated on or before this year. + date_before_year: Option, + /// Narrows before-bound to this month (1–12). Requires `date_before_year`. + date_before_month: Option, + /// Narrows before-bound to this day (1–31). Requires `date_before_year` and `date_before_month`. + date_before_day: Option, } fn default_page() -> u32 { 1 } +/// Build an ISO date prefix string from optional year/month/day components. +/// +/// Returns `None` if no year is given. For before-bounds, missing month +/// defaults to `12` and missing day defaults to `31` so the bound is +/// inclusive of the entire specified year/month. +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string())); +/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string())); +/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string())); +/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string())); +/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None); +/// ``` +fn build_date_bound( + year: Option, + month: Option, + day: Option, + is_before: bool, +) -> Option { + match (year, month, day) { + (None, _, _) => None, + (Some(y), None, _) => { + if is_before { + Some(format!("{y:04}-12-31")) + } else { + Some(format!("{y:04}")) + } + } + (Some(y), Some(m), None) => { + if is_before { + Some(format!("{y:04}-{m:02}-31")) + } else { + Some(format!("{y:04}-{m:02}")) + } + } + (Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")), + } +} + // ── Handlers ────────────────────────────────────────────────────────────────── /// `GET /api/` — return the OpenAPI specification as JSON. @@ -108,12 +161,65 @@ async fn openapi_handler() -> Response { /// `GET /api/quotes` — list quotes with optional filtering and pagination. /// -/// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and -/// no filters. Returns [`crate::db::ListResult`] serialised as JSON. +/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and +/// month/day variants) query parameters. Defaults to page 1 and no filters. +/// Returns [`crate::db::ListResult`] serialised as JSON. +/// +/// Returns `400 Bad Request` when date component ordering is violated (e.g. +/// `date_after_month` provided without `date_after_year`). #[cfg_attr(target_arch = "wasm32", worker::send)] async fn list_handler(State(repo): State, Query(params): Query) -> Response { + // Validate: month requires year, day requires year+month + if params.date_after_month.is_some() && params.date_after_year.is_none() { + return error_response( + StatusCode::BAD_REQUEST, + "date_after_month requires date_after_year", + ); + } + if params.date_after_day.is_some() + && (params.date_after_year.is_none() || params.date_after_month.is_none()) + { + return error_response( + StatusCode::BAD_REQUEST, + "date_after_day requires date_after_year and date_after_month", + ); + } + if params.date_before_month.is_some() && params.date_before_year.is_none() { + return error_response( + StatusCode::BAD_REQUEST, + "date_before_month requires date_before_year", + ); + } + if params.date_before_day.is_some() + && (params.date_before_year.is_none() || params.date_before_month.is_none()) + { + return error_response( + StatusCode::BAD_REQUEST, + "date_before_day requires date_before_year and date_before_month", + ); + } + + let date_after = build_date_bound( + params.date_after_year, + params.date_after_month, + params.date_after_day, + false, + ); + let date_before = build_date_bound( + params.date_before_year, + params.date_before_month, + params.date_before_day, + true, + ); + match repo - .list_quotes(params.page, params.author.as_deref(), params.tag.as_deref()) + .list_quotes( + params.page, + params.author.as_deref(), + params.tag.as_deref(), + date_after.as_deref(), + date_before.as_deref(), + ) .await { Ok(result) => (StatusCode::OK, Json(result)).into_response(), @@ -291,6 +397,8 @@ mod tests { page: u32, _author: Option<&str>, _tag: Option<&str>, + _date_after: Option<&str>, + _date_before: Option<&str>, ) -> Result { let quotes = self.quotes.lock().unwrap(); let all: Vec = quotes.iter().map(|(q, _)| q.clone()).collect(); @@ -1320,4 +1428,134 @@ mod integration_tests { // The random handler returns the full Quote, not a CreateResponse assert!(v.get("id").is_some(), "should be a Quote, not an error"); } + + // ── Date range filter integration tests ─────────────────────────────────── + + /// Create a quote with a specific date via PUT /api/quotes. + async fn create_quote_with_date( + app: Router, + text: &str, + date: Option<&str>, + ) -> (Router, serde_json::Value, String) { + let mut payload = json!({ + "text": text, + "author": "DateAuthor", + "tags": [], + }); + if let Some(d) = date { + payload["date"] = json!(d); + } + let req = Request::builder() + .method(Method::PUT) + .uri("/api/quotes") + .header("Content-Type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + let resp = ServiceExt::>::oneshot(app.clone(), req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let v = body_json(resp).await; + let auth_code = v["auth_code"].as_str().unwrap().to_owned(); + let quote = v["quote"].clone(); + (app, quote, auth_code) + } + + /// `?date_after_year=` filters out quotes dated before that year. + #[tokio::test] + async fn integration_date_filter_after_year() { + let (app, _f) = test_router().await; + let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; + let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; + let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; + + let req = Request::builder() + .method(Method::GET) + .uri("/api/quotes?date_after_year=2000") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + // Only the 2020 quote qualifies; 1990 is before 2000, no-date excluded + assert_eq!(v["total_count"], 1); + assert_eq!(v["quotes"][0]["text"], "New quote"); + } + + /// `?date_before_year=` filters out quotes dated after that year. + #[tokio::test] + async fn integration_date_filter_before_year() { + let (app, _f) = test_router().await; + let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; + let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await; + let (app, _, _) = create_quote_with_date(app, "No date quote", None).await; + + let req = Request::builder() + .method(Method::GET) + .uri("/api/quotes?date_before_year=2000") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + // Only the 1990 quote qualifies; 2020 is after 2000-12-31, no-date excluded + assert_eq!(v["total_count"], 1); + assert_eq!(v["quotes"][0]["text"], "Old quote"); + } + + /// `?date_after_year=&date_before_year=` combined bounds narrow the window. + #[tokio::test] + async fn integration_date_filter_range() { + let (app, _f) = test_router().await; + let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await; + let (app, _, _) = create_quote_with_date(app, "Mid quote", Some("2000-06-15")).await; + let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-12-31")).await; + + let req = Request::builder() + .method(Method::GET) + .uri("/api/quotes?date_after_year=1995&date_before_year=2010") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + assert_eq!(v["total_count"], 1); + assert_eq!(v["quotes"][0]["text"], "Mid quote"); + } + + /// `?date_after_month=` without a year returns 400 Bad Request. + #[tokio::test] + async fn integration_date_filter_month_without_year_returns_400() { + let (app, _f) = test_router().await; + let req = Request::builder() + .method(Method::GET) + .uri("/api/quotes?date_after_month=6") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + /// `?date_before_day=` without year+month returns 400 Bad Request. + #[tokio::test] + async fn integration_date_filter_day_without_year_month_returns_400() { + let (app, _f) = test_router().await; + let req = Request::builder() + .method(Method::GET) + .uri("/api/quotes?date_before_day=15") + .body(Body::empty()) + .unwrap(); + let resp = ServiceExt::>::oneshot(app, req) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } } diff --git a/quotesdb/src/bin/ui/api.rs b/quotesdb/src/bin/ui/api.rs index a29d098..72ce874 100644 --- a/quotesdb/src/bin/ui/api.rs +++ b/quotesdb/src/bin/ui/api.rs @@ -52,10 +52,14 @@ pub enum ApiError { /// - `page` — 1-indexed page number. /// - `author` — Optional author name filter (URL-encoded automatically). /// - `tag` — Optional tag filter (URL-encoded automatically). +/// - `date_after_year` — Optional lower-bound year (inclusive). +/// - `date_before_year` — Optional upper-bound year (inclusive). pub async fn list_quotes( page: u32, author: Option<&str>, tag: Option<&str>, + date_after_year: Option<&str>, + date_before_year: Option<&str>, ) -> Result { let mut url = format!("/api/quotes?page={page}"); if let Some(a) = author { @@ -64,6 +68,12 @@ pub async fn list_quotes( if let Some(t) = tag { url.push_str(&format!("&tag={}", js_sys::encode_uri_component(t))); } + if let Some(y) = date_after_year { + url.push_str(&format!("&date_after_year={y}")); + } + if let Some(y) = date_before_year { + url.push_str(&format!("&date_before_year={y}")); + } fetch_json(&url).await } diff --git a/quotesdb/src/bin/ui/pages/author.rs b/quotesdb/src/bin/ui/pages/author.rs index 9b1500a..83722cc 100644 --- a/quotesdb/src/bin/ui/pages/author.rs +++ b/quotesdb/src/bin/ui/pages/author.rs @@ -49,7 +49,7 @@ pub fn author_page(props: &AuthorPageProps) -> Html { Some(tag_val.clone()) }; spawn_local(async move { - match api::list_quotes(page_val, Some(&name), tag.as_deref()).await { + match api::list_quotes(page_val, Some(&name), tag.as_deref(), None, None).await { Ok(resp) => { quotes.set(resp.quotes); total_pages.set(resp.total_pages.max(1)); diff --git a/quotesdb/src/bin/ui/pages/browse.rs b/quotesdb/src/bin/ui/pages/browse.rs index 2537b95..21d4472 100644 --- a/quotesdb/src/bin/ui/pages/browse.rs +++ b/quotesdb/src/bin/ui/pages/browse.rs @@ -1,4 +1,4 @@ -//! Browse page — paginated quote list with author and tag filter controls. +//! Browse page — paginated quote list with author, tag, and date filter controls. use crate::api; use crate::components::error::ErrorDisplay; @@ -11,57 +11,91 @@ use yew::prelude::*; /// Browse page component. /// -/// Displays a paginated list of quotes. Supports filtering by author name -/// and tag. Fetches from the API whenever page, author, or tag state changes. +/// Displays a paginated list of quotes. Supports filtering by author name, +/// tag, and an optional date range (year granularity). Fetches from the API +/// whenever page, author, tag, or date state changes. #[function_component(BrowsePage)] pub fn browse_page() -> Html { let page = use_state(|| 1u32); let total_pages = use_state(|| 1u32); let author_filter = use_state(String::new); let tag_filter = use_state(String::new); + let date_after_year: UseStateHandle = use_state(String::new); + let date_before_year: UseStateHandle = use_state(String::new); let quotes: UseStateHandle> = use_state(Vec::new); let error: UseStateHandle> = use_state(|| None); let loading = use_state(|| true); - // Fetch quotes whenever page, author, or tag changes + // Fetch quotes whenever page, author, tag, or date bounds change { let page = page.clone(); let total_pages = total_pages.clone(); let author_filter = author_filter.clone(); let tag_filter = tag_filter.clone(); + let date_after_year = date_after_year.clone(); + let date_before_year = date_before_year.clone(); let quotes = quotes.clone(); let error = error.clone(); let loading = loading.clone(); let page_val = *page; let author_val = (*author_filter).clone(); let tag_val = (*tag_filter).clone(); - use_effect_with((page_val, author_val.clone(), tag_val.clone()), move |_| { - loading.set(true); - error.set(None); - let author = if author_val.is_empty() { - None - } else { - Some(author_val.clone()) - }; - let tag = if tag_val.is_empty() { - None - } else { - Some(tag_val.clone()) - }; - spawn_local(async move { - match api::list_quotes(page_val, author.as_deref(), tag.as_deref()).await { - Ok(resp) => { - quotes.set(resp.quotes); - total_pages.set(resp.total_pages.max(1)); - loading.set(false); - } - Err(e) => { - error.set(Some(e.to_string())); - loading.set(false); + let date_after_year_val = (*date_after_year).clone(); + let date_before_year_val = (*date_before_year).clone(); + use_effect_with( + ( + page_val, + author_val.clone(), + tag_val.clone(), + date_after_year_val.clone(), + date_before_year_val.clone(), + ), + move |_| { + loading.set(true); + error.set(None); + let author = if author_val.is_empty() { + None + } else { + Some(author_val.clone()) + }; + let tag = if tag_val.is_empty() { + None + } else { + Some(tag_val.clone()) + }; + let after_year = if date_after_year_val.is_empty() { + None + } else { + Some(date_after_year_val.clone()) + }; + let before_year = if date_before_year_val.is_empty() { + None + } else { + Some(date_before_year_val.clone()) + }; + spawn_local(async move { + match api::list_quotes( + page_val, + author.as_deref(), + tag.as_deref(), + after_year.as_deref(), + before_year.as_deref(), + ) + .await + { + Ok(resp) => { + quotes.set(resp.quotes); + total_pages.set(resp.total_pages.max(1)); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } } - } - }); - }); + }); + }, + ); } let on_page = { @@ -89,6 +123,26 @@ pub fn browse_page() -> Html { }) }; + let on_date_after_year_input = { + let date_after_year = date_after_year.clone(); + let page = page.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + date_after_year.set(input.value()); + page.set(1); + }) + }; + + let on_date_before_year_input = { + let date_before_year = date_before_year.clone(); + let page = page.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + date_before_year.set(input.value()); + page.set(1); + }) + }; + html! {

{ "Browse Quotes" }

@@ -108,6 +162,30 @@ pub fn browse_page() -> Html { value={(*tag_filter).clone()} oninput={on_tag_input} /> +
+ + +
+
+ + +
if *loading {