evOo-local/packages/core/src/quiet-hours.ts
Claude a163447461
feat(notifications): add push subscriptions, notification settings and web push interfaces
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
2026-10-03 13:13:22 +00:00

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);
}