The alert detail screen now creates with a lifetime (1/3/7/14 days) and shows expiresAt, lists subscriber rows by their non-identifying fields, and renders each ending (delivered, expired, disarmed, deleted) as a terminal state instead of a stale alert. Settings configures the single instance origin and reports the real readiness/listening checklist; the trigger pad keeps its 1200ms hold, and time helpers format the deadline and last-seen values.
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
/**
|
|
* Human time helpers for the alarm surfaces.
|
|
*
|
|
* The takeover shows a count-up ("2s ago") rather than a server timestamp: on a
|
|
* lock screen at arm's length an RFC3339 string is unreadable, and the number
|
|
* that matters is how long the phone has been screaming. The absolute time is
|
|
* still available as the accessibility label.
|
|
*/
|
|
|
|
/** Parse a server RFC3339 string. Returns null when absent or unparseable. */
|
|
export function parseServerTime(value: string | null | undefined): Date | null {
|
|
if (!value) return null;
|
|
const ms = Date.parse(value);
|
|
return Number.isNaN(ms) ? null : new Date(ms);
|
|
}
|
|
|
|
const SECOND = 1000;
|
|
const MINUTE = 60 * SECOND;
|
|
const HOUR = 60 * MINUTE;
|
|
const DAY = 24 * HOUR;
|
|
|
|
/**
|
|
* Compact elapsed time. Deliberately coarse above a minute: the takeover updates
|
|
* every second only while the alarm is fresh, and "13m ago" is more useful than
|
|
* "13m 42s ago" on a wall of red.
|
|
*/
|
|
export function relativeTime(from: Date, now: number): string {
|
|
const elapsed = now - from.getTime();
|
|
// A clock skew between phone and server must not render "in 3 seconds".
|
|
if (elapsed < SECOND) return 'just now';
|
|
if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`;
|
|
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
|
|
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
|
|
return `${Math.floor(elapsed / DAY)}d ago`;
|
|
}
|
|
|
|
/** Convenience: relative time for a server string, or null when unparseable. */
|
|
export function relativeSince(value: string | null | undefined, now: number): string | null {
|
|
const date = parseServerTime(value);
|
|
return date ? relativeTime(date, now) : null;
|
|
}
|
|
|
|
/**
|
|
* Time left before an alert self-destructs, e.g. `Ends in 6d 4h` / `Ends in
|
|
* 12m`. Null when there is no deadline or it cannot be parsed; a deadline in
|
|
* the past renders `Ended` (the service will 404 from then on).
|
|
*/
|
|
export function remainingLifetime(expiresAt: string | null | undefined, now: number): string | null {
|
|
const date = parseServerTime(expiresAt);
|
|
if (!date) return null;
|
|
const left = date.getTime() - now;
|
|
if (left <= 0) return 'Ended';
|
|
if (left < MINUTE) return 'Ends in under a minute';
|
|
if (left < HOUR) return `Ends in ${Math.floor(left / MINUTE)}m`;
|
|
if (left < DAY) {
|
|
const hours = Math.floor(left / HOUR);
|
|
const minutes = Math.floor((left % HOUR) / MINUTE);
|
|
return minutes ? `Ends in ${hours}h ${minutes}m` : `Ends in ${hours}h`;
|
|
}
|
|
const days = Math.floor(left / DAY);
|
|
const hours = Math.floor((left % DAY) / HOUR);
|
|
return hours ? `Ends in ${days}d ${hours}h` : `Ends in ${days}d`;
|
|
}
|
|
|
|
/** True when the deadline is present and already past. */
|
|
export function isPastDeadline(expiresAt: string | null | undefined, now: number): boolean {
|
|
const date = parseServerTime(expiresAt);
|
|
return !!date && date.getTime() <= now;
|
|
}
|
|
|
|
/** Local wall-clock time for the accessibility label of a relative stamp. */
|
|
export function absoluteLocal(date: Date): string {
|
|
const hh = String(date.getHours()).padStart(2, '0');
|
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
const ss = String(date.getSeconds()).padStart(2, '0');
|
|
return `${hh}:${mm}:${ss}`;
|
|
}
|