feat(home): add relative dates to timeline and brew cards

- Add relative_date() helper to views module for humanized timestamps
- Show relative dates on home page timeline preview and brew cards
- Format: "Just now", "Xm ago", "Xh ago", "Yesterday", "Xd/Xw ago"
This commit is contained in:
Jon Seager 2026-02-04 19:03:05 +00:00
parent ba7a38b16f
commit cdf90d1ff3
No known key found for this signature in database
4 changed files with 56 additions and 4 deletions

View file

@ -1,5 +1,7 @@
use crate::domain::brews::{Brew, BrewWithDetails};
use super::relative_date;
#[derive(Clone)]
pub struct BrewView {
pub id: String,
@ -20,6 +22,7 @@ pub struct BrewView {
pub water_temp: String,
pub ratio: String,
pub created_at: String,
pub relative_date_label: String,
// Raw values for "brew again" feature
pub coffee_weight_raw: f64,
pub grind_setting_raw: f64,
@ -60,6 +63,7 @@ impl BrewView {
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
ratio,
created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(),
relative_date_label: relative_date(brew.brew.created_at),
coffee_weight_raw: brew.brew.coffee_weight,
grind_setting_raw: brew.brew.grind_setting,
water_volume_raw: brew.brew.water_volume,

View file

@ -28,8 +28,49 @@ pub struct StatsView {
pub gear: u64,
}
use chrono::{DateTime, Utc};
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
fn relative_date(dt: DateTime<Utc>) -> 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()
}
}
pub struct Paginated<T> {
pub items: Vec<T>,
pub page: u32,

View file

@ -1,5 +1,7 @@
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
use super::relative_date;
#[derive(Clone)]
pub struct TimelineEventDetailView {
pub label: String,
@ -25,6 +27,7 @@ pub struct TimelineEventView {
pub id: String,
pub kind_label: &'static str,
pub date_label: String,
pub relative_date_label: String,
pub time_label: Option<String>,
pub iso_timestamp: String,
pub title: String,
@ -116,6 +119,7 @@ impl TimelineEventView {
id: id.to_string(),
kind_label,
date_label: occurred_at.format("%B %d, %Y").to_string(),
relative_date_label: relative_date(occurred_at),
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
iso_timestamp: occurred_at.to_rfc3339(),
title,

View file

@ -192,10 +192,13 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
{% for brew in recent_brews %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="block truncate font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a>
<p class="truncate text-sm text-stone-500">{{ brew.roaster_name }}</p>
</div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time>
</div>
<div class="mt-2 space-y-0.5 text-sm">
<p class="truncate"><span class="font-medium text-stone-800">{{ brew.coffee_weight }} / {{ brew.water_volume }}</span> <span class="text-stone-500">· {{ brew.ratio }} · {{ brew.water_temp }}</span></p>
<p class="truncate"><span class="font-medium text-stone-800">{{ brew.grind_setting }}</span> <span class="text-stone-500">· {{ brew.grinder_name }}</span></p>
@ -262,7 +265,7 @@
<span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span>
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a>
</div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time>
</div>
{% endfor %}
</div>