Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -711,6 +726,7 @@ export default function App() {
{
id: nextId(),
timestamp: formatTime(),
ts: entryTs(),
kind: 'system',
text: msg.text,
},
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/ChatDisplay/ChatDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -271,7 +282,7 @@ export function ChatDisplay({ entries, contacts, showCallsignChips }: Props) {
flexShrink: 0,
}}
>
{entry.timestamp}
{entry.ts ? formatMessageTime(entry.ts) : entry.timestamp}
</Typography>

{entry.kind === 'rx' && entry.source !== 'cw' && (
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/DisplayApp/DisplayChatConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/FamilyPanel/FamilyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/components/FamilyPanel/MemberCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/components/NeighborhoodPanel/IncidentLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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
Expand Down Expand Up @@ -101,7 +99,7 @@ export function IncidentLog({ incidents, onClear }: IncidentLogProps) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: density.gap * 2, flexWrap: 'wrap', minWidth: 0 }}>
<Chip size="small" label={categoryLabel(entry.category)} sx={{ maxWidth: '100%' }} />
<Typography variant="caption" color="text.secondary">
{formatTime(entry.ts)}
{formatAlertTime(entry.ts)}
</Typography>
</Box>
{/* overflowWrap keeps an unbroken string — a URL, a plate number —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/NeighborhoodPanel/shared.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 });
}
31 changes: 31 additions & 0 deletions frontend/src/hooks/useDayKey.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 14 additions & 7 deletions frontend/src/hooks/useDisplaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
Loading
Loading