diff --git a/e2e/tests/archive.spec.ts b/e2e/tests/archive.spec.ts new file mode 100644 index 0000000..f04d7f3 --- /dev/null +++ b/e2e/tests/archive.spec.ts @@ -0,0 +1,83 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin, createList, addItem } from "../helpers"; + +test("a user can archive a list and it moves to the archive page", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + + // Archive from the list page. + await page.click('button:has-text("Archive")'); + + // Lands back on the lists page; the list is no longer shown. + await expect(page).toHaveURL(/\/lists/); + await expect(page.locator(".list-card").filter({ hasText: "Weekly shop" })).toHaveCount(0); + + // The archived list is reachable from the archive page. + await page.goto("/archive"); + await expect(page.locator(".list-card").filter({ hasText: "Weekly shop" })).toBeVisible(); +}); + +test("the lists frame links to the archive page", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await page.goto("/lists"); + + await page.click('a.archive-link'); + await expect(page).toHaveURL(/\/archive/); +}); + +test("an archived list is read-only", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await addItem(page, "Apple"); + await page.click('button:has-text("Archive")'); + + // Open the archived list directly. + await page.goto("/archive"); + await page.locator(".list-card").filter({ hasText: "Weekly shop" }).click(); + await expect(page).toHaveURL(/\/lists\/\d+/); + + // The item is still visible. + await expect(page.locator(".item-row").filter({ hasText: "Apple" })).toBeVisible(); + + // No mutation UI is present. + await expect(page.locator("#add-item-form")).toHaveCount(0); + await expect(page.locator(".item-actions-button")).toHaveCount(0); + await expect(page.locator(".check-form")).toHaveCount(0); + await expect(page.locator('button:has-text("+ Add meal")')).toHaveCount(0); +}); + +test("a user can restore an archived list and edit it again", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await addItem(page, "Apple"); + await page.click('button:has-text("Archive")'); + + // Open the archived list and restore it. + await page.goto("/archive"); + await page.locator(".list-card").filter({ hasText: "Weekly shop" }).click(); + await page.click('button:has-text("Restore")'); + + // Back on the lists page, the list is active again. + await expect(page).toHaveURL(/\/lists/); + await expect(page.locator(".list-card").filter({ hasText: "Weekly shop" })).toBeVisible(); + + // The list is editable again. + await page.locator(".list-card").filter({ hasText: "Weekly shop" }).click(); + await expect(page.locator("#add-item-form")).toBeVisible(); + await addItem(page, "Banana"); + await expect(page.locator(".item-row").filter({ hasText: "Banana" })).toBeVisible(); +}); + +test("the archive page shows the list name and created date", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createList(page, "Weekly shop"); + await page.click('button:has-text("Archive")'); + + await page.goto("/archive"); + const card = page.locator(".list-card").filter({ hasText: "Weekly shop" }); + await expect(card).toBeVisible(); + // The card shows a created date (e.g. "Created 7 Aug 2026"). + await expect(card.locator("small")).toContainText("Created"); +}); diff --git a/e2e/tests/meals.spec.ts b/e2e/tests/meals.spec.ts index 8303027..3a9fc71 100644 --- a/e2e/tests/meals.spec.ts +++ b/e2e/tests/meals.spec.ts @@ -32,6 +32,7 @@ test("a user can delete a meal", async ({ page }) => { await registerAndLogin(page, "alice@example.com"); await createMeal(page, "Spaghetti Bolognese", ""); + page.on("dialog", (dialog) => dialog.accept()); await page.click('button:has-text("Delete")'); await expect(page).toHaveURL(/\/meals$/); await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0); diff --git a/migrations/20240104000000_archive_lists.sql b/migrations/20240104000000_archive_lists.sql new file mode 100644 index 0000000..82aac05 --- /dev/null +++ b/migrations/20240104000000_archive_lists.sql @@ -0,0 +1 @@ +ALTER TABLE lists ADD COLUMN archived_at INTEGER; diff --git a/src/domain.rs b/src/domain.rs index 4a11df2..eaa3359 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -48,6 +48,10 @@ pub struct GroceryList { pub id: i64, pub name: String, pub revision: i64, + /// Unix timestamp of when the list was created. + pub created_at: i64, + /// Unix timestamp of when the list was archived; `None` when active. + pub archived_at: Option, } #[derive(Clone, Debug)] diff --git a/src/http.rs b/src/http.rs index 8c31f68..727df1a 100644 --- a/src/http.rs +++ b/src/http.rs @@ -53,6 +53,8 @@ pub enum AppError { BadRequest(String), #[error("not found")] NotFound, + #[error("list is archived")] + Archived, } impl IntoResponse for AppError { @@ -72,6 +74,10 @@ impl IntoResponse for AppError { StatusCode::NOT_FOUND, views::error_page("404", "That page could not be found."), ), + AppError::Archived => status_html_response( + StatusCode::CONFLICT, + views::error_page("409", "This list is archived and cannot be modified."), + ), } } } @@ -96,7 +102,10 @@ pub fn build_router(state: AppState) -> Router { ) .route("/account/password", post(change_password)) .route("/lists", get(lists_page).post(create_list)) + .route("/archive", get(archive_page)) .route("/lists/{list_id}", get(list_page)) + .route("/lists/{list_id}/archive", post(archive_list)) + .route("/lists/{list_id}/unarchive", post(unarchive_list)) .route("/lists/{list_id}/items", post(add_item)) .route("/lists/{list_id}/items/{item_id}/check", post(check_item)) .route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) @@ -603,6 +612,17 @@ async fn lists_page( ))) } +async fn archive_page( + State(state): State, + user: CurrentUser, +) -> Result { + let archived_lists = state.lists.list_archived_summaries().await?; + Ok(html_response(views::archive_page( + &user.session.user, + &archived_lists, + ))) +} + async fn create_list( State(state): State, user: CurrentUser, @@ -640,6 +660,30 @@ async fn list_page( ))) } +async fn archive_list( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_list(&state, list_id).await?; + state.lists.archive_list(list_id).await?; + Ok(Redirect::to("/lists").into_response()) +} + +async fn unarchive_list( + State(state): State, + user: CurrentUser, + Path(list_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_list(&state, list_id).await?; + state.lists.unarchive_list(list_id).await?; + Ok(Redirect::to("/lists").into_response()) +} + async fn add_item( State(state): State, user: CurrentUser, @@ -647,7 +691,7 @@ async fn add_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; let name = form.name.trim().to_owned(); let quantity = form.quantity.trim().to_owned(); let note = form.note.trim().to_owned(); @@ -671,7 +715,7 @@ async fn check_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; let checked = match form.checked.as_str() { "1" | "true" => true, "0" | "false" => false, @@ -691,7 +735,7 @@ async fn edit_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; let name = form.name.trim().to_owned(); if name.is_empty() || name.chars().count() > 120 { return Err(AppError::BadRequest( @@ -719,7 +763,7 @@ async fn delete_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; state.lists.delete_item(list_id, item_id).await?; list_fragment_response(&state, &user, list_id).await } @@ -957,7 +1001,7 @@ async fn add_meal_to_list( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; state.meals.add_meal_to_list(form.meal_id, list_id).await?; list_fragment_response(&state, &user, list_id).await } @@ -969,7 +1013,7 @@ async fn remove_meal_from_list( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_list(&state, list_id).await?; + require_mutable_list(&state, list_id).await?; state .meals .remove_meal_from_list(list_id, list_meal_id) @@ -1181,6 +1225,7 @@ async fn list_fragment_response( let items = state.lists.items(list_id).await?; let categories = state.lists.categories().await?; let list_meals = state.meals.list_meals_on_list(list_id).await?; + let editable = access.archived_at.is_none(); Ok(html_response(PreEscaped( views::list_items_fragment( &access, @@ -1188,10 +1233,17 @@ async fn list_fragment_response( &categories, &user.session.csrf_token, false, + editable, ) .into_string() - + &views::list_meals_panel(&list_meals, list_id, &user.session.csrf_token, true) - .into_string(), + + &views::list_meals_panel( + &list_meals, + list_id, + &user.session.csrf_token, + true, + editable, + ) + .into_string(), ))) } @@ -1206,6 +1258,19 @@ async fn require_list( .ok_or(AppError::NotFound) } +/// Like [`require_list`], but also rejects archived lists so they stay +/// immutable until restored. +async fn require_mutable_list( + state: &AppState, + list_id: i64, +) -> Result { + let list = require_list(state, list_id).await?; + if list.archived_at.is_some() { + return Err(AppError::Archived); + } + Ok(list) +} + async fn optional_user( state: &AppState, headers: &HeaderMap, diff --git a/src/ports.rs b/src/ports.rs index 0bfdd5e..2383424 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -88,6 +88,10 @@ pub trait SessionRepository: Send + Sync { #[async_trait] pub trait ListRepository: Send + Sync { async fn list_summaries(&self, txn: &mut SqliteConnection) -> DomainResult>; + async fn list_archived_summaries( + &self, + txn: &mut SqliteConnection, + ) -> DomainResult>; async fn create_list( &self, txn: &mut SqliteConnection, @@ -98,6 +102,12 @@ pub trait ListRepository: Send + Sync { txn: &mut SqliteConnection, list_id: i64, ) -> DomainResult>; + async fn set_archived( + &self, + txn: &mut SqliteConnection, + list_id: i64, + archived: bool, + ) -> DomainResult<()>; } #[async_trait] diff --git a/src/services.rs b/src/services.rs index 7b716e1..9fa5d9f 100644 --- a/src/services.rs +++ b/src/services.rs @@ -199,6 +199,13 @@ impl ListService { .await } + pub async fn list_archived_summaries(&self) -> DomainResult> { + let lists = Arc::clone(&self.lists); + self.db + .run(move |txn| Box::pin(async move { lists.list_archived_summaries(txn).await })) + .await + } + pub async fn create_list(&self, name: String) -> DomainResult { let lists = Arc::clone(&self.lists); self.db @@ -213,6 +220,20 @@ impl ListService { .await } + pub async fn archive_list(&self, list_id: i64) -> DomainResult<()> { + let lists = Arc::clone(&self.lists); + self.db + .run(move |txn| Box::pin(async move { lists.set_archived(txn, list_id, true).await })) + .await + } + + pub async fn unarchive_list(&self, list_id: i64) -> DomainResult<()> { + let lists = Arc::clone(&self.lists); + self.db + .run(move |txn| Box::pin(async move { lists.set_archived(txn, list_id, false).await })) + .await + } + pub async fn items(&self, list_id: i64) -> DomainResult> { let items = Arc::clone(&self.items); self.db diff --git a/src/sqlite.rs b/src/sqlite.rs index fc780dd..b66d2b3 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -442,8 +442,9 @@ pub struct SqliteListRepository; impl ListRepository for SqliteListRepository { async fn list_summaries(&self, txn: &mut SqliteConnection) -> DomainResult> { let rows = sqlx::query( - "SELECT l.id, l.name, l.revision + "SELECT l.id, l.name, l.revision, l.created_at, l.archived_at FROM lists l + WHERE l.archived_at IS NULL ORDER BY l.created_at DESC", ) .fetch_all(&mut *txn) @@ -455,6 +456,33 @@ impl ListRepository for SqliteListRepository { id: row.get(0), name: row.get(1), revision: row.get(2), + created_at: row.get(3), + archived_at: row.get(4), + }) + .collect()) + } + + async fn list_archived_summaries( + &self, + txn: &mut SqliteConnection, + ) -> DomainResult> { + let rows = sqlx::query( + "SELECT l.id, l.name, l.revision, l.created_at, l.archived_at + FROM lists l + WHERE l.archived_at IS NOT NULL + ORDER BY l.archived_at DESC, l.created_at DESC", + ) + .fetch_all(&mut *txn) + .await + .map_err(db_error)?; + Ok(rows + .into_iter() + .map(|row| GroceryList { + id: row.get(0), + name: row.get(1), + revision: row.get(2), + created_at: row.get(3), + archived_at: row.get(4), }) .collect()) } @@ -479,6 +507,8 @@ impl ListRepository for SqliteListRepository { id: list_id, name, revision: 0, + created_at: now(), + archived_at: None, }) } @@ -488,7 +518,7 @@ impl ListRepository for SqliteListRepository { list_id: i64, ) -> DomainResult> { let row = sqlx::query( - "SELECT l.id, l.name, l.revision + "SELECT l.id, l.name, l.revision, l.created_at, l.archived_at FROM lists l WHERE l.id = ?1", ) @@ -500,8 +530,36 @@ impl ListRepository for SqliteListRepository { id: row.get(0), name: row.get(1), revision: row.get(2), + created_at: row.get(3), + archived_at: row.get(4), })) } + + async fn set_archived( + &self, + txn: &mut SqliteConnection, + list_id: i64, + archived: bool, + ) -> DomainResult<()> { + let result = if archived { + sqlx::query("UPDATE lists SET archived_at = ?1 WHERE id = ?2") + .bind(now()) + .bind(list_id) + .execute(&mut *txn) + .await + .map_err(db_error)? + } else { + sqlx::query("UPDATE lists SET archived_at = NULL WHERE id = ?1") + .bind(list_id) + .execute(&mut *txn) + .await + .map_err(db_error)? + }; + if result.rows_affected() == 0 { + return Err(DomainError::NotFound); + } + Ok(()) + } } #[derive(Clone, Copy)] @@ -1608,6 +1666,85 @@ mod tests { assert_eq!(ids, vec![first.id, second.id]); } + #[tokio::test] + async fn archiving_a_list_hides_it_from_summaries() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let lists = SqliteListRepository; + + let list_id = list.id; + db.run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.set_archived(txn, list_id, true).await }) + }) + .await + .unwrap(); + + let summaries = db + .run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.list_summaries(txn).await }) + }) + .await + .unwrap(); + assert!(summaries.is_empty()); + + let archived = db + .run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.list_archived_summaries(txn).await }) + }) + .await + .unwrap(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].id, list.id); + assert!(archived[0].archived_at.is_some()); + } + + #[tokio::test] + async fn unarchiving_a_list_restores_it_to_summaries() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let lists = SqliteListRepository; + + let list_id = list.id; + db.run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.set_archived(txn, list_id, true).await }) + }) + .await + .unwrap(); + db.run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.set_archived(txn, list_id, false).await }) + }) + .await + .unwrap(); + + let summaries = db + .run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.list_summaries(txn).await }) + }) + .await + .unwrap(); + assert_eq!(summaries.len(), 1); + assert!(summaries[0].archived_at.is_none()); + } + + #[tokio::test] + async fn archiving_a_missing_list_fails() { + let db = setup().await; + let lists = SqliteListRepository; + let result = db + .run(move |txn| { + let lists = lists.clone(); + Box::pin(async move { lists.set_archived(txn, 9999, true).await }) + }) + .await; + assert!(result.is_err()); + } + #[tokio::test] async fn get_list_returns_list_or_none() { let db = setup().await; diff --git a/src/views.rs b/src/views.rs index c5c9bda..5121cb8 100644 --- a/src/views.rs +++ b/src/views.rs @@ -192,6 +192,7 @@ pub fn lists_page( div class="panel-heading" { h2 { "Lists" } span class="count-badge" { (lists.len()) } + a class="archive-link" href="/archive" { "Archived →" } } @if lists.is_empty() { div class="empty-state" { @@ -242,6 +243,49 @@ pub fn lists_page( ) } +pub fn archive_page(user: &User, archived_lists: &[GroceryList]) -> Markup { + page( + "Archive", + Some(user), + html! { + div class="page-heading" { + div { + p class="eyebrow" { "ARCHIVE" } + h1 class="page-title" { "Archived lists" } + p class="lede" { "Past lists, kept for reference." } + } + a class="button button-quiet" href="/lists" { "← Back to lists" } + } + section class="panel" { + div class="panel-heading" { + h2 { "Archive" } + span class="count-badge" { (archived_lists.len()) } + } + @if archived_lists.is_empty() { + div class="empty-state" { + div class="empty-mark" { "🗄" } + h3 { "Nothing archived yet" } + p { "Archive a list and it will show up here." } + } + } @else { + div class="list-cards" { + @for list in archived_lists { + a class="list-card archived-list-card" href=(format!("/lists/{}", list.id)) { + span class="list-card-icon" { "🗄" } + span class="list-card-copy" { + strong { (list.name) } + small { "Created " (format_date(list.created_at)) } + } + span class="list-card-arrow" { "→" } + } + } + } + } + } + }, + ) +} + pub fn meals_page( user: &User, meals: &[Meal], @@ -712,6 +756,13 @@ mod tests { assert!(html.contains(""), "expected , got: {html}"); assert!(html.contains(""), "expected , got: {html}"); } + + #[test] + fn format_date_renders_human_readable_date() { + // 2026-08-07T00:00:00Z in Unix seconds. + let ts = 1_786_060_800; + assert_eq!(format_date(ts), "7 Aug 2026"); + } } pub fn list_page( @@ -733,6 +784,9 @@ pub fn list_page( span class="live-pill" { span class="live-dot" {} "Live" } } } + @if let Some(ts) = list.archived_at { + div class="archived-banner" { "This list was archived on " (format_date(ts)) "." } + } div id="meal-picker" class="meal-picker" {} div class="list-layout" { section class="panel list-panel" { @@ -742,11 +796,24 @@ pub fn list_page( h1 { (list.name) } p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" } } + div class="list-topbar-actions" { + @if list.archived_at.is_some() { + form method="post" action=(format!("/lists/{}/unarchive", list.id)) { + input type="hidden" name="csrf" value=(csrf_token); + button class="button button-small button-quiet" type="submit" { "Restore" } + } + } @else { + form method="post" action=(format!("/lists/{}/archive", list.id)) { + input type="hidden" name="csrf" value=(csrf_token); + button class="button button-small button-quiet" type="submit" { "Archive" } + } + } + } } - (list_content_fragment(list, items, categories, csrf_token, false)) + (list_content_fragment(list, items, categories, csrf_token, false, list.archived_at.is_none())) } aside class="side-column" { - (list_meals_panel(list_meals, list.id, csrf_token, false)) + (list_meals_panel(list_meals, list.id, csrf_token, false, list.archived_at.is_none())) (presence_panel(presence, false)) section class="panel tip-panel" { span class="tip-label" { "TIP" } @@ -765,17 +832,18 @@ pub fn list_content_fragment( categories: &[Category], csrf_token: &str, out_of_band: bool, + editable: bool, ) -> Markup { if out_of_band { html! { div id="list-content" hx-swap-oob="outerHTML" { - (list_content(list, items, categories, csrf_token)) + (list_content(list, items, categories, csrf_token, editable)) } } } else { html! { div id="list-content" { - (list_content(list, items, categories, csrf_token)) + (list_content(list, items, categories, csrf_token, editable)) } } } @@ -786,26 +854,29 @@ fn list_content( items: &[Item], categories: &[Category], csrf_token: &str, + editable: bool, ) -> Markup { html! { - form - id="add-item-form" - class="add-item-form" - hx-post=(format!("/lists/{}/items", list.id)) - hx-target="#list-items" - hx-swap="outerHTML" - hx-on::after-request="if (event.detail.successful) this.reset()" - { - input type="hidden" name="csrf" value=(csrf_token); - label class="sr-only" for="item-name" { "Item name" } - input id="item-name" name="name" type="text" maxlength="120" placeholder="Add an item..." autocomplete="off" required; - select id="item-category" name="category_id" aria-label="Category" { - (category_options(categories, None)) + @if editable { + form + id="add-item-form" + class="add-item-form" + hx-post=(format!("/lists/{}/items", list.id)) + hx-target="#list-items" + hx-swap="outerHTML" + hx-on::after-request="if (event.detail.successful) this.reset()" + { + input type="hidden" name="csrf" value=(csrf_token); + label class="sr-only" for="item-name" { "Item name" } + input id="item-name" name="name" type="text" maxlength="120" placeholder="Add an item..." autocomplete="off" required; + select id="item-category" name="category_id" aria-label="Category" { + (category_options(categories, None)) + } + input id="item-quantity" name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; + button id="add-item-button" class="button button-primary add-button" type="submit" { "+ Add" } } - input id="item-quantity" name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; - button id="add-item-button" class="button button-primary add-button" type="submit" { "+ Add" } } - (list_items_fragment(list, items, categories, csrf_token, false)) + (list_items_fragment(list, items, categories, csrf_token, false, editable)) } } @@ -815,23 +886,29 @@ pub fn list_items_fragment( categories: &[Category], csrf_token: &str, out_of_band: bool, + editable: bool, ) -> Markup { if out_of_band { html! { div id="list-items" class="items" data-revision=(list.revision) hx-swap-oob="outerHTML" { - (list_items_content(items, categories, csrf_token)) + (list_items_content(items, categories, csrf_token, editable)) } } } else { html! { div id="list-items" class="items" data-revision=(list.revision) { - (list_items_content(items, categories, csrf_token)) + (list_items_content(items, categories, csrf_token, editable)) } } } } -fn list_items_content(items: &[Item], categories: &[Category], csrf_token: &str) -> Markup { +fn list_items_content( + items: &[Item], + categories: &[Category], + csrf_token: &str, + editable: bool, +) -> Markup { html! { @if items.is_empty() { div class="empty-items" { @@ -842,7 +919,7 @@ fn list_items_content(items: &[Item], categories: &[Category], csrf_token: &str) } @else { div class="item-list" { @for group in item_groups(items, categories) { - (category_group(&group.0, &group.1, categories, csrf_token)) + (category_group(&group.0, &group.1, categories, csrf_token, editable)) } } } @@ -876,32 +953,37 @@ fn category_group( items: &[&Item], categories: &[Category], csrf_token: &str, + editable: bool, ) -> Markup { html! { section class="category-group" { h2 class="category-heading" { (name) } @for item in items { - (item_row(item, categories, csrf_token)) + (item_row(item, categories, csrf_token, editable)) } } } } -fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup { +fn item_row(item: &Item, categories: &[Category], csrf_token: &str, editable: bool) -> Markup { let next_checked = if item.checked { "0" } else { "1" }; html! { article class=(if item.checked { "item-row is-checked" } else { "item-row" }) id=(format!("item-{}", item.id)) data-version=(item.version) { - form - class="check-form" - hx-post=(format!("/lists/{}/items/{}/check", item.list_id, item.id)) - hx-target="#list-items" - hx-swap="outerHTML" - { - input type="hidden" name="csrf" value=(csrf_token); - input type="hidden" name="checked" value=(next_checked); - button id=(format!("item-check-{}", item.id)) class="check-button" type="submit" aria-label=(if item.checked { "Mark unchecked" } else { "Mark complete" }) { - @if item.checked { "✓" } @else { "" } + @if editable { + form + class="check-form" + hx-post=(format!("/lists/{}/items/{}/check", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + { + input type="hidden" name="csrf" value=(csrf_token); + input type="hidden" name="checked" value=(next_checked); + button id=(format!("item-check-{}", item.id)) class="check-button" type="submit" aria-label=(if item.checked { "Mark unchecked" } else { "Mark complete" }) { + @if item.checked { "✓" } @else { "" } + } } + } @else if item.checked { + span class="check-button check-button-static" { "✓" } } label class="item-copy" for=(format!("item-check-{}", item.id)) { @if !item.quantity.is_empty() { @@ -912,39 +994,41 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup { small { (item.note) } } } - button type="button" class="item-actions-button" aria-label="Item actions" onclick=(format!("document.getElementById('item-edit-{}').showModal()", item.id)) { "•••" } - dialog id=(format!("item-edit-{}", item.id)) class="item-modal" { - div class="item-modal-card" { - div class="item-modal-header" { - h3 { (item.name) } - button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "✕" } - } - form - hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id)) - hx-target="#list-items" - hx-swap="outerHTML" - class="stack" - { - input type="hidden" name="csrf" value=(csrf_token); - label { "Name" } - input id=(format!("item-edit-name-{}", item.id)) name="name" value=(item.name) maxlength="120" required; - label { "Category" } - select id=(format!("item-edit-category-{}", item.id)) name="category_id" { - (category_options(categories, item.category_id)) + @if editable { + button type="button" class="item-actions-button" aria-label="Item actions" onclick=(format!("document.getElementById('item-edit-{}').showModal()", item.id)) { "•••" } + dialog id=(format!("item-edit-{}", item.id)) class="item-modal" { + div class="item-modal-card" { + div class="item-modal-header" { + h3 { (item.name) } + button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "✕" } + } + form + hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + class="stack" + { + input type="hidden" name="csrf" value=(csrf_token); + label { "Name" } + input id=(format!("item-edit-name-{}", item.id)) name="name" value=(item.name) maxlength="120" required; + label { "Category" } + select id=(format!("item-edit-category-{}", item.id)) name="category_id" { + (category_options(categories, item.category_id)) + } + label { "Quantity" } + input id=(format!("item-edit-quantity-{}", item.id)) name="quantity" value=(item.quantity) maxlength="40"; + label { "Note" } + input id=(format!("item-edit-note-{}", item.id)) name="note" value=(item.note) maxlength="120"; + button id=(format!("item-edit-save-{}", item.id)) class="button button-primary" type="submit" { "Save" } + } + form + hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) + hx-target="#list-items" + hx-swap="outerHTML" + { + input type="hidden" name="csrf" value=(csrf_token); + button id=(format!("item-edit-delete-{}", item.id)) class="button button-danger" type="submit" { "Remove item" } } - label { "Quantity" } - input id=(format!("item-edit-quantity-{}", item.id)) name="quantity" value=(item.quantity) maxlength="40"; - label { "Note" } - input id=(format!("item-edit-note-{}", item.id)) name="note" value=(item.note) maxlength="120"; - button id=(format!("item-edit-save-{}", item.id)) class="button button-primary" type="submit" { "Save" } - } - form - hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) - hx-target="#list-items" - hx-swap="outerHTML" - { - input type="hidden" name="csrf" value=(csrf_token); - button id=(format!("item-edit-delete-{}", item.id)) class="button button-danger" type="submit" { "Remove item" } } } } @@ -1017,9 +1101,10 @@ pub fn live_list_fragments( list_meals: &[ListMeal], csrf_token: &str, ) -> Markup { + let editable = list.archived_at.is_none(); html! { - (list_content_fragment(list, items, categories, csrf_token, true)) - (list_meals_panel(list_meals, list.id, csrf_token, true)) + (list_content_fragment(list, items, categories, csrf_token, true, editable)) + (list_meals_panel(list_meals, list.id, csrf_token, true, editable)) } } @@ -1028,6 +1113,7 @@ pub fn list_meals_panel( list_id: i64, csrf_token: &str, out_of_band: bool, + editable: bool, ) -> Markup { let panel = html! { div class="panel-heading" { @@ -1042,20 +1128,24 @@ pub fn list_meals_panel( div class="list-meal-row" { span class="list-meal-icon" { "🍽" } span class="list-meal-name" { (meal.name) } - form - hx-post=(format!("/lists/{}/meals/{}/remove", list_id, meal.id)) - hx-target="#list-items" - hx-swap="outerHTML" - class="list-meal-remove" - { - input type="hidden" name="csrf" value=(csrf_token); - button type="submit" class="list-meal-remove-button" aria-label=(format!("Remove {} from list", meal.name)) { "✕" } + @if editable { + form + hx-post=(format!("/lists/{}/meals/{}/remove", list_id, meal.id)) + hx-target="#list-items" + hx-swap="outerHTML" + class="list-meal-remove" + { + input type="hidden" name="csrf" value=(csrf_token); + button type="submit" class="list-meal-remove-button" aria-label=(format!("Remove {} from list", meal.name)) { "✕" } + } } } } } } - button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" } + @if editable { + button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" } + } }; if out_of_band { @@ -1153,6 +1243,32 @@ pub fn invite_result(url: &str) -> Markup { } } +/// Formats a Unix timestamp as a human-readable date (e.g. "7 Aug 2026"). +fn format_date(timestamp: i64) -> String { + let days = timestamp.div_euclid(86_400); + let (y, m, d) = civil_from_days(days); + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec", + ]; + format!("{} {} {}", d, MONTHS[(m - 1) as usize], y) +} + +/// Converts a count of days since the Unix epoch into a (year, month, day) +/// civil date using Howard Hinnant's `civil_from_days` algorithm. +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + pub fn error_page(status: &str, message: &str) -> Markup { page( status, diff --git a/static/style.css b/static/style.css index 18253d5..04aa1ef 100644 --- a/static/style.css +++ b/static/style.css @@ -71,6 +71,8 @@ h3 { margin-bottom: 6px; font-size: 1rem; } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, .8fr); gap: 22px; align-items: start; } .panel { padding: 26px; border: 1px solid rgba(221, 225, 210, .9); border-radius: 24px; background: rgba(255, 253, 248, .88); box-shadow: var(--shadow); } .panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; } +.archive-link { margin-left: auto; color: var(--muted); font-size: .78rem; font-weight: 700; text-decoration: none; } +.archive-link:hover { color: var(--deep-sage); } .count-badge { display: inline-grid; place-items: center; min-width: 27px; height: 27px; padding: 0 8px; border-radius: 99px; color: var(--deep-sage); background: #e8f0e1; font-size: .78rem; font-weight: 800; } .stack { display: grid; gap: 9px; } .stack label { color: var(--muted); font-size: .82rem; font-weight: 700; } @@ -100,6 +102,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .list-card-copy { display: grid; flex: 1; gap: 2px; } .list-card-copy small { color: var(--muted); font-size: .75rem; } .list-card-arrow { color: var(--muted); font-size: 1.25rem; } +.archived-list-card { opacity: .72; } +.archived-list-card .list-card-icon { color: var(--muted); background: #eef0ea; } +.archived-list-card .list-card-copy { align-items: flex-start; } +.archived-list-card form { margin-left: auto; } +.archived-banner { margin-bottom: 18px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 12px; color: var(--muted); background: #f1f3ec; font-size: .82rem; font-weight: 700; } .empty-state { padding: 35px 18px 24px; text-align: center; color: var(--muted); } .empty-mark { display: grid; place-items: center; width: 50px; height: 50px; margin: 0 auto 15px; border-radius: 18px; color: var(--deep-sage); background: #edf3e8; font-size: 1.8rem; } .empty-state h3 { color: var(--ink); } @@ -152,6 +159,8 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .check-form { flex: 0 0 auto; } .check-button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 2px solid #c8d6c1; border-radius: 9px; color: #fff; background: transparent; cursor: pointer; font-size: .88rem; font-weight: 900; } .is-checked .check-button { border-color: var(--deep-sage); background: var(--deep-sage); } +.check-button-static { cursor: default; } +.is-checked .check-button-static { border-color: var(--deep-sage); background: var(--deep-sage); } .item-copy { display: grid; grid-template-columns: auto 1fr; flex: 1; min-width: 0; gap: 2px 7px; align-items: baseline; } .item-copy strong { grid-column: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .item-qty { grid-column: 1; grid-row: 1; color: var(--muted); font-weight: 700; white-space: nowrap; }