feat: Discovery alert filters + notification reasons (v0.9.6)

- Add 4th "Discovery" tab in Alert Filters for member/topic follow notifications,
  with per-source enable toggle, independent event-type filters, and per-entity
  mute chips (mute specific members/topics without unfollowing)
- Enrich notification event payloads with follow_mode, matched_member_name,
  matched_member_id, and matched_topic so each event knows why it was created
- Dispatcher branches on payload.source for member_follow/topic_follow events,
  checking source-level enabled toggle, per-event-type filters, and muted_ids/muted_tags
- Add _build_reason helper; ntfy messages append a "why" line (📌/👤/🏷)
- EventRow in notification history shows a small italic reason line
- Update How It Works: fix stale member/topic paragraph, add Discovery alerts item

Authored-by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-14 13:21:22 -04:00
parent 91473e6464
commit 247a874c8d
5 changed files with 281 additions and 15 deletions

View File

@@ -1,13 +1,13 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery, useMutation, useQueries } from "@tanstack/react-query";
import {
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter,
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
} from "lucide-react";
import Link from "next/link";
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api";
import { useFollows } from "@/lib/hooks/useFollows";
import type { NotificationEvent } from "@/lib/types";
@@ -66,6 +66,25 @@ const MODES = [
] as const;
type ModeKey = "neutral" | "pocket_veto" | "pocket_boost";
type DiscoverySourceKey = "member_follow" | "topic_follow";
type FilterTabKey = ModeKey | "discovery";
const DISCOVERY_SOURCES = [
{
key: "member_follow" as DiscoverySourceKey,
label: "Member Follows",
description: "Bills sponsored by members you follow",
preset: { new_document: false, new_amendment: false, vote: true, presidential: true,
committee_report: true, calendar: false, procedural: false, referral: false },
},
{
key: "topic_follow" as DiscoverySourceKey,
label: "Topic Follows",
description: "Bills matching topics you follow",
preset: { new_document: false, new_amendment: false, vote: true, presidential: true,
committee_report: false, calendar: false, procedural: false, referral: false },
},
] as const;
function ModeFilterSection({
preset,
@@ -73,8 +92,8 @@ function ModeFilterSection({
onChange,
}: {
preset: Record<string, boolean>;
filters: Record<string, boolean>;
onChange: (f: Record<string, boolean>) => void;
filters: Record<string, boolean | string[]>;
onChange: (f: Record<string, boolean | string[]>) => void;
}) {
const milestoneCheckRef = useRef<HTMLInputElement>(null);
const on = MILESTONE_KEYS.filter((k) => filters[k]);
@@ -217,14 +236,40 @@ export default function NotificationsPage() {
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>>>({
const [alertFilters, setAlertFilters] = useState<Record<ModeKey, Record<string, boolean | string[]>>>({
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 [discoveryFilters, setDiscoveryFilters] = useState<Record<DiscoverySourceKey, Record<string, boolean | string[]>>>({
member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset },
topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset },
});
const [activeFilterTab, setActiveFilterTab] = useState<FilterTabKey>("neutral");
const [filtersSaved, setFiltersSaved] = useState(false);
// Per-entity mute lists for Discovery — plain arrays; names resolved from memberById at render time
const [mutedMemberIds, setMutedMemberIds] = useState<string[]>([]);
const [mutedTopicTags, setMutedTopicTags] = useState<string[]>([]);
// Derive member/topic follows for the mute dropdowns
const memberFollows = follows.filter((f) => f.follow_type === "member");
const topicFollows = follows.filter((f) => f.follow_type === "topic");
// Batch-fetch member names so the "Mute a member…" dropdown shows real names
const memberQueries = useQueries({
queries: memberFollows.map((f) => ({
queryKey: ["member", f.follow_value],
queryFn: () => membersAPI.get(f.follow_value),
staleTime: 5 * 60 * 1000,
})),
});
const memberById: Record<string, string> = Object.fromEntries(
memberFollows
.map((f, i) => [f.follow_value, memberQueries[i]?.data?.name])
.filter(([, name]) => name)
);
// Detect the browser's local timezone once on mount
useEffect(() => {
try {
@@ -259,6 +304,12 @@ export default function NotificationsPage() {
pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) },
pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) },
});
setDiscoveryFilters({
member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset, ...(af.member_follow || {}) },
topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset, ...(af.topic_follow || {}) },
});
setMutedMemberIds(((af.member_follow as Record<string, unknown>)?.muted_ids as string[]) || []);
setMutedTopicTags(((af.topic_follow as Record<string, unknown>)?.muted_tags as string[]) || []);
}
}, [settings]);
@@ -310,7 +361,11 @@ export default function NotificationsPage() {
const saveAlertFilters = () => {
update.mutate(
{ alert_filters: alertFilters },
{ alert_filters: {
...alertFilters,
member_follow: { ...discoveryFilters.member_follow, muted_ids: mutedMemberIds },
topic_follow: { ...discoveryFilters.topic_follow, muted_tags: mutedTopicTags },
} },
{ onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } }
);
};
@@ -506,6 +561,16 @@ export default function NotificationsPage() {
{mode.label}
</button>
))}
<button
onClick={() => setActiveFilterTab("discovery")}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeFilterTab === "discovery"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Discovery
</button>
</div>
{/* Tab panels */}
@@ -528,6 +593,130 @@ export default function NotificationsPage() {
</div>
</div>
))}
{activeFilterTab === "discovery" && (
<div className="space-y-6">
{/* Member follows */}
{(() => {
const src = DISCOVERY_SOURCES[0];
const srcFilters = discoveryFilters[src.key];
const { enabled, ...alertOnly } = srcFilters;
const unmutedMembers = memberFollows.filter((f) => !mutedMemberIds.includes(f.follow_value));
return (
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" />
<div>
<span className="text-sm font-medium">Notify me about bills from member follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div>
</label>
{!!enabled && (
<div className="ml-6 space-y-4">
<ModeFilterSection preset={src.preset} filters={alertOnly}
onChange={(f) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} />
{/* Muted members */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Muted members</p>
<div className="flex flex-wrap gap-1.5">
{mutedMemberIds.length === 0 ? (
<span className="text-xs text-muted-foreground italic">None all followed members will trigger notifications</span>
) : mutedMemberIds.map((id) => (
<span key={id} className="inline-flex items-center gap-1.5 text-xs border border-border bg-muted rounded-full px-2.5 py-1">
<span>{memberById[id] ?? id}</span>
<button onClick={() => setMutedMemberIds((prev) => prev.filter((x) => x !== id))}
className="text-muted-foreground hover:text-foreground transition-colors leading-none">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
{unmutedMembers.length > 0 && (
<select value=""
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
<option value="" disabled>Mute a member</option>
{unmutedMembers.map((f) => (
<option key={f.follow_value} value={f.follow_value}>
{memberById[f.follow_value] ?? f.follow_value}
</option>
))}
</select>
)}
</div>
</div>
)}
</div>
);
})()}
<div className="border-t border-border" />
{/* Topic follows */}
{(() => {
const src = DISCOVERY_SOURCES[1];
const srcFilters = discoveryFilters[src.key];
const { enabled, ...alertOnly } = srcFilters;
const unmutedTopics = topicFollows.filter((f) => !mutedTopicTags.includes(f.follow_value));
return (
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={!!enabled}
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
className="rounded" />
<div>
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
</div>
</label>
{!!enabled && (
<div className="ml-6 space-y-4">
<ModeFilterSection preset={src.preset} filters={alertOnly}
onChange={(f) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} />
{/* Muted topics */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Muted topics</p>
<div className="flex flex-wrap gap-1.5">
{mutedTopicTags.length === 0 ? (
<span className="text-xs text-muted-foreground italic">None all followed topics will trigger notifications</span>
) : mutedTopicTags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-1.5 text-xs border border-border bg-muted rounded-full px-2.5 py-1">
<span>{tag}</span>
<button onClick={() => setMutedTopicTags((prev) => prev.filter((x) => x !== tag))}
className="text-muted-foreground hover:text-foreground transition-colors leading-none">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
{unmutedTopics.length > 0 && (
<select value=""
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
<option value="" disabled>Mute a topic</option>
{unmutedTopics.map((f) => (
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
))}
</select>
)}
</div>
</div>
)}
</div>
);
})()}
<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 Discovery Filters"}
</button>
</div>
</div>
)}
</section>
{/* Quiet Hours */}
@@ -734,6 +923,26 @@ export default function NotificationsPage() {
{billTitle && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
)}
{(() => {
const src = p.source as string | undefined;
const modeLabels: Record<string, string> = {
pocket_veto: "Pocket Veto", pocket_boost: "Pocket Boost", neutral: "Following",
};
let reason: string | null = null;
if (src === "bill_follow") {
const mode = p.follow_mode as string | undefined;
reason = mode ? `${modeLabels[mode] ?? "Following"} this bill` : null;
} else if (src === "member_follow") {
const name = p.matched_member_name as string | undefined;
reason = name ? `You follow ${name}` : "Member you follow";
} else if (src === "topic_follow") {
const topic = p.matched_topic as string | undefined;
reason = topic ? `You follow "${topic}"` : "Topic you follow";
}
return reason ? (
<p className="text-xs text-muted-foreground mt-0.5 italic">{reason}</p>
) : null;
})()}
{briefSummary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
)}