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:
@@ -50,6 +50,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
|
|||||||
quiet_hours_start=prefs.get("quiet_hours_start"),
|
quiet_hours_start=prefs.get("quiet_hours_start"),
|
||||||
quiet_hours_end=prefs.get("quiet_hours_end"),
|
quiet_hours_end=prefs.get("quiet_hours_end"),
|
||||||
timezone=prefs.get("timezone"),
|
timezone=prefs.get("timezone"),
|
||||||
|
alert_filters=prefs.get("alert_filters"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +101,8 @@ async def update_notification_settings(
|
|||||||
prefs["quiet_hours_end"] = body.quiet_hours_end
|
prefs["quiet_hours_end"] = body.quiet_hours_end
|
||||||
if body.timezone is not None:
|
if body.timezone is not None:
|
||||||
prefs["timezone"] = body.timezone
|
prefs["timezone"] = body.timezone
|
||||||
|
if body.alert_filters is not None:
|
||||||
|
prefs["alert_filters"] = body.alert_filters
|
||||||
# Allow clearing quiet hours by passing -1
|
# Allow clearing quiet hours by passing -1
|
||||||
if body.quiet_hours_start == -1:
|
if body.quiet_hours_start == -1:
|
||||||
prefs.pop("quiet_hours_start", None)
|
prefs.pop("quiet_hours_start", None)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class NotificationSettingsResponse(BaseModel):
|
|||||||
quiet_hours_start: Optional[int] = None
|
quiet_hours_start: Optional[int] = None
|
||||||
quiet_hours_end: Optional[int] = None
|
quiet_hours_end: Optional[int] = None
|
||||||
timezone: Optional[str] = None # IANA name, e.g. "America/New_York"
|
timezone: Optional[str] = None # IANA name, e.g. "America/New_York"
|
||||||
|
alert_filters: Optional[dict] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
quiet_hours_start: Optional[int] = None
|
quiet_hours_start: Optional[int] = None
|
||||||
quiet_hours_end: Optional[int] = None
|
quiet_hours_end: Optional[int] = None
|
||||||
timezone: Optional[str] = None # IANA name sent by the browser on save
|
timezone: Optional[str] = None # IANA name sent by the browser on save
|
||||||
|
alert_filters: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class NotificationEventSchema(BaseModel):
|
class NotificationEventSchema(BaseModel):
|
||||||
|
|||||||
@@ -339,16 +339,13 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
|
|||||||
emit_bill_notification,
|
emit_bill_notification,
|
||||||
emit_member_follow_notifications,
|
emit_member_follow_notifications,
|
||||||
emit_topic_follow_notifications,
|
emit_topic_follow_notifications,
|
||||||
is_milestone_action,
|
categorize_action,
|
||||||
is_referral_action,
|
|
||||||
)
|
)
|
||||||
action_text = parsed.get("latest_action_text", "")
|
action_text = parsed.get("latest_action_text", "")
|
||||||
is_milestone = is_milestone_action(action_text)
|
action_category = categorize_action(action_text)
|
||||||
is_referral = not is_milestone and is_referral_action(action_text)
|
if action_category:
|
||||||
if is_milestone or is_referral:
|
emit_bill_notification(db, existing, "bill_updated", action_text, action_category=action_category)
|
||||||
tier = "progress" if is_milestone else "referral"
|
emit_member_follow_notifications(db, existing, "bill_updated", action_text, action_category=action_category)
|
||||||
emit_bill_notification(db, existing, "bill_updated", action_text, milestone_tier=tier)
|
|
||||||
emit_member_follow_notifications(db, existing, "bill_updated", action_text, milestone_tier=tier)
|
|
||||||
# Topic followers — pull tags from the bill's latest brief
|
# Topic followers — pull tags from the bill's latest brief
|
||||||
from app.models.brief import BillBrief
|
from app.models.brief import BillBrief
|
||||||
latest_brief = (
|
latest_brief = (
|
||||||
@@ -359,7 +356,7 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
|
|||||||
)
|
)
|
||||||
topic_tags = latest_brief.topic_tags or [] if latest_brief else []
|
topic_tags = latest_brief.topic_tags or [] if latest_brief else []
|
||||||
emit_topic_follow_notifications(
|
emit_topic_follow_notifications(
|
||||||
db, existing, "bill_updated", action_text, topic_tags, milestone_tier=tier
|
db, existing, "bill_updated", action_text, topic_tags, action_category=action_category
|
||||||
)
|
)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,32 @@ _EVENT_PRIORITY = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_FILTER_DEFAULTS = {
|
||||||
|
"new_document": False, "new_amendment": False, "vote": False,
|
||||||
|
"presidential": False, "committee_report": False, "calendar": False,
|
||||||
|
"procedural": False, "referral": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
|
||||||
|
payload = event.payload or {}
|
||||||
|
|
||||||
|
# Map event type directly for document events
|
||||||
|
if event.event_type == "new_document":
|
||||||
|
key = "new_document"
|
||||||
|
elif event.event_type == "new_amendment":
|
||||||
|
key = "new_amendment"
|
||||||
|
else:
|
||||||
|
# Use action_category if present (new events), fall back from milestone_tier (old events)
|
||||||
|
key = payload.get("action_category")
|
||||||
|
if not key:
|
||||||
|
key = "referral" if payload.get("milestone_tier") == "referral" else "vote"
|
||||||
|
|
||||||
|
all_filters = prefs.get("alert_filters") or {}
|
||||||
|
mode_filters = all_filters.get(follow_mode) or {}
|
||||||
|
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
|
||||||
|
|
||||||
|
|
||||||
def _in_quiet_hours(prefs: dict, now: datetime) -> bool:
|
def _in_quiet_hours(prefs: dict, now: datetime) -> bool:
|
||||||
"""Return True if the current local time falls within the user's quiet window.
|
"""Return True if the current local time falls within the user's quiet window.
|
||||||
|
|
||||||
@@ -104,23 +130,12 @@ def dispatch_notifications(self):
|
|||||||
).first()
|
).first()
|
||||||
follow_mode = follow.follow_mode if follow else "neutral"
|
follow_mode = follow.follow_mode if follow else "neutral"
|
||||||
|
|
||||||
# Pocket Veto: only milestone (bill_updated) events; skip LLM brief events
|
|
||||||
if follow_mode == "pocket_veto" and event.event_type in ("new_document", "new_amendment"):
|
|
||||||
event.dispatched_at = now
|
|
||||||
db.commit()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Referral-tier events (committee referrals) are noisy for loose topic follows;
|
|
||||||
# suppress them only for topic-follow events so direct bill/member followers
|
|
||||||
# still get notified when their bill is referred to committee.
|
|
||||||
payload = event.payload or {}
|
|
||||||
is_topic_follow = payload.get("source") == "topic_follow"
|
|
||||||
if follow_mode == "neutral" and payload.get("milestone_tier") == "referral" and is_topic_follow:
|
|
||||||
event.dispatched_at = now
|
|
||||||
db.commit()
|
|
||||||
continue
|
|
||||||
|
|
||||||
prefs = user.notification_prefs or {}
|
prefs = user.notification_prefs or {}
|
||||||
|
|
||||||
|
if not _should_dispatch(event, prefs, follow_mode):
|
||||||
|
event.dispatched_at = now
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
|
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
|
||||||
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
|
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
|
||||||
ntfy_token = prefs.get("ntfy_token", "").strip()
|
ntfy_token = prefs.get("ntfy_token", "").strip()
|
||||||
|
|||||||
@@ -4,39 +4,31 @@ Centralised here to avoid circular imports.
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
_MILESTONE_KEYWORDS = [
|
_VOTE_KW = ["passed", "failed", "agreed to", "roll call"]
|
||||||
"passed", "failed", "agreed to",
|
_PRES_KW = ["signed", "vetoed", "enacted", "presented to the president"]
|
||||||
"signed", "vetoed", "enacted",
|
_COMMITTEE_KW = ["markup", "ordered to be reported", "ordered reported", "reported by", "discharged"]
|
||||||
"presented to the president",
|
_CALENDAR_KW = ["placed on"]
|
||||||
"ordered to be reported", "ordered reported",
|
_PROCEDURAL_KW = ["cloture", "conference"]
|
||||||
"reported by", "discharged",
|
_REFERRAL_KW = ["referred to"]
|
||||||
"placed on", # placed on calendar
|
|
||||||
"cloture", "roll call",
|
|
||||||
"markup", # markup session scheduled/completed
|
|
||||||
"conference", # conference committee activity
|
|
||||||
]
|
|
||||||
|
|
||||||
# Committee referral — meaningful for pocket_veto/boost but noisy for neutral
|
|
||||||
_REFERRAL_KEYWORDS = [
|
|
||||||
"referred to",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Events created within this window for the same (user, bill, event_type) are suppressed
|
# Events created within this window for the same (user, bill, event_type) are suppressed
|
||||||
_DEDUP_MINUTES = 30
|
_DEDUP_MINUTES = 30
|
||||||
|
|
||||||
|
|
||||||
def is_milestone_action(action_text: str) -> bool:
|
def categorize_action(action_text: str) -> str | None:
|
||||||
|
"""Return the action category string, or None if not notification-worthy."""
|
||||||
t = (action_text or "").lower()
|
t = (action_text or "").lower()
|
||||||
return any(kw in t for kw in _MILESTONE_KEYWORDS)
|
if any(kw in t for kw in _VOTE_KW): return "vote"
|
||||||
|
if any(kw in t for kw in _PRES_KW): return "presidential"
|
||||||
|
if any(kw in t for kw in _COMMITTEE_KW): return "committee_report"
|
||||||
def is_referral_action(action_text: str) -> bool:
|
if any(kw in t for kw in _CALENDAR_KW): return "calendar"
|
||||||
t = (action_text or "").lower()
|
if any(kw in t for kw in _PROCEDURAL_KW): return "procedural"
|
||||||
return any(kw in t for kw in _REFERRAL_KEYWORDS)
|
if any(kw in t for kw in _REFERRAL_KW): return "referral"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
bill, action_summary: str, milestone_tier: str = "progress", source: str = "bill_follow"
|
bill, action_summary: str, action_category: str, source: str = "bill_follow"
|
||||||
) -> dict:
|
) -> dict:
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
|
base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
|
||||||
@@ -45,7 +37,9 @@ def _build_payload(
|
|||||||
"bill_label": f"{bill.bill_type.upper()} {bill.bill_number}",
|
"bill_label": f"{bill.bill_type.upper()} {bill.bill_number}",
|
||||||
"brief_summary": (action_summary or "")[:300],
|
"brief_summary": (action_summary or "")[:300],
|
||||||
"bill_url": f"{base_url}/bills/{bill.bill_id}",
|
"bill_url": f"{base_url}/bills/{bill.bill_id}",
|
||||||
"milestone_tier": milestone_tier,
|
"action_category": action_category,
|
||||||
|
# kept for RSS/history backwards compat
|
||||||
|
"milestone_tier": "referral" if action_category == "referral" else "progress",
|
||||||
"source": source,
|
"source": source,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +56,7 @@ def _is_duplicate(db, user_id: int, bill_id: str, event_type: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def emit_bill_notification(
|
def emit_bill_notification(
|
||||||
db, bill, event_type: str, action_summary: str, milestone_tier: str = "progress"
|
db, bill, event_type: str, action_summary: str, action_category: str = "vote"
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Create NotificationEvent rows for every user following this bill. Returns count."""
|
"""Create NotificationEvent rows for every user following this bill. Returns count."""
|
||||||
from app.models.follow import Follow
|
from app.models.follow import Follow
|
||||||
@@ -72,7 +66,7 @@ def emit_bill_notification(
|
|||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
payload = _build_payload(bill, action_summary, milestone_tier, source="bill_follow")
|
payload = _build_payload(bill, action_summary, action_category, source="bill_follow")
|
||||||
count = 0
|
count = 0
|
||||||
for follow in followers:
|
for follow in followers:
|
||||||
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
||||||
@@ -90,7 +84,7 @@ def emit_bill_notification(
|
|||||||
|
|
||||||
|
|
||||||
def emit_member_follow_notifications(
|
def emit_member_follow_notifications(
|
||||||
db, bill, event_type: str, action_summary: str, milestone_tier: str = "progress"
|
db, bill, event_type: str, action_summary: str, action_category: str = "vote"
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify users following the bill's sponsor (dedup prevents double-alerts for bill+member followers)."""
|
"""Notify users following the bill's sponsor (dedup prevents double-alerts for bill+member followers)."""
|
||||||
if not bill.sponsor_id:
|
if not bill.sponsor_id:
|
||||||
@@ -103,7 +97,7 @@ def emit_member_follow_notifications(
|
|||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
payload = _build_payload(bill, action_summary, milestone_tier, source="member_follow")
|
payload = _build_payload(bill, action_summary, action_category, source="member_follow")
|
||||||
count = 0
|
count = 0
|
||||||
for follow in followers:
|
for follow in followers:
|
||||||
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
||||||
@@ -122,7 +116,7 @@ def emit_member_follow_notifications(
|
|||||||
|
|
||||||
def emit_topic_follow_notifications(
|
def emit_topic_follow_notifications(
|
||||||
db, bill, event_type: str, action_summary: str, topic_tags: list,
|
db, bill, event_type: str, action_summary: str, topic_tags: list,
|
||||||
milestone_tier: str = "progress",
|
action_category: str = "vote",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify users following any of the bill's topic tags."""
|
"""Notify users following any of the bill's topic tags."""
|
||||||
if not topic_tags:
|
if not topic_tags:
|
||||||
@@ -143,7 +137,7 @@ def emit_topic_follow_notifications(
|
|||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
payload = _build_payload(bill, action_summary, milestone_tier, source="topic_follow")
|
payload = _build_payload(bill, action_summary, action_category, source="topic_follow")
|
||||||
count = 0
|
count = 0
|
||||||
for follow in followers:
|
for follow in followers:
|
||||||
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
||||||
FlaskConical, Clock, Calendar, FileText, AlertTriangle,
|
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
|
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" },
|
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) {
|
function timeAgo(iso: string) {
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
const m = Math.floor(diff / 60000);
|
const m = Math.floor(diff / 60000);
|
||||||
@@ -98,6 +216,15 @@ export default function NotificationsPage() {
|
|||||||
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
|
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
|
||||||
const [digestSaved, setDigestSaved] = useState(false);
|
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
|
// Detect the browser's local timezone once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +252,14 @@ export default function NotificationsPage() {
|
|||||||
} else {
|
} else {
|
||||||
setQuietEnabled(false);
|
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]);
|
}, [settings]);
|
||||||
|
|
||||||
const saveNtfy = (enabled: boolean) => {
|
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 () => {
|
const testNtfy = async () => {
|
||||||
setNtfyTesting(true);
|
setNtfyTesting(true);
|
||||||
setNtfyTestResult(null);
|
setNtfyTestResult(null);
|
||||||
@@ -338,6 +480,56 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Quiet Hours */}
|
||||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export interface NotificationSettings {
|
|||||||
quiet_hours_start: number | null;
|
quiet_hours_start: number | null;
|
||||||
quiet_hours_end: number | null;
|
quiet_hours_end: number | null;
|
||||||
timezone: string | null; // IANA name, e.g. "America/New_York"
|
timezone: string | null; // IANA name, e.g. "America/New_York"
|
||||||
|
alert_filters: Record<string, Record<string, boolean>> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Collection {
|
export interface Collection {
|
||||||
|
|||||||
Reference in New Issue
Block a user