feat: weekly digest + local-time quiet hours

Weekly Digest (send_weekly_digest Celery task):
- Runs every Monday 8:30 AM UTC via beat schedule
- Queries all followed bills updated in the past 7 days per user
- Sends low-priority ntfy push (Priority: low, Tags: newspaper,calendar)
- Creates a NotificationEvent (weekly_digest type) for RSS feed visibility
- Admin can trigger immediately via POST /api/admin/trigger-weekly-digest
- Manual Controls panel now includes "Send Weekly Digest" button

Local-time quiet hours:
- Browser auto-detects IANA timezone via Intl.DateTimeFormat().resolvedOptions().timeZone
- Timezone saved to notification_prefs alongside quiet_hours_start/end on Save
- Dispatcher converts UTC → user's local time (zoneinfo stdlib) before hour comparison
- Falls back to UTC if timezone absent or unrecognised
- Quiet hours UI: 12-hour AM/PM selectors, shows detected timezone as hint
- Clearing quiet hours also clears stored timezone

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 22:04:54 -05:00
parent a0e7ab4cd3
commit 0de8c83987
10 changed files with 234 additions and 32 deletions

View File

@@ -16,10 +16,11 @@ const AUTH_METHODS = [
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
];
const HOURS = Array.from({ length: 24 }, (_, i) => ({
value: i,
label: `${i.toString().padStart(2, "0")}:00 UTC`,
}));
const HOURS = Array.from({ length: 24 }, (_, i) => {
const period = i < 12 ? "AM" : "PM";
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
return { value: i, label: `${hour}:00 ${period}` };
});
const EVENT_META: Record<string, { label: string; icon: typeof Bell; color: string }> = {
new_document: { label: "New Bill Text", icon: FileText, color: "text-blue-500" },
@@ -83,12 +84,23 @@ export default function NotificationsPage() {
const [quietStart, setQuietStart] = useState(22);
const [quietEnd, setQuietEnd] = useState(8);
const [quietSaved, setQuietSaved] = useState(false);
const [detectedTimezone, setDetectedTimezone] = useState<string>("");
const [savedTimezone, setSavedTimezone] = useState<string | null>(null);
// Digest state
const [digestEnabled, setDigestEnabled] = useState(false);
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
const [digestSaved, setDigestSaved] = useState(false);
// Detect the browser's local timezone once on mount
useEffect(() => {
try {
setDetectedTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
} catch {
// very old browser — leave empty, dispatcher will fall back to UTC
}
}, []);
// Populate from loaded settings
useEffect(() => {
if (!settings) return;
@@ -99,6 +111,7 @@ export default function NotificationsPage() {
setPassword(settings.ntfy_password ?? "");
setDigestEnabled(settings.digest_enabled ?? false);
setDigestFrequency(settings.digest_frequency ?? "daily");
setSavedTimezone(settings.timezone ?? null);
if (settings.quiet_hours_start != null) {
setQuietEnabled(true);
setQuietStart(settings.quiet_hours_start);
@@ -130,17 +143,20 @@ export default function NotificationsPage() {
};
const saveQuietHours = () => {
const onSuccess = () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); };
if (quietEnabled) {
update.mutate(
{ quiet_hours_start: quietStart, quiet_hours_end: quietEnd },
{ onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
{
quiet_hours_start: quietStart,
quiet_hours_end: quietEnd,
// Include the detected timezone so the dispatcher knows which local time to compare
...(detectedTimezone ? { timezone: detectedTimezone } : {}),
},
{ onSuccess }
);
} else {
// -1 signals the backend to clear the values
update.mutate(
{ quiet_hours_start: -1 },
{ onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
);
// -1 signals the backend to clear quiet hours + timezone
update.mutate({ quiet_hours_start: -1 }, { onSuccess });
}
};
@@ -324,7 +340,7 @@ export default function NotificationsPage() {
</h2>
<p className="text-xs text-muted-foreground mt-1">
Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end.
All times are UTC. RSS is unaffected.
RSS is unaffected.
</p>
</div>
@@ -335,23 +351,33 @@ export default function NotificationsPage() {
</label>
{quietEnabled && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
)}
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
{detectedTimezone && (
<p className="text-xs text-muted-foreground">
Times are in your local timezone: <span className="font-medium text-foreground">{detectedTimezone}</span>
{savedTimezone && savedTimezone !== detectedTimezone && (
<span className="text-amber-600 dark:text-amber-400"> · saved as {savedTimezone} save to update</span>
)}
</p>
)}
</div>
)}

View File

@@ -734,6 +734,13 @@ export default function SettingsPage() {
count: stats?.pending_llm,
countLabel: "bills pending analysis",
},
{
key: "weekly-digest",
name: "Send Weekly Digest",
description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.",
fn: adminAPI.triggerWeeklyDigest,
status: "on-demand",
},
];
const maintenance: ControlItem[] = [

View File

@@ -241,6 +241,8 @@ export const adminAPI = {
apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
resumeAnalysis: () =>
apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
triggerWeeklyDigest: () =>
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
getApiHealth: () =>
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
getTaskStatus: (taskId: string) =>

View File

@@ -171,6 +171,7 @@ export interface NotificationSettings {
digest_frequency: "daily" | "weekly";
quiet_hours_start: number | null;
quiet_hours_end: number | null;
timezone: string | null; // IANA name, e.g. "America/New_York"
}
export interface NotificationEvent {