1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import * as DateTime from "effect/DateTime";
3import { DEFAULT_SNOOZE_PRESETS, type SnoozePresetSetting } from "@t3tools/contracts/settings";
4
5import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
6
7interface SettlementRunLike {
8 readonly turnId?: unknown;
9 readonly assistantMessageId?: unknown;
10 readonly status?: string;
11 readonly state?: string;
12 readonly requestedAt?: string | null;
13 readonly startedAt?: string | null;
14 readonly completedAt?: string | null;
15}
16
17interface SettlementRuntimeLike {
18 readonly threadId?: unknown;
19 readonly providerName?: unknown;
20 readonly runtimeMode?: unknown;
21 readonly activeTurnId?: unknown;
22 readonly lastError?: unknown;
23 readonly status: string;
24 readonly updatedAt?: string;
25}
26
27interface QueuedThreadShell {
28 readonly latestUserMessageAt?: string | null;
29 readonly latestTurn?: SettlementRunLike | null;
30 readonly latestRun?: SettlementRunLike | null;
31 readonly session?: SettlementRuntimeLike | null;
32 readonly runtime?: SettlementRuntimeLike | null;
33}
34
35/**
36 * Failed starts and stale shells from older servers can leave user messages unadopted.
37 * Bound the pending window so those threads can eventually settle.
38 */
39export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
40const DAY_MS = 24 * 60 * 60 * 1_000;
41
42export function hasQueuedTurnStart(
43 shell: QueuedThreadShell,
44 options: { readonly now: string },
45): boolean {
46 if (
47 shell.runtime?.status === "preparing" ||
48 shell.runtime?.status === "queued" ||
49 shell.runtime?.status === "starting"
50 ) {
51 return true;
52 }
53 if (shell.latestUserMessageAt == null) return false;
54 if (shell.session?.status === "error") return false;
55 const messageAt = Date.parse(shell.latestUserMessageAt);
56 if (Number.isNaN(messageAt)) return false;
57 const nowMs = Date.parse(options.now);
58 if (Number.isNaN(nowMs)) return false;
59 // Bounded on both sides: message timestamps originate on whichever device
60 // sent the message, so a clock ahead of this one yields a negative age
61 // that would otherwise hold the queued state for the whole skew. Mirrors
62 // the decider's guard.
63 if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false;
64 const turn = shell.latestRun ?? shell.latestTurn ?? null;
65 if (turn === null) return true;
66 return [turn.requestedAt, turn.startedAt, turn.completedAt].every(
67 (candidate) => candidate == null || Date.parse(candidate) < messageAt,
68 );
69}
70
71export interface ThreadSnoozeShell extends QueuedThreadShell {
72 readonly source?: Pick<EnvironmentThreadShell["source"], "pendingRuntimeRequest">;
73 readonly snoozedUntil?: string | null;
74 readonly snoozedAt?: string | null;
75 readonly hasPendingApprovals: boolean;
76 readonly hasPendingUserInput: boolean;
77 readonly pendingUserInputAt?: string | null;
78 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
79 readonly pendingGuestMessageCount?: number;
80}
81
82function pendingQuestionAt(shell: ThreadSnoozeShell): string | null {
83 const request = shell.source?.pendingRuntimeRequest;
84 return (
85 shell.pendingUserInputAt ??
86 (request?.kind === "user_input" ? DateTime.formatIso(request.createdAt) : null)
87 );
88}
89
90/**
91 * Only a request for the user or a fresh failure ends a snooze early. Completed runs never do:
92 * a snoozed long-running thread keeps waking itself for background work and stays hidden
93 * until its timer.
94 */
95export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
96 const request = shell.source?.pendingRuntimeRequest;
97 if (shell.hasPendingApprovals || (request != null && request.kind !== "user_input")) return true;
98 const questionAt = pendingQuestionAt(shell);
99 // Snoozing an existing question means "ask me later". Only a new question
100 // wakes it early; missing or invalid timestamps keep the request visible.
101 if (
102 (shell.hasPendingUserInput || request?.kind === "user_input") &&
103 (shell.snoozedAt == null ||
104 questionAt == null ||
105 !(Date.parse(questionAt) <= Date.parse(shell.snoozedAt)))
106 ) {
107 return true;
108 }
109 if (shell.pendingSubagentRequest != null) return true;
110 if ((shell.pendingGuestMessageCount ?? 0) > 0) return true;
111 const failedAt = failureEdgeAt(shell);
112 return (
113 failedAt !== undefined &&
114 (shell.snoozedAt == null ||
115 (failedAt != null && Date.parse(failedAt) > Date.parse(shell.snoozedAt)))
116 );
117}
118
119// V2 runtime.updatedAt includes metadata writes, not just failures.
120// Legacy sessions only expose the runtime timestamp.
121function failureEdgeAt(shell: ThreadSnoozeShell): string | null | undefined {
122 const runtime = shell.runtime ?? shell.session ?? null;
123 if (runtime?.status !== "error" && runtime?.status !== "failed") return undefined;
124 const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
125 if (latestRun?.status === "failed") {
126 return latestRun.completedAt ?? null;
127 }
128 return runtime.updatedAt ?? null;
129}
130
131export function canSnooze(
132 shell: Pick<
133 ThreadSnoozeShell,
134 | "hasPendingApprovals"
135 | "hasPendingUserInput"
136 | "pendingSubagentRequest"
137 | "pendingGuestMessageCount"
138 | "latestUserMessageAt"
139 | "latestTurn"
140 | "latestRun"
141 | "session"
142 | "runtime"
143 | "source"
144 >,
145 options: { readonly now: string },
146): boolean {
147 if (shell.hasPendingApprovals) return false;
148 const request = shell.source?.pendingRuntimeRequest;
149 if (request != null && request.kind !== "user_input") return false;
150 if (shell.pendingSubagentRequest != null) return false;
151 // A waiting guest message would wake it at once.
152 if ((shell.pendingGuestMessageCount ?? 0) > 0) return false;
153 if (hasQueuedTurnStart(shell, options)) return false;
154 return true;
155}
156
157export function effectiveSnoozed(
158 shell: ThreadSnoozeShell,
159 options: { readonly now: string },
160): boolean {
161 if (shell.snoozedUntil == null) return false;
162 const wakeAtMs = Date.parse(shell.snoozedUntil);
163 if (Number.isNaN(wakeAtMs)) return false;
164 if (wakeAtMs <= Date.parse(options.now)) return false;
165 return !threadRaisedHandWhileSnoozed(shell);
166}
167
168export function threadWokeAt(
169 shell: ThreadSnoozeShell,
170 options: { readonly now: string },
171): string | null {
172 if (shell.snoozedUntil == null) return null;
173 const wakeAtMs = Date.parse(shell.snoozedUntil);
174 if (Number.isNaN(wakeAtMs)) return null;
175 // An early hand-raise wake stays authoritative even after the scheduled
176 // wake time passes: reporting snoozedUntil then would resurface a Woke
177 // indicator the user already cleared by visiting (snoozedUntil is newer
178 // than that visit's lastVisitedAt).
179 if (threadRaisedHandWhileSnoozed(shell)) {
180 const failedAt = failureEdgeAt(shell);
181 const questionAt = pendingQuestionAt(shell);
182 const freshFailure =
183 failedAt != null &&
184 (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt));
185 const freshQuestion =
186 questionAt != null &&
187 shell.snoozedAt != null &&
188 Date.parse(questionAt) > Date.parse(shell.snoozedAt);
189 if (freshFailure && (!freshQuestion || Date.parse(failedAt) >= Date.parse(questionAt))) {
190 return failedAt;
191 }
192 if (freshQuestion) return questionAt;
193 const runtime = shell.runtime ?? shell.session ?? null;
194 return runtime?.updatedAt ?? shell.snoozedAt ?? null;
195 }
196 return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;
197}
198
199const HOUR_MS = 60 * 60 * 1_000;
200
201const WEEKDAY_INDEX = {
202 sunday: 0,
203 monday: 1,
204 tuesday: 2,
205 wednesday: 3,
206 thursday: 4,
207 friday: 5,
208 saturday: 6,
209} as const;
210
211export interface SnoozePreset {
212 readonly id: string;
213 readonly label: string;
214 readonly whenLabel: string;
215 readonly snoozedUntil: string;
216}
217
218export interface SnoozePresetOptions {
219 readonly presets?: ReadonlyArray<SnoozePresetSetting>;
220 readonly formatTime?: (date: Date) => string;
221}
222
223function snoozeTimeOfDayLabel(date: Date): string {
224 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
225}
226
227// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets
228// land on the wrong local day across DST transitions (a spring-forward day
229// is 23 hours, so 23:30 + 24h skips the whole next day).
230function addSnoozeDays(base: Date, days: number): Date {
231 const next = DateTime.toDate(DateTime.makeUnsafe(base));
232 next.setDate(next.getDate() + days);
233 return next;
234}
235
236function pluralize(count: number, unit: string): string {
237 return `${count} ${unit}${count === 1 ? "" : "s"}`;
238}
239
240/** "3 hours", "90 minutes", "2 days": the largest unit that divides evenly. */
241function formatSnoozeDuration(minutes: number): string {
242 if (minutes % (24 * 60) === 0) return pluralize(minutes / (24 * 60), "day");
243 if (minutes % 60 === 0) return pluralize(minutes / 60, "hour");
244 return pluralize(minutes, "minute");
245}
246
247function capitalize(word: string): string {
248 return word[0]!.toUpperCase() + word.slice(1);
249}
250
251function snoozePresetDaysAhead(
252 setting: Extract<SnoozePresetSetting, { kind: "at" }>,
253 now: Date,
254): number {
255 if (setting.day === "today") return 0;
256 if (setting.day === "tomorrow") return 1;
257 return (WEEKDAY_INDEX[setting.day] - now.getDay() + 7) % 7 || 7;
258}
259
260/** Labels follow the actual day gap, so a Monday option on a Sunday reads "Tomorrow". */
261function snoozePresetLabel(setting: SnoozePresetSetting, daysAhead: number): string {
262 if (setting.kind === "after") return `In ${formatSnoozeDuration(setting.minutes)}`;
263 if (daysAhead === 0) {
264 const hour = Number(setting.time.slice(0, 2));
265 return hour < 12 ? "This morning" : hour < 17 ? "This afternoon" : "This evening";
266 }
267 if (daysAhead === 1) return "Tomorrow";
268 if (setting.day === "monday") return "Next week";
269 return daysAhead === 7 ? `Next ${capitalize(setting.day)}` : capitalize(setting.day);
270}
271
272export function snoozePresetId(setting: SnoozePresetSetting): string {
273 return setting.kind === "after"
274 ? `after:${setting.minutes}`
275 : `at:${setting.day}:${setting.time}`;
276}
277
278export function resolveSnoozePreset(
279 setting: SnoozePresetSetting,
280 now: Date,
281 formatTime: (date: Date) => string = snoozeTimeOfDayLabel,
282): SnoozePreset | null {
283 let wake: Date;
284 let daysAhead = 0;
285 if (setting.kind === "after") {
286 wake = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + setting.minutes * 60_000));
287 } else {
288 daysAhead = snoozePresetDaysAhead(setting, now);
289 wake = addSnoozeDays(now, daysAhead);
290 wake.setHours(Number(setting.time.slice(0, 2)), Number(setting.time.slice(3)), 0, 0);
291 // A today option whose time has passed is left out until tomorrow.
292 if (wake.getTime() <= now.getTime()) return null;
293 }
294 const label = snoozePresetLabel(setting, daysAhead);
295 // Durations and "Next week" don't name the wake day, so a wake on another day shows it.
296 const labelNamesDay = setting.kind === "at" && label !== "Next week";
297 const time = formatTime(wake);
298 return {
299 id: snoozePresetId(setting),
300 label,
301 whenLabel:
302 labelNamesDay || wake.toDateString() === now.toDateString()
303 ? time
304 : `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`,
305 snoozedUntil: wake.toISOString(),
306 };
307}
308
309/** The snooze menu: soonest first, one row per wake minute. */
310export function resolveSnoozePresets(
311 now: Date,
312 options: SnoozePresetOptions = {},
313): ReadonlyArray<SnoozePreset> {
314 // By minute, so a duration (which keeps the clock's seconds) and a set time in the same
315 // minute tie, and the stable sort keeps whichever comes first in settings.
316 const wakeMinute = (preset: SnoozePreset) => Math.floor(Date.parse(preset.snoozedUntil) / 60_000);
317 const seen = new Set<number>();
318 return (
319 (options.presets ?? DEFAULT_SNOOZE_PRESETS)
320 .flatMap((setting) => resolveSnoozePreset(setting, now, options.formatTime) ?? [])
321 // flatMap returned a new array; Hermes has no `toSorted`.
322 .sort((a, b) => wakeMinute(a) - wakeMinute(b))
323 .filter((preset) => {
324 if (seen.has(wakeMinute(preset))) return false;
325 seen.add(wakeMinute(preset));
326 return true;
327 })
328 );
329}
330
331/**
332 * A menu choice as picked. A duration re-resolves from its setting (not the deduped menu) so it
333 * starts at selection. A set time keeps the shown wake, which a day change would otherwise shift.
334 */
335export function pickSnoozePreset(input: {
336 readonly shown: ReadonlyArray<SnoozePreset>;
337 readonly id: string;
338 readonly now: Date;
339 readonly presets?: ReadonlyArray<SnoozePresetSetting>;
340 readonly formatTime?: (date: Date) => string;
341}): SnoozePreset | undefined {
342 const original = input.shown.find((preset) => preset.id === input.id);
343 const ahead =
344 original && Date.parse(original.snoozedUntil) > input.now.getTime() ? original : undefined;
345 const setting = (input.presets ?? DEFAULT_SNOOZE_PRESETS).find(
346 (candidate) => candidate.kind === "after" && snoozePresetId(candidate) === input.id,
347 );
348 return (setting && resolveSnoozePreset(setting, input.now, input.formatTime)) ?? ahead;
349}
350
351export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string {
352 const wakeMs = Date.parse(snoozedUntil);
353 const nowMs = Date.parse(options.now);
354 if (Number.isNaN(wakeMs) || Number.isNaN(nowMs)) return "now";
355 const remainingMs = wakeMs - nowMs;
356 if (remainingMs <= 0) return "now";
357 if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`;
358 if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`;
359 return `${Math.ceil(remainingMs / DAY_MS)}d`;
360}
361
362export type CustomSnoozeInput =
363 | { readonly mode: "date"; readonly date: string; readonly time: string }
364 | {
365 readonly mode: "duration";
366 readonly amount: string;
367 readonly unit: "minutes" | "hours" | "days";
368 };
369
370export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string | null {
371 let wake: Date;
372 if (input.mode === "duration") {
373 const amount = Number(input.amount);
374 if (!Number.isFinite(amount) || amount <= 0) return null;
375 const unitMs = { minutes: 60_000, hours: HOUR_MS, days: 24 * HOUR_MS }[input.unit];
376 wake = new Date(now.getTime() + amount * unitMs);
377 } else {
378 if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date) || !/^\d{2}:\d{2}$/.test(input.time)) return null;
379 wake = new Date(`${input.date}T${input.time}:00`);
380 // Reject rolled-over dates and nonexistent local times during DST changes.
381 if (localSnoozeDate(wake) !== input.date || localSnoozeTime(wake) !== input.time) return null;
382 }
383 return Number.isFinite(wake.getTime()) && wake.getTime() > now.getTime()
384 ? wake.toISOString()
385 : null;
386}
387
388export function localSnoozeDate(date: Date): string {
389 return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
390}
391
392export function localSnoozeTime(date: Date): string {
393 return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
394}
395
396/**
397 * Whether a woken thread woke after the user last saw it. A run that completed
398 * after the last visit counts as seeing it, since the user watched it finish.
399 */
400export function isThreadWokeUnseen(input: {
401 readonly wokeAt: string | null;
402 readonly settled: boolean;
403 readonly lastVisitedAt: string | null | undefined;
404 readonly latestRunCompletedAt: string | null;
405}): boolean {
406 if (input.wokeAt === null || input.settled) return false;
407 const wokeAtMs = Date.parse(input.wokeAt);
408 if (Number.isNaN(wokeAtMs)) return false;
409 const seenAt = [input.lastVisitedAt, input.latestRunCompletedAt]
410 .map((value) => (value ? Date.parse(value) : NaN))
411 .filter((ms) => !Number.isNaN(ms));
412 return seenAt.every((ms) => ms < wokeAtMs);
413}