Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
49 lines
1.5 KiB
TypeScript
49 lines
1.5 KiB
TypeScript
import { knownTimeZone } from "./cron.js";
|
|
|
|
/** Minutes after local midnight; `start` > `end` means the range crosses midnight. */
|
|
export interface MinuteRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
/** The local calendar date ("YYYY-MM-DD") and minute of day at `now`, UTC when the zone is unknown. */
|
|
export function localDateAndMinute(
|
|
now: Date,
|
|
timeZone: string | null | undefined,
|
|
): { date: string; minute: number } {
|
|
const parts = new Map(
|
|
new Intl.DateTimeFormat("en-US", {
|
|
timeZone: knownTimeZone(timeZone) ?? "UTC",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hourCycle: "h23",
|
|
})
|
|
.formatToParts(now)
|
|
.map((part) => [part.type, part.value]),
|
|
);
|
|
return {
|
|
date: `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`,
|
|
minute: Number(parts.get("hour")) * 60 + Number(parts.get("minute")),
|
|
};
|
|
}
|
|
|
|
/** True when `minute` lies in the range; the start is inclusive, the end exclusive. */
|
|
export function isQuietMinute(range: MinuteRange | null, minute: number): boolean {
|
|
if (!range || range.start === range.end) return false;
|
|
return range.start < range.end
|
|
? minute >= range.start && minute < range.end
|
|
: minute >= range.start || minute < range.end;
|
|
}
|
|
|
|
/** True when `now` falls in the quiet hours of a user in `timeZone`. */
|
|
export function isQuietTime(
|
|
range: MinuteRange | null,
|
|
timeZone: string | null | undefined,
|
|
now: Date,
|
|
): boolean {
|
|
return isQuietMinute(range, localDateAndMinute(now, timeZone).minute);
|
|
}
|