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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user