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
This commit is contained in:
Jon Seager 2026-02-08 15:14:39 +00:00
parent 585eb77799
commit 0b321068aa
No known key found for this signature in database
10 changed files with 241 additions and 45 deletions

View file

@ -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`. 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<Utc>, now: DateTime<Utc>) -> 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 Handling & Logging
**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI). **Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI).

View file

@ -154,7 +154,7 @@ pub fn bag_timeline_event(
}, },
TimelineEventDetail { TimelineEventDetail {
label: "Amount".to_string(), label: "Amount".to_string(),
value: format!("{}g", bag.amount), value: super::formatting::format_weight(bag.amount),
}, },
], ],
tasting_notes: vec![], tasting_notes: vec![],

View file

@ -119,7 +119,7 @@ impl BrewWithDetails {
}, },
TimelineEventDetail { TimelineEventDetail {
label: "Coffee".to_string(), label: "Coffee".to_string(),
value: format!("{:.1}g", self.brew.coffee_weight), value: super::formatting::format_weight(self.brew.coffee_weight),
}, },
TimelineEventDetail { TimelineEventDetail {
label: "Water".to_string(), label: "Water".to_string(),

215
src/domain/formatting.rs Normal file
View file

@ -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<Utc>, now: DateTime<Utc>) -> 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> {
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");
}
}

View file

@ -5,6 +5,7 @@ pub mod cafes;
pub mod country_stats; pub mod country_stats;
pub mod cups; pub mod cups;
pub mod errors; pub mod errors;
pub mod formatting;
pub mod gear; pub mod gear;
pub mod ids; pub mod ids;
pub mod listing; pub mod listing;

View file

@ -1,4 +1,5 @@
use crate::domain::bags::BagWithRoast; use crate::domain::bags::BagWithRoast;
use crate::domain::formatting::format_weight;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BagView { pub struct BagView {
@ -30,8 +31,8 @@ impl BagView {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(), roast_id: bag.bag.roast_id.to_string(),
roast_date: bag.bag.roast_date.map(|d| d.to_string()), roast_date: bag.bag.roast_date.map(|d| d.to_string()),
amount: format!("{:.1}", bag.bag.amount), amount: format_weight(bag.bag.amount),
remaining: format!("{:.1}", bag.bag.remaining), remaining: format_weight(bag.bag.remaining),
closed: bag.bag.closed, closed: bag.bag.closed,
finished_date: bag finished_date: bag
.bag .bag
@ -59,7 +60,7 @@ pub struct BagOptionView {
impl From<BagWithRoast> for BagOptionView { impl From<BagWithRoast> for BagOptionView {
fn from(bag: BagWithRoast) -> Self { fn from(bag: BagWithRoast) -> Self {
let remaining = format!("{:.0}g", bag.bag.remaining); let remaining = format_weight(bag.bag.remaining);
Self { Self {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
label: format!( label: format!(

View file

@ -1,6 +1,7 @@
use std::fmt::Write; use std::fmt::Write;
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time}; use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
use crate::domain::formatting::format_weight;
use super::relative_date; use super::relative_date;
@ -96,7 +97,7 @@ impl BrewView {
roaster_name: brew.roaster_name, roaster_name: brew.roaster_name,
roast_slug: brew.roast_slug, roast_slug: brew.roast_slug,
roaster_slug: brew.roaster_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_id: brew.brew.grinder_id.into_inner(),
grinder_model: brew grinder_model: brew
.grinder_name .grinder_name

View file

@ -35,42 +35,7 @@ use chrono::{DateTime, Utc};
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
fn relative_date(dt: DateTime<Utc>) -> String { fn relative_date(dt: DateTime<Utc>) -> String {
let now = Utc::now(); crate::domain::formatting::format_relative_time(dt, 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()
}
} }
pub struct Paginated<T> { pub struct Paginated<T> {

View file

@ -10,7 +10,7 @@
<div class="h-2 rounded-full bg-surface-alt overflow-hidden"> <div class="h-2 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> <div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
</div> </div>
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}g</p> <p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
</div> </div>
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}

View file

@ -56,7 +56,7 @@
<div class="h-2.5 rounded-full bg-surface-alt overflow-hidden"> <div class="h-2.5 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> <div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
</div> </div>
<div class="mt-0.5 text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}g</div> <div class="mt-0.5 text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</div> </div>
{% endif %} {% endif %}
</td> </td>
@ -65,7 +65,7 @@
<div class="h-1.5 rounded-full bg-surface-alt overflow-hidden"> <div class="h-1.5 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div> <div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
</div> </div>
<div class="mt-0.5 text-right text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}g</div> <div class="mt-0.5 text-right text-xs text-text-muted">{{ bag.remaining }} / {{ bag.amount }}</div>
</td> </td>
{% endif %} {% endif %}
<td data-label="Finished" class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Finished" class="mobile-hidden whitespace-nowrap px-4 py-3 font-medium text-text-secondary">