add reward card feature with barcodes

This commit is contained in:
2026-08-08 15:55:18 -04:00
parent ab46e63bf0
commit 975cf38170
13 changed files with 771 additions and 11 deletions
+16
View File
@@ -113,3 +113,19 @@ pub struct PresenceUser {
pub user_id: i64,
pub display_name: String,
}
/// A stored rewards-card number that can be shown as a scannable barcode.
#[derive(Clone, Debug)]
pub struct RewardsCard {
pub id: i64,
/// The owner of this card.
#[allow(dead_code)]
pub user_id: i64,
pub store_name: String,
pub number: String,
/// Symbology used to render the barcode (e.g. "code128", "code39").
pub symbology: String,
/// When the card was added.
#[allow(dead_code)]
pub created_at: i64,
}
+75 -1
View File
@@ -23,7 +23,9 @@ use tracing::{Level, error, warn};
use crate::assets;
use crate::domain::{DomainError, SessionUser};
use crate::ports::{HubEvent, RealtimeNotifier};
use crate::services::{AuthService, InvitationService, ListService, MealService};
use crate::services::{
AuthService, InvitationService, ListService, MealService, RewardsCardService,
};
use crate::views;
use crate::webauthn::WebAuthnService;
@@ -33,6 +35,7 @@ pub struct AppState {
pub lists: Arc<ListService>,
pub meals: Arc<MealService>,
pub invitations: Arc<InvitationService>,
pub rewards_cards: Arc<RewardsCardService>,
pub webauthn: Arc<WebAuthnService>,
pub realtime: Arc<dyn RealtimeNotifier>,
pub cookie_secure: bool,
@@ -130,6 +133,8 @@ pub fn build_router(state: AppState) -> Router {
"/lists/{list_id}/meals/{list_meal_id}/remove",
post(remove_meal_from_list),
)
.route("/rewards", get(rewards_page).post(create_rewards_card))
.route("/rewards/{card_id}/delete", post(delete_rewards_card))
.route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation))
@@ -261,6 +266,14 @@ struct CategoryForm {
csrf: String,
}
#[derive(Debug, Deserialize)]
struct RewardsCardForm {
store_name: String,
number: String,
symbology: String,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct PasskeyRegisterStartForm {
csrf: String,
@@ -819,6 +832,67 @@ async fn delete_meal_category(
Ok(Redirect::to("/meals").into_response())
}
async fn rewards_page(
State(state): State<AppState>,
user: CurrentUser,
) -> Result<Response, AppError> {
let cards = state.rewards_cards.list_cards(user.session.user.id).await?;
Ok(html_response(views::rewards_page(
&user.session.user,
&cards,
&user.session.csrf_token,
)))
}
async fn create_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
LoggedForm(form): LoggedForm<RewardsCardForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let store_name = form.store_name.trim().to_owned();
let number = form.number.trim().to_owned();
let symbology = normalize_symbology(&form.symbology);
if store_name.is_empty() || store_name.chars().count() > 60 {
return Err(AppError::BadRequest(
"Store names must be between 1 and 60 characters.".into(),
));
}
if number.is_empty() || number.chars().count() > 80 {
return Err(AppError::BadRequest(
"Card numbers must be between 1 and 80 characters.".into(),
));
}
state
.rewards_cards
.create_card(user.session.user.id, store_name, number, symbology)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
async fn delete_rewards_card(
State(state): State<AppState>,
user: CurrentUser,
Path(card_id): Path<i64>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
state
.rewards_cards
.delete_card(user.session.user.id, card_id)
.await?;
Ok(Redirect::to("/rewards").into_response())
}
/// Normalizes a submitted symbology value to a known barcode type, falling
/// back to Code 128 for unknown or empty values.
fn normalize_symbology(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"code39" => "code39".to_owned(),
_ => "code128".to_owned(),
}
}
async fn meals_page(
State(state): State<AppState>,
user: CurrentUser,
+12 -3
View File
@@ -22,15 +22,18 @@ use crate::hub::InMemoryHub;
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository,
PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository,
PasswordHasher, RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator,
UserRepository,
};
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode};
use crate::services::{
AuthService, InvitationService, ListService, MealService, RegistrationMode, RewardsCardService,
};
use crate::sqlite::{
SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository,
SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository,
SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository,
SqliteSessionRepository, SqliteUserRepository,
SqliteRewardsCardRepository, SqliteSessionRepository, SqliteUserRepository,
};
use crate::webauthn::{AppWebauthnConfig, WebAuthnService};
@@ -90,6 +93,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let meal_categories: Arc<dyn MealCategoryRepository> = Arc::new(SqliteMealCategoryRepository);
let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository);
let passkeys: Arc<dyn PasskeyRepository> = Arc::new(SqlitePasskeyRepository);
let rewards_cards: Arc<dyn RewardsCardRepository> = Arc::new(SqliteRewardsCardRepository);
let hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator);
let realtime: Arc<dyn RealtimeNotifier> = Arc::new(InMemoryHub::default());
@@ -114,6 +118,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Arc::clone(&invitations),
Arc::clone(&tokens),
));
let rewards_cards_service = Arc::new(RewardsCardService::new(
db.clone(),
Arc::clone(&rewards_cards),
));
let meals_service = Arc::new(MealService::new(
db.clone(),
Arc::clone(&meals),
@@ -157,6 +165,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
lists: lists_service,
meals: meals_service,
invitations: invitations_service,
rewards_cards: rewards_cards_service,
webauthn: webauthn_service,
realtime,
cookie_secure,
+25 -1
View File
@@ -3,7 +3,7 @@ use sqlx::SqliteConnection;
use crate::domain::{
Category, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient,
Passkey, PresenceUser, SessionUser, User,
Passkey, PresenceUser, RewardsCard, SessionUser, User,
};
/// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to),
@@ -314,3 +314,27 @@ pub enum HubEvent {
ListChanged { list_id: i64, revision: i64 },
PresenceChanged { list_id: i64 },
}
/// Stores and retrieves a user's rewards cards.
#[async_trait]
pub trait RewardsCardRepository: Send + Sync {
async fn list_cards(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<Vec<RewardsCard>>;
async fn create_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard>;
async fn delete_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<()>;
}
+47 -2
View File
@@ -1,12 +1,13 @@
use std::sync::Arc;
use crate::domain::{
DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, SessionUser, User,
DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, RewardsCard,
SessionUser, User,
};
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher,
RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository,
RealtimeNotifier, RewardsCardRepository, SessionRepository, TokenGenerator, UserRepository,
};
use crate::sqlite::SqliteDatabase;
@@ -628,3 +629,47 @@ impl InvitationService {
.await
}
}
pub struct RewardsCardService {
db: SqliteDatabase,
cards: Arc<dyn RewardsCardRepository>,
}
impl RewardsCardService {
pub fn new(db: SqliteDatabase, cards: Arc<dyn RewardsCardRepository>) -> Self {
Self { db, cards }
}
pub async fn list_cards(&self, user_id: i64) -> DomainResult<Vec<RewardsCard>> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| Box::pin(async move { cards.list_cards(txn, user_id).await }))
.await
}
pub async fn create_card(
&self,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| {
Box::pin(async move {
cards
.create_card(txn, user_id, store_name, number, symbology)
.await
})
})
.await
}
pub async fn delete_card(&self, user_id: i64, card_id: i64) -> DomainResult<()> {
let cards = Arc::clone(&self.cards);
self.db
.run(move |txn| Box::pin(async move { cards.delete_card(txn, user_id, card_id).await }))
.await
}
}
+205 -2
View File
@@ -8,12 +8,12 @@ use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectO
use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory,
MealIngredient, Passkey, SessionUser, User,
MealIngredient, Passkey, RewardsCard, SessionUser, User,
};
use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository,
SessionRepository, UserRepository,
RewardsCardRepository, SessionRepository, UserRepository,
};
/// The embedded SQL migrations, applied automatically on startup.
@@ -1292,6 +1292,94 @@ impl MealIngredientRepository for SqliteMealIngredientRepository {
}
}
#[derive(Clone, Copy)]
pub struct SqliteRewardsCardRepository;
#[async_trait]
impl RewardsCardRepository for SqliteRewardsCardRepository {
async fn list_cards(
&self,
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<Vec<RewardsCard>> {
let rows = sqlx::query(
"SELECT id, user_id, store_name, number, symbology, created_at
FROM rewards_cards
WHERE user_id = ?1
ORDER BY created_at DESC, id DESC",
)
.bind(user_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| RewardsCard {
id: row.get(0),
user_id: row.get(1),
store_name: row.get(2),
number: row.get(3),
symbology: row.get(4),
created_at: row.get(5),
})
.collect())
}
async fn create_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
store_name: String,
number: String,
symbology: String,
) -> DomainResult<RewardsCard> {
sqlx::query(
"INSERT INTO rewards_cards (user_id, store_name, number, symbology, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(user_id)
.bind(&store_name)
.bind(&number)
.bind(&symbology)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
let card_id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(RewardsCard {
id: card_id,
user_id,
store_name,
number,
symbology,
created_at: now(),
})
}
async fn delete_card(
&self,
txn: &mut SqliteConnection,
user_id: i64,
card_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM rewards_cards WHERE id = ?1 AND user_id = ?2")
.bind(card_id)
.bind(user_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
async fn ensure_category(txn: &mut SqliteConnection, category_id: Option<i64>) -> DomainResult<()> {
let Some(category_id) = category_id else {
return Ok(());
@@ -2786,4 +2874,119 @@ mod tests {
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- RewardsCardRepository ----
#[tokio::test]
async fn rewards_card_crud_roundtrip() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let cards = SqliteRewardsCardRepository;
let created = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move {
cards
.create_card(
txn,
user.id,
"Kroger".into(),
"6011 2345 6789".into(),
"code128".into(),
)
.await
})
})
.await
.unwrap();
assert!(created.id > 0);
assert_eq!(created.user_id, user.id);
assert_eq!(created.store_name, "Kroger");
assert_eq!(created.number, "6011 2345 6789");
assert_eq!(created.symbology, "code128");
let listed = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, user.id).await })
})
.await
.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, created.id);
db.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, user.id, created.id).await })
})
.await
.unwrap();
let after = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, user.id).await })
})
.await
.unwrap();
assert!(after.is_empty());
}
#[tokio::test]
async fn rewards_cards_are_scoped_to_their_user() {
let db = setup().await;
let alice = create_user(&db, "alice@example.com").await;
let bob = create_user(&db, "bob@example.com").await;
let cards = SqliteRewardsCardRepository;
db.run(move |txn| {
let cards = cards.clone();
Box::pin(async move {
cards
.create_card(
txn,
alice.id,
"Kroger".into(),
"123".into(),
"code128".into(),
)
.await
})
})
.await
.unwrap();
let bobs = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.list_cards(txn, bob.id).await })
})
.await
.unwrap();
assert!(bobs.is_empty());
// Bob cannot delete Alice's card.
let result = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, bob.id, 1).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn delete_missing_rewards_card_fails() {
let db = setup().await;
let user = create_user(&db, "alice@example.com").await;
let cards = SqliteRewardsCardRepository;
let result = db
.run(move |txn| {
let cards = cards.clone();
Box::pin(async move { cards.delete_card(txn, user.id, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
}
+205 -2
View File
@@ -4,7 +4,8 @@ use pulldown_cmark::{Options, Parser, html as cmark_html};
use crate::{
domain::PresenceUser,
domain::{
Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey, User,
Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey,
RewardsCard, User,
},
};
@@ -585,7 +586,7 @@ pub fn meal_page(
}
div class="list-topbar-actions" {
button type="button" class="button button-small button-quiet" onclick="document.getElementById('meal-edit-modal').showModal()" { "Edit" }
form method="post" action=(format!("/meals/{}/delete", meal.id)) onsubmit="return confirm('Delete this meal and its ingredients? This cannot be undone.')" {
form method="post" action=(format!("/meals/{}/delete", meal.id)) hx-confirm="Delete this meal and its ingredients? This cannot be undone." {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link bordered-delete" type="submit" { "Delete" }
}
@@ -743,6 +744,19 @@ fn render_markdown(source: &str) -> Markup {
mod tests {
use super::*;
/// Extracts the rendered SVG width from `viewBox="0 0 <width> <height>"`.
fn svg_width(svg: &str) -> u32 {
let viewbox = svg
.split("viewBox=\"")
.nth(1)
.expect("expected viewBox")
.split('"')
.next()
.unwrap();
let width = viewbox.split_whitespace().nth(2).expect("expected width");
width.parse().expect("expected numeric width")
}
#[test]
fn render_markdown_turns_bullets_into_list_html() {
let html = render_markdown("- one\n- two\n").into_string();
@@ -764,6 +778,76 @@ mod tests {
let ts = 1_786_060_800;
assert_eq!(format_date(ts), "7 Aug 2026");
}
#[test]
fn barcode_svg_renders_code128_svg() {
let html = barcode_svg("code128", "601123456789").into_string();
assert!(html.contains("<svg"), "expected svg, got: {html}");
assert!(html.contains("</svg>"), "expected closing svg, got: {html}");
}
#[test]
fn code128_uses_set_c_for_even_digit_numbers() {
// Set C (Ć) packs two digits per symbol, so an even-length digit-only
// number should be encoded with set C rather than set B.
assert_eq!(code128_input("601123456789"), "Ć601123456789");
// Non-numeric data falls back to set B.
assert_eq!(code128_input("ABC123"), "ƁABC123");
assert_eq!(code128_input(""), "Ɓ");
}
#[test]
fn code128_odd_digit_numbers_lead_with_set_b_then_switch_to_set_c() {
// 21-digit number: first digit in set B, then set C for the rest.
assert_eq!(
code128_input("606171584511340224537"),
"Ɓ6Ć06171584511340224537"
);
}
#[test]
fn code128_set_c_renders_shorter_than_set_b() {
let number = "601123456789";
let set_c = barcode_svg("code128", number).into_string();
// Force set B by using a non-numeric character so the digit-only
// fast path doesn't kick in.
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
let width_c = svg_width(&set_c);
let width_b = svg_width(&set_b);
assert!(
width_c < width_b,
"expected set C ({width_c}) narrower than set B ({width_b})"
);
}
#[test]
fn code128_odd_digit_number_renders_shorter_than_pure_set_b() {
let number = "606171584511340224537";
let mixed = barcode_svg("code128", number).into_string();
// Force pure set B by appending a non-numeric character.
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
let width_mixed = svg_width(&mixed);
let width_b = svg_width(&set_b);
assert!(
width_mixed < width_b,
"expected mixed ({width_mixed}) narrower than set B ({width_b})"
);
}
#[test]
fn barcode_svg_renders_code39_svg() {
let html = barcode_svg("code39", "ABC123").into_string();
assert!(html.contains("<svg"), "expected svg, got: {html}");
}
#[test]
fn barcode_svg_falls_back_to_text_for_invalid_number() {
// Code 39 only supports uppercase letters and digits, so lowercase
// input cannot be encoded and falls back to plain text.
let html = barcode_svg("code39", "abc").into_string();
assert!(!html.contains("<svg"), "expected no svg, got: {html}");
assert!(html.contains("abc"), "expected fallback text, got: {html}");
}
}
pub fn list_page(
@@ -1260,6 +1344,124 @@ pub fn invite_result(url: &str) -> Markup {
}
}
pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Markup {
page(
"Rewards cards",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "REWARDS CARDS" }
h1 class="page-title" { "Rewards cards" }
p class="lede" { "Your store cards, ready to scan." }
}
}
div class="dashboard-grid" {
section class="panel" {
div class="panel-heading" {
h2 { "Your cards" }
span class="count-badge" { (cards.len()) }
}
@if cards.is_empty() {
div class="empty-state" {
div class="empty-mark" { "🏷" }
h3 { "No rewards cards yet" }
p { "Add a card to see its barcode here." }
}
} @else {
div class="rewards-grid" {
@for card in cards {
div class="rewards-card" {
div class="rewards-card-heading" {
strong { (card.store_name) }
form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link bordered-delete" type="submit" { "Remove" }
}
}
div class="rewards-barcode" {
(barcode_svg(&card.symbology, &card.number))
}
p class="rewards-number" { (card.number) }
}
}
}
}
}
section class="panel create-panel" {
div class="panel-heading" { h2 { "Add a card" } }
form method="post" action="/rewards" class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label for="store-name" { "Store name" }
input id="store-name" name="store_name" type="text" maxlength="60" placeholder="e.g. Kroger" required;
label for="card-number" { "Card number" }
input id="card-number" name="number" type="text" maxlength="80" placeholder="e.g. 6011 2345 6789" required;
label for="symbology" { "Barcode type" }
select id="symbology" name="symbology" {
option value="code128" selected { "Code 128 (recommended)" }
option value="code39" { "Code 39" }
}
button class="button button-primary" type="submit" { "Add card" }
}
}
}
},
)
}
/// Renders a rewards-card number as an inline SVG barcode using the given
/// symbology. Falls back to a plain text label if the number can't be encoded
/// (for example, a Code 128 number that isn't valid for the chosen symbology).
fn barcode_svg(symbology: &str, number: &str) -> Markup {
let generated = match symbology {
"code39" => barcoders::sym::code39::Code39::new(number)
.ok()
.map(|code| code.encode())
.and_then(|encoded| {
barcoders::generators::svg::SVG::new(60)
.generate(&encoded)
.ok()
}),
_ => barcoders::sym::code128::Code128::new(&code128_input(number))
.ok()
.map(|code| code.encode())
.and_then(|encoded| {
barcoders::generators::svg::SVG::new(60)
.generate(&encoded)
.ok()
}),
};
match generated {
Some(svg) => html! { (maud::PreEscaped(svg)) },
None => html! { span class="rewards-unencodable" { (number) } },
}
}
/// Builds the Code 128 input string, choosing the most compact character set.
///
/// Code 128 set C packs two digits per symbol, so numeric data renders roughly
/// half as wide as set B (one character per symbol). Google Wallet (via ZXing)
/// picks set C automatically for numeric runs, so we do the same to keep the
/// rendered barcode compact and consistent with it:
///
/// - Even-length digit-only numbers: all set C (`Ć` prefix).
/// - Odd-length digit-only numbers: first digit in set B, then switch to set C
/// for the remaining even count of digits (e.g. `Ɓ6Ć0171...`).
/// - Anything else: set B (`Ɓ` prefix).
fn code128_input(number: &str) -> String {
let is_all_digits = !number.is_empty() && number.chars().all(|c| c.is_ascii_digit());
if !is_all_digits {
return format!("Ɓ{number}");
}
let len = number.chars().count();
if len % 2 == 0 {
format!("Ć{number}")
} else {
let (first, rest) = number.split_at(1);
format!("Ɓ{first}Ć{rest}")
}
}
/// 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);
@@ -1323,6 +1525,7 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
nav class="site-nav" {
a href="/lists" { "Lists" }
a href="/meals" { "Meals" }
a href="/rewards" { "Rewards" }
}
div class="account-nav" {
a class="user-name" href="/account" { (user.display_name) }