From 0b321068aa14c01b1ddf254e8837724b76034d1d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 15:14:39 +0000 Subject: [PATCH] refactor(domain): extract shared formatting helpers - Add format_weight() and format_relative_time() in domain/formatting.rs - Replace ad-hoc weight format strings across views and timeline events - Move relative_date() body to domain layer with explicit now param - Remove hardcoded "g" suffix from bag templates (now in format_weight) - Document formatting helpers in CLAUDE.md --- CLAUDE.md | 13 ++ src/domain/bags.rs | 2 +- src/domain/brews.rs | 2 +- src/domain/formatting.rs | 215 +++++++++++++++++++++++++ src/domain/mod.rs | 1 + src/presentation/web/views/bags.rs | 7 +- src/presentation/web/views/brews.rs | 3 +- src/presentation/web/views/mod.rs | 37 +---- templates/partials/bag_card.html | 2 +- templates/partials/lists/bag_list.html | 4 +- 10 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 src/domain/formatting.rs diff --git a/CLAUDE.md b/CLAUDE.md index 987887b..3602f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,6 +213,19 @@ All macros have doc comments with usage examples. Check the source files for ful Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (see macro docs). Each repository has an `order_clause()` method for sort query generation — use `order_clause` as the method name, not `sort_clause`. +### Display Formatting + +`domain/formatting.rs` contains shared formatting helpers with unit tests. Always use these instead of ad-hoc `format!()` calls: + +| Function | Signature | Output examples | +|----------|-----------|-----------------| +| `format_relative_time` | `(dt: DateTime, now: DateTime) -> String` | "Just now", "5m ago", "Yesterday", "2w ago", "Mar 15" | +| `format_weight` | `(grams: f64) -> String` | "15g", "15.5g", "250g", "1.0kg", "2.3kg" | + +**`format_relative_time`** — accepts an explicit `now` parameter for testability. Callers pass `Utc::now()` at the call site. Covers seconds through absolute dates, with title case ("Just now", "Yesterday"). + +**`format_weight`** — displays grams up to 999g, switches to kg for 1000g+. Whole-gram values omit the decimal ("250g"), fractional values show one decimal ("15.5g"). Kilogram values always show one decimal ("1.5kg"). All weight values in the database are stored in grams. + ### Error Handling & Logging **Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI). diff --git a/src/domain/bags.rs b/src/domain/bags.rs index 334ab48..e8eed91 100644 --- a/src/domain/bags.rs +++ b/src/domain/bags.rs @@ -154,7 +154,7 @@ pub fn bag_timeline_event( }, TimelineEventDetail { label: "Amount".to_string(), - value: format!("{}g", bag.amount), + value: super::formatting::format_weight(bag.amount), }, ], tasting_notes: vec![], diff --git a/src/domain/brews.rs b/src/domain/brews.rs index 36c6da8..30dc4f2 100644 --- a/src/domain/brews.rs +++ b/src/domain/brews.rs @@ -119,7 +119,7 @@ impl BrewWithDetails { }, TimelineEventDetail { label: "Coffee".to_string(), - value: format!("{:.1}g", self.brew.coffee_weight), + value: super::formatting::format_weight(self.brew.coffee_weight), }, TimelineEventDetail { label: "Water".to_string(), diff --git a/src/domain/formatting.rs b/src/domain/formatting.rs new file mode 100644 index 0000000..3484b7b --- /dev/null +++ b/src/domain/formatting.rs @@ -0,0 +1,215 @@ +use chrono::{DateTime, Datelike, Utc}; + +/// Format a datetime as a human-readable relative time string. +/// +/// Examples: "Just now", "5m ago", "3h ago", "Yesterday", "4d ago", "2w ago", +/// "Mar 15" (same year), "Mar 15, 2024" (different year). +pub fn format_relative_time(dt: DateTime, now: DateTime) -> String { + let delta = now.signed_duration_since(dt); + let secs = delta.num_seconds(); + + if secs < 60 { + return "Just now".to_string(); + } + + let mins = delta.num_minutes(); + if mins < 60 { + return format!("{mins}m ago"); + } + + let hours = delta.num_hours(); + if hours < 24 { + return format!("{hours}h ago"); + } + + let days = delta.num_days(); + if days == 1 { + return "Yesterday".to_string(); + } + if days < 7 { + return format!("{days}d ago"); + } + + let weeks = days / 7; + if days < 30 { + return format!("{weeks}w ago"); + } + + if dt.year() == now.year() { + dt.format("%b %d").to_string() + } else { + dt.format("%b %d, %Y").to_string() + } +} + +/// Format a weight in grams for display. Uses "g" up to 999g, "kg" for 1000g+. +/// +/// Whole-gram values omit the decimal ("250g"), fractional values show one +/// decimal place ("15.5g"). Kilogram values always show one decimal ("1.5kg"). +pub fn format_weight(grams: f64) -> String { + if grams >= 1000.0 { + format!("{:.1}kg", grams / 1000.0) + } else if (grams - grams.round()).abs() < 0.05 { + format!("{grams:.0}g") + } else { + format!("{grams:.1}g") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn utc(y: i32, m: u32, d: u32, h: u32, min: u32, s: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, min, s).unwrap() + } + + #[test] + fn just_now_zero_seconds() { + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(now, now), "Just now"); + } + + #[test] + fn just_now_59_seconds() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 59); + assert_eq!(format_relative_time(dt, now), "Just now"); + } + + #[test] + fn minutes_boundary_60_seconds() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 1, 12, 1, 0); + assert_eq!(format_relative_time(dt, now), "1m ago"); + } + + #[test] + fn minutes_ago() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 1, 12, 45, 0); + assert_eq!(format_relative_time(dt, now), "45m ago"); + } + + #[test] + fn hours_boundary_60_minutes() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 1, 13, 0, 0); + assert_eq!(format_relative_time(dt, now), "1h ago"); + } + + #[test] + fn hours_ago() { + let dt = utc(2025, 6, 1, 6, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "6h ago"); + } + + #[test] + fn yesterday_exactly_24h() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 2, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "Yesterday"); + } + + #[test] + fn yesterday_36h() { + let dt = utc(2025, 6, 1, 0, 0, 0); + let now = utc(2025, 6, 2, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "Yesterday"); + } + + #[test] + fn days_ago_2() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 3, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "2d ago"); + } + + #[test] + fn days_ago_6() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 7, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "6d ago"); + } + + #[test] + fn weeks_ago_1() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 8, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "1w ago"); + } + + #[test] + fn weeks_ago_3() { + let dt = utc(2025, 6, 1, 12, 0, 0); + let now = utc(2025, 6, 22, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "3w ago"); + } + + #[test] + fn same_year_absolute_date() { + let dt = utc(2025, 3, 15, 10, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "Mar 15"); + } + + #[test] + fn different_year_absolute_date() { + let dt = utc(2024, 3, 15, 10, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "Mar 15, 2024"); + } + + #[test] + fn boundary_at_30_days() { + let dt = utc(2025, 5, 2, 12, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "May 02"); + } + + #[test] + fn future_timestamp_returns_just_now() { + let dt = utc(2025, 6, 1, 13, 0, 0); + let now = utc(2025, 6, 1, 12, 0, 0); + assert_eq!(format_relative_time(dt, now), "Just now"); + } + + // --- format_weight tests --- + + #[test] + fn weight_zero() { + assert_eq!(format_weight(0.0), "0g"); + } + + #[test] + fn weight_whole_grams() { + assert_eq!(format_weight(15.0), "15g"); + assert_eq!(format_weight(250.0), "250g"); + } + + #[test] + fn weight_fractional_grams() { + assert_eq!(format_weight(15.5), "15.5g"); + assert_eq!(format_weight(234.3), "234.3g"); + } + + #[test] + fn weight_boundary_999() { + assert_eq!(format_weight(999.0), "999g"); + assert_eq!(format_weight(999.9), "999.9g"); + } + + #[test] + fn weight_boundary_1000() { + assert_eq!(format_weight(1000.0), "1.0kg"); + } + + #[test] + fn weight_kilograms() { + assert_eq!(format_weight(1500.0), "1.5kg"); + assert_eq!(format_weight(2345.6), "2.3kg"); + assert_eq!(format_weight(10000.0), "10.0kg"); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 93a339d..7890bab 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -5,6 +5,7 @@ pub mod cafes; pub mod country_stats; pub mod cups; pub mod errors; +pub mod formatting; pub mod gear; pub mod ids; pub mod listing; diff --git a/src/presentation/web/views/bags.rs b/src/presentation/web/views/bags.rs index dedd516..0b0dd41 100644 --- a/src/presentation/web/views/bags.rs +++ b/src/presentation/web/views/bags.rs @@ -1,4 +1,5 @@ use crate::domain::bags::BagWithRoast; +use crate::domain::formatting::format_weight; #[derive(Debug, Clone)] pub struct BagView { @@ -30,8 +31,8 @@ impl BagView { id: bag.bag.id.to_string(), roast_id: bag.bag.roast_id.to_string(), roast_date: bag.bag.roast_date.map(|d| d.to_string()), - amount: format!("{:.1}", bag.bag.amount), - remaining: format!("{:.1}", bag.bag.remaining), + amount: format_weight(bag.bag.amount), + remaining: format_weight(bag.bag.remaining), closed: bag.bag.closed, finished_date: bag .bag @@ -59,7 +60,7 @@ pub struct BagOptionView { impl From for BagOptionView { fn from(bag: BagWithRoast) -> Self { - let remaining = format!("{:.0}g", bag.bag.remaining); + let remaining = format_weight(bag.bag.remaining); Self { id: bag.bag.id.to_string(), label: format!( diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index 0f9c918..8c7df3e 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -1,6 +1,7 @@ use std::fmt::Write; use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time}; +use crate::domain::formatting::format_weight; use super::relative_date; @@ -96,7 +97,7 @@ impl BrewView { roaster_name: brew.roaster_name, roast_slug: brew.roast_slug, roaster_slug: brew.roaster_slug, - coffee_weight: format!("{:.1}g", brew.brew.coffee_weight), + coffee_weight: format_weight(brew.brew.coffee_weight), grinder_id: brew.brew.grinder_id.into_inner(), grinder_model: brew .grinder_name diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 8f7e17f..2c9de28 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -35,42 +35,7 @@ use chrono::{DateTime, Utc}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; fn relative_date(dt: DateTime) -> String { - let now = Utc::now(); - let delta = now.signed_duration_since(dt); - let secs = delta.num_seconds(); - - if secs < 60 { - return "Just now".to_string(); - } - - let mins = delta.num_minutes(); - if mins < 60 { - return format!("{mins}m ago"); - } - - let hours = delta.num_hours(); - if hours < 24 { - return format!("{hours}h ago"); - } - - let days = delta.num_days(); - if days == 1 { - return "Yesterday".to_string(); - } - if days < 7 { - return format!("{days}d ago"); - } - - let weeks = days / 7; - if days < 30 { - return format!("{weeks}w ago"); - } - - if dt.format("%Y").to_string() == now.format("%Y").to_string() { - dt.format("%b %d").to_string() - } else { - dt.format("%b %d, %Y").to_string() - } + crate::domain::formatting::format_relative_time(dt, Utc::now()) } pub struct Paginated { diff --git a/templates/partials/bag_card.html b/templates/partials/bag_card.html index 352cc9c..6563b3a 100644 --- a/templates/partials/bag_card.html +++ b/templates/partials/bag_card.html @@ -10,7 +10,7 @@
-

{{ bag.remaining }} / {{ bag.amount }}g

+

{{ bag.remaining }} / {{ bag.amount }}

{% if is_authenticated %} diff --git a/templates/partials/lists/bag_list.html b/templates/partials/lists/bag_list.html index c217784..0993567 100644 --- a/templates/partials/lists/bag_list.html +++ b/templates/partials/lists/bag_list.html @@ -56,7 +56,7 @@
-
{{ bag.remaining }} / {{ bag.amount }}g
+
{{ bag.remaining }} / {{ bag.amount }}
{% endif %} @@ -65,7 +65,7 @@
-
{{ bag.remaining }} / {{ bag.amount }}g
+
{{ bag.remaining }} / {{ bag.amount }}
{% endif %}