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:
parent
ba7a38b16f
commit
cdf90d1ff3
4 changed files with 56 additions and 4 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
use crate::domain::brews::{Brew, BrewWithDetails};
|
use crate::domain::brews::{Brew, BrewWithDetails};
|
||||||
|
|
||||||
|
use super::relative_date;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct BrewView {
|
pub struct BrewView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -20,6 +22,7 @@ pub struct BrewView {
|
||||||
pub water_temp: String,
|
pub water_temp: String,
|
||||||
pub ratio: String,
|
pub ratio: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub relative_date_label: String,
|
||||||
// Raw values for "brew again" feature
|
// Raw values for "brew again" feature
|
||||||
pub coffee_weight_raw: f64,
|
pub coffee_weight_raw: f64,
|
||||||
pub grind_setting_raw: f64,
|
pub grind_setting_raw: f64,
|
||||||
|
|
@ -60,6 +63,7 @@ impl BrewView {
|
||||||
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
||||||
ratio,
|
ratio,
|
||||||
created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
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,
|
coffee_weight_raw: brew.brew.coffee_weight,
|
||||||
grind_setting_raw: brew.brew.grind_setting,
|
grind_setting_raw: brew.brew.grind_setting,
|
||||||
water_volume_raw: brew.brew.water_volume,
|
water_volume_raw: brew.brew.water_volume,
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,49 @@ pub struct StatsView {
|
||||||
pub gear: u64,
|
pub gear: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 struct Paginated<T> {
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
pub page: u32,
|
pub page: u32,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
||||||
|
|
||||||
|
use super::relative_date;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TimelineEventDetailView {
|
pub struct TimelineEventDetailView {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
|
@ -25,6 +27,7 @@ pub struct TimelineEventView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind_label: &'static str,
|
pub kind_label: &'static str,
|
||||||
pub date_label: String,
|
pub date_label: String,
|
||||||
|
pub relative_date_label: String,
|
||||||
pub time_label: Option<String>,
|
pub time_label: Option<String>,
|
||||||
pub iso_timestamp: String,
|
pub iso_timestamp: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -116,6 +119,7 @@ impl TimelineEventView {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
kind_label,
|
kind_label,
|
||||||
date_label: occurred_at.format("%B %d, %Y").to_string(),
|
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()),
|
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
|
||||||
iso_timestamp: occurred_at.to_rfc3339(),
|
iso_timestamp: occurred_at.to_rfc3339(),
|
||||||
title,
|
title,
|
||||||
|
|
|
||||||
|
|
@ -192,10 +192,13 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
|
||||||
{% for brew in recent_brews %}
|
{% 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="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">
|
<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>
|
<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>
|
<p class="truncate text-sm text-stone-500">{{ brew.roaster_name }}</p>
|
||||||
</div>
|
</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">
|
<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.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>
|
<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>
|
<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>
|
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue