@crossworks/client-types 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Pure presentation helpers for the Journey view (Activity → Reaction). No DB
3
+ * imports, so client components can pull these in. Server data fetching lives
4
+ * in ./journey.ts. The job here: turn a raw trace (kind + source + node type)
5
+ * into a human-readable "what you did" line + an icon key + a pipeline
6
+ * category, so the feed reads like a story rather than a log.
7
+ */
8
+
9
+ export type ActionCategory = 'content' | 'dialog' | 'automation';
10
+
11
+ export type ActionIconKey =
12
+ | 'chat'
13
+ | 'pdf'
14
+ | 'doc'
15
+ | 'image'
16
+ | 'file'
17
+ | 'email'
18
+ | 'note'
19
+ | 'event'
20
+ | 'task'
21
+ | 'telegram'
22
+ | 'tool'
23
+ | 'automation'
24
+ | 'secret';
25
+
26
+ export type ActionPresentation = {
27
+ /** Human "what happened" line, e.g. "Email ingested". */
28
+ label: string;
29
+ /** Which of the two reaction pipelines this fed. */
30
+ category: ActionCategory;
31
+ iconKey: ActionIconKey;
32
+ };
33
+
34
+ /** Friendly source label for the chip under an action. */
35
+ export function sourceLabel(source: string | null): string {
36
+ switch (source) {
37
+ case 'assistant_upload':
38
+ return 'chat upload';
39
+ case 'assistant':
40
+ return 'chat';
41
+ case 'telegram':
42
+ return 'telegram';
43
+ case 'telegram_upload':
44
+ return 'telegram upload';
45
+ case 'file_upload':
46
+ return 'files';
47
+ case 'file_create':
48
+ return 'files';
49
+ case 'file_edit':
50
+ return 'files';
51
+ case 'note_create':
52
+ return 'notes';
53
+ case 'agent_tool':
54
+ return 'agent';
55
+ case 'extractor':
56
+ return 'pipeline';
57
+ default:
58
+ return source || 'system';
59
+ }
60
+ }
61
+
62
+ function iconForNode(nodeType: string | null, mime: string | null): ActionIconKey {
63
+ switch (nodeType) {
64
+ case 'note':
65
+ return 'note';
66
+ case 'email':
67
+ case 'email_thread':
68
+ return 'email';
69
+ case 'task':
70
+ return 'task';
71
+ case 'event':
72
+ return 'event';
73
+ case 'secret':
74
+ return 'secret';
75
+ case 'telegram_message':
76
+ return 'telegram';
77
+ case 'file': {
78
+ const m = (mime ?? '').toLowerCase();
79
+ if (m.includes('pdf')) return 'pdf';
80
+ if (m.startsWith('image/')) return 'image';
81
+ if (
82
+ m.includes('word') ||
83
+ m.includes('officedocument') ||
84
+ m.includes('spreadsheet') ||
85
+ m.includes('excel') ||
86
+ m.includes('csv')
87
+ )
88
+ return 'doc';
89
+ return 'file';
90
+ }
91
+ default:
92
+ return 'file';
93
+ }
94
+ }
95
+
96
+ function contentLabel(nodeType: string | null, mime: string | null, source: string | null): string {
97
+ // Prefer the explicit human action when we recorded one.
98
+ switch (source) {
99
+ case 'assistant_upload':
100
+ return 'Uploaded in chat';
101
+ case 'telegram_upload':
102
+ return 'Sent media via Telegram';
103
+ case 'file_upload':
104
+ return 'Uploaded a file';
105
+ case 'file_create':
106
+ return 'Created a file';
107
+ case 'file_edit':
108
+ return 'Edited a file';
109
+ case 'note_create':
110
+ return 'Wrote a note';
111
+ case 'agent_tool':
112
+ return 'Agent created content';
113
+ default:
114
+ break;
115
+ }
116
+ // Otherwise derive from what the node turned out to be.
117
+ switch (nodeType) {
118
+ case 'email':
119
+ case 'email_thread':
120
+ return 'Email ingested';
121
+ case 'note':
122
+ return 'Note added';
123
+ case 'event':
124
+ return 'Event created';
125
+ case 'task':
126
+ return 'Task created';
127
+ case 'secret':
128
+ return 'Secret stored';
129
+ case 'file': {
130
+ const m = (mime ?? '').toLowerCase();
131
+ if (m.includes('pdf')) return 'PDF ingested';
132
+ if (m.startsWith('image/')) return 'Image ingested';
133
+ return 'File added';
134
+ }
135
+ default:
136
+ return 'Content added';
137
+ }
138
+ }
139
+
140
+ export function deriveAction(t: {
141
+ kind: string;
142
+ nodeType: string | null;
143
+ mime: string | null;
144
+ source: string | null;
145
+ }): ActionPresentation {
146
+ // Conversation messages are dialog, even though the extractor (a content-
147
+ // pipeline worker) fires on their node. The transcript/text lives in the
148
+ // conversation store (L2 recent_turns) and flows through the dialog pipeline;
149
+ // the node is a shadow that the content index deliberately ignores. Labelling
150
+ // it "content" would mis-file chatter — and most such nodes skip anyway.
151
+ if (t.nodeType === 'telegram_message') {
152
+ return { label: 'Telegram message', category: 'dialog', iconKey: 'telegram' };
153
+ }
154
+ switch (t.kind) {
155
+ case 'responder_turn':
156
+ return { label: 'Conversation turn', category: 'dialog', iconKey: 'chat' };
157
+ case 'summarizer_run':
158
+ return {
159
+ label: 'Rolled up conversation digests',
160
+ category: 'automation',
161
+ iconKey: 'automation',
162
+ };
163
+ case 'reflector_run':
164
+ return {
165
+ label: 'Updated persona from conversation',
166
+ category: 'automation',
167
+ iconKey: 'automation',
168
+ };
169
+ case 'heartbeat_fire':
170
+ return { label: 'Scheduled automation fired', category: 'automation', iconKey: 'automation' };
171
+ default:
172
+ // extractor_run / content_ingest / photo_ingest / manual → content pipeline
173
+ return {
174
+ label: contentLabel(t.nodeType, t.mime, t.source),
175
+ category: 'content',
176
+ iconKey: iconForNode(t.nodeType, t.mime),
177
+ };
178
+ }
179
+ }
180
+
181
+ /** Where each pipeline lands, for the legend/reference panel. */
182
+ export const PIPELINE_LEGEND: {
183
+ category: ActionCategory;
184
+ title: string;
185
+ flow: string;
186
+ blurb: string;
187
+ }[] = [
188
+ {
189
+ category: 'content',
190
+ title: 'Content pipeline',
191
+ flow: 'L6 store → L5 index → L4 facts → graph',
192
+ blurb:
193
+ 'Anything you add as knowledge — files, PDFs, images, notes, emails — is stored, summarised + embedded into the searchable index, mined for durable facts, and linked into the entity graph.',
194
+ },
195
+ {
196
+ category: 'dialog',
197
+ title: 'Dialog pipeline',
198
+ flow: 'L2 recent turns → L3 digests → L1 persona',
199
+ blurb:
200
+ 'Conversation lands as recent turns, gets rolled up into digests by the summarizer, and shapes the agent’s persona via the reflector.',
201
+ },
202
+ {
203
+ category: 'automation',
204
+ title: 'Automation',
205
+ flow: 'timers + heartbeats',
206
+ blurb:
207
+ 'Background work the brain does on its own schedule — digest roll-ups, persona reflection, and heartbeat-triggered actions.',
208
+ },
209
+ ];
210
+
211
+ // ── Journey view DTOs (moved from server/web/lib/journey.ts) ─────────────────
212
+ import type { TraceDetail } from './traces-format';
213
+
214
+ export type LandedLayers = {
215
+ /** L6 content_store — the node itself. */
216
+ node: { id: string; type: string; title: string } | null;
217
+ /** L5 content_index — the searchable catalogue entry. */
218
+ index: {
219
+ summary: string | null;
220
+ hasEmbedding: boolean;
221
+ hasText: boolean;
222
+ tags: string[];
223
+ } | null;
224
+ /** L4 profile — durable facts mined from this node (currently-valid only). */
225
+ facts: { content: string; kind: string; entityName: string | null }[];
226
+ /** Graph — entities mentioned in this node. */
227
+ mentions: { name: string; kind: string }[];
228
+ /** Graph — relations this node drew between entities (subject→object). */
229
+ relations: { subject: string; relation: string; object: string }[];
230
+ };
231
+
232
+ export type ActivityItem = ActionPresentation & {
233
+ traceId: string;
234
+ kind: string;
235
+ status: string;
236
+ startedAt: string;
237
+ durationMs: number | null;
238
+ costMicroUsd: number;
239
+ stepCount: number;
240
+ /** Node title (or recorded filename) for the subject of the action. */
241
+ title: string | null;
242
+ subjectKind: string | null;
243
+ subjectId: string | null;
244
+ /** Outcome — what entered the brain. Facts mined + entities linked +
245
+ * relations drawn from this action's node (0 for non-content / dialog
246
+ * actions). */
247
+ factCount: number;
248
+ mentionCount: number;
249
+ relationCount: number;
250
+ };
251
+
252
+ export type JourneyDetail = TraceDetail & { landed: LandedLayers | null };
253
+
254
+ /** Live snapshot for the always-on Activity surfaces: what's running right now,
255
+ * what recently succeeded, and what failed. */
256
+ export type LiveActivity = {
257
+ active: ActivityItem[];
258
+ recent: ActivityItem[];
259
+ failures: ActivityItem[];
260
+ };
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ approachProgress,
4
+ buildIcsHref,
5
+ countdownParts,
6
+ dayGroup,
7
+ eventProgress,
8
+ eventState,
9
+ formatRelativeShort,
10
+ } from './event-time';
11
+
12
+ const DAY = 86_400_000;
13
+ const HOUR = 3_600_000;
14
+ const MIN = 60_000;
15
+ const NOW = Date.UTC(2026, 4, 25, 12, 0, 0); // 2026-05-25 12:00 UTC
16
+ const iso = (ms: number) => new Date(ms).toISOString();
17
+
18
+ describe('eventState', () => {
19
+ it('classifies upcoming / in_progress / past', () => {
20
+ expect(eventState(iso(NOW + HOUR), null, NOW)).toBe('upcoming');
21
+ expect(eventState(iso(NOW - HOUR), iso(NOW + HOUR), NOW)).toBe('in_progress');
22
+ expect(eventState(iso(NOW - 2 * HOUR), iso(NOW - HOUR), NOW)).toBe('past');
23
+ });
24
+ it('treats a no-end event as a point in time', () => {
25
+ expect(eventState(iso(NOW), null, NOW)).toBe('in_progress'); // exact instant
26
+ expect(eventState(iso(NOW - 1), null, NOW)).toBe('past');
27
+ });
28
+ });
29
+
30
+ describe('countdownParts', () => {
31
+ it('splits remaining time into d/h/m/s', () => {
32
+ const p = countdownParts(iso(NOW + DAY + 2 * HOUR + 3 * MIN + 4000), NOW);
33
+ expect(p).toMatchObject({ days: 1, hours: 2, minutes: 3, seconds: 4 });
34
+ });
35
+ it('clamps the past to zero', () => {
36
+ expect(countdownParts(iso(NOW - HOUR), NOW)).toMatchObject({ days: 0, hours: 0, total: 0 });
37
+ });
38
+ });
39
+
40
+ describe('formatRelativeShort', () => {
41
+ it('formats future + past at the right unit', () => {
42
+ expect(formatRelativeShort(iso(NOW + 3 * DAY), NOW)).toBe('in 3d');
43
+ expect(formatRelativeShort(iso(NOW + 5 * HOUR), NOW)).toBe('in 5h');
44
+ expect(formatRelativeShort(iso(NOW - 5 * HOUR), NOW)).toBe('5h ago');
45
+ expect(formatRelativeShort(iso(NOW + 30_000), NOW)).toBe('now');
46
+ });
47
+ });
48
+
49
+ describe('eventProgress', () => {
50
+ it('is the fraction through a timed event', () => {
51
+ expect(eventProgress(iso(NOW - HOUR), iso(NOW + HOUR), NOW)).toBeCloseTo(0.5);
52
+ expect(eventProgress(iso(NOW), null, NOW)).toBe(0); // no end
53
+ });
54
+ });
55
+
56
+ describe('approachProgress', () => {
57
+ it('fills from 0 (≥horizon away) to 1 (at start)', () => {
58
+ expect(approachProgress(iso(NOW + 7 * DAY), NOW)).toBe(0);
59
+ expect(approachProgress(iso(NOW + 3.5 * DAY), NOW)).toBeCloseTo(0.5);
60
+ expect(approachProgress(iso(NOW), NOW)).toBe(1);
61
+ });
62
+ });
63
+
64
+ describe('dayGroup (UTC)', () => {
65
+ const g = (ms: number) => dayGroup(iso(ms), NOW, 'UTC');
66
+ it('buckets by day boundary', () => {
67
+ expect(g(NOW + 6 * HOUR)).toBe('today');
68
+ expect(g(NOW + DAY)).toBe('tomorrow');
69
+ expect(g(NOW + 3 * DAY)).toBe('this_week');
70
+ expect(g(NOW + 10 * DAY)).toBe('later');
71
+ expect(g(NOW - DAY)).toBe('past');
72
+ });
73
+ });
74
+
75
+ describe('buildIcsHref', () => {
76
+ it('builds a calendar data URL, or null without a start', () => {
77
+ const href = buildIcsHref({ title: 'Standup', startsAt: iso(NOW), location: 'Zoom' });
78
+ expect(href).toMatch(/^data:text\/calendar/);
79
+ const decoded = decodeURIComponent(href!);
80
+ expect(decoded).toContain('BEGIN:VEVENT');
81
+ expect(decoded).toContain('SUMMARY:Standup');
82
+ expect(decoded).toContain('LOCATION:Zoom');
83
+ expect(buildIcsHref({ title: 'x', startsAt: null })).toBeNull();
84
+ });
85
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Pure time helpers for the events surface — countdowns, relative labels, the
3
+ * approach-ring + in-progress fractions, day grouping, and an .ics builder.
4
+ * Pure (no React, no DOM) so they're unit-testable and reusable on the server
5
+ * (share presenter) and client (live detail). All take `now` (ms) explicitly so
6
+ * the live components can drive them off a ticking clock and tests are
7
+ * deterministic.
8
+ */
9
+
10
+ const MIN = 60_000;
11
+ const HOUR = 3_600_000;
12
+ const DAY = 86_400_000;
13
+
14
+ export type EventState = 'upcoming' | 'in_progress' | 'past';
15
+
16
+ /** Where an event sits relative to `now`. An event with no end is treated as a
17
+ * point in time (in_progress only at the exact instant, then past). */
18
+ export function eventState(startsAt: string, endsAt: string | null, now: number): EventState {
19
+ const start = new Date(startsAt).getTime();
20
+ const end = endsAt ? new Date(endsAt).getTime() : start;
21
+ if (now < start) return 'upcoming';
22
+ if (now <= end) return 'in_progress';
23
+ return 'past';
24
+ }
25
+
26
+ export type CountdownParts = {
27
+ days: number;
28
+ hours: number;
29
+ minutes: number;
30
+ seconds: number;
31
+ total: number; // remaining ms (clamped ≥ 0)
32
+ };
33
+
34
+ /** Break the time until `targetIso` into d/h/m/s (clamped at zero). */
35
+ export function countdownParts(targetIso: string, now: number): CountdownParts {
36
+ const total = Math.max(0, new Date(targetIso).getTime() - now);
37
+ let ms = total;
38
+ const days = Math.floor(ms / DAY);
39
+ ms -= days * DAY;
40
+ const hours = Math.floor(ms / HOUR);
41
+ ms -= hours * HOUR;
42
+ const minutes = Math.floor(ms / MIN);
43
+ ms -= minutes * MIN;
44
+ const seconds = Math.floor(ms / 1000);
45
+ return { days, hours, minutes, seconds, total };
46
+ }
47
+
48
+ /** Compact relative label for list cards: "in 3d", "in 5h", "in 12m", "now",
49
+ * "5m ago", "2d ago". Minute-resolution (the list ticks per minute). */
50
+ export function formatRelativeShort(iso: string, now: number): string {
51
+ const diff = new Date(iso).getTime() - now;
52
+ const abs = Math.abs(diff);
53
+ if (abs < MIN) return 'now';
54
+ const unit = abs >= DAY ? 'd' : abs >= HOUR ? 'h' : 'm';
55
+ const n =
56
+ abs >= DAY
57
+ ? Math.round(abs / DAY)
58
+ : abs >= HOUR
59
+ ? Math.round(abs / HOUR)
60
+ : Math.round(abs / MIN);
61
+ return diff > 0 ? `in ${n}${unit}` : `${n}${unit} ago`;
62
+ }
63
+
64
+ /** Fraction (0..1) through an event with a known end — for the in-progress bar. */
65
+ export function eventProgress(startsAt: string, endsAt: string | null, now: number): number {
66
+ if (!endsAt) return 0;
67
+ const s = new Date(startsAt).getTime();
68
+ const e = new Date(endsAt).getTime();
69
+ if (e <= s) return 0;
70
+ return Math.min(1, Math.max(0, (now - s) / (e - s)));
71
+ }
72
+
73
+ /** Ring fill (0..1) for an upcoming event: empty until `horizonMs` before start,
74
+ * then fills smoothly to full at start. Default horizon: 7 days. */
75
+ export function approachProgress(startsAt: string, now: number, horizonMs = 7 * DAY): number {
76
+ const remaining = new Date(startsAt).getTime() - now;
77
+ if (remaining <= 0) return 1;
78
+ if (remaining >= horizonMs) return 0;
79
+ return 1 - remaining / horizonMs;
80
+ }
81
+
82
+ export type DayGroup = 'today' | 'tomorrow' | 'this_week' | 'later' | 'past';
83
+
84
+ /** Bucket an event by its start, in the given IANA timezone, relative to `now`.
85
+ * Day boundaries are computed in `tz` so "today" matches the user's wall clock. */
86
+ export function dayGroup(startsAt: string, now: number, tz: string): DayGroup {
87
+ const start = new Date(startsAt).getTime();
88
+ const startDay = dayIndex(start, tz);
89
+ const nowDay = dayIndex(now, tz);
90
+ const delta = startDay - nowDay;
91
+ if (delta < 0) return 'past';
92
+ if (delta === 0) return 'today';
93
+ if (delta === 1) return 'tomorrow';
94
+ if (delta <= 7) return 'this_week';
95
+ return 'later';
96
+ }
97
+
98
+ /** Integer day number (since epoch) for an instant, in `tz`. */
99
+ function dayIndex(ms: number, tz: string): number {
100
+ // en-CA → YYYY-MM-DD; Date.UTC of those parts gives a tz-independent day count.
101
+ const [y, m, d] = new Intl.DateTimeFormat('en-CA', {
102
+ timeZone: tz,
103
+ year: 'numeric',
104
+ month: '2-digit',
105
+ day: '2-digit',
106
+ })
107
+ .format(new Date(ms))
108
+ .split('-')
109
+ .map(Number);
110
+ return Math.floor(Date.UTC(y!, m! - 1, d!) / DAY);
111
+ }
112
+
113
+ /** RFC5545 UTC stamp: 20260524T143000Z. */
114
+ function icsStamp(iso: string): string {
115
+ return new Date(iso)
116
+ .toISOString()
117
+ .replace(/[-:]/g, '')
118
+ .replace(/\.\d{3}/, '');
119
+ }
120
+ function icsEscape(s: string): string {
121
+ return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
122
+ }
123
+
124
+ export type IcsInput = {
125
+ title: string;
126
+ body?: string;
127
+ startsAt: string | null;
128
+ endsAt?: string | null;
129
+ location?: string | null;
130
+ };
131
+
132
+ /** Build an `.ics` data URL so the event can be added to any calendar. Null if
133
+ * there's no start. Shared by the events detail + the public share presenter. */
134
+ export function buildIcsHref(e: IcsInput): string | null {
135
+ if (!e.startsAt) return null;
136
+ const lines = [
137
+ 'BEGIN:VCALENDAR',
138
+ 'VERSION:2.0',
139
+ 'PRODID:-//Mantle//Events//EN',
140
+ 'BEGIN:VEVENT',
141
+ `UID:${icsStamp(e.startsAt)}-${Math.random().toString(36).slice(2)}@mantle`,
142
+ `DTSTAMP:${icsStamp(new Date().toISOString())}`,
143
+ `DTSTART:${icsStamp(e.startsAt)}`,
144
+ ...(e.endsAt ? [`DTEND:${icsStamp(e.endsAt)}`] : []),
145
+ `SUMMARY:${icsEscape(e.title)}`,
146
+ ...(e.location ? [`LOCATION:${icsEscape(e.location)}`] : []),
147
+ ...(e.body ? [`DESCRIPTION:${icsEscape(e.body)}`] : []),
148
+ 'END:VEVENT',
149
+ 'END:VCALENDAR',
150
+ ];
151
+ return `data:text/calendar;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
152
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure, dependency-free byte/percent formatters. Safe to import from both
3
+ * server and client components (no DB / Node-only deps), mirroring
4
+ * `traces-format.ts`.
5
+ */
6
+
7
+ /** Human-readable bytes, e.g. 1234567 → "1.2 MB". */
8
+ export function formatBytes(n: number | null | undefined): string {
9
+ if (n == null || !Number.isFinite(n)) return '—';
10
+ if (n < 1024) return `${n} B`;
11
+ const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
12
+ let v = n / 1024;
13
+ let i = 0;
14
+ while (v >= 1024 && i < units.length - 1) {
15
+ v /= 1024;
16
+ i++;
17
+ }
18
+ return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;
19
+ }
20
+
21
+ /** A 0..1 ratio (or 0..100 already-percent) → "12%". Pass `already=true`
22
+ * when the input is already a 0..100 percentage. */
23
+ export function formatPct(value: number | null | undefined, already = false): string {
24
+ if (value == null || !Number.isFinite(value)) return '—';
25
+ const pct = already ? value : value * 100;
26
+ return `${pct.toFixed(pct >= 10 || pct === 0 ? 0 : 1)}%`;
27
+ }
28
+
29
+ /** Compact integer with thousands separators, e.g. 12345 → "12,345".
30
+ * Pins the `en-US` locale so server (Node) and client (browser) render the
31
+ * SAME separator — a bare `toLocaleString()` formats per the runtime's locale,
32
+ * which differs between Node and the browser (e.g. "1 931" vs "1,931") and
33
+ * causes a React hydration mismatch. */
34
+ export function formatCount(n: number | null | undefined): string {
35
+ if (n == null || !Number.isFinite(n)) return '—';
36
+ return n.toLocaleString('en-US');
37
+ }
38
+
39
+ /** Seconds → "3d 4h", "5h 12m", "8m", "42s". For uptime display. */
40
+ export function formatUptime(seconds: number | null | undefined): string {
41
+ if (seconds == null || !Number.isFinite(seconds)) return '—';
42
+ const s = Math.floor(seconds);
43
+ const d = Math.floor(s / 86400);
44
+ const h = Math.floor((s % 86400) / 3600);
45
+ const m = Math.floor((s % 3600) / 60);
46
+ if (d > 0) return `${d}d ${h}h`;
47
+ if (h > 0) return `${h}h ${m}m`;
48
+ if (m > 0) return `${m}m`;
49
+ return `${s}s`;
50
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Deterministic date/time formatting for React components that render
3
+ * on BOTH the server (Node) and the client (browser).
4
+ *
5
+ * `Date.prototype.toLocaleString()` without arguments returns different
6
+ * strings on the two sides — Node uses the OS locale, the browser uses
7
+ * the user's preference. That diverges the SSR HTML from the hydrated
8
+ * tree and triggers a React hydration mismatch warning. Symptom: a
9
+ * red error in dev about "server rendered text didn't match the
10
+ * client", and the affected subtree gets regenerated on the client.
11
+ *
12
+ * Fix: pin the locale on every call. We use 'en-GB' across the app —
13
+ * the user is in South Africa, en-GB matches local conventions
14
+ * (DD/MM/YYYY, 24-hour time) and renders identically wherever Node
15
+ * runs. If we ever localise the UI properly, swap this for an i18n
16
+ * provider read from a cookie that's shared between SSR + client.
17
+ *
18
+ * Three formats covered:
19
+ * - formatDateTime: '19/05/2026, 10:38:41' — full timestamp
20
+ * - formatDate: '19/05/2026' — date only
21
+ * - formatTime: '10:38:41' — time only
22
+ *
23
+ * All three accept Date | string | number | null | undefined and return
24
+ * 'never' for missing values so callers don't have to do the null check.
25
+ */
26
+
27
+ const LOCALE = 'en-GB';
28
+
29
+ const DATETIME_OPTS: Intl.DateTimeFormatOptions = {
30
+ year: 'numeric',
31
+ month: '2-digit',
32
+ day: '2-digit',
33
+ hour: '2-digit',
34
+ minute: '2-digit',
35
+ second: '2-digit',
36
+ hour12: false,
37
+ };
38
+
39
+ const DATE_OPTS: Intl.DateTimeFormatOptions = {
40
+ year: 'numeric',
41
+ month: '2-digit',
42
+ day: '2-digit',
43
+ };
44
+
45
+ const TIME_OPTS: Intl.DateTimeFormatOptions = {
46
+ hour: '2-digit',
47
+ minute: '2-digit',
48
+ second: '2-digit',
49
+ hour12: false,
50
+ };
51
+
52
+ type Input = Date | string | number | null | undefined;
53
+
54
+ function toDate(input: Input): Date | null {
55
+ if (input == null) return null;
56
+ if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input;
57
+ const d = new Date(input);
58
+ return Number.isNaN(d.getTime()) ? null : d;
59
+ }
60
+
61
+ export function formatDateTime(input: Input, missing = 'never'): string {
62
+ const d = toDate(input);
63
+ if (!d) return missing;
64
+ return new Intl.DateTimeFormat(LOCALE, DATETIME_OPTS).format(d);
65
+ }
66
+
67
+ export function formatDate(input: Input, missing = 'never'): string {
68
+ const d = toDate(input);
69
+ if (!d) return missing;
70
+ return new Intl.DateTimeFormat(LOCALE, DATE_OPTS).format(d);
71
+ }
72
+
73
+ export function formatTime(input: Input, missing = 'never'): string {
74
+ const d = toDate(input);
75
+ if (!d) return missing;
76
+ return new Intl.DateTimeFormat(LOCALE, TIME_OPTS).format(d);
77
+ }
78
+
79
+ /**
80
+ * `YYYY-MM-DD` in the viewer's LOCAL timezone (defaults to now). Use this to
81
+ * compare against day-bucketed date strings — `new Date().toISOString().slice(0,10)`
82
+ * is UTC, so it highlights the wrong day for a viewer whose local date differs
83
+ * from UTC (e.g. evening in the Americas, or already-tomorrow in APAC).
84
+ */
85
+ export function localDay(date: Date = new Date()): string {
86
+ const p = (n: number) => String(n).padStart(2, '0');
87
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
88
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { safeDownloadHeaders } from './safe-download';
3
+
4
+ describe('safeDownloadHeaders', () => {
5
+ it('serves SVG inline as image/svg+xml so <img> embeds render', () => {
6
+ const h = safeDownloadHeaders('image/svg+xml', 'logo.svg');
7
+ expect(h['content-type']).toBe('image/svg+xml');
8
+ expect(h['content-disposition']).toMatch(/^inline;/);
9
+ expect(h['x-content-type-options']).toBe('nosniff');
10
+ });
11
+
12
+ it('sandboxes SVG with a scriptless CSP (no script execution on direct nav)', () => {
13
+ const h = safeDownloadHeaders('image/svg+xml', 'logo.svg');
14
+ const csp = h['content-security-policy'] ?? '';
15
+ expect(csp).toContain('sandbox');
16
+ expect(csp).not.toContain('allow-scripts');
17
+ expect(csp).toContain("default-src 'none'");
18
+ });
19
+
20
+ it('handles SVG mime parameters and casing', () => {
21
+ const h = safeDownloadHeaders('IMAGE/SVG+XML; charset=utf-8', 'logo.svg');
22
+ expect(h['content-type']).toBe('image/svg+xml');
23
+ expect(h['content-disposition']).toMatch(/^inline;/);
24
+ expect(h['content-security-policy']).toContain('sandbox');
25
+ });
26
+
27
+ it('forces HTML to an attachment', () => {
28
+ const h = safeDownloadHeaders('text/html', 'page.html');
29
+ expect(h['content-type']).toBe('application/octet-stream');
30
+ expect(h['content-disposition']).toMatch(/^attachment;/);
31
+ });
32
+
33
+ it('renders a PNG inline with its real type', () => {
34
+ const h = safeDownloadHeaders('image/png', 'pic.png');
35
+ expect(h['content-type']).toBe('image/png');
36
+ expect(h['content-disposition']).toMatch(/^inline;/);
37
+ expect(h['x-content-type-options']).toBe('nosniff');
38
+ });
39
+
40
+ it('ignores parameters on the mime type and is case-insensitive', () => {
41
+ expect(safeDownloadHeaders('IMAGE/PNG; charset=binary', 'p.png')['content-type']).toBe(
42
+ 'image/png',
43
+ );
44
+ });
45
+
46
+ it('percent-encodes the filename so it cannot inject header content', () => {
47
+ const h = safeDownloadHeaders('application/pdf', 'a"; evil\r\nx: y.pdf');
48
+ expect(h['content-disposition']).not.toContain('"');
49
+ expect(h['content-disposition']).not.toContain('\r');
50
+ expect(h['content-disposition']).not.toContain('\n');
51
+ expect(h['content-disposition']).toContain("filename*=UTF-8''");
52
+ });
53
+
54
+ it('treats empty/unknown types as non-inline downloads', () => {
55
+ expect(safeDownloadHeaders('', 'x')['content-disposition']).toMatch(/^attachment;/);
56
+ expect(safeDownloadHeaders(null, 'x')['content-type']).toBe('application/octet-stream');
57
+ });
58
+ });