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:
parent
585eb77799
commit
0b321068aa
10 changed files with 241 additions and 45 deletions
13
CLAUDE.md
13
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`.
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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![],
|
||||||
|
|
|
||||||
|
|
@ -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
215
src/domain/formatting.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue