feat: granular per-mode alert filters (v0.9.3)
Replace coarse milestone/referral suppression with 8 named action categories (vote, presidential, committee_report, calendar, procedural, referral, new_document, new_amendment), each independently togglable per follow mode (Follow / Pocket Veto / Pocket Boost). - notification_utils: categorize_action() replaces is_milestone_action / is_referral_action; _build_payload stores action_category in payload - congress_poller: use categorize_action() in _update_bill_if_changed - notification_dispatcher: _should_dispatch() checks per-mode filter dict from notification_prefs; follow mode looked up before filter check - schemas + api: alert_filters (nested dict) wired through settings GET/PUT endpoints; no DB migration required - frontend: tabbed Alert Filters section (Follow / Pocket Veto / Pocket Boost), each with independent 8-toggle filter set, milestone parent checkbox (indeterminate-aware), Load defaults button, and per-tab Save button Authored-By: Jack Levy
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
||||
FlaskConical, Clock, Calendar, FileText, AlertTriangle,
|
||||
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
|
||||
@@ -29,6 +29,124 @@ const EVENT_META: Record<string, { label: string; icon: typeof Bell; color: stri
|
||||
bill_updated: { label: "Bill Updated", icon: AlertTriangle, color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const FILTER_ROWS = [
|
||||
{ key: "new_document", label: "New bill text", hint: "The full text of the bill is published" },
|
||||
{ key: "new_amendment", label: "Amendment filed", hint: "An amendment is filed against the bill" },
|
||||
{ key: "vote", label: "Chamber votes", hint: "Bill passes or fails a House or Senate vote" },
|
||||
{ key: "presidential", label: "Presidential action", hint: "Signed into law, vetoed, or enacted" },
|
||||
{ key: "committee_report", label: "Committee report", hint: "Committee votes to advance or kill the bill" },
|
||||
{ key: "calendar", label: "Calendar placement", hint: "Scheduled for floor consideration" },
|
||||
{ key: "procedural", label: "Procedural", hint: "Senate cloture votes; conference committee activity" },
|
||||
{ key: "referral", label: "Committee referral", hint: "Bill assigned to a committee — first step for almost every bill" },
|
||||
] as const;
|
||||
|
||||
const MILESTONE_KEYS = ["vote", "presidential", "committee_report", "calendar", "procedural"] as const;
|
||||
|
||||
const ALL_OFF = Object.fromEntries(FILTER_ROWS.map((r) => [r.key, false]));
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
key: "neutral",
|
||||
label: "Follow",
|
||||
description: "Bills you follow in standard mode",
|
||||
preset: { new_document: true, new_amendment: false, vote: true, presidential: true, committee_report: true, calendar: false, procedural: false, referral: false },
|
||||
},
|
||||
{
|
||||
key: "pocket_veto",
|
||||
label: "Pocket Veto",
|
||||
description: "Bills you're watching to oppose",
|
||||
preset: { new_document: false, new_amendment: false, vote: true, presidential: true, committee_report: false, calendar: false, procedural: false, referral: false },
|
||||
},
|
||||
{
|
||||
key: "pocket_boost",
|
||||
label: "Pocket Boost",
|
||||
description: "Bills you're actively supporting",
|
||||
preset: { new_document: true, new_amendment: true, vote: true, presidential: true, committee_report: true, calendar: true, procedural: true, referral: true },
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ModeKey = "neutral" | "pocket_veto" | "pocket_boost";
|
||||
|
||||
function ModeFilterSection({
|
||||
preset,
|
||||
filters,
|
||||
onChange,
|
||||
}: {
|
||||
preset: Record<string, boolean>;
|
||||
filters: Record<string, boolean>;
|
||||
onChange: (f: Record<string, boolean>) => void;
|
||||
}) {
|
||||
const milestoneCheckRef = useRef<HTMLInputElement>(null);
|
||||
const on = MILESTONE_KEYS.filter((k) => filters[k]);
|
||||
const milestoneState = on.length === 0 ? "off" : on.length === MILESTONE_KEYS.length ? "on" : "indeterminate";
|
||||
|
||||
useEffect(() => {
|
||||
if (milestoneCheckRef.current) {
|
||||
milestoneCheckRef.current.indeterminate = milestoneState === "indeterminate";
|
||||
}
|
||||
}, [milestoneState]);
|
||||
|
||||
const toggleMilestones = () => {
|
||||
const val = milestoneState !== "on";
|
||||
onChange({ ...filters, ...Object.fromEntries(MILESTONE_KEYS.map((k) => [k, val])) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-end mb-1">
|
||||
<button
|
||||
onClick={() => onChange({ ...ALL_OFF, ...preset })}
|
||||
className="bg-muted hover:bg-accent text-xs px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Load defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters["new_document"]}
|
||||
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters["new_amendment"]}
|
||||
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
|
||||
</label>
|
||||
|
||||
<div className="py-1.5">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
||||
onChange={toggleMilestones} className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
|
||||
</label>
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
||||
const row = FILTER_ROWS.find((r) => r.key === k)!;
|
||||
return (
|
||||
<label key={k} className="flex items-start gap-3 py-1 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters[k]}
|
||||
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters["referral"]}
|
||||
onChange={(e) => onChange({ ...filters, referral: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">Committee referral</span><span className="text-xs text-muted-foreground ml-2">Bill assigned to a committee — first step for almost every bill</span></div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
@@ -98,6 +216,15 @@ export default function NotificationsPage() {
|
||||
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
|
||||
const [digestSaved, setDigestSaved] = useState(false);
|
||||
|
||||
// Alert filter state — one set of 8 filters per follow mode
|
||||
const [alertFilters, setAlertFilters] = useState<Record<ModeKey, Record<string, boolean>>>({
|
||||
neutral: { ...ALL_OFF, ...MODES[0].preset },
|
||||
pocket_veto: { ...ALL_OFF, ...MODES[1].preset },
|
||||
pocket_boost: { ...ALL_OFF, ...MODES[2].preset },
|
||||
});
|
||||
const [activeFilterTab, setActiveFilterTab] = useState<ModeKey>("neutral");
|
||||
const [filtersSaved, setFiltersSaved] = useState(false);
|
||||
|
||||
// Detect the browser's local timezone once on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -125,6 +252,14 @@ export default function NotificationsPage() {
|
||||
} else {
|
||||
setQuietEnabled(false);
|
||||
}
|
||||
if (settings.alert_filters) {
|
||||
const af = settings.alert_filters as Record<string, Record<string, boolean>>;
|
||||
setAlertFilters({
|
||||
neutral: { ...ALL_OFF, ...MODES[0].preset, ...(af.neutral || {}) },
|
||||
pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) },
|
||||
pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) },
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const saveNtfy = (enabled: boolean) => {
|
||||
@@ -173,6 +308,13 @@ export default function NotificationsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const saveAlertFilters = () => {
|
||||
update.mutate(
|
||||
{ alert_filters: alertFilters },
|
||||
{ onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } }
|
||||
);
|
||||
};
|
||||
|
||||
const testNtfy = async () => {
|
||||
setNtfyTesting(true);
|
||||
setNtfyTestResult(null);
|
||||
@@ -338,6 +480,56 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alert Filters */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" /> Alert Filters
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Each follow mode has its own independent filter set. "Load defaults" resets that mode to its recommended starting point.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.key}
|
||||
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeFilterTab === mode.key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab panels */}
|
||||
{MODES.map((mode) => activeFilterTab === mode.key && (
|
||||
<div key={mode.key} className="space-y-4">
|
||||
<ModeFilterSection
|
||||
preset={mode.preset}
|
||||
filters={alertFilters[mode.key as ModeKey]}
|
||||
onChange={(f) => setAlertFilters((prev) => ({ ...prev, [mode.key]: f }))}
|
||||
/>
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
onClick={saveAlertFilters}
|
||||
disabled={update.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{filtersSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
|
||||
{filtersSaved ? "Saved!" : `Save ${mode.label} Filters`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -182,6 +182,7 @@ export interface NotificationSettings {
|
||||
quiet_hours_start: number | null;
|
||||
quiet_hours_end: number | null;
|
||||
timezone: string | null; // IANA name, e.g. "America/New_York"
|
||||
alert_filters: Record<string, Record<string, boolean>> | null;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
|
||||
Reference in New Issue
Block a user