mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(measurements): unify timestamp display via formatDateOrRelative
This commit is contained in:
1 parent
e7a7e576a5
commit
281e7a54d4
3 files changed
+185
-3
No files matched your search
@@ -56,7 +56,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Fragment, useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
@@ -568,8 +568,16 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{/*
|
||||
v1.4.43 QoL (L8) — `formatDateOrRelative`
|
||||
renders timestamps inside the last 24 h
|
||||
as "vor 3 min" so a fresh entry visible
|
||||
on the dashboard briefing and on the
|
||||
list view never disagrees about how to
|
||||
phrase "when".
|
||||
*/}
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(m.measuredAt)}
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.notes ? (
|
||||
@@ -714,7 +722,13 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</span>
|
||||
)}
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
<span>{formatDateTime(m.measuredAt)}</span>
|
||||
{/*
|
||||
v1.4.43 QoL (L8) — see desktop
|
||||
counterpart at the same `measuredAt`
|
||||
site. Relative under 24 h, absolute
|
||||
older.
|
||||
*/}
|
||||
<span>{formatDateOrRelative(m.measuredAt, t)}</span>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -937,6 +951,18 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/*
|
||||
v1.4.43 QoL (L7) — `[Cancel] [Save]` order is
|
||||
iOS-first intentional per Apple HIG, which
|
||||
puts the primary / confirmation action
|
||||
rightmost. iOS is the load-bearing mobile
|
||||
target for HealthLog and the web shell renders
|
||||
desktop + iOS PWA from the same component, so
|
||||
the current order survives. Do NOT flip to
|
||||
`[Save] [Cancel]` in a future "Android parity"
|
||||
refactor — fork a platform-specific shell
|
||||
instead if Android becomes a real audience.
|
||||
*/}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.4.43 QoL (L8) — `formatDateOrRelative()` boundary cases.
|
||||
*
|
||||
* The helper switches between relative ("vor 12 min") and absolute
|
||||
* ("21.05.2026, 14:32") rendering based on a 24h cutoff, so two
|
||||
* adjacent timestamps in the same view never mix formats. Pin every
|
||||
* boundary so future tweaks (e.g. extending the relative window)
|
||||
* don't silently shift the cutover.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { formatDateOrRelative } from "../format";
|
||||
|
||||
function fakeT(key: string, params?: Record<string, string | number>): string {
|
||||
// Deterministic stand-in — just echo the key + params so the
|
||||
// assertions can pin which branch we hit without binding to actual
|
||||
// translation copy.
|
||||
if (!params) return key;
|
||||
return `${key}:${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",")}`;
|
||||
}
|
||||
|
||||
const NOW = new Date("2026-05-21T12:00:00Z").getTime();
|
||||
const SECOND = 1_000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
describe("formatDateOrRelative", () => {
|
||||
it("returns just-now for < 60 seconds (lower boundary)", () => {
|
||||
const iso = new Date(NOW - 15 * SECOND).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeJustNow",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns just-now at the 59-second edge", () => {
|
||||
const iso = new Date(NOW - 59 * SECOND).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeJustNow",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns minutesAgo at exactly the 1-minute mark", () => {
|
||||
const iso = new Date(NOW - 1 * MINUTE).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeMinutesAgo:count=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns minutesAgo within the [1m, 1h) window", () => {
|
||||
const iso = new Date(NOW - 30 * MINUTE).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeMinutesAgo:count=30",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hoursAgo at exactly the 1-hour mark", () => {
|
||||
const iso = new Date(NOW - 1 * HOUR).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeHoursAgo:count=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hoursAgo at the 23h59m edge", () => {
|
||||
const iso = new Date(NOW - (23 * HOUR + 59 * MINUTE)).toISOString();
|
||||
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
|
||||
"insights.relativeHoursAgo:count=23",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to absolute formatting at exactly 24h", () => {
|
||||
const iso = new Date(NOW - 24 * HOUR).toISOString();
|
||||
const result = formatDateOrRelative(iso, fakeT, NOW);
|
||||
// The absolute branch does NOT route through the translator, so
|
||||
// it never produces an `insights.relative*` string.
|
||||
expect(result).not.toContain("insights.relative");
|
||||
// It returns a date+time formatted via the legacy formatter, which
|
||||
// contains digits separated by punctuation.
|
||||
expect(result).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("falls back to absolute for a 7-day-old timestamp", () => {
|
||||
const iso = new Date(NOW - 7 * DAY).toISOString();
|
||||
const result = formatDateOrRelative(iso, fakeT, NOW);
|
||||
expect(result).not.toContain("insights.relative");
|
||||
expect(result).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("falls back to absolute for future timestamps (never paints 'in 3 min')", () => {
|
||||
const iso = new Date(NOW + 5 * MINUTE).toISOString();
|
||||
const result = formatDateOrRelative(iso, fakeT, NOW);
|
||||
// No relative copy — the absolute formatter handles it.
|
||||
expect(result).not.toContain("insights.relative");
|
||||
expect(result).toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("returns empty string for unparseable input", () => {
|
||||
expect(formatDateOrRelative("not a date", fakeT, NOW)).toBe("");
|
||||
});
|
||||
|
||||
it("accepts a Date instance as well as ISO string", () => {
|
||||
expect(
|
||||
formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW),
|
||||
).toBe("insights.relativeMinutesAgo:count=30");
|
||||
});
|
||||
});
|
||||
@@ -72,3 +72,51 @@ export function formatTime(date: Date | string): string {
|
||||
return formatters().time(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 QoL (L8) — render a timestamp as a relative phrase when it
|
||||
* happened within the last 24 hours, otherwise fall back to the
|
||||
* absolute locale-aware date+time. Closes the visual inconsistency
|
||||
* the measurement-list surfaced: the same screen could show "vor 3
|
||||
* min" (briefing) alongside "21.05.2026, 14:32" (list rows) for
|
||||
* adjacent events.
|
||||
*
|
||||
* Buckets:
|
||||
* - < 1 m → "just now"
|
||||
* - < 1 h → "vor N min" / "N min ago"
|
||||
* - < 24 h → "vor N Std." / "N h ago"
|
||||
* - ≥ 24 h → absolute `formatDateTime`
|
||||
*
|
||||
* The relative branch reuses the `insights.relative*` i18n keys (same
|
||||
* keys `formatRelativeTime()` reads from `@/lib/i18n/relative-time`).
|
||||
* Caller supplies a `t()` translator so this helper stays usable
|
||||
* server-side; clients should pass `useTranslations().t`. The buckets
|
||||
* mirror the briefing's existing copy so users never see two
|
||||
* different relative phrasings in the same view.
|
||||
*/
|
||||
export function formatDateOrRelative(
|
||||
iso: Date | string,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
/**
|
||||
* Optional "now" for deterministic tests. Defaults to `Date.now()`.
|
||||
*/
|
||||
nowMs: number = Date.now(),
|
||||
): string {
|
||||
const target =
|
||||
iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
|
||||
if (Number.isNaN(target)) return "";
|
||||
const diffMs = nowMs - target;
|
||||
// Future timestamps fall back to absolute — relative copy doesn't
|
||||
// model them and "in 3 min" would mislead in a health-log context.
|
||||
if (diffMs < 0) return formatDateTime(iso);
|
||||
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return t("insights.relativeMinutesAgo", { count: minutes });
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return t("insights.relativeHoursAgo", { count: hours });
|
||||
}
|
||||
return formatDateTime(iso);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user