From a3085851729dee756a646d7859f0a72b7ee0c51b Mon Sep 17 00:00:00 2001 From: Benjamin Date: Thu, 6 Aug 2026 19:47:39 -0400 Subject: [PATCH] feat(ui): date-stamp message timestamps that aren't from today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timestamps rendered time-only everywhere, so a 09:15 from this morning and a 09:15 from three days ago read identically in a scrollback, on the kiosk wall, and in the neighborhood log. Messages from today keep the bare time; anything older now reads "Aug 5, 21:40". The label has to be computed at render time rather than baked at ingest: ChatEntry.timestamp is a string built once when the WS frame arrives, so a line logged at 23:50 would keep claiming "today" after midnight on an always-on display. ChatEntry gains an optional raw ISO `ts` alongside the existing string (kept as a fallback so older entries and fixtures still render), and useDayKey re-renders the message surfaces on the local midnight boundary. Live rx_message frames carry no server ts — only history is stamped — so the client records arrival time for those. Also folds the five copy-pasted toLocaleTimeString bodies into one utils/datetime helper. padHour preserves each caller's existing hour style, so nothing changes visually beyond the added date. --- frontend/src/App.tsx | 20 +++++- .../components/ChatDisplay/ChatDisplay.tsx | 13 +++- .../DisplayApp/DisplayChatConsole.tsx | 10 ++- .../components/FamilyPanel/FamilyPanel.tsx | 4 ++ .../src/components/FamilyPanel/MemberCard.tsx | 7 +- .../CoordinatorDashboard.tsx | 4 ++ .../NeighborhoodPanel/IncidentLog.tsx | 6 +- .../NeighborhoodPanel/NeighborhoodPanel.tsx | 4 ++ .../components/NeighborhoodPanel/shared.ts | 5 +- frontend/src/hooks/useDayKey.ts | 31 ++++++++ frontend/src/hooks/useDisplaySocket.ts | 21 ++++-- frontend/src/utils/__tests__/datetime.test.ts | 70 +++++++++++++++++++ frontend/src/utils/datetime.ts | 45 ++++++++++++ 13 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 frontend/src/hooks/useDayKey.ts create mode 100644 frontend/src/utils/__tests__/datetime.test.ts create mode 100644 frontend/src/utils/datetime.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1507615..ecb3d68 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,7 @@ import { CalibrationDialog } from './components/CalibrationDialog/CalibrationDia import { UsersPanel } from './components/UsersPanel/UsersPanel'; import { DEFAULTS as QUICK_DEFAULTS } from './components/QuickMessages/QuickMessages'; import { newlyMissed } from './family/presence'; +import { formatMessageTime } from './utils/datetime'; import { useDeviceClass } from './hooks/useDeviceClass'; import { ScreenFlash, VIBRATE_PATTERNS, type FlashKind } from './components/ScreenFlash/ScreenFlash'; import './App.css'; @@ -81,8 +82,15 @@ function nextId() { } function formatTime(isoOrNow?: string): string { - const d = isoOrNow ? new Date(isoOrNow) : new Date(); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return formatMessageTime(isoOrNow); +} + +// Raw instant to hang on a ChatEntry so its label can be re-formatted later +// (a message from yesterday must grow a date once midnight passes). Live +// rx_message frames carry no server ts — the broadcast only stamps one when +// recording to history — so fall back to the arrival time. +function entryTs(iso?: string): string { + return iso ?? new Date().toISOString(); } function normalizeForDedup(s: string): string { @@ -130,6 +138,7 @@ export function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { return { id: nextId(), timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'tx', sender: msg.display_name || msg.operator || msg.callsign, recipient, @@ -140,6 +149,7 @@ export function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { return { id: nextId(), timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'chat', sender: msg.display_name || msg.operator || msg.callsign, text: msg.text, @@ -149,6 +159,7 @@ export function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { return { id: nextId(), timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'rx', sender: msg.from || msg.callsign || undefined, text: msg.text, @@ -443,6 +454,7 @@ export default function App() { { id, timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'rx', sender: msg.from || msg.callsign || undefined, text: msg.text, @@ -480,6 +492,7 @@ export default function App() { { id, timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'rx', sender: msg.from || msg.callsign || undefined, text: msg.text, @@ -661,6 +674,7 @@ export default function App() { { id: nextId(), timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'tx', sender: msg.display_name || msg.operator || msg.callsign, recipient, @@ -676,6 +690,7 @@ export default function App() { { id: nextId(), timestamp: formatTime(msg.ts), + ts: entryTs(msg.ts), kind: 'chat', sender: msg.display_name || msg.operator || msg.callsign, text: msg.text, @@ -711,6 +726,7 @@ export default function App() { { id: nextId(), timestamp: formatTime(), + ts: entryTs(), kind: 'system', text: msg.text, }, diff --git a/frontend/src/components/ChatDisplay/ChatDisplay.tsx b/frontend/src/components/ChatDisplay/ChatDisplay.tsx index d0fed0e..5eb7291 100644 --- a/frontend/src/components/ChatDisplay/ChatDisplay.tsx +++ b/frontend/src/components/ChatDisplay/ChatDisplay.tsx @@ -2,10 +2,18 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Typography, Fab, Chip, Tooltip } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import type { CallsignSpan, Contact } from '../../types/ws'; +import { formatMessageTime } from '../../utils/datetime'; +import { useDayKey } from '../../hooks/useDayKey'; export interface ChatEntry { id: string; + /** Pre-formatted label, kept as the fallback for entries built before `ts` + * existed (and for fixtures that only set this). */ timestamp: string; + /** Raw ISO-8601 instant the message happened. Preferred over `timestamp`: + * formatting at render time is what lets the label gain a date once the + * message is no longer from today. */ + ts?: string; kind: 'rx' | 'tx' | 'system' | 'chat'; sender?: string; recipient?: string; // e.g. "WSLZ233 — Dave"; absent when broadcast to ALL @@ -202,6 +210,9 @@ export function ChatDisplay({ entries, contacts, showCallsignChips }: Props) { const [showScrollBtn, setShowScrollBtn] = useState(false); const callsignIdx = useMemo(() => buildCallsignIndex(contacts), [contacts]); + // Re-render at midnight so yesterday's lines pick up their date prefix + // without a reload on a screen that stays open. + useDayKey(); function handleScroll() { const el = containerRef.current; @@ -271,7 +282,7 @@ export function ChatDisplay({ entries, contacts, showCallsignChips }: Props) { flexShrink: 0, }} > - {entry.timestamp} + {entry.ts ? formatMessageTime(entry.ts) : entry.timestamp} {entry.kind === 'rx' && entry.source !== 'cw' && ( diff --git a/frontend/src/components/DisplayApp/DisplayChatConsole.tsx b/frontend/src/components/DisplayApp/DisplayChatConsole.tsx index c7e4540..fef15b8 100644 --- a/frontend/src/components/DisplayApp/DisplayChatConsole.tsx +++ b/frontend/src/components/DisplayApp/DisplayChatConsole.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react'; import { Box, Paper, Typography, useTheme } from '@mui/material'; import { alpha, type Theme } from '@mui/material/styles'; import type { ChatEntry } from '../ChatDisplay/ChatDisplay'; +import { formatMessageTime } from '../../utils/datetime'; +import { useDayKey } from '../../hooks/useDayKey'; interface Props { messages: ChatEntry[]; @@ -48,6 +50,9 @@ function kindMain(palette: Theme['palette'], kind: ChatEntry['kind']): string { export function DisplayChatConsole({ messages, eink }: Props) { const theme = useTheme(); + // A wall panel runs for weeks — re-render at midnight so yesterday's traffic + // stops reading as today's. + useDayKey(); // E-ink shows finalized text only; partials would fight the slow refresh. // Newest first: on a wall panel the eye starts at the top, and it means new // traffic never pushes older lines out of view mid-read. @@ -140,7 +145,10 @@ export function DisplayChatConsole({ messages, eink }: Props) { ? { color: 'text.primary', border: `1px solid ${theme.palette.text.primary}` } : { color: main, bgcolor: alpha(main, 0.16) }; const context = captionContext(m); - const caption = `${context ? `${context} · ` : ''}${m.timestamp}`; + // Formatted here, not at ingest: a line logged before midnight has to + // pick up its date once the day rolls over (see useDayKey above). + const stamp = m.ts ? formatMessageTime(m.ts) : m.timestamp; + const caption = `${context ? `${context} · ` : ''}${stamp}`; const prefix = m.sender && m.recipient ? `${m.sender} → ${m.recipient}` : m.sender; return ( diff --git a/frontend/src/components/FamilyPanel/FamilyPanel.tsx b/frontend/src/components/FamilyPanel/FamilyPanel.tsx index 5b81d67..41fd2e0 100644 --- a/frontend/src/components/FamilyPanel/FamilyPanel.tsx +++ b/frontend/src/components/FamilyPanel/FamilyPanel.tsx @@ -5,6 +5,7 @@ import { MemberCard } from './MemberCard'; import { ReminderEditor } from './ReminderEditor'; import { densitySpec } from '../../family/density'; import { useEscapeToHome } from '../../hooks/useEscapeToHome'; +import { useDayKey } from '../../hooks/useDayKey'; export interface FamilyPanelProps { entries: FamilyPresenceEntry[]; @@ -27,6 +28,9 @@ export interface FamilyPanelProps { * relying on DesktopApp's — the two are never mounted at once. */ export function FamilyPanel(props: FamilyPanelProps) { useEscapeToHome(props.onGoHome); + // "OK ✓ 9:15" hides the date only while it means today — the midnight + // re-render is what keeps that true on a board left up overnight. + useDayKey(); const now = new Date(); const showReminders = props.isAdmin && !props.isKid; // Cards get roomier for a small household and tighter for a big one, so a diff --git a/frontend/src/components/FamilyPanel/MemberCard.tsx b/frontend/src/components/FamilyPanel/MemberCard.tsx index 02bf5f5..22849eb 100644 --- a/frontend/src/components/FamilyPanel/MemberCard.tsx +++ b/frontend/src/components/FamilyPanel/MemberCard.tsx @@ -4,6 +4,7 @@ import type { FamilyPresenceEntry } from '../../types/ws'; import { deriveStatus } from '../../family/presence'; import { densitySpec } from '../../family/density'; import type { DensitySpec } from '../../family/density'; +import { formatMessageTime } from '../../utils/datetime'; interface Props { entry: FamilyPresenceEntry; @@ -13,10 +14,6 @@ interface Props { density?: DensitySpec; } -function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); -} - function formatRelative(iso: string | null, now: Date): string { if (!iso) return 'never'; const diffMs = now.getTime() - new Date(iso).getTime(); @@ -38,7 +35,7 @@ function statusChip(entry: FamilyPresenceEntry, now: Date): { label: string; col const status = deriveStatus(entry, now); if (status === 'on_air') return { label: 'On air', color: 'info' }; if (status === 'ok') { - const time = entry.last_ok ? formatTime(entry.last_ok) : ''; + const time = entry.last_ok ? formatMessageTime(entry.last_ok, { now, padHour: false }) : ''; return { label: `OK ✓${time ? ` ${time}` : ''}`, color: 'success' }; } return { label: 'No word', color: 'default' }; diff --git a/frontend/src/components/NeighborhoodPanel/CoordinatorDashboard.tsx b/frontend/src/components/NeighborhoodPanel/CoordinatorDashboard.tsx index de992d8..ab10926 100644 --- a/frontend/src/components/NeighborhoodPanel/CoordinatorDashboard.tsx +++ b/frontend/src/components/NeighborhoodPanel/CoordinatorDashboard.tsx @@ -16,6 +16,7 @@ import { StreetAlertDialog } from './StreetAlertDialog'; import { ConfirmDialog } from '../ConfirmDialog'; import { useIncidentDialog } from './useIncidentDialog'; import { currentCallLabel, formatAlertTime } from './shared'; +import { useDayKey } from '../../hooks/useDayKey'; import type { NeighborhoodPanelProps } from './shared'; export interface CoordinatorDashboardProps extends NeighborhoodPanelProps { @@ -35,6 +36,9 @@ export interface CoordinatorDashboardProps extends NeighborhoodPanelProps { * ≥1200px switch lives in NeighborhoodPanel. */ export function CoordinatorDashboard(props: CoordinatorDashboardProps) { useEscapeToHome(props.onGoHome); + // Alert and incident stamps say "today" by omitting the date — re-render at + // midnight so an ops console left running overnight stops claiming that. + useDayKey(); const [streetAlertOpen, setStreetAlertOpen] = useState(false); const [clearCheckinsConfirmOpen, setClearCheckinsConfirmOpen] = useState(false); diff --git a/frontend/src/components/NeighborhoodPanel/IncidentLog.tsx b/frontend/src/components/NeighborhoodPanel/IncidentLog.tsx index 655db5b..18df8ca 100644 --- a/frontend/src/components/NeighborhoodPanel/IncidentLog.tsx +++ b/frontend/src/components/NeighborhoodPanel/IncidentLog.tsx @@ -13,6 +13,7 @@ import { import type { IncidentEntry } from '../../types/ws'; import { incidentDensitySpec } from '../../neighborhood/density'; import { INCIDENT_CATEGORIES } from './IncidentDialog'; +import { formatAlertTime } from './shared'; interface IncidentLogProps { incidents: IncidentEntry[]; @@ -25,9 +26,6 @@ function categoryLabel(category: string): string { return INCIDENT_CATEGORIES.find((c) => c.value === category)?.label ?? category; } -function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); -} /** Neighborhood incident feed: a category filter plus a newest-first list. * Incidents already arrive newest-first from the server (see @@ -101,7 +99,7 @@ export function IncidentLog({ incidents, onClear }: IncidentLogProps) { - {formatTime(entry.ts)} + {formatAlertTime(entry.ts)} {/* overflowWrap keeps an unbroken string — a URL, a plate number — diff --git a/frontend/src/components/NeighborhoodPanel/NeighborhoodPanel.tsx b/frontend/src/components/NeighborhoodPanel/NeighborhoodPanel.tsx index 025779f..f46baab 100644 --- a/frontend/src/components/NeighborhoodPanel/NeighborhoodPanel.tsx +++ b/frontend/src/components/NeighborhoodPanel/NeighborhoodPanel.tsx @@ -11,6 +11,7 @@ import { ConfirmDialog } from '../ConfirmDialog'; import { CoordinatorDashboard } from './CoordinatorDashboard'; import { useIncidentDialog } from './useIncidentDialog'; import { currentCallLabel, formatAlertTime } from './shared'; +import { useDayKey } from '../../hooks/useDayKey'; import type { NeighborhoodPanelProps } from './shared'; export type { NeighborhoodPanelProps } from './shared'; @@ -69,6 +70,9 @@ interface StackedNeighborhoodViewProps extends NeighborhoodPanelProps { * relying on DesktopApp's — the two are never mounted at once. */ function StackedNeighborhoodView(props: StackedNeighborhoodViewProps) { useEscapeToHome(props.onGoHome); + // Alert and incident stamps say "today" by omitting the date — re-render at + // midnight so a tab left open overnight stops claiming that. + useDayKey(); const [streetAlert, setStreetAlert] = useState(''); const [alertConfirmOpen, setAlertConfirmOpen] = useState(false); diff --git a/frontend/src/components/NeighborhoodPanel/shared.ts b/frontend/src/components/NeighborhoodPanel/shared.ts index d260c1c..8ae3ba6 100644 --- a/frontend/src/components/NeighborhoodPanel/shared.ts +++ b/frontend/src/components/NeighborhoodPanel/shared.ts @@ -1,6 +1,7 @@ import type { Contact, IncidentEntry, NeighborhoodAlertMsg, NeighborhoodRosterRow } from '../../types/ws'; import type { ChatEntry } from '../ChatDisplay/ChatDisplay'; import type { TxComposition } from '../../plugins'; +import { formatMessageTime } from '../../utils/datetime'; /** Props shared by NeighborhoodPanel (the switch + its stacked view) and * CoordinatorDashboard (the wide-screen ops view NeighborhoodPanel @@ -85,6 +86,8 @@ export function currentCallLabel(userId: string, roster: NeighborhoodRosterRow[] return row?.name || row?.callsign || ''; } +/** Alert/incident stamp: bare time today, "Aug 5, 9:40 PM" for anything older. + * padHour: false keeps the unpadded hour these panels have always shown. */ export function formatAlertTime(iso: string): string { - return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return formatMessageTime(iso, { padHour: false }); } diff --git a/frontend/src/hooks/useDayKey.ts b/frontend/src/hooks/useDayKey.ts new file mode 100644 index 0000000..ac801de --- /dev/null +++ b/frontend/src/hooks/useDayKey.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +function dayKey(d: Date): string { + return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; +} + +function msUntilNextMidnight(now: Date): number { + const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0); + // Never schedule a zero/negative delay: a timer that fires immediately would + // spin if the clock sits exactly on the boundary. + return Math.max(next.getTime() - now.getTime(), 1000); +} + +/** Local calendar-day key that changes at midnight, re-rendering the caller. + * + * Timestamps render as "time only when it's today" (see utils/datetime), so a + * screen left open across midnight would keep yesterday's traffic looking like + * today's. Consumers don't need the returned value — reading it is enough to + * subscribe. Sleeps to the boundary rather than polling, so an idle kiosk + * wakes once a day. */ +export function useDayKey(): string { + const [key, setKey] = useState(() => dayKey(new Date())); + + useEffect(() => { + const timer = setTimeout(() => setKey(dayKey(new Date())), msUntilNextMidnight(new Date())); + return () => clearTimeout(timer); + // Re-arms itself for the following midnight each time the key advances. + }, [key]); + + return key; +} diff --git a/frontend/src/hooks/useDisplaySocket.ts b/frontend/src/hooks/useDisplaySocket.ts index eb0fbb6..225b62e 100644 --- a/frontend/src/hooks/useDisplaySocket.ts +++ b/frontend/src/hooks/useDisplaySocket.ts @@ -8,6 +8,7 @@ import type { DisplayAckMsg, } from '../types/ws'; import type { ChatEntry } from '../components/ChatDisplay/ChatDisplay'; +import { formatMessageTime } from '../utils/datetime'; const MIN_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 30000; @@ -56,9 +57,11 @@ function nextId(): string { return `display-msg-${++entryCounter}`; } -function formatTime(iso?: string): string { - const d = iso ? new Date(iso) : new Date(); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +// Raw instant kept alongside the pre-formatted label so the wall can grow a +// date on a message once it is no longer from today. Live rx_message frames +// carry no server ts (only history is stamped), so fall back to arrival time. +function entryTs(iso?: string): string { + return iso ?? new Date().toISOString(); } function capMessages(entries: ChatEntry[]): ChatEntry[] { @@ -75,7 +78,8 @@ function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { : undefined; return { id: nextId(), - timestamp: formatTime(msg.ts), + timestamp: formatMessageTime(msg.ts), + ts: entryTs(msg.ts), kind: 'tx', sender: msg.display_name || msg.operator || msg.callsign, recipient, @@ -85,7 +89,8 @@ function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { if (msg.type === 'chat_echo') { return { id: nextId(), - timestamp: formatTime(msg.ts), + timestamp: formatMessageTime(msg.ts), + ts: entryTs(msg.ts), kind: 'chat', sender: msg.display_name || msg.operator || msg.callsign, text: msg.text, @@ -94,7 +99,8 @@ function streamMsgToEntry(msg: StoredStreamMsg): ChatEntry { // rx_message return { id: nextId(), - timestamp: formatTime(msg.ts), + timestamp: formatMessageTime(msg.ts), + ts: entryTs(msg.ts), kind: 'rx', sender: msg.from || msg.callsign || undefined, text: msg.text, @@ -201,7 +207,8 @@ export function useDisplaySocket(token: string | null): UseDisplaySocketResult { case 'system_msg': appendMessage({ id: nextId(), - timestamp: formatTime(), + timestamp: formatMessageTime(undefined), + ts: entryTs(), kind: 'system', text: msg.text, }); diff --git a/frontend/src/utils/__tests__/datetime.test.ts b/frontend/src/utils/__tests__/datetime.test.ts new file mode 100644 index 0000000..168b019 --- /dev/null +++ b/frontend/src/utils/__tests__/datetime.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { formatMessageTime, isSameLocalDay } from '../datetime'; + +// Fixed reference so these never depend on the wall clock. Local time, since +// the "same day" question is a local-calendar one. +const NOW = new Date(2026, 7, 6, 14, 30); // Aug 6 2026, 14:30 local + +function localIso(y: number, m: number, d: number, h: number, min: number): string { + return new Date(y, m, d, h, min).toISOString(); +} + +describe('isSameLocalDay', () => { + it('is true for two instants on the same local date', () => { + expect(isSameLocalDay(new Date(2026, 7, 6, 0, 1), new Date(2026, 7, 6, 23, 59))).toBe(true); + }); + + it('is false one minute either side of local midnight', () => { + expect(isSameLocalDay(new Date(2026, 7, 5, 23, 59), new Date(2026, 7, 6, 0, 1))).toBe(false); + }); + + it('is false for the same day-of-month in a different month or year', () => { + expect(isSameLocalDay(new Date(2026, 6, 6, 12, 0), NOW)).toBe(false); + expect(isSameLocalDay(new Date(2025, 7, 6, 12, 0), NOW)).toBe(false); + }); +}); + +describe('formatMessageTime', () => { + it('omits the date for a message from today', () => { + const out = formatMessageTime(localIso(2026, 7, 6, 9, 15), { now: NOW }); + expect(out).not.toMatch(/Aug/); + expect(out).toMatch(/9:15|09:15/); + }); + + it('prefixes the date for a message from yesterday', () => { + const out = formatMessageTime(localIso(2026, 7, 5, 21, 40), { now: NOW }); + expect(out).toMatch(/^Aug 5, /); + expect(out).toMatch(/9:40|21:40/); + }); + + it('prefixes the date across a year boundary', () => { + const nye = new Date(2026, 0, 1, 0, 30); + const out = formatMessageTime(localIso(2025, 11, 31, 23, 50), { now: nye }); + expect(out).toMatch(/^Dec 31, /); + }); + + it('pads the hour by default and leaves it unpadded when asked', () => { + const iso = localIso(2026, 7, 6, 9, 5); + expect(formatMessageTime(iso, { now: NOW })).toMatch(/\b09:05/); + expect(formatMessageTime(iso, { now: NOW, padHour: false })).toMatch(/\b9:05/); + }); + + it('treats a missing timestamp as now, so it renders time-only', () => { + expect(formatMessageTime(undefined, { now: NOW })).toBe( + formatMessageTime(NOW.toISOString(), { now: NOW }), + ); + }); + + it('accepts both backend ISO flavors', () => { + // utc_now_iso() emits "...Z"; .isoformat() emits "+00:00" with microseconds. + const z = formatMessageTime('2026-08-05T21:40:00Z', { now: NOW }); + const offset = formatMessageTime('2026-08-05T21:40:00.123456+00:00', { now: NOW }); + expect(z).toBe(offset); + }); + + it('falls back to now rather than "Invalid Date" on unparseable input', () => { + expect(formatMessageTime('not a timestamp', { now: NOW })).toBe( + formatMessageTime(NOW.toISOString(), { now: NOW }), + ); + }); +}); diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..7a92d2e --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -0,0 +1,45 @@ +/** Shared timestamp formatting for message-style surfaces (chat log, kiosk + * wall, neighborhood alerts/incidents, family "last OK"). + * + * The rule: today's traffic reads as a bare time, anything older carries a + * short date. That keeps the common case uncluttered — on the kiosk the + * caption is a single line — while making a scrollback that spans days + * readable. Because "today" changes at midnight on an always-on display, + * callers must format at render time from a raw ISO string rather than + * baking a label at ingest (see useDayKey). */ + +/** True when both dates fall on the same calendar day in the local zone. */ +export function isSameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export interface FormatMessageTimeOptions { + /** "Today" reference point. Defaults to the current clock. */ + now?: Date; + /** Zero-padded hour ("09:15") vs. locale-natural ("9:15 AM"). Defaults to + * padded — the two hour styles already in the app differ, and quietly + * unifying them would restyle panels nobody asked to change. */ + padHour?: boolean; +} + +/** Time-only when `iso` lands on the same local day as `now`, otherwise + * "Aug 5, 21:40". An absent or unparseable `iso` is treated as now, which + * preserves the old no-arg `formatTime()` behavior used for system messages. */ +export function formatMessageTime(iso: string | undefined, opts: FormatMessageTimeOptions = {}): string { + const { now = new Date(), padHour = true } = opts; + const parsed = iso ? new Date(iso) : now; + const d = Number.isNaN(parsed.getTime()) ? now : parsed; + + const time = d.toLocaleTimeString(undefined, { + hour: padHour ? '2-digit' : 'numeric', + minute: '2-digit', + }); + if (isSameLocalDay(d, now)) return time; + + const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return `${date}, ${time}`; +}