From c3236c835170886e9de63eee92cdb44c416470d7 Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:08:52 -0500 Subject: [PATCH 1/7] refactor(web): share preference option lists between card and bulk bar --- .../components/watchlist/BulkActionBar.tsx | 44 ++------- .../watchlist/PreferenceToggles.tsx | 97 ++++++------------- web/app/lib/api.ts | 7 +- web/app/lib/bulkSelection.ts | 9 +- web/app/lib/preferenceOptions.ts | 36 +++++++ web/app/routes/watchlist.tsx | 9 +- 6 files changed, 87 insertions(+), 115 deletions(-) create mode 100644 web/app/lib/preferenceOptions.ts diff --git a/web/app/components/watchlist/BulkActionBar.tsx b/web/app/components/watchlist/BulkActionBar.tsx index 8cc52772..865f8ec9 100644 --- a/web/app/components/watchlist/BulkActionBar.tsx +++ b/web/app/components/watchlist/BulkActionBar.tsx @@ -42,11 +42,11 @@ import type { BulkAxisDelta, BulkOutcome, BulkRemoveTagOutcome, + PreferencesDelta, TagRef, WatchlistEntry, } from "~/lib/api" import { - EVENT_TYPE_ORDER, buildAxisDelta, cycleTriState, initialTriStates, @@ -57,47 +57,21 @@ import { type TriState, } from "~/lib/bulkSelection" import { useTagVocabulary } from "~/lib/useTagVocabulary" -import { DEFAULT_RELEASE_TYPES } from "~/lib/watchlistView" - -export interface PreferencesDelta { - releaseTypes: BulkAxisDelta - mutedEventTypes: BulkAxisDelta -} +import { + EVENT_TYPE_ORDER, + MUTE_OPTIONS, + RELEASE_OPTIONS, + RELEASE_TYPE_ORDER, +} from "~/lib/preferenceOptions" type PrefStates = Record> -// Same options, order and aria-labels as the card's PreferenceToggles. -const RELEASE_OPTIONS = [ - { type: "album", label: "Album", aria: "Album release type" }, - { type: "single", label: "Single", aria: "Single release type" }, - { type: "ep", label: "EP", aria: "EP release type" }, - { type: "deluxe", label: "Deluxe", aria: "Deluxe release type" }, -] - -const MUTE_OPTIONS = [ - { - type: "new_release", - label: "New release", - aria: "Mute new release alerts", - }, - { - type: "guest_feature", - label: "Guest feature", - aria: "Mute guest feature alerts", - }, - { - type: "deluxe_change", - label: "Deluxe change", - aria: "Mute deluxe change alerts", - }, -] - function computePrefStates(selected: WatchlistEntry[]): PrefStates { return { release_types: initialTriStates( selected, "release_types", - DEFAULT_RELEASE_TYPES + RELEASE_TYPE_ORDER ), muted_event_types: initialTriStates( selected, @@ -268,7 +242,7 @@ export function BulkActionBar({ const prefsDelta: PreferencesDelta = { releaseTypes: buildAxisDelta( - DEFAULT_RELEASE_TYPES, + RELEASE_TYPE_ORDER, prefsInitial.release_types, prefsCurrent.release_types ), diff --git a/web/app/components/watchlist/PreferenceToggles.tsx b/web/app/components/watchlist/PreferenceToggles.tsx index f08463e1..72e34783 100644 --- a/web/app/components/watchlist/PreferenceToggles.tsx +++ b/web/app/components/watchlist/PreferenceToggles.tsx @@ -3,6 +3,7 @@ import { toast } from "sonner" import { Checkbox } from "~/components/ui/checkbox" import { type WatchlistEntry, updateWatchlistPreferences } from "~/lib/api" +import { MUTE_OPTIONS, RELEASE_OPTIONS } from "~/lib/preferenceOptions" // PreferenceToggles renders the two D-12 preference axes -- release-type // filters and muted event types -- as inline checkboxes directly in the @@ -90,42 +91,21 @@ export function PreferenceToggles({ Release types
- - - - + {RELEASE_OPTIONS.map((o) => ( + + ))}
@@ -134,36 +114,21 @@ export function PreferenceToggles({ Mute alerts
- - - + {MUTE_OPTIONS.map((o) => ( + + ))}
diff --git a/web/app/lib/api.ts b/web/app/lib/api.ts index 953e6801..ec4c3f1c 100644 --- a/web/app/lib/api.ts +++ b/web/app/lib/api.ts @@ -431,11 +431,16 @@ export interface BulkAxisDelta { unset: string[] } +export interface PreferencesDelta { + releaseTypes: BulkAxisDelta + mutedEventTypes: BulkAxisDelta +} + // bulkUpdatePreferences sends only the changed types per axis (BULK-03); the // server leaves every other type, and notes, untouched. export async function bulkUpdatePreferences( ids: number[], - delta: { releaseTypes: BulkAxisDelta; mutedEventTypes: BulkAxisDelta } + delta: PreferencesDelta ): Promise { return apiFetch("/watchlist/bulk/preferences", { method: "PATCH", diff --git a/web/app/lib/bulkSelection.ts b/web/app/lib/bulkSelection.ts index 78191a77..e1f7c401 100644 --- a/web/app/lib/bulkSelection.ts +++ b/web/app/lib/bulkSelection.ts @@ -1,7 +1,7 @@ import type { BulkAxisDelta, TagRef, WatchlistEntry } from "~/lib/api" import { sortTagsByName } from "~/lib/tags" -// Pure selection helpers: type-only imports, because route tests automock ~/lib/api. +// Pure helpers; type-only api imports because route tests automock ~/lib/api. export type SelectAllState = "checked" | "indeterminate" | "unchecked" @@ -94,13 +94,6 @@ export function removableTags( export type TriState = "checked" | "unchecked" | "indeterminate" -// Mirrors Go `EventTypes`. -export const EVENT_TYPE_ORDER = [ - "new_release", - "guest_feature", - "deluxe_change", -] as const - export type PreferenceAxis = "release_types" | "muted_event_types" // Checked when every entry has the type, unchecked when none does (D-06). diff --git a/web/app/lib/preferenceOptions.ts b/web/app/lib/preferenceOptions.ts new file mode 100644 index 00000000..820d2e77 --- /dev/null +++ b/web/app/lib/preferenceOptions.ts @@ -0,0 +1,36 @@ +// Shared by the card's PreferenceToggles and the bulk Preferences popover. +// Type order mirrors ReleaseTypes / EventTypes in internal/watchlist/service.go. + +export interface PreferenceOption { + type: string + label: string + aria: string +} + +export const RELEASE_OPTIONS: readonly PreferenceOption[] = [ + { type: "album", label: "Album", aria: "Album release type" }, + { type: "single", label: "Single", aria: "Single release type" }, + { type: "ep", label: "EP", aria: "EP release type" }, + { type: "deluxe", label: "Deluxe", aria: "Deluxe release type" }, +] + +export const MUTE_OPTIONS: readonly PreferenceOption[] = [ + { + type: "new_release", + label: "New release", + aria: "Mute new release alerts", + }, + { + type: "guest_feature", + label: "Guest feature", + aria: "Mute guest feature alerts", + }, + { + type: "deluxe_change", + label: "Deluxe change", + aria: "Mute deluxe change alerts", + }, +] + +export const RELEASE_TYPE_ORDER = RELEASE_OPTIONS.map((o) => o.type) +export const EVENT_TYPE_ORDER = MUTE_OPTIONS.map((o) => o.type) diff --git a/web/app/routes/watchlist.tsx b/web/app/routes/watchlist.tsx index 1185f6ef..891fcb9a 100644 --- a/web/app/routes/watchlist.tsx +++ b/web/app/routes/watchlist.tsx @@ -20,7 +20,7 @@ import { type SearchResponse, type TagRef, type BulkAddTagOutcome, - type BulkAxisDelta, + type PreferencesDelta, type BulkOutcome, type BulkRemoveOutcome, type BulkRemoveTagOutcome, @@ -719,10 +719,9 @@ export default function Watchlist() { } // One request carrying only the changed types; null keeps the popover open. - async function handleBulkPreferences(delta: { - releaseTypes: BulkAxisDelta - mutedEventTypes: BulkAxisDelta - }): Promise { + async function handleBulkPreferences( + delta: PreferencesDelta + ): Promise { const sentIds = effective.map((e) => e.id) setBulkInFlight(true) let out: BulkOutcome From ac137d4b0d12604181fa40ad1054236262c55b1e Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:10:43 -0500 Subject: [PATCH 2/7] refactor(web): extract bulk preferences popover and bar clearance hook --- .../components/watchlist/BulkActionBar.tsx | 259 +----------------- .../watchlist/BulkPreferencesPopover.tsx | 236 ++++++++++++++++ web/app/lib/useBarClearance.ts | 25 ++ 3 files changed, 276 insertions(+), 244 deletions(-) create mode 100644 web/app/components/watchlist/BulkPreferencesPopover.tsx create mode 100644 web/app/lib/useBarClearance.ts diff --git a/web/app/components/watchlist/BulkActionBar.tsx b/web/app/components/watchlist/BulkActionBar.tsx index 865f8ec9..59c41b84 100644 --- a/web/app/components/watchlist/BulkActionBar.tsx +++ b/web/app/components/watchlist/BulkActionBar.tsx @@ -1,29 +1,7 @@ -import { - Loader2, - SlidersHorizontal, - TagPlus, - TagX, - Trash2, - X, -} from "lucide-react" -import { - useEffect, - useLayoutEffect, - useRef, - useState, - type RefObject, -} from "react" +import { Loader2, TagPlus, TagX, Trash2, X } from "lucide-react" +import { useEffect, useRef, useState, type RefObject } from "react" import { Button } from "~/components/ui/button" -import { Checkbox } from "~/components/ui/checkbox" -import { - Popover, - PopoverContent, - PopoverDescription, - PopoverHeader, - PopoverTitle, - PopoverTrigger, -} from "~/components/ui/popover" import { Combobox, ComboboxContent, @@ -33,13 +11,13 @@ import { ComboboxList, } from "~/components/ui/combobox" import { Separator } from "~/components/ui/separator" +import { BulkPreferencesPopover } from "~/components/watchlist/BulkPreferencesPopover" import { DISMISS_REASONS, TagCombobox, } from "~/components/watchlist/TagCombobox" import type { BulkAddTagOutcome, - BulkAxisDelta, BulkOutcome, BulkRemoveTagOutcome, PreferencesDelta, @@ -47,39 +25,12 @@ import type { WatchlistEntry, } from "~/lib/api" import { - buildAxisDelta, - cycleTriState, - initialTriStates, removableTags, tagsOnEvery, type RemovableTag, - type PreferenceAxis, - type TriState, } from "~/lib/bulkSelection" +import { useBarClearance } from "~/lib/useBarClearance" import { useTagVocabulary } from "~/lib/useTagVocabulary" -import { - EVENT_TYPE_ORDER, - MUTE_OPTIONS, - RELEASE_OPTIONS, - RELEASE_TYPE_ORDER, -} from "~/lib/preferenceOptions" - -type PrefStates = Record> - -function computePrefStates(selected: WatchlistEntry[]): PrefStates { - return { - release_types: initialTriStates( - selected, - "release_types", - RELEASE_TYPE_ORDER - ), - muted_event_types: initialTriStates( - selected, - "muted_event_types", - EVENT_TYPE_ORDER - ), - } -} export interface BulkActionBarProps { selected: WatchlistEntry[] @@ -95,7 +46,7 @@ export interface BulkActionBarProps { const ACTION_CLASS = "size-11 px-0 sm:h-9 sm:w-auto sm:px-3" -// Single-value, non-creatable picker over the tags the selection carries (D-12). +// Picks one tag already carried by the selection; cannot create (D-12). function RemoveTagPicker({ selected, disabled, @@ -168,8 +119,8 @@ function RemoveTagPicker({ ) } -// Sticky selection bar (D-10). It only opens the remove dialog; the route -// hosts it so the dialog outlives the bar's unmount (U1). +// Sticky selection bar (D-10). The route hosts the remove dialog so it +// outlives the bar's unmount. export function BulkActionBar({ selected, inFlight, @@ -186,18 +137,9 @@ export function BulkActionBar({ const removeTagButtonRef = useRef(null) const focusAfterIdleRef = useRef<"add-tag" | "remove-tag" | null>(null) const pendingRef = useRef(false) - const albumRef = useRef(null) - const applyRef = useRef(null) - const focusApplyRef = useRef(false) const [mode, setMode] = useState<"idle" | "add-tag" | "remove-tag">("idle") const [pending, setPending] = useState(false) const [prefsOpen, setPrefsOpen] = useState(false) - const [prefsPending, setPrefsPending] = useState(false) - const [prefsInitial, setPrefsInitial] = useState(() => - computePrefStates(selected) - ) - const [prefsCurrent, setPrefsCurrent] = useState(prefsInitial) - // The bar renders inside TagVocabularyProvider, so the vocabulary lives here (D-12). const { vocabulary, ensureLoaded, remember } = useTagVocabulary() const count = selected.length @@ -212,91 +154,6 @@ export function BulkActionBar({ target?.focus() }, [mode]) - // A failed apply disables then re-enables Apply, so refocus it after commit. - useEffect(() => { - if (!prefsPending && focusApplyRef.current) { - focusApplyRef.current = false - applyRef.current?.focus() - } - }, [prefsPending]) - - function handlePrefsOpenChange(open: boolean) { - if (prefsPending) return - if (open) { - const initial = computePrefStates(selected) - setPrefsInitial(initial) - setPrefsCurrent(initial) - } - setPrefsOpen(open) - } - - function cyclePref(axis: PreferenceAxis, type: string) { - setPrefsCurrent((prev) => ({ - ...prev, - [axis]: { - ...prev[axis], - [type]: cycleTriState(prefsInitial[axis][type], prev[axis][type]), - }, - })) - } - - const prefsDelta: PreferencesDelta = { - releaseTypes: buildAxisDelta( - RELEASE_TYPE_ORDER, - prefsInitial.release_types, - prefsCurrent.release_types - ), - mutedEventTypes: buildAxisDelta( - EVENT_TYPE_ORDER, - prefsInitial.muted_event_types, - prefsCurrent.muted_event_types - ), - } - const prefsChanged = - prefsDelta.releaseTypes.set.length + - prefsDelta.releaseTypes.unset.length + - prefsDelta.mutedEventTypes.set.length + - prefsDelta.mutedEventTypes.unset.length > - 0 - - async function applyPreferences() { - if (prefsPending || !prefsChanged) return - setPrefsPending(true) - const outcome = await onApplyPreferences(prefsDelta) - setPrefsPending(false) - if (!outcome) { - focusApplyRef.current = true - return - } - setPrefsOpen(false) - onSucceeded() - } - - function renderPrefOption( - axis: PreferenceAxis, - option: { type: string; label: string; aria: string }, - ref?: RefObject - ) { - const state = prefsCurrent[axis][option.type] - return ( - - ) - } - function exitInline(focus: "add-tag" | "remove-tag" | null) { focusAfterIdleRef.current = focus setMode("idle") @@ -338,26 +195,7 @@ export function BulkActionBar({ onSucceeded() } - // Publishes the bar's height so the page, toasts and scroll padding clear it (U2). - useLayoutEffect(() => { - const wrapper = wrapperRef.current - if (!wrapper) return - const root = document.documentElement - const measure = () => { - const inset = parseFloat(getComputedStyle(wrapper).bottom) - const bottom = Number.isFinite(inset) ? inset : 0 - const height = wrapper.getBoundingClientRect().height - root.style.setProperty("--bulk-bar-clearance", `${height + bottom + 8}px`) - } - measure() - const observer = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure) - observer?.observe(wrapper) - return () => { - observer?.disconnect() - root.style.removeProperty("--bulk-bar-clearance") - } - }, []) + useBarClearance(wrapperRef) return ( + + + ) +} diff --git a/web/app/lib/useBarClearance.ts b/web/app/lib/useBarClearance.ts new file mode 100644 index 00000000..cb9b1f00 --- /dev/null +++ b/web/app/lib/useBarClearance.ts @@ -0,0 +1,25 @@ +import { useLayoutEffect, type RefObject } from "react" + +// Publishes the bar's height as --bulk-bar-clearance so the page padding and +// toasts stay clear of it (see root.tsx). +export function useBarClearance(ref: RefObject) { + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const root = document.documentElement + const measure = () => { + const inset = parseFloat(getComputedStyle(el).bottom) + const bottom = Number.isFinite(inset) ? inset : 0 + const height = el.getBoundingClientRect().height + root.style.setProperty("--bulk-bar-clearance", `${height + bottom + 8}px`) + } + measure() + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure) + observer?.observe(el) + return () => { + observer?.disconnect() + root.style.removeProperty("--bulk-bar-clearance") + } + }, [ref]) +} From a3dbe7ce468e99998f25c00e54d75e7b5695254a Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:13:39 -0500 Subject: [PATCH 3/7] refactor(web): move bulk toast copy into a pure outcome-messages module --- web/app/lib/bulkOutcomeMessages.test.ts | 156 ++++++++++++++++++++++++ web/app/lib/bulkOutcomeMessages.ts | 129 ++++++++++++++++++++ web/app/routes/watchlist.tsx | 107 ++++++---------- 3 files changed, 321 insertions(+), 71 deletions(-) create mode 100644 web/app/lib/bulkOutcomeMessages.test.ts create mode 100644 web/app/lib/bulkOutcomeMessages.ts diff --git a/web/app/lib/bulkOutcomeMessages.test.ts b/web/app/lib/bulkOutcomeMessages.test.ts new file mode 100644 index 00000000..2b2550e3 --- /dev/null +++ b/web/app/lib/bulkOutcomeMessages.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest" + +import type { BulkAddTagOutcome, BulkRemoveTagOutcome } from "~/lib/api" +import { + addTagToast, + failureToast, + preferencesToast, + removeTagToast, + removeWatchlistToast, +} from "~/lib/bulkOutcomeMessages" + +const tag = { id: 7, name: "trap" } +const nameOf = (id: number) => `Artist ${id}` + +function add( + applied: number[], + skipped: BulkAddTagOutcome["skipped"] +): BulkAddTagOutcome { + return { tag, applied, skipped } +} + +describe("addTagToast", () => { + it.each([ + { + name: "plain success", + outcome: add([1, 2], []), + expected: { level: "success", title: "Added “trap” to 2 artists." }, + }, + { + name: "singular", + outcome: add([1], []), + expected: { level: "success", title: "Added “trap” to 1 artist." }, + }, + { + name: "gone note on success", + outcome: add([1], [{ id: 2, reason: "gone" }]), + expected: { + level: "success", + title: "Added “trap” to 1 artist.", + description: "1 artist was already removed from your watchlist.", + }, + }, + { + name: "some at cap, named from the server", + outcome: add([1], [{ id: 2, name: "Kid", reason: "cap" }]), + expected: { + level: "warning", + title: "Added “trap” to 1 artist.", + description: "1 already has 10 tags: Kid.", + duration: 8000, + }, + }, + { + name: "all at cap, with gone note", + outcome: add( + [], + [ + { id: 2, name: "Kid", reason: "cap" }, + { id: 3, reason: "gone" }, + ] + ), + expected: { + level: "warning", + title: + "Couldn't add “trap” — every selected artist already has 10 tags.", + description: "Kid. 1 artist was already removed from your watchlist.", + duration: 8000, + }, + }, + ])("$name", ({ outcome, expected }) => { + expect(addTagToast(outcome, [1, 2, 3], nameOf)).toEqual(expected) + }) + + it("names cap skips in sent order, falling back to nameOf", () => { + const toast = addTagToast( + add( + [], + [ + { id: 9, reason: "cap" }, + { id: 4, name: "Four", reason: "cap" }, + ] + ), + [4, 9], + nameOf + ) + expect(toast.description).toBe("Four and Artist 9.") + }) + + it("uses restore wording", () => { + expect(addTagToast(add([1, 2], []), [1, 2], nameOf, "restore").title).toBe( + "Restored “trap” on 2 artists." + ) + expect( + addTagToast( + add([], [{ id: 1, name: "Kid", reason: "cap" }]), + [1], + nameOf, + "restore" + ).title + ).toBe("Couldn't restore “trap” — every artist already has 10 tags.") + }) +}) + +describe("removeTagToast", () => { + const out = ( + carried: number, + skipped: BulkRemoveTagOutcome["skipped"] = [] + ): BulkRemoveTagOutcome => ({ applied: [], carried, skipped }) + + it("reports the carrier count and keeps the toast long", () => { + expect(removeTagToast("trap", out(1))).toEqual({ + level: "success", + title: "Removed “trap” from 1 artist.", + duration: 8000, + }) + }) + + it("adds the gone note", () => { + expect( + removeTagToast("trap", out(3, [{ id: 1, reason: "gone" }])).description + ).toBe("1 artist was already removed from your watchlist.") + }) +}) + +describe("preferencesToast / removeWatchlistToast", () => { + it("pluralises and adds the gone note", () => { + expect(preferencesToast(2, [])).toEqual({ + level: "success", + title: "Updated preferences for 2 artists.", + }) + expect( + preferencesToast(1, [{ reason: "gone" }, { reason: "gone" }]) + ).toEqual({ + level: "success", + title: "Updated preferences for 1 artist.", + description: "2 artists were already removed from your watchlist.", + }) + expect(removeWatchlistToast(3, 1)).toEqual({ + level: "success", + title: "Removed 3 artists from your watchlist.", + description: "1 artist was already removed from your watchlist.", + }) + }) +}) + +describe("failureToast", () => { + it("uses the fallback unless the server reported a cap", () => { + expect(failureToast(null, "Nope.")).toEqual({ + level: "error", + title: "Nope.", + }) + expect(failureToast(1000, "Nope.").title).toBe( + "Too many artists selected — select at most 1000 and try again." + ) + }) +}) diff --git a/web/app/lib/bulkOutcomeMessages.ts b/web/app/lib/bulkOutcomeMessages.ts new file mode 100644 index 00000000..de89b813 --- /dev/null +++ b/web/app/lib/bulkOutcomeMessages.ts @@ -0,0 +1,129 @@ +import type { BulkAddTagOutcome, BulkRemoveTagOutcome } from "~/lib/api" +import { formatAlreadyRemoved, formatNameList } from "~/lib/format" + +// Pure toast copy for bulk outcomes; the caller owns sonner and any action. + +export interface ToastSpec { + level: "success" | "warning" | "error" + title: string + description?: string + duration?: number +} + +const LONG_MS = 8000 + +function plural(n: number): string { + return n === 1 ? "" : "s" +} + +function countGone(skipped: readonly { reason: string }[]): number { + return skipped.filter((s) => s.reason === "gone").length +} + +function withDescription( + level: ToastSpec["level"], + title: string, + gone: number, + extra: Partial = {} +): ToastSpec { + return { + level, + title, + ...(gone > 0 && { description: formatAlreadyRemoved(gone) }), + ...extra, + } +} + +export function failureToast( + maxIds: number | null, + fallback: string +): ToastSpec { + return { + level: "error", + title: + maxIds === null + ? fallback + : `Too many artists selected — select at most ${maxIds} and try again.`, + } +} + +// Cap skips are named in `sentIds` order; `nameOf` covers skips the server left unnamed. +export function addTagToast( + outcome: BulkAddTagOutcome, + sentIds: readonly number[], + nameOf: (id: number) => string, + variant: "add" | "restore" = "add" +): ToastSpec { + const n = outcome.applied.length + const tag = `“${outcome.tag.name}”` + const caps = outcome.skipped + .filter((s) => s.reason === "cap") + .sort((a, b) => sentIds.indexOf(a.id) - sentIds.indexOf(b.id)) + const names = formatNameList(caps.map((s) => s.name ?? nameOf(s.id))) + const gone = countGone(outcome.skipped) + const join = (...parts: string[]) => parts.filter(Boolean).join(" ") + const goneNote = gone > 0 ? formatAlreadyRemoved(gone) : "" + + if (caps.length > 0 && n === 0) { + return { + level: "warning", + title: + variant === "restore" + ? `Couldn't restore ${tag} — every artist already has 10 tags.` + : `Couldn't add ${tag} — every selected artist already has 10 tags.`, + description: join(`${names}.`, goneNote), + duration: LONG_MS, + } + } + const title = + variant === "restore" + ? `Restored ${tag} on ${n} artist${plural(n)}.` + : `Added ${tag} to ${n} artist${plural(n)}.` + if (caps.length > 0) { + const k = caps.length + return { + level: "warning", + title, + description: join( + `${k} already ${k === 1 ? "has" : "have"} 10 tags: ${names}.`, + goneNote + ), + duration: LONG_MS, + } + } + return withDescription("success", title, gone) +} + +export function removeTagToast( + tagName: string, + outcome: BulkRemoveTagOutcome +): ToastSpec { + return withDescription( + "success", + `Removed “${tagName}” from ${outcome.carried} artist${plural(outcome.carried)}.`, + countGone(outcome.skipped), + { duration: LONG_MS } + ) +} + +export function preferencesToast( + applied: number, + skipped: readonly { reason: string }[] +): ToastSpec { + return withDescription( + "success", + `Updated preferences for ${applied} artist${plural(applied)}.`, + countGone(skipped) + ) +} + +export function removeWatchlistToast( + total: number, + alreadyGone: number +): ToastSpec { + return withDescription( + "success", + `Removed ${total} artist${plural(total)} from your watchlist.`, + alreadyGone + ) +} diff --git a/web/app/routes/watchlist.tsx b/web/app/routes/watchlist.tsx index 891fcb9a..ef2130b9 100644 --- a/web/app/routes/watchlist.tsx +++ b/web/app/routes/watchlist.tsx @@ -39,7 +39,15 @@ import { selectAllState, toggleAllVisible, } from "~/lib/bulkSelection" -import { formatAlreadyRemoved, formatNameList } from "~/lib/format" +import { + addTagToast, + failureToast, + preferencesToast, + removeTagToast, + removeWatchlistToast, + type ToastSpec, +} from "~/lib/bulkOutcomeMessages" +import { formatNameList } from "~/lib/format" import { isAddableSource } from "~/lib/sources" import { useScrollAnchor } from "~/lib/useScrollAnchor" import { useUrlParams } from "~/lib/useUrlParams" @@ -89,6 +97,16 @@ function plural(n: number): string { return n === 1 ? "" : "s" } +function showToast( + spec: ToastSpec, + action?: { label: string; onClick: () => void } +) { + const { level, title, ...rest } = spec + const options = { ...rest, ...(action && { action }) } + if (Object.keys(options).length === 0) toast[level](title) + else toast[level](title, options) +} + function filterKeyOf(view: WatchlistView): string { return `${view.sort}|${view.tagIds.join(",")}|${view.muted}|${view.custom}` } @@ -580,63 +598,22 @@ export default function Watchlist() { // `max` comes from the response body; Go and TS share no constant. function showBulkFailure(error: unknown, fallbackMessage: string) { - if (error instanceof ApiError && error.code === "bulk_too_many_ids") { - const max = (error.body as { max?: unknown } | undefined)?.max - if (typeof max === "number") { - toast.error( - `Too many artists selected — select at most ${max} and try again.` - ) - return - } - } - toast.error(fallbackMessage) + const max = + error instanceof ApiError && error.code === "bulk_too_many_ids" + ? (error.body as { max?: unknown } | undefined)?.max + : undefined + showToast( + failureToast(typeof max === "number" ? max : null, fallbackMessage) + ) } - // Cap skips are named in the order sent (visible order), not the server's id order (D-01). function reportBulkAddOutcome( outcome: BulkAddTagOutcome, sentIds: number[], variant: "add" | "restore" = "add" ) { - const n = outcome.applied.length - const tag = `“${outcome.tag.name}”` - const caps = outcome.skipped - .filter((s) => s.reason === "cap") - .sort((a, b) => sentIds.indexOf(a.id) - sentIds.indexOf(b.id)) - const names = caps.map( - (s) => s.name ?? entries?.find((e) => e.id === s.id)?.name ?? "" - ) - const g = outcome.skipped.filter((s) => s.reason === "gone").length - const gone = g > 0 ? formatAlreadyRemoved(g) : "" - const join = (...parts: string[]) => parts.filter(Boolean).join(" ") - - if (caps.length > 0 && n === 0) { - toast.warning( - variant === "restore" - ? `Couldn't restore “${outcome.tag.name}” — every artist already has 10 tags.` - : `Couldn't add ${tag} — every selected artist already has 10 tags.`, - { - description: join(`${formatNameList(names)}.`, gone), - duration: 8000, - } - ) - return - } - const title = `${variant === "restore" ? "Restored" : "Added"} ${tag} ${variant === "restore" ? "on" : "to"} ${n} artist${plural(n)}.` - if (caps.length > 0) { - const k = caps.length - toast.warning(title, { - description: join( - `${k} already ${k === 1 ? "has" : "have"} 10 tags: ${formatNameList(names)}.`, - gone - ), - duration: 8000, - }) - } else if (gone) { - toast.success(title, { description: gone }) - } else { - toast.success(title) - } + const nameOf = (id: number) => entries?.find((e) => e.id === id)?.name ?? "" + showToast(addTagToast(outcome, sentIds, nameOf, variant)) } // One request for the whole selection; null tells the bar to stay selected. @@ -684,19 +661,14 @@ export default function Watchlist() { setBulkInFlight(false) markStickyMany(out.applied) refresh() - const g = out.skipped.filter((s) => s.reason === "gone").length - toast.success( - `Removed “${tag.name}” from ${out.carried} artist${plural(out.carried)}.`, - { - duration: 8000, - ...(g > 0 && { description: formatAlreadyRemoved(g) }), - ...(out.applied.length > 0 && { - action: { + showToast( + removeTagToast(tag.name, out), + out.applied.length > 0 + ? { label: "Undo", onClick: () => undoRemoveTag(tag.name, out.applied), - }, - }), - } + } + : undefined ) return out } @@ -735,11 +707,7 @@ export default function Watchlist() { setBulkInFlight(false) markStickyMany(out.applied) refresh() - const n = out.applied.length - const g = out.skipped.filter((s) => s.reason === "gone").length - const msg = `Updated preferences for ${n} artist${plural(n)}.` - if (g > 0) toast.success(msg, { description: formatAlreadyRemoved(g) }) - else toast.success(msg) + showToast(preferencesToast(out.applied.length, out.skipped)) return out } @@ -778,10 +746,7 @@ export default function Watchlist() { const gone = new Set([...outcome.out.removed, ...outcome.out.already_gone]) setEntries((rows) => (rows ? rows.filter((r) => !gone.has(r.id)) : rows)) finishBulkSuccess() - const g = outcome.out.already_gone.length - const msg = `Removed ${gone.size} artist${plural(gone.size)} from your watchlist.` - if (g > 0) toast.success(msg, { description: formatAlreadyRemoved(g) }) - else toast.success(msg) + showToast(removeWatchlistToast(gone.size, outcome.out.already_gone.length)) refresh() } From 66e19903b3adf6d640a728f4f14a918a122ddb0d Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:16:22 -0500 Subject: [PATCH 4/7] refactor(web): move bulk selection and actions out of the watchlist route into useBulkSelection --- web/app/lib/bulkOutcomeMessages.ts | 2 +- web/app/lib/useBulkSelection.ts | 394 +++++++++++++++++++++++++++++ web/app/routes/watchlist.tsx | 366 ++------------------------- 3 files changed, 419 insertions(+), 343 deletions(-) create mode 100644 web/app/lib/useBulkSelection.ts diff --git a/web/app/lib/bulkOutcomeMessages.ts b/web/app/lib/bulkOutcomeMessages.ts index de89b813..8e497287 100644 --- a/web/app/lib/bulkOutcomeMessages.ts +++ b/web/app/lib/bulkOutcomeMessages.ts @@ -12,7 +12,7 @@ export interface ToastSpec { const LONG_MS = 8000 -function plural(n: number): string { +export function plural(n: number): string { return n === 1 ? "" : "s" } diff --git a/web/app/lib/useBulkSelection.ts b/web/app/lib/useBulkSelection.ts new file mode 100644 index 00000000..5f0cd5ba --- /dev/null +++ b/web/app/lib/useBulkSelection.ts @@ -0,0 +1,394 @@ +import { + useEffect, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react" +import { toast } from "sonner" + +import type { ConfirmDialogProps } from "~/components/common/ConfirmDialog" +import type { BulkActionBarProps } from "~/components/watchlist/BulkActionBar" +import { + ApiError, + bulkAddTag, + bulkRemoveTag, + bulkRemoveWatchlist, + bulkUpdatePreferences, + type BulkAddTagOutcome, + type BulkOutcome, + type BulkRemoveOutcome, + type BulkRemoveTagOutcome, + type PreferencesDelta, + type TagRef, + type WatchlistEntry, +} from "~/lib/api" +import { + addTagToast, + failureToast, + plural, + preferencesToast, + removeTagToast, + removeWatchlistToast, + type ToastSpec, +} from "~/lib/bulkOutcomeMessages" +import { + applyRange, + effectiveSelection, + selectAllState, + toggleAllVisible, + type SelectAllState, +} from "~/lib/bulkSelection" +import { formatNameList } from "~/lib/format" +import { EMPTY_IDS } from "~/lib/watchlistView" + +const BULK_REMOVE_FAILED = + "Couldn't remove the selected artists — nothing was removed. Try again." + +type RemoveOutcome = + | { ok: true; out: BulkRemoveOutcome } + | { ok: false; error: unknown } + +function showToast( + spec: ToastSpec, + action?: { label: string; onClick: () => void } +) { + const { level, title, ...rest } = spec + const options = { ...rest, ...(action && { action }) } + if (Object.keys(options).length === 0) toast[level](title) + else toast[level](title, options) +} + +function removeDialogCopy(names: string[]) { + const one = names.length === 1 + return { + title: one + ? `Remove ${names[0]} from your watchlist?` + : `Remove ${names.length} artists from your watchlist?`, + description: one + ? "Their note is deleted but their tags are kept; alerts already found for them may still be sent, and re-adding them later may alert you about releases from while they were removed." + : `${formatNameList(names)}. Their notes are deleted but their tags are kept; alerts already found for them may still be sent, and re-adding any of them later may alert you about releases from while they were removed.`, + actionLabel: one ? "Remove 1 artist" : `Remove ${names.length} artists`, + } +} + +export interface UseBulkSelectionOptions { + entries: WatchlistEntry[] | null + visible: WatchlistEntry[] + // A selection range never survives a change of what the user sees. + filterKey: string + query: string + setEntries: Dispatch> + refresh: () => unknown + markStickyMany: (ids: readonly number[]) => void + announce: (message: string) => void +} + +export interface BulkSelection { + // Selected ∩ visible; ids hidden by a filter stay selected but inert (D-11). + effective: WatchlistEntry[] + selectedIds: ReadonlySet + inFlight: boolean + onSelectChange: (id: number, next: boolean, shift: boolean) => void + selectAll: { + state: SelectAllState + disabled: boolean + onChange: () => void + } + // Attach to the elements focus falls back to after a bulk action. + refs: { + pageHeading: RefObject + selectAll: RefObject + clearFilters: RefObject + } + bar: BulkActionBarProps + confirmDialog: ConfirmDialogProps +} + +export function useBulkSelection({ + entries, + visible, + filterKey, + query, + setEntries, + refresh, + markStickyMany, + announce, +}: UseBulkSelectionOptions): BulkSelection { + const [selectedIds, setSelectedIds] = useState>(EMPTY_IDS) + const [inFlight, setInFlight] = useState(false) + // ids and names are snapshotted at open and kept while the dialog closes. + const [removeConfirm, setRemoveConfirm] = useState<{ + open: boolean + ids: number[] + names: string[] + }>({ open: false, ids: [], names: [] }) + const [pendingFocus, setPendingFocus] = useState(false) + const removeOutcomeRef = useRef(null) + const removeCloseKindRef = useRef<"success" | "return">("return") + const anchorRef = useRef(null) + const pageHeadingRef = useRef(null) + const selectAllRef = useRef(null) + const clearFiltersRef = useRef(null) + const removeButtonRef = useRef(null) + // Undo outlives the render that built it, so it reads the live visible order. + const visibleIdsRef = useRef([]) + + const effective = effectiveSelection(visible, selectedIds) + visibleIdsRef.current = visible.map((e) => e.id) + + useEffect(() => { + anchorRef.current = null + }, [filterKey, query]) + + useEffect(() => { + if (selectedIds.size === 0) anchorRef.current = null + }, [selectedIds]) + + // First of: Select all visible, the filtered-to-zero Clear filters, the h1. + function resolveFallbackTarget(): HTMLElement | null { + if (entries && entries.length > 0) { + return visible.length > 0 ? selectAllRef.current : clearFiltersRef.current + } + return pageHeadingRef.current + } + + // Focus moves only once the bar has unmounted and nothing is in flight. + useEffect(() => { + if (!pendingFocus || inFlight || effective.length > 0) return + setPendingFocus(false) + resolveFallbackTarget()?.focus() + // eslint-disable-next-line react-hooks/exhaustive-deps -- resolveFallbackTarget reads this render's values + }, [pendingFocus, inFlight, effective.length]) + + function onSelectChange(id: number, next: boolean, shift: boolean) { + const order = visible.map((e) => e.id) + let result: ReadonlySet + if (shift) { + result = applyRange(order, anchorRef.current, id, next, selectedIds) + } else if (selectedIds.has(id) === next) { + result = selectedIds + } else { + const out = new Set(selectedIds) + if (next) out.add(id) + else out.delete(id) + result = out + } + setSelectedIds(result) + anchorRef.current = id + const n = effectiveSelection(visible, result).length + if (effective.length === 0 && n > 0) { + announce(`${n} selected. Selection actions follow the artist list.`) + } + } + + // The box's own next value is ignored: the result depends on the visible set. + function onSelectAllChange() { + const next = toggleAllVisible(visible, selectedIds) + setSelectedIds(next) + const n = effectiveSelection(visible, next).length + announce( + n > 0 ? `Selected ${n} visible artist${plural(n)}.` : "Selection cleared." + ) + } + + function finishSuccess() { + setSelectedIds(EMPTY_IDS) + anchorRef.current = null + setPendingFocus(true) + } + + function clearSelection() { + finishSuccess() + announce("Selection cleared.") + } + + // `max` comes from the response body; Go and TS share no constant. + function showFailure(error: unknown, fallbackMessage: string) { + const max = + error instanceof ApiError && error.code === "bulk_too_many_ids" + ? (error.body as { max?: unknown } | undefined)?.max + : undefined + showToast( + failureToast(typeof max === "number" ? max : null, fallbackMessage) + ) + } + + function reportAddOutcome( + outcome: BulkAddTagOutcome, + sentIds: number[], + variant: "add" | "restore" = "add" + ) { + const nameOf = (id: number) => entries?.find((e) => e.id === id)?.name ?? "" + showToast(addTagToast(outcome, sentIds, nameOf, variant)) + } + + // Resolves null on failure, which keeps the bar's selection and inline editor. + async function addTag(name: string): Promise { + const sentIds = effective.map((e) => e.id) + setInFlight(true) + let out: BulkAddTagOutcome + try { + out = await bulkAddTag(sentIds, name) + } catch (error) { + setInFlight(false) + showFailure( + error, + `Couldn't add “${name}” to the selected artists — try again.` + ) + return null + } + setInFlight(false) + markStickyMany(out.applied) + refresh() + reportAddOutcome(out, sentIds) + return out + } + + async function removeTag(tag: TagRef): Promise { + const sentIds = effective.map((e) => e.id) + setInFlight(true) + let out: BulkRemoveTagOutcome + try { + out = await bulkRemoveTag(sentIds, tag.id) + } catch (error) { + setInFlight(false) + showFailure( + error, + `Couldn't remove “${tag.name}” from the selected artists — try again.` + ) + if (error instanceof ApiError && error.status === 404) refresh() + return null + } + setInFlight(false) + markStickyMany(out.applied) + refresh() + showToast( + removeTagToast(tag.name, out), + out.applied.length > 0 + ? { + label: "Undo", + onClick: () => undoRemoveTag(tag.name, out.applied), + } + : undefined + ) + return out + } + + // Leaves selection and bar alone; only ids whose link was removed get the tag back. + async function undoRemoveTag(name: string, ids: number[]) { + let out: BulkAddTagOutcome + try { + out = await bulkAddTag(ids, name) + } catch { + toast.error(`Couldn't restore “${name}” — try again.`) + return + } + markStickyMany(out.applied) + refresh() + const order = visibleIdsRef.current + const shown = order.filter((id) => ids.includes(id)) + const hidden = ids.filter((id) => !order.includes(id)).sort((a, b) => a - b) + reportAddOutcome(out, [...shown, ...hidden], "restore") + } + + // Resolves null on failure, which keeps the popover open. + async function applyPreferences( + delta: PreferencesDelta + ): Promise { + const sentIds = effective.map((e) => e.id) + setInFlight(true) + let out: BulkOutcome + try { + out = await bulkUpdatePreferences(sentIds, delta) + } catch (error) { + setInFlight(false) + showFailure(error, "Couldn't update preferences — try again.") + return null + } + setInFlight(false) + markStickyMany(out.applied) + refresh() + showToast(preferencesToast(out.applied.length, out.skipped)) + return out + } + + function openRemoveConfirm() { + setRemoveConfirm({ + open: true, + ids: effective.map((e) => e.id), + names: effective.map((e) => e.name), + }) + } + + // Never throws or touches selection or entries; those wait for the dialog to close. + async function confirmRemove() { + setInFlight(true) + try { + const out = await bulkRemoveWatchlist(removeConfirm.ids) + removeOutcomeRef.current = { ok: true, out } + } catch (error) { + removeOutcomeRef.current = { ok: false, error } + } + } + + function onRemoveDialogOpenChange(open: boolean) { + if (open) return + const outcome = removeOutcomeRef.current + removeOutcomeRef.current = null + removeCloseKindRef.current = outcome?.ok ? "success" : "return" + setRemoveConfirm((prev) => ({ ...prev, open: false })) + setInFlight(false) + if (!outcome) return + if (!outcome.ok) { + showFailure(outcome.error, BULK_REMOVE_FAILED) + return + } + const gone = new Set([...outcome.out.removed, ...outcome.out.already_gone]) + setEntries((rows) => (rows ? rows.filter((r) => !gone.has(r.id)) : rows)) + finishSuccess() + showToast(removeWatchlistToast(gone.size, outcome.out.already_gone.length)) + refresh() + } + + return { + effective, + selectedIds, + inFlight, + onSelectChange, + selectAll: { + state: selectAllState(visible.length, effective.length), + disabled: visible.length === 0 || inFlight, + onChange: onSelectAllChange, + }, + refs: { + pageHeading: pageHeadingRef, + selectAll: selectAllRef, + clearFilters: clearFiltersRef, + }, + bar: { + selected: effective, + inFlight, + onRemove: openRemoveConfirm, + onAddTag: addTag, + onRemoveTag: removeTag, + onApplyPreferences: applyPreferences, + onSucceeded: finishSuccess, + onClear: clearSelection, + removeButtonRef, + }, + confirmDialog: { + open: removeConfirm.open, + onOpenChange: onRemoveDialogOpenChange, + ...removeDialogCopy(removeConfirm.names), + pendingLabel: "Removing…", + variant: "destructive", + onConfirm: confirmRemove, + finalFocus: () => + removeCloseKindRef.current === "success" + ? resolveFallbackTarget() + : removeButtonRef.current, + }, + } +} diff --git a/web/app/routes/watchlist.tsx b/web/app/routes/watchlist.tsx index ef2130b9..4a036a1c 100644 --- a/web/app/routes/watchlist.tsx +++ b/web/app/routes/watchlist.tsx @@ -19,36 +19,13 @@ import { type SearchArtist, type SearchResponse, type TagRef, - type BulkAddTagOutcome, - type PreferencesDelta, - type BulkOutcome, - type BulkRemoveOutcome, - type BulkRemoveTagOutcome, type WatchlistEntry, addWatchlist, - bulkAddTag, - bulkRemoveTag, - bulkRemoveWatchlist, - bulkUpdatePreferences, listWatchlist, removeWatchlist, } from "~/lib/api" -import { - applyRange, - effectiveSelection, - selectAllState, - toggleAllVisible, -} from "~/lib/bulkSelection" -import { - addTagToast, - failureToast, - preferencesToast, - removeTagToast, - removeWatchlistToast, - type ToastSpec, -} from "~/lib/bulkOutcomeMessages" -import { formatNameList } from "~/lib/format" import { isAddableSource } from "~/lib/sources" +import { useBulkSelection } from "~/lib/useBulkSelection" import { useScrollAnchor } from "~/lib/useScrollAnchor" import { useUrlParams } from "~/lib/useUrlParams" import { @@ -86,27 +63,6 @@ import { const Q_WRITE_DEBOUNCE_MS = 300 const COUNT_ANNOUNCE_DEBOUNCE_MS = 500 -const BULK_REMOVE_FAILED = - "Couldn't remove the selected artists — nothing was removed. Try again." - -type RemoveOutcome = - | { ok: true; out: BulkRemoveOutcome } - | { ok: false; error: unknown } - -function plural(n: number): string { - return n === 1 ? "" : "s" -} - -function showToast( - spec: ToastSpec, - action?: { label: string; onClick: () => void } -) { - const { level, title, ...rest } = spec - const options = { ...rest, ...(action && { action }) } - if (Object.keys(options).length === 0) toast[level](title) - else toast[level](title, options) -} - function filterKeyOf(view: WatchlistView): string { return `${view.sort}|${view.tagIds.join(",")}|${view.muted}|${view.custom}` } @@ -119,24 +75,6 @@ export default function Watchlist() { ) const [statusMessage, setStatusMessage] = useState("") const [manageTagsOpen, setManageTagsOpen] = useState(false) - // Never trimmed to the visible set; the effective selection is derived (D-11). - const [selectedIds, setSelectedIds] = useState>(EMPTY_IDS) - const [bulkInFlight, setBulkInFlight] = useState(false) - // ids and names are snapshotted at open and kept while the dialog closes. - const [removeConfirm, setRemoveConfirm] = useState<{ - open: boolean - ids: number[] - names: string[] - }>({ open: false, ids: [], names: [] }) - const removeOutcomeRef = useRef(null) - const anchorRef = useRef(null) - const pageHeadingRef = useRef(null) - const selectAllRef = useRef(null) - const clearFiltersRef = useRef(null) - const removeButtonRef = useRef(null) - const removeCloseKindRef = useRef<"success" | "return">("return") - const [pendingFocus, setPendingFocus] = useState(false) - const { params, write } = useUrlParams() const location = useLocation() const navigationType = useNavigationType() @@ -148,8 +86,6 @@ export default function Watchlist() { const filterInputRef = useRef(null) const countTimer = useRef | null>(null) const countRef = useRef({ n: 0, m: 0, k: 0 }) - // Undo outlives the render that built it, so it reads the live visible order. - const visibleIdsRef = useRef([]) // Cards the user just touched or added stay visible past a filter (G4). Keyed // to the filter: the router commits the URL in a transition, so the reset must // happen in that same render. @@ -267,15 +203,6 @@ export default function Watchlist() { } }, [view.q]) - // A range never survives a change of what the user sees or an empty selection. - useEffect(() => { - anchorRef.current = null - }, [filterKey, query]) - - useEffect(() => { - if (selectedIds.size === 0) anchorRef.current = null - }, [selectedIds]) - const headingRef = useRef(null) // G-25-12: after a keyboard toggle removes its card, focus the fallback card's chip, else the heading. function focusAfterCardLeaves(fallback: HTMLElement | null) { @@ -544,212 +471,6 @@ export default function Watchlist() { }) } - function handleSelectChange(id: number, next: boolean, shift: boolean) { - const order = visible.map((e) => e.id) - let result: ReadonlySet - if (shift) { - result = applyRange(order, anchorRef.current, id, next, selectedIds) - } else if (selectedIds.has(id) === next) { - result = selectedIds - } else { - const out = new Set(selectedIds) - if (next) out.add(id) - else out.delete(id) - result = out - } - setSelectedIds(result) - anchorRef.current = id - const n = effectiveSelection(visible, result).length - if (effective.length === 0 && n > 0) { - announce(`${n} selected. Selection actions follow the artist list.`) - } - } - - // The box's own next value is ignored: the result depends on the visible set. - function handleSelectAllVisible() { - const next = toggleAllVisible(visible, selectedIds) - setSelectedIds(next) - const n = effectiveSelection(visible, next).length - announce( - n > 0 ? `Selected ${n} visible artist${plural(n)}.` : "Selection cleared." - ) - } - - function finishBulkSuccess() { - setSelectedIds(EMPTY_IDS) - anchorRef.current = null - setPendingFocus(true) - } - - // First of: Select all visible, the filtered-to-zero Clear filters, the h1. - function resolveFallbackTarget(): HTMLElement | null { - if (entries && entries.length > 0) { - return visible.length > 0 ? selectAllRef.current : clearFiltersRef.current - } - return pageHeadingRef.current - } - - function clearSelection() { - setSelectedIds(EMPTY_IDS) - anchorRef.current = null - setPendingFocus(true) - announce("Selection cleared.") - } - - // `max` comes from the response body; Go and TS share no constant. - function showBulkFailure(error: unknown, fallbackMessage: string) { - const max = - error instanceof ApiError && error.code === "bulk_too_many_ids" - ? (error.body as { max?: unknown } | undefined)?.max - : undefined - showToast( - failureToast(typeof max === "number" ? max : null, fallbackMessage) - ) - } - - function reportBulkAddOutcome( - outcome: BulkAddTagOutcome, - sentIds: number[], - variant: "add" | "restore" = "add" - ) { - const nameOf = (id: number) => entries?.find((e) => e.id === id)?.name ?? "" - showToast(addTagToast(outcome, sentIds, nameOf, variant)) - } - - // One request for the whole selection; null tells the bar to stay selected. - async function handleBulkAddTag( - name: string - ): Promise { - const sentIds = effective.map((e) => e.id) - setBulkInFlight(true) - let out: BulkAddTagOutcome - try { - out = await bulkAddTag(sentIds, name) - } catch (error) { - setBulkInFlight(false) - showBulkFailure( - error, - `Couldn't add “${name}” to the selected artists — try again.` - ) - return null - } - setBulkInFlight(false) - markStickyMany(out.applied) - refresh() - reportBulkAddOutcome(out, sentIds) - return out - } - - // One request for the selection; null keeps it selected. Undo (U4) re-adds by name. - async function handleBulkRemoveTag( - tag: TagRef - ): Promise { - const sentIds = effective.map((e) => e.id) - setBulkInFlight(true) - let out: BulkRemoveTagOutcome - try { - out = await bulkRemoveTag(sentIds, tag.id) - } catch (error) { - setBulkInFlight(false) - showBulkFailure( - error, - `Couldn't remove “${tag.name}” from the selected artists — try again.` - ) - if (error instanceof ApiError && error.status === 404) refresh() - return null - } - setBulkInFlight(false) - markStickyMany(out.applied) - refresh() - showToast( - removeTagToast(tag.name, out), - out.applied.length > 0 - ? { - label: "Undo", - onClick: () => undoRemoveTag(tag.name, out.applied), - } - : undefined - ) - return out - } - - // Touches neither the selection nor the bar; only the removed-link ids get the tag back. - async function undoRemoveTag(name: string, ids: number[]) { - let out: BulkAddTagOutcome - try { - out = await bulkAddTag(ids, name) - } catch { - toast.error(`Couldn't restore “${name}” — try again.`) - return - } - markStickyMany(out.applied) - refresh() - const order = visibleIdsRef.current - const shown = order.filter((id) => ids.includes(id)) - const hidden = ids.filter((id) => !order.includes(id)).sort((a, b) => a - b) - reportBulkAddOutcome(out, [...shown, ...hidden], "restore") - } - - // One request carrying only the changed types; null keeps the popover open. - async function handleBulkPreferences( - delta: PreferencesDelta - ): Promise { - const sentIds = effective.map((e) => e.id) - setBulkInFlight(true) - let out: BulkOutcome - try { - out = await bulkUpdatePreferences(sentIds, delta) - } catch (error) { - setBulkInFlight(false) - showBulkFailure(error, "Couldn't update preferences — try again.") - return null - } - setBulkInFlight(false) - markStickyMany(out.applied) - refresh() - showToast(preferencesToast(out.applied.length, out.skipped)) - return out - } - - function openRemoveConfirm() { - setRemoveConfirm({ - open: true, - ids: effective.map((e) => e.id), - names: effective.map((e) => e.name), - }) - } - - // Never throws and never touches selection or entries: those wait for the - // dialog to close (U1). - async function confirmRemove() { - setBulkInFlight(true) - try { - const out = await bulkRemoveWatchlist(removeConfirm.ids) - removeOutcomeRef.current = { ok: true, out } - } catch (error) { - removeOutcomeRef.current = { ok: false, error } - } - } - - function handleRemoveDialogOpenChange(open: boolean) { - if (open) return - const outcome = removeOutcomeRef.current - removeOutcomeRef.current = null - removeCloseKindRef.current = outcome?.ok ? "success" : "return" - setRemoveConfirm((prev) => ({ ...prev, open: false })) - setBulkInFlight(false) - if (!outcome) return - if (!outcome.ok) { - showBulkFailure(outcome.error, BULK_REMOVE_FAILED) - return - } - const gone = new Set([...outcome.out.removed, ...outcome.out.already_gone]) - setEntries((rows) => (rows ? rows.filter((r) => !gone.has(r.id)) : rows)) - finishBulkSuccess() - showToast(removeWatchlistToast(gone.size, outcome.out.already_gone.length)) - refresh() - } - if (entries) { for (const r of entries) { for (const t of r.tags) tagNamesRef.current.set(t.id, t.name) @@ -770,22 +491,21 @@ export default function Watchlist() { const visible = entries ? selectVisible(entries, { ...view, q: query }, stickyIds) : [] - const effective = effectiveSelection(visible, selectedIds) - visibleIdsRef.current = visible.map((e) => e.id) + const bulk = useBulkSelection({ + entries, + visible, + filterKey, + query, + setEntries, + refresh, + markStickyMany, + announce, + }) countRef.current = { n: visible.length, m: entries?.length ?? 0, - k: effective.length, + k: bulk.effective.length, } - const allState = selectAllState(visible.length, effective.length) - - // Focus moves only once the bar has unmounted and nothing is in flight (F4). - useEffect(() => { - if (!pendingFocus || bulkInFlight || effective.length > 0) return - setPendingFocus(false) - resolveFallbackTarget()?.focus() - // eslint-disable-next-line react-hooks/exhaustive-deps -- resolveFallbackTarget reads this render's values - }, [pendingFocus, bulkInFlight, effective.length]) function handleTagsChange(ids: number[]) { write((p) => { @@ -844,7 +564,7 @@ export default function Watchlist() {

@@ -865,33 +585,7 @@ export default function Watchlist() { onOpenChange={setManageTagsOpen} /> - - removeCloseKindRef.current === "success" - ? resolveFallbackTarget() - : removeButtonRef.current - } - /> +
@@ -944,12 +638,12 @@ export default function Watchlist() {
@@ -981,7 +675,7 @@ export default function Watchlist() { heading="No artists match these filters" body={`Try a different name or fewer filters, or clear them to see all ${entries.length} artist${entries.length === 1 ? "" : "s"}.`} action={ - } @@ -1001,26 +695,14 @@ export default function Watchlist() { toggleFilterTag, }} announce={announce} - selected={selectedIds.has(entry.id)} - selectionDisabled={bulkInFlight} - onSelectChange={handleSelectChange} + selected={bulk.selectedIds.has(entry.id)} + selectionDisabled={bulk.inFlight} + onSelectChange={bulk.onSelectChange} /> ))} )} - {effective.length > 0 && ( - - )} + {bulk.effective.length > 0 && } )}
From 54688033c63d69e4eda1fda435ef38b37500d062 Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:17:44 -0500 Subject: [PATCH 5/7] docs(quick-261019-jkv): record phase 26 bulk UI cleanup and add bulk glossary terms --- .planning/STATE.md | 1 + .../261019-jkv-PLAN.md | 12 ++++++++++++ .../261019-jkv-SUMMARY.md | 14 ++++++++++++++ CONTEXT.md | 12 ++++++++++++ web/app/lib/bulkSelection.ts | 4 ++-- 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-PLAN.md create mode 100644 .planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index af9e3376..32a25d3b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -514,6 +514,7 @@ _Closed 2026-09-05: Phase 16 gap G-16-1 (n1-boot guard-adoption skip) — quick | 261007-lc4 | Phase 24 comment cleanup: comment-only trim to 1-3 line why-notes with one doc ref, fixed inaccurate WithTags/merge-trigger/buildTagSuggestions comments, removed plan narration | 2026-10-07 | be98a62 | [261007-lc4-phase-24-comment-cleanup](./quick/261007-lc4-phase-24-comment-cleanup/) | | 261011-gq7 | Amend Phase 26 CONTEXT and UI-SPEC with grill decisions Q1–Q10 (derived selection, outcome-only bulk responses + refresh, ordered-lock bulk tag attach, single-statement remove/preferences, pending-alert disclosure) and add withdraw-pending-events todo | 2026-10-11 | 6408d28 | [261011-gq7-amend-phase-26-docs-with-grill-decisions](./quick/261011-gq7-amend-phase-26-docs-with-grill-decisions/) | | 261011-moj | Amend Phase 26 CONTEXT and UI-SPEC with UI review U1–U4 (route-level confirm + close→clear→focus order, scroll-padding focus clearance, one-row phone bar, remove-tag Undo) | 2026-10-11 | 9ac478c | [261011-moj-amend-phase-26-ui-spec-and-context-with-](./quick/261011-moj-amend-phase-26-ui-spec-and-context-with-/) | +| 261019-jkv | Refactor phase 26 bulk UI: shared preference options, popover and outcome-messages modules, useBulkSelection hook | 2026-10-19 | 66e1990 | [261019-jkv-refactor-phase-26-bulk-ui-preferenceopti](./quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/) | ### Roadmap Evolution diff --git a/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-PLAN.md b/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-PLAN.md new file mode 100644 index 00000000..bca9821a --- /dev/null +++ b/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-PLAN.md @@ -0,0 +1,12 @@ +# Quick 261019-jkv: phase 26 bulk UI cleanup + +Source: architecture review + grilling (candidates 1 and 2). Branch refactor/phase-26-bulk-cleanup. +Each step is one commit; gate = prettier --write/--check, pnpm test (web only, no Go changes). + +1. lib/preferenceOptions.ts: shared option lists + EVENT_TYPE_ORDER; PreferencesDelta defined once in api.ts. +2. BulkPreferencesPopover.tsx + lib/useBarClearance.ts extracted from BulkActionBar.tsx. +3. lib/bulkOutcomeMessages.ts: pure toast data + table tests; route switched over. +4. lib/useBulkSelection.ts: selection, remove-confirm, handlers, Undo, focus out of routes/watchlist.tsx. +Comments in touched files: 1-3 lines, one design-doc ref, no opaque tags. +CONTEXT.md: add bulk selection, effective selection, bulk outcome. +Existing integration tests stay unedited and green. diff --git a/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-SUMMARY.md b/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-SUMMARY.md new file mode 100644 index 00000000..428592f0 --- /dev/null +++ b/.planning/quick/261019-jkv-refactor-phase-26-bulk-ui-preferenceopti/261019-jkv-SUMMARY.md @@ -0,0 +1,14 @@ +--- +status: complete +--- +# Quick 261019-jkv: phase 26 bulk UI cleanup + +Four refactor commits on refactor/phase-26-bulk-cleanup, no behavior change; all 793 web tests pass, existing integration tests unedited. + +- preferenceOptions.ts shared by the card and bulk bar; PreferencesDelta defined once in api.ts. +- BulkPreferencesPopover and useBarClearance extracted (BulkActionBar 585 -> 330 lines). +- bulkOutcomeMessages.ts: pure toast data with table tests. +- useBulkSelection.ts: selection, remove confirm, handlers, Undo, focus (watchlist.tsx 1065 -> 711 lines). +- CONTEXT.md: bulk selection, effective selection, bulk outcome. + +Not done: embedded SPA bundle (internal/webassets) not rebuilt; integration cases duplicated by the new pure tests were kept, not deleted. diff --git a/CONTEXT.md b/CONTEXT.md index b7c61a16..b3a70f95 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,3 +95,15 @@ _Avoid_: filter (that narrows the existing watchlist) **Note**: The single plain-text annotation on a watchlist entry. It goes away when the artist is removed from the watchlist, and the remove toast's Undo restores it. _Avoid_: notes (there is only one), comment, memo + +**Bulk selection**: +The set of watchlist entries the user has checked for one bulk action (add or remove tag, change preferences, remove). It is never trimmed when a filter hides an entry, so it can hold entries that are not currently shown. +_Avoid_: multi-select, batch + +**Effective selection**: +The bulk selection restricted to the entries currently visible. Every bulk action, the bar's count and Undo work on this set, so a hidden entry is never changed by accident. +_Avoid_: visible selection, active selection + +**Bulk outcome**: +What a bulk request reports back: the entries it **applied** to, and the entries it **skipped** with a reason (the artist is at the tag cap, or the entry is already gone). A skip is a result, not an error; the whole request only fails if nothing could be attempted. +_Avoid_: partial failure, result set diff --git a/web/app/lib/bulkSelection.ts b/web/app/lib/bulkSelection.ts index e1f7c401..64ca76ad 100644 --- a/web/app/lib/bulkSelection.ts +++ b/web/app/lib/bulkSelection.ts @@ -21,7 +21,7 @@ export function selectAllState( return effectiveCount >= visibleCount ? "checked" : "indeterminate" } -// Unchecking drops only the visible ids; hidden selections survive (grill Q9). +// Unchecking drops only the visible ids; hidden selections survive. export function toggleAllVisible( visible: WatchlistEntry[], selected: ReadonlySet @@ -37,7 +37,7 @@ export function toggleAllVisible( } // Sets the inclusive anchor..clicked span, in visible order, to `next`. Without -// a usable anchor it toggles only the clicked id. Spans only `order` (T-26-45). +// a usable anchor it toggles only the clicked id. Spans only `order`. export function applyRange( order: readonly number[], anchor: number | null, From c5da0897dd4ea874cfc3d5a8bfd93c7bfda7e7bf Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:30:42 -0500 Subject: [PATCH 6/7] chore(graphify): ignore tests, sqlc output, package.json and per-plan logs --- .graphifyignore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.graphifyignore b/.graphifyignore index 2ade0ae0..74cd6a46 100644 --- a/.graphifyignore +++ b/.graphifyignore @@ -45,6 +45,26 @@ cmd/migration-check/testdata/ # Frontend tooling config -- one node per JSON key, no architecture signal web/tsconfig.json web/components.json +web/package.json + +# Tests and generated code: ~60% of graph edges were test plumbing (testing.T, +# NewTestPool) and sqlc output, drowning real architecture. Source is findable by name. +*_test.go +*.test.ts +*.test.tsx +internal/db/sqlc/ + +# Per-plan execution logs for every phase; CONTEXT/RESEARCH/UI-SPEC keep the decisions +.planning/phases/*/*-PLAN.md +.planning/phases/*/*-SUMMARY.md +.planning/phases/*/*-REVIEW.md +.planning/phases/*/*-REVIEW-FIX.md +.planning/phases/*/*-UAT.md +.planning/phases/*/*-PATTERNS.md +.planning/phases/*/*-VERIFICATION.md +.planning/phases/*/*-SECURITY.md +.planning/phases/*/*-VALIDATION.md +.planning/phases/*/*-DISCUSSION-LOG.md # Maintenance: when a milestone completes, add that milestone's phase # directory under .planning/milestones/ to this list before the next From 48e977ee689a59fd8fd1a4eab3afbded4163e095 Mon Sep 17 00:00:00 2001 From: Caliber66 Date: Mon, 19 Oct 2026 14:30:43 -0500 Subject: [PATCH 7/7] chore(graphify): rebuild graph after phase 26 bulk cleanup --- graphify-out/GRAPH_REPORT.md | 855 +- graphify-out/graph.html | 10 +- graphify-out/graph.json | 156623 ++++++++------------------------ graphify-out/manifest.json | 1356 +- 4 files changed, 37904 insertions(+), 120940 deletions(-) diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index 50eb5dce..5baaa914 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,652 +1,475 @@ -# Graph Report - drop-tracker (2026-10-06) +# Graph Report - drop-tracker (2026-10-19) ## Corpus Check -- 332 files · ~451,884 words +- 226 files · ~242,404 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 3075 nodes · 9590 edges · 173 communities (110 shown, 25 thin omitted) -- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 1060 edges (avg confidence: 0.85) +- 1784 nodes · 3773 edges · 128 communities (75 shown, 17 thin omitted) +- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 212 edges (avg confidence: 0.83) - Token cost: 0 input · 0 output ## Community Hubs (Navigation) -- Watchlist & Detector DB Tests -- Poller Tests -- Watchlist HTTP Tests -- HTTP Handlers & JSON Helpers (3) -- shadcn UI Primitives -- Frontend API Client & Watchlist Route -- Status Endpoint & Server -- MusicBrainz Client Tests -- DB Migration Runner -- System Page & Source Panels -- Coverage-Report Tool -- Login Throttle Tests (11) -- Notifier Integration Tests (12) -- Auth Gate Tests -- History Route & Event Cards -- Tags sqlc Queries -- Tag Chips & Combobox UI -- MusicBrainz Detection -- Tags HTTP Tests -- Shared Test Fakes & Types -- Migration-Check Tests -- Notifier Integration Tests (21) -- Artist Matcher Tests -- sqlc Models & Watchlist Queries -- Deezer Client Tests (24) -- Dialogs, Auth Store & App Shell (25) -- Artist Note & Preference Toggles -- Watchlist Service -- Notifier Integration Tests (28) -- Digest Chunking Tests (29) -- SQL Schema Parser -- HTTP Server Wiring Tests -- Notifier Integration Tests (32) -- Discord Digest Formatting -- Tags Service -- Deezer-MusicBrainz Matcher -- Digest Scheduler Tests -- MusicBrainz Recordings -- CI Pipeline & Local Gates -- Settings HTTP Tests -- Notifier Core -- Query Column Reference Analysis -- Search UI Components -- Web Runtime Dependencies (43) -- Migration-Check Scanner -- Config Loading Tests -- Notifier Integration Tests (46) -- Events Service Tests (47) -- Dialogs, Auth Store & App Shell (48) -- Web Dev Dependencies (49) -- Events sqlc Queries -- Digest Chunking Tests (51) -- Tag Schema Tests -- SQL Lexer (Dollar Quoting) -- Readiness Endpoint Tests -- Discord Embed Formatting Tests -- ConfirmDialog & AlertDialog -- HTTP Handlers & JSON Helpers (57) -- Tags & Notes Migration -- Artist Backfill -- Notifier DB Timeout Tests -- Phase 25 Tag Filter Plans -- Deezer Client Tests (62) -- Notification Settings Service -- DB Pool Config -- Phase 24 Optimistic UI & Notes -- Phase 25 Release-Date Enrichment -- Artist Search Stubs -- Digest Chunk Builder -- Phase 24 UI Contract Concepts -- Events Service Tests (70) -- MusicBrainz Releases -- Phase 25 URL State & Name Filter -- Server Entrypoint -- v1.6 Phases & Requirements (74) -- Discord Webhook Client -- Discord Client Retry Tests -- Activity Gate -- v1.6 Phases & Requirements (78) -- Phase 24 Pattern Map Concepts -- Phase 24 Postgres Research Findings -- Phase 24 Review, UAT & Verification -- Phase 25 History Tag Filter Plans -- Web package.json Scripts -- Search Cancellation Stubs -- Migration-Check Git Diff -- Codebase Architecture Map -- MusicBrainz Artist Lookup -- MusicBrainz Artist Search -- Per-Artist Tag Cap Decisions -- Phase 24 Security Threats -- Release Detector -- Single-Instance ADRs (92) -- Query Refs Tests -- SPA Embed Handler -- End-to-End Boot Tests -- Single-Instance ADRs (96) -- Status Snapshot & Ring Buffer (97) -- Digest Ack Split -- Tag Cap Locking & Merge -- Migration Rollback Rules -- Status Snapshot & Ring Buffer (101) -- Web Template & Status Client -- Login Throttle Tests (103) -- Build Info -- Combobox Interaction -- Digest Mode Concepts -- Digest Slot & Watermark -- Deezer Client -- v1.4 Observability Milestone (109) -- Comment Discipline Convention -- Graph-First & GSD Conventions -- clsx Dependency -- Migration Safety & Passphrase Gate -- Issue Tracker Conventions -- Health Endpoint -- Notification Settings Migration -- Raw SQL Statement -- Milestone Log & Retrospective -- Feature Options (Sinks, Digest) -- v1.4 Observability Milestone (121) -- React Dependency -- React Router Dev -- Tailwind Vite Plugin -- Web Dev Dependencies (125) -- Web Runtime Dependencies (126) -- React Types -- Web Dev Dependencies (128) -- Vitest Dependency -- Domain Glossary -- Watched Artist vs Credited Artist +- writeError +- 26 RESEARCH doc +- .HandleLogin +- watchlistView.ts +- main.go +- parse.go +- github.com/danielrpof/drop-tracker/internal/db/sqlc.Event +- 24 CONTEXT doc +- combobox.tsx +- deezer/client.go +- format.ts +- Phase 25 Research +- In-process poll-run ring buffer +- useBulkSelection.ts +- notifier.go +- DigestSettings.tsx +- migrate.go +- BulkPreferencesPopover.tsx +- match.go +- service.go +- TagChips.tsx +- history.tsx +- system.tsx +- api.ts +- 25 UI SPEC doc +- SearchResultsColumns.tsx +- root.tsx +- poller.go +- TESTING doc +- EventCard.tsx +- ManageTagsDialog +- service.go (2) +- ROADMAP doc +- TagChips.tsx (2) +- artist_tags +- full-pipeline.yml +- CONCERNS doc +- dialog.tsx +- alert-dialog.tsx +- server.go +- CONTEXT doc +- detector.go +- settings.go +- search.go +- STRUCTURE doc +- v1.0 MILESTONE AUDIT doc +- ROADMAP doc (2) +- musicbrainz.go +- REQUIREMENTS doc +- useBulkSelection.ts (2) +- INTEGRATIONS doc +- status.go +- musicbrainz.go (2) +- STATE doc +- STACK doc +- WatchlistBulkStore +- bulk.go +- tags.go +- recordings.go +- logging.go +- releases.go +- CONTEXT doc (2) +- artist_lookup.go +- terraform always on env doc +- activity.go +- backfill.go +- alerter.go +- CONTEXT doc (3) +- schema_version.go +- server.go (2) +- settings.go (2) +- releasegroups.go +- HistoryFilters.tsx +- pnpm workspace doc +- CONTEXT doc (4) +- CLAUDE doc +- CLAUDE doc (2) +- issue tracker doc +- health.go +- 000008_notification_settings.up.sql +- parse.go (2) +- MILESTONES doc +- 2026 09 08 feature module doc +- docker-compose.yml - github.com/danielrpof/drop-tracker -- 2026-09-08-feature-module-ideas-post-v1.3.md -- Soft-delete Event Retention -- v1.0 Milestone Audit +- Feature/Module Ideas Post-v1.3 +- Option C: Operator Status Panel + +- Option E: Watchlist Organization +- Phase 25 External API Coverage Declaration +- In-process poll-run ring buffer (2) +- Design Grilling Before Planning +- Don't Roadmap Phases Gated on Uncontrolled ## God Nodes (most connected - your core abstractions) -1. `New()` - 228 edges -2. `NewIsolatedTestPool()` - 146 edges -3. `NewTestPool()` - 137 edges -4. `New()` - 116 edges -5. `cn()` - 104 edges -6. `discardLogger()` - 97 edges -7. `New()` - 79 edges -8. `NewService()` - 78 edges -9. `newTestLogger()` - 77 edges -10. `newTestLogger()` - 64 edges +1. `cn()` - 115 edges +2. `run()` - 45 edges +3. `Watchlist()` - 36 edges +4. `Phase 26 Bulk Edit and Remove Research` - 30 edges +5. `Phase 26 UI Design Contract` - 28 edges +6. `WatchlistEntry` - 26 edges +7. `Phase 25 UI Design Contract` - 26 edges +8. `writeError()` - 24 edges +9. `Phase 24: Artist Tags & Notes` - 24 edges +10. `Testing Patterns` - 21 edges ## Surprising Connections (you probably didn't know these) -- `handleHealth (internal/httpserver/health.go)` --shares_data_with--> `NewPool()` [INFERRED] - .planning/codebase/ARCHITECTURE.md → internal/db/pool.go - `Comment Discipline Rule` --semantically_similar_to--> `Coding Conventions` [INFERRED] [semantically similar] .claude/CLAUDE.md → .planning/codebase/CONVENTIONS.md -- `Tag merge via UPDATE tag_id (never insert)` --semantically_similar_to--> `Expand / backfill / contract pattern (000006, 000007)` [INFERRED] [semantically similar] - docs/adr/0004-per-artist-tag-cap-trigger.md → internal/db/migrations/README.md -- `G7: keep both comboboxes` --references--> `Combobox()` [EXTRACTED] - .planning/phases/25-find-filter-watchlist-and-history/25-CONTEXT.md → web/app/components/history/HistoryFilters.tsx - `pre-commit config (gitleaks, golangci-lint --fix, prettier --write)` --semantically_similar_to--> `gitleaks job` [EXTRACTED] [semantically similar] .pre-commit-config.yaml → .github/workflows/full-pipeline.yml +- `scanFile()` --calls--> `Parse()` [EXTRACTED] + cmd/migration-check/main.go → internal/sqlscan/parse.go +- `crossReferenceFinding()` --references--> `RefSet` [EXTRACTED] + cmd/migration-check/main.go → internal/sqlscan/queryrefs.go +- `run()` --calls--> `NewActivityGate()` [EXTRACTED] + cmd/server/main.go → internal/artistart/activity.go ## Import Cycles - None detected. ## Hyperedges (group relationships) +- **Bulk tag add cap-safety mechanism** — docs_adr_0004_per_artist_tag_cap_trigger_cap_trigger, docs_adr_0004_per_artist_tag_cap_trigger_bulk_prelock, planning_phases_26_bulk_edit_remove_26_context_apply_where_fits [EXTRACTED 0.95] +- **Digest scheduling vocabulary** — context_digest_slot, context_slot_record, context_grace_window, context_digest_window, context_watermark [EXTRACTED 0.95] +- **Tag data model** — context_tag, context_tag_vocabulary, context_tag_link, context_carrier, context_merge [EXTRACTED 0.95] +- **Bulk selection and action UI** — planning_phases_26_bulk_edit_remove_26_ui_spec_derived_selection_effective_selection, planning_phases_26_bulk_edit_remove_26_ui_spec_select_all_visible_tri_state, planning_phases_26_bulk_edit_remove_26_ui_spec_sticky_bulk_action_bar, planning_phases_26_bulk_edit_remove_26_ui_spec_success_focus_order_and_fallback_chain [EXTRACTED 1.00] +- **Bulk tag add safe-insert flow** — planning_phases_26_bulk_edit_remove_26_research_bulk_tag_add_apply_where_it_fits_d_01, planning_phases_26_bulk_edit_remove_26_research_ordered_pre_lock_then_guarded_insert, planning_phases_26_bulk_edit_remove_26_research_adr_0004_tag_cap_trigger, planning_phases_26_bulk_edit_remove_26_research_tag_orphan_on_all_capped_create [EXTRACTED 1.00] - **Release-path gate jobs feeding build-scan then release** — github_workflows_full_pipeline_lint, github_workflows_full_pipeline_test, github_workflows_full_pipeline_gitleaks, github_workflows_full_pipeline_trivy_fs, github_workflows_full_pipeline_frontend_test, github_workflows_full_pipeline_migration_check, github_workflows_full_pipeline_n1_boot, github_workflows_full_pipeline_build_scan, github_workflows_full_pipeline_release [EXTRACTED 1.00] -- **Digest scheduling domain model** — context_digest_mode, context_outbox, context_digest_window, context_digest_slot, context_slot_record, context_watermark, context_grace_window, context_flush [EXTRACTED 1.00] +- **Phase 28 safety work: inert seam, escaping, fixture preconditions** — planning_roadmap_tagsreader, planning_roadmap_escapemd, planning_roadmap_chunkfixture, planning_roadmap_phase28 [EXTRACTED 1.00] - **N-1 rollback safety enforcement** — internal_db_migrations_readme_n1_invariant, internal_db_migrations_readme_n1_boot_job, internal_db_migrations_readme_ahead_of_source_test, internal_db_migrations_readme_migration_check, internal_db_migrations_readme_expand_backfill_contract [EXTRACTED 1.00] -- **History tag filter (HIST-02) end to end** — planning_phases_25_find_filter_watchlist_and_history_25_02_plan, planning_phases_25_find_filter_watchlist_and_history_25_06_plan, planning_phases_25_find_filter_watchlist_and_history_25_09_plan, planning_phases_25_find_filter_watchlist_and_history_25_context_d11_history_single_tag, planning_phases_25_find_filter_watchlist_and_history_25_research_pattern_tag_filter_retention [EXTRACTED 1.00] -- **Single-query latest/next release enrichment** — planning_phases_25_find_filter_watchlist_and_history_25_01_plan, planning_phases_25_find_filter_watchlist_and_history_25_context_latest_release_date, planning_phases_25_find_filter_watchlist_and_history_25_context_next_release_date, planning_phases_25_find_filter_watchlist_and_history_25_context_g1_no_retention, planning_phases_25_find_filter_watchlist_and_history_25_context_g2_upcoming_utc, planning_phases_25_find_filter_watchlist_and_history_25_research_pattern_lateral_enrichment [EXTRACTED 1.00] - **Single-instance in-process coordination decisions** — docs_adr_0001_in_process_ring_buffer_for_poll_run_history_ring_buffer, docs_adr_0002_one_outbox_one_sender_lock_notifier_notifying_lock, docs_adr_0001_in_process_ring_buffer_for_poll_run_history_single_instance_constraint, docs_adr_0002_one_outbox_one_sender_lock_postgres_advisory_lock [EXTRACTED 1.00] +- **v1.6 tags-first dependency chain (Phases 24-26)** — planning_roadmap_phase24, planning_roadmap_phase25, planning_roadmap_phase26, planning_roadmap_phase27, planning_roadmap_phase28 [EXTRACTED 1.00] +- **Watchlist filter toolbar composition** — planning_phases_25_find_filter_watchlist_and_history_25_ui_spec_name_filter, planning_phases_25_find_filter_watchlist_and_history_25_ui_spec_sort_select_six_options, planning_phases_25_find_filter_watchlist_and_history_25_ui_spec_tag_filter_any_of_combobox, planning_phases_25_find_filter_watchlist_and_history_25_ui_spec_preference_toggles_filter, planning_phases_25_find_filter_watchlist_and_history_25_ui_spec_clear_filters [EXTRACTED 1.00] - **Local hooks mirroring CI gates** — pre_commit_config, golangci, claude_claude_definition_of_done, github_workflows_full_pipeline [INFERRED 0.85] -- **Watchlist find/filter composition (search, sort, tags, toggles)** — planning_phases_25_find_filter_watchlist_and_history_25_04_plan, planning_phases_25_find_filter_watchlist_and_history_25_05_plan, planning_phases_25_find_filter_watchlist_and_history_25_07_plan, planning_phases_25_find_filter_watchlist_and_history_25_08_plan, planning_phases_25_find_filter_watchlist_and_history_25_research_pattern_selectvisible, planning_phases_25_find_filter_watchlist_and_history_25_context_d10_three_empty_states [INFERRED 0.85] +- **Watchlist find/filter composition (search, sort, tags, toggles)** — planning_phases_25_find_filter_watchlist_and_history_25_research_pattern_selectvisible [INFERRED 0.85] - **Manage tags rename/merge/delete UI flow** — planning_phases_24_artist_tags_notes_24_ui_spec_managetagsdialog, planning_phases_24_artist_tags_notes_24_ui_spec_confirmdialog, planning_phases_24_artist_tags_notes_24_context_d_09_rename_merge_confirm, planning_phases_24_artist_tags_notes_24_context_d_16_reusable_alert_dialog, planning_phases_24_artist_tags_notes_24_context_d_22_vocabulary_api, planning_phases_24_artist_tags_notes_24_ui_spec_focus_management_r5 [INFERRED 0.85] -- **Per-artist 10-tag cap enforcement** — planning_phases_24_artist_tags_notes_24_context_d_18_cap_trigger, planning_phases_24_artist_tags_notes_24_context_d_19_merge_never_inserts, planning_phases_24_artist_tags_notes_24_context_adr_0004_cap_trigger, planning_phases_24_artist_tags_notes_24_research_before_insert_trigger_before_on_conflict, planning_phases_24_artist_tags_notes_24_research_for_no_key_update_lock, planning_phases_24_artist_tags_notes_24_security_t_24_01_02_cap_tampering, planning_phases_24_artist_tags_notes_24_security_migrations_000011_000012 [INFERRED 0.85] +- **Per-artist 10-tag cap enforcement** — planning_phases_24_artist_tags_notes_24_context_d_18_cap_trigger, planning_phases_24_artist_tags_notes_24_context_d_19_merge_never_inserts, planning_phases_24_artist_tags_notes_24_context_adr_0004_cap_trigger, planning_phases_24_artist_tags_notes_24_research_before_insert_trigger_before_on_conflict, planning_phases_24_artist_tags_notes_24_research_for_no_key_update_lock [INFERRED 0.85] - **Poll-run history to /status to SPA flow** — docs_adr_0001_in_process_ring_buffer_for_poll_run_history_runrecorder_seam, docs_adr_0001_in_process_ring_buffer_for_poll_run_history_ring_buffer, docs_api_status_contract_pollruns_snapshot, docs_api_status_contract_handlestatus, docs_api_status_contract_spa_api_client [INFERRED 0.85] +- **Tag integrity: normalization, unique index, cap trigger** — planning_roadmap_tagidentity, planning_roadmap_migration10, planning_roadmap_captrigger, planning_roadmap_migration1112 [INFERRED 0.85] -## Communities (173 total, 25 thin omitted) +## Communities (128 total, 17 thin omitted) -### Community 0 - "Watchlist & Detector DB Tests" -Cohesion: 0.05 -Nodes (160): fakeReleaseDetailSource, insertEventFailingQuerier, ExpectedSchemaVersion(), TestExpectedSchemaVersion(), TestSchemaVersion_Integration(), TestSchemaVersionReader_Delegates(), New(), TestSQLCPing() (+152 more) - -### Community 1 - "Poller Tests" -Cohesion: 0.06 -Nodes (103): cron.Cron, sync/atomic.Bool, New(), runOneArtist(), decodeLogRecords(), deezerID(), eightEntries(), failFirstThree() (+95 more) - -### Community 2 - "Watchlist HTTP Tests" -Cohesion: 0.06 -Nodes (82): errorBody, healthBody, noteEntryBody, SearchSource, watchlistEntryBody, TestHandleListEvents_CursorRejection(), TestHandleListEvents_EmptyReturnsEmptyArrayAndNullCursor(), TestHandleListEvents_HappyPathReturns200WithEnvelope() (+74 more) - -### Community 3 - "HTTP Handlers & JSON Helpers (3)" -Cohesion: 0.05 -Nodes (47): globalCounter, ipLimiter, loginRequest, loginThrottle, Cursor, cursorWireForm, net/http.Request, net/http.ResponseWriter (+39 more) - -### Community 4 - "shadcn UI Primitives" -Cohesion: 0.05 -Nodes (64): AboutInstanceProps, Cadence, CADENCE_LABELS, SaveStatus, Alert(), AlertAction(), AlertDescription(), AlertTitle() (+56 more) - -### Community 5 - "Frontend API Client & Watchlist Route" -Cohesion: 0.06 -Nodes (47): Phase 25 Pattern Map, mockDeleteTag, mockListTags, mockMergeTag, mockRenameTag, openCollisionConfirm(), renderDialog(), SearchBox() (+39 more) +### Community 0 - "writeError" +Cohesion: 0.07 +Nodes (51): Cursor, cursorWireForm, ListParams, Page, Service, github.com/danielrpof/drop-tracker/internal/db/sqlc.ListEventsRow, net/http.Request, net/http.ResponseWriter (+43 more) -### Community 6 - "Status Endpoint & Server" +### Community 1 - "26 RESEARCH doc" Cohesion: 0.06 -Nodes (56): chi.Router, net/http.Handler, attachTagRequest, deleteTagResponse, fakeStatusStore, mergeTagRequest, Option, Pinger (+48 more) - -### Community 7 - "MusicBrainz Client Tests" -Cohesion: 0.08 -Nodes (62): TestLookupArtist_DecodesFixture(), TestLookupArtist_EmptyMBID(), TestLookupArtist_MalformedJSON(), TestLookupArtist_NonOKStatus(), TestLookupArtist_NoRelationsNoAliasesYieldsNonNilZeroLengthSlices(), TestLookupArtist_RequestShape(), TestReleasesForRecording_DecodesFixture(), TestReleasesForRecording_EmptyMBID() (+54 more) +Nodes (66): ADR 0004 tag cap trigger, Bulk remove-tag by tag id (D-22), Bulk tag add apply-where-it-fits (D-01), ConfirmDialog close-order pitfall, Deadlock-free lock ordering, Derived selection (D-11), Grill amendments Q1-Q10, Id count and body bounds (+58 more) -### Community 8 - "DB Migration Runner" +### Community 2 - ".HandleLogin" Cohesion: 0.07 -Nodes (54): Atomic CAS overlap-guard pattern (poll cycles), main(), run(), highestMigrationVersion(), scratchSchemaDSN(), TestRun_AppliesHeadSchema(), TestRun_MissingDatabaseURL(), retryConfig (+46 more) +Nodes (38): globalCounter, ipLimiter, loginRequest, loginThrottle, Token, sync.Mutex, sync.Once, time.Ticker (+30 more) -### Community 9 - "System Page & Source Panels" +### Community 3 - "watchlistView.ts" Cohesion: 0.07 -Nodes (47): AboutInstance(), DigestSettings(), handleCadenceChange(), handleDigestModeChange(), save(), DigestSettingsProps, mockUpdateDigestSettings, classifyOutcome() (+39 more) - -### Community 10 - "Coverage-Report Tool" -Cohesion: 0.08 -Nodes (56): appendFile(), backendTotalPct(), formatDelta(), frontendLinesPct(), main(), parseBlockLine(), readBackend(), readFrontend() (+48 more) +Nodes (55): addWatchlist(), removeWatchlist(), sortTagsByName(), anchorCandidates(), useScrollAnchor(), rewriteTags(), byId(), canonicalWatchlistParams() (+47 more) -### Community 11 - "Login Throttle Tests (11)" -Cohesion: 0.11 -Nodes (46): discordAlerter, noopAlerter, recordingAlerter, golang.org/x/time/rate.Limit, time.Duration, Alerter, NoOpAlerter(), SelectAlerter() (+38 more) +### Community 4 - "main.go" +Cohesion: 0.07 +Nodes (54): appendFile(), backendTotalPct(), formatDelta(), frontendLinesPct(), main(), parseBlockLine(), readBackend(), readFrontend() (+46 more) -### Community 12 - "Notifier Integration Tests (12)" -Cohesion: 0.15 -Nodes (50): bytes.Buffer, github.com/jackc/pgx/v5/pgxpool.Pool, decodeLogRecords(), decodeModeTransitionRecords(), erroringFrom(), erroringSettings(), flippingSettings(), insertPendingEvent() (+42 more) +### Community 5 - "parse.go" +Cohesion: 0.07 +Nodes (41): buildPrevReleaseRefs(), strings.Builder, NormalizeIdent(), StripIdent(), StripSchemaQualifier(), copyDollarQuoted(), copySingleQuoted(), dollarTagAt() (+33 more) -### Community 13 - "Auth Gate Tests" -Cohesion: 0.11 -Nodes (43): fakeAlerter, stubPinger, syncBuffer, Token, net/http.Cookie, net/http.Response, deleteSession(), discardLogger() (+35 more) +### Community 6 - "github.com/danielrpof/drop-tracker/internal/db/sqlc.Event" +Cohesion: 0.09 +Nodes (43): discordAlerter, allowedMentions, EmbedImage, retry429Body, webhookPayload, github.com/danielrpof/drop-tracker/internal/db/sqlc.Event, golang.org/x/text/collate.Collator, Client (+35 more) -### Community 14 - "History Route & Event Cards" +### Community 7 - "24 CONTEXT doc" Cohesion: 0.06 -Nodes (29): CoverArt(), CoverArtProps, EmptyState(), EmptyStateProps, EVENT_BADGE, EventCard(), EventCardProps, GuestFeatureBody() (+21 more) +Nodes (48): Phase 24 Context: Artist Tags & Notes, ADR 0004: Per-artist tag cap trigger, D-03: Optimistic chip add/remove with rollback, D-05: Note clamped to 2 lines with more expander, D-06: Note edited in place with Save/Cancel and counter, D-07: Manage tags dialog from Watchlist header, D-09: Inline rename; collision opens merge confirmation, D-10: Notes on watchlist row, tags on artists.id (+40 more) -### Community 15 - "Tags sqlc Queries" +### Community 8 - "combobox.tsx" Cohesion: 0.08 -Nodes (20): context.Context, fakeWatchlistCounter, noopPinger, stubPinger, Artist, Queries, Queries, Queries (+12 more) +Nodes (31): Button(), buttonVariants, ComboboxChip(), ComboboxChips(), ComboboxChipsInput(), ComboboxClear(), ComboboxContent(), ComboboxEmpty() (+23 more) -### Community 16 - "Tag Chips & Combobox UI" +### Community 9 - "deezer/client.go" Cohesion: 0.07 -Nodes (30): CollisionTarget, ManageTagsDialogProps, FocusRequest, nextPendingId(), PendingTag, TagActions, TagChips(), attachErrorMessage() (+22 more) +Nodes (28): Artist, AlbumLister, APIError, artistAlbumsResponse, errorProbe, context.CancelFunc, golang.org/x/time/rate.Limiter, io.ReadCloser (+20 more) -### Community 17 - "MusicBrainz Detection" -Cohesion: 0.08 -Nodes (30): log/slog.Logger, Detector, newNotifyGate(), nullableString(), deluxeDetectionEnabled(), eventTypeMuted(), releaseTypeAllowed(), TestFilter_DeluxeIsAGateNotAType() (+22 more) +### Community 10 - "format.ts" +Cohesion: 0.10 +Nodes (37): EventCard(), DigestSettings(), handleCadenceChange(), handleDigestModeChange(), save(), classifyOutcome(), OutcomeBadge(), OutcomeTier (+29 more) -### Community 18 - "Tags HTTP Tests" -Cohesion: 0.12 -Nodes (41): testing.T, tagsServerOpts, tagSummaryWire, tagWire, watchlistTagsEntryBody, TestMigrationsReadme_ContainsRequiredPhrases(), TestMigrationsReadme_IsNonTrivial(), TestSearchArtist_WireShapeKeySet() (+33 more) - -### Community 19 - "Shared Test Fakes & Types" -Cohesion: 0.07 -Nodes (22): stubAlbumLister, stubGroupLister, syncBuf, artistAlbumsResponse, net/http.RoundTripper, sync.Mutex, capturingRoundTripper, Album (+14 more) +### Community 11 - "Phase 25 Research" +Cohesion: 0.06 +Nodes (38): Bulk outcome, Bulk selection, Effective selection, Filter, Search, ADR 0004: per-artist tag cap trigger, Ordered pre-lock for bulk attach, Locking skip-existing tag cap trigger (artist_tags) (+30 more) -### Community 20 - "Migration-Check Tests" -Cohesion: 0.11 -Nodes (41): main(), run(), readPrevReleaseFixture(), runCapture(), runScanCapture(), stubQueriesGitShow(), TestChangedFiles_AddedAndModified(), TestChangedFiles_NothingUnderGlobIsFalseAndEmpty() (+33 more) +### Community 12 - "In-process poll-run ring buffer" +Cohesion: 0.06 +Nodes (40): ADR 0001: In-process ring buffer for poll-run history, poll_runs table (rejected option), In-process poll-run ring buffer (pollruns.Store), poller.RunRecorder seam, Single-instance constraint (no leader election in robfig/cron), httpserver.StatusStore seam, ADR 0002: One outbox, one sender lock, CAS-skip on held lock (+32 more) -### Community 21 - "Notifier Integration Tests (21)" -Cohesion: 0.20 -Nodes (40): TestDigestScheduler_NotDue_Quiet_NoLogsNoWrites(), countNotified(), digestSettingsReader(), flippingDigestSettings(), insertPendingEventsForChunking(), snapshotSettings(), TestNoOp_SendDigestIfDue_ReturnsNilTouchesNothing(), TestSendDigestIfDue_AllSuppressed_ZeroSendAckedSlotAdvancesSentAtStaysNull() (+32 more) +### Community 13 - "useBulkSelection.ts" +Cohesion: 0.12 +Nodes (35): BulkActionBarProps, RemoveTagPicker(), BulkPreferencesPopoverProps, DISMISS_REASONS, BulkAddTagOutcome, BulkAxisDelta, BulkOutcome, BulkRemoveTagOutcome (+27 more) -### Community 22 - "Artist Matcher Tests" +### Community 14 - "notifier.go" Cohesion: 0.13 -Nodes (37): stubSearcherFunc, NewMatcher(), TestMatch_EmptyNameFailsClosedWithoutOutboundCall(), TestMatch_MatchedCandidateWithEmptyPictureYieldsNilImageURL(), TestMatch_NoCloseNameCandidateFailsClosed(), TestMatch_SearchErrorSurfaces(), TestMatch_SingleCandidateIssuesNoTieBreakFetch(), TestMatch_SingleCloseNameCandidate() (+29 more) +Nodes (24): Cadence, context.Context, github.com/danielrpof/drop-tracker/internal/db/sqlc.Querier, log/slog.Logger, NewClient(), ackDigestBatch(), ackEventsOnly(), Notifier (+16 more) -### Community 23 - "sqlc Models & Watchlist Queries" -Cohesion: 0.08 -Nodes (19): stubStore, notifyGate, github.com/jackc/pgx/v5/pgtype.Timestamptz, UpsertArtistParams, Artist, NotificationSetting, Queries, Queries (+11 more) +### Community 15 - "DigestSettings.tsx" +Cohesion: 0.12 +Nodes (28): Cadence, CADENCE_LABELS, DigestSettingsProps, SaveStatus, Avatar(), AvatarBadge(), AvatarFallback(), AvatarGroup() (+20 more) -### Community 24 - "Deezer Client Tests (24)" -Cohesion: 0.14 -Nodes (35): buildArtistAlbumsPageJSON(), pagedArtistAlbumsServer(), TestArtistAlbums_CancellationBetweenPagesAborts(), TestArtistAlbums_DecodesFixture(), TestArtistAlbums_EmptyArtistIDReturnsErrorWithZeroRequests(), TestArtistAlbums_MidFetchErrorStopsWithNoRetry(), TestArtistAlbums_NonexistentArtistReturnsEmptyNonNilNoError(), TestArtistAlbums_PacingAppliesAcrossPages() (+27 more) +### Community 16 - "migrate.go" +Cohesion: 0.12 +Nodes (30): main(), run(), retryConfig, RetryOption, github.com/golang-migrate/migrate/v4/source.Driver, github.com/jackc/pgx/v5/pgxpool.Config, github.com/jackc/pgx/v5/pgxpool.Pool, testing.T (+22 more) -### Community 25 - "Dialogs, Auth Store & App Shell (25)" -Cohesion: 0.08 -Nodes (16): PassphraseScreen(), handleSubmit(), Input(), Toaster(), createSession(), deleteSession(), authStore, gateActive (+8 more) +### Community 17 - "BulkPreferencesPopover.tsx" +Cohesion: 0.10 +Nodes (27): Popover(), PopoverContent(), PopoverDescription(), PopoverHeader(), PopoverTitle(), PopoverTrigger(), BulkPreferencesPopover(), changeOpen() (+19 more) -### Community 26 - "Artist Note & Preference Toggles" -Cohesion: 0.09 -Nodes (21): Button(), buttonVariants, Checkbox(), ArtistNote(), handleSave(), ArtistNoteProps, FocusRequest, saveErrorMessage() (+13 more) +### Community 18 - "match.go" +Cohesion: 0.17 +Nodes (20): AlbumLister, ArtistDetailLookup, ArtistFetcher, ArtistSearcher, Matcher, Option, ReleaseGroupLister, Result (+12 more) -### Community 27 - "Watchlist Service" +### Community 19 - "service.go" Cohesion: 0.11 -Nodes (17): stubStore, fullEntryBody, stubStore, ListWatchlistRow, TestNormalizeSet(), entryFromRow(), AddParams, Entry (+9 more) - -### Community 28 - "Notifier Integration Tests (28)" -Cohesion: 0.21 -Nodes (34): NewService(), countArtistTagsByTagID(), countTagsByLowerName(), seedEntry(), TestService_Attach_AtCapReturnsErrTagCapReachedAndRollsBackOrphan(), TestService_Attach_CreatesAndLinksNewTag(), TestService_Attach_ExistingLinkReturnsCreatedFalse(), TestService_Attach_UnknownEntryReturnsErrEntryNotFound() (+26 more) - -### Community 29 - "Digest Chunking Tests (29)" -Cohesion: 0.16 -Nodes (32): buildDigestGroups(), chunkDigest(), containsID(), makeSyntheticGroup(), stripChunkMarkers(), syntheticInvariantBatch(), TestBuildDigestChunks_CapTotalReflectsKeptChunksNotUncapped(), TestBuildDigestChunks_CapTruncatesAndReportsDeferred() (+24 more) +Nodes (17): github.com/danielrpof/drop-tracker/internal/db/sqlc.DBTX, github.com/danielrpof/drop-tracker/internal/db/sqlc.Queries, tagCollisionResponse, BulkSkip, Service, goneIDs(), NormalizeName(), Service (+9 more) -### Community 30 - "SQL Schema Parser" -Cohesion: 0.08 -Nodes (21): Parse(), SchemaColumns(), TestParse_AddCheckNeverAddColumn(), TestParse_AddColumnNotNullAndDefaultAreIndependent(), TestParse_AlterTableActionsInClauseOrder(), TestParse_CreateTableColumnsExcludeConstraintClauses(), TestParse_DropTableNormalizesAndKeepsRaw(), TestParse_EachActionTypeFromItsClauseForm() (+13 more) - -### Community 31 - "HTTP Server Wiring Tests" -Cohesion: 0.11 -Nodes (25): log/slog.Level, syncBuffer, Config, assertInert(), newCapturingServer(), newGatedServer(), requestIDsInLog(), TestGatedServer_TrustProxyHeaders_RealIPWiring() (+17 more) +### Community 20 - "TagChips.tsx" +Cohesion: 0.12 +Nodes (23): Textarea(), ArtistNote(), handleChange(), handleSave(), openEditor(), ArtistNoteProps, FocusRequest, saveErrorMessage() (+15 more) -### Community 32 - "Notifier Integration Tests (32)" -Cohesion: 0.18 -Nodes (26): time.Time, insertPendingEventTypedRaw(), mustLoadDigestZone(), newManualTickSource(), newMutableClock(), newSignalingSink(), seedNotificationSettings(), sweep() (+18 more) +### Community 21 - "history.tsx" +Cohesion: 0.13 +Nodes (22): EmptyState(), EmptyStateProps, ComboboxOption, ComboboxProps, EVENT_TYPE_OPTIONS, HistoryFilters(), HistoryFiltersProps, HistoryFiltersValue (+14 more) -### Community 33 - "Discord Digest Formatting" -Cohesion: 0.15 -Nodes (27): golang.org/x/text/collate.Collator, Event, artistKey(), digestLine(), escapeMarkdown(), lineLabel(), sortDigestGroup(), TestArtistKey() (+19 more) +### Community 22 - "system.tsx" +Cohesion: 0.13 +Nodes (23): AboutInstance(), AboutInstanceProps, Alert(), AlertAction(), AlertDescription(), AlertTitle(), alertVariants, Card() (+15 more) -### Community 34 - "Tags Service" -Cohesion: 0.11 -Nodes (13): fakeTagStore, DBTX, Queries, NormalizeName(), TestNormalizeName(), AttachResult, Summary, Tag (+5 more) +### Community 23 - "api.ts" +Cohesion: 0.13 +Nodes (22): ApiError, apiFetch(), BulkRemoveOutcome, bulkRemoveTag(), bulkRemoveWatchlist(), BulkSkip, bulkUpdatePreferences(), deleteTag() (+14 more) -### Community 35 - "Deezer-MusicBrainz Matcher" +### Community 24 - "25 UI SPEC doc" Cohesion: 0.15 -Nodes (20): AlbumLister, ArtistDetailLookup, ArtistFetcher, ArtistSearcher, Matcher, Option, ReleaseGroupLister, aliasQueryNames() (+12 more) +Nodes (26): 24px dense target exception, Accent and case folding match, Clear filters, Click-to-filter tag chips, Client-side filtering over single payload, Combobox hardening max-h-72, ComboboxChip vendored edit, Dark-only design system base-maia (+18 more) -### Community 36 - "Digest Scheduler Tests" -Cohesion: 0.17 -Nodes (20): defaultTickSource(), DigestScheduler, NewDigestScheduler(), newFakeSink(), newSyncTestLogger(), TestDigestScheduler_CheckError_LoggedAndLoopContinues(), TestDigestScheduler_ChecksReceiveInjectedClockValue(), TestDigestScheduler_ContextCancelled_LoopExitsWithoutStop() (+12 more) +### Community 25 - "SearchResultsColumns.tsx" +Cohesion: 0.12 +Nodes (21): SearchBox(), handleChange(), runSearch(), SearchBoxProps, DEEZER_ADD_HINT, SearchResultRow(), SearchResultRowProps, SearchResultsColumns() (+13 more) -### Community 37 - "MusicBrainz Recordings" -Cohesion: 0.11 -Nodes (13): datedRecordingSource, erroringRecordingSource, fakeRecordingSource, noRecordingSource, Client, RecordingRelease, ArtistCreditEntry, Client (+5 more) +### Community 26 - "root.tsx" +Cohesion: 0.10 +Nodes (10): PassphraseScreen(), handleSubmit(), Toaster(), createSession(), deleteSession(), authStore, gateActive, listeners (+2 more) -### Community 38 - "CI Pipeline & Local Gates" -Cohesion: 0.09 -Nodes (27): Definition of Done (local CI gates), No AI Attribution in Commits/PRs, Recommended Tech Stack (chi, sqlc, pgx, golang-migrate, robfig/cron, caarlos0/env, slog, httplog), Full Pipeline GitHub Actions Workflow, build-scan job (image build, provenance check, tz boot check, Trivy image scan), coverage-comment job (report-only sticky PR comment), frontend-test job (prettier --check + Vitest), gitleaks job (+19 more) +### Community 27 - "poller.go" +Cohesion: 0.19 +Nodes (16): cron.Cron, sync/atomic.Bool, New(), runOneArtist(), WithDeezerWorkers(), WithMusicBrainzWorkers(), WithRunRecorder(), AlbumSource (+8 more) -### Community 39 - "Settings HTTP Tests" -Cohesion: 0.20 -Nodes (26): net/http/httptest.Server, settingsBody, settingsServerOpts, WithSettings(), SettingsStore, assertSettingsDefaults(), getSettingsRaw(), loginForSettings() (+18 more) +### Community 28 - "TESTING doc" +Cohesion: 0.14 +Nodes (22): Co-located _test package convention, Concurrent testing with WaitGroup, Error sanitization test, Frontend test framework absent, Func-field stubs, Go stdlib testing runner, httptest.Server API mocking, Inline string-constant fixtures (+14 more) -### Community 40 - "Notifier Core" +### Community 29 - "EventCard.tsx" Cohesion: 0.13 -Nodes (18): Querier, ackDigestBatch(), ackEventsOnly(), Notifier, Notifier, listUnnotified(), logSettingsReadFailure(), markNotified() (+10 more) +Nodes (14): CoverArt(), CoverArtProps, EVENT_BADGE, EventCardProps, GuestFeatureBody(), guestFeatureHref(), UNKNOWN_EVENT_BADGE, watchlistNote() (+6 more) -### Community 41 - "Query Column Reference Analysis" -Cohesion: 0.18 -Nodes (19): NormalizeIdent(), StripIdent(), StripSchemaQualifier(), SplitTopLevelCommas(), createTableColumns(), parseAlterAction(), classifyBareColumn(), expandStar() (+11 more) - -### Community 42 - "Search UI Components" -Cohesion: 0.12 -Nodes (21): SearchResultRow(), SearchResultRowProps, SearchResultsColumns(), SearchResultsColumnsProps, SOURCE_LABELS, SourceColumn(), SourceColumnProps, sourceLabel() (+13 more) - -### Community 43 - "Web Runtime Dependencies (43)" -Cohesion: 0.08 -Nodes (25): @base-ui/react, class-variance-authority, @fontsource-variable/inter, isbot, lucide-react, next-themes, react-dom, react-router (+17 more) +### Community 30 - "ManageTagsDialog" +Cohesion: 0.13 +Nodes (14): BulkActionBar(), commitAddTag(), commitRemoveTag(), exitInline(), openAddTag(), ManageTagsDialog(), handleConfirmDelete(), handleConfirmMerge() (+6 more) -### Community 44 - "Migration-Check Scanner" -Cohesion: 0.18 -Nodes (20): buildPrevReleaseRefs(), buildReport(), classifyAction(), crossReferenceFinding(), newFinding(), parseAnnotation(), pathAllowedForGitShow(), readAtTag() (+12 more) +### Community 31 - "service.go (2)" +Cohesion: 0.21 +Nodes (14): github.com/danielrpof/drop-tracker/internal/db/sqlc.ListWatchlistRow, entryFromRow(), Entry, Service, NewService(), NormalizeNote(), normalizeSet(), WithArtistArt() (+6 more) -### Community 45 - "Config Loading Tests" -Cohesion: 0.18 -Nodes (24): Load(), configEnvKeys(), envExampleKeys(), repoRoot(), setDiff(), setRequired(), TestDockerComposeWiresGateEnvVars(), TestDotEnvIsNotTracked() (+16 more) +### Community 32 - "ROADMAP doc" +Cohesion: 0.14 +Nodes (20): Notes requirement NOTE-01, Bulk endpoints (delete, tags add/remove, preferences), Per-artist 10-tag cap trigger, Chunk fixture precondition todo, Deviation from research: vertical Phase 24 slice, latest_release_date lateral join, Migrations 000011/000012 tag cap trigger fixes, Confirm-step timeout trap (matchTimeout 8s) (+12 more) -### Community 46 - "Notifier Integration Tests (46)" -Cohesion: 0.17 -Nodes (21): Cadence, time.Location, NewService(), TestService_FreshSchemaSeedsOneRowWithDefaults(), TestService_LastSentWatermarkUntouched(), TestService_SecondRowRejectedByCheckConstraint(), TestService_UpdateIsIdempotent(), TestService_UpdateLeavesSlotUntouched() (+13 more) +### Community 33 - "TagChips.tsx (2)" +Cohesion: 0.13 +Nodes (16): nextPendingId(), TagActions, TagChips(), attachErrorMessage(), handleCommit(), handleRemove(), TagComboboxProps, WatchlistRowProps (+8 more) -### Community 47 - "Events Service Tests (47)" -Cohesion: 0.28 -Nodes (22): NewService(), datePtr(), insertTestArtist(), insertTestEvent(), insertTestEventAt(), insertTestEventTyped(), insertTestEventWithDate(), TestHandleListEvents_CursorRoundTripsThroughHTTP() (+14 more) +### Community 34 - "artist_tags" +Cohesion: 0.12 +Nodes (13): artists, watchlist, events, artist_tags, artist_tags_cap_trigger, check_artist_tags_max_per_artist(), check_artist_tags_max_per_artist, tags (+5 more) -### Community 48 - "Dialogs, Auth Store & App Shell (48)" +### Community 35 - "full-pipeline.yml" Cohesion: 0.14 -Nodes (17): Dialog(), DialogContent(), DialogDescription(), DialogFooter(), DialogHeader(), DialogOverlay(), DialogTitle(), FocusRequest (+9 more) - -### Community 49 - "Web Dev Dependencies (49)" -Cohesion: 0.09 -Nodes (23): jsdom, prettier, prettier-plugin-tailwindcss, tailwindcss, @testing-library/dom, @testing-library/react, @testing-library/user-event, @types/node (+15 more) +Nodes (18): Definition of Done (local CI gates), No AI Attribution in Commits/PRs, Recommended Tech Stack (chi, sqlc, pgx, golang-migrate, robfig/cron, caarlos0/env, slog, httplog), Full Pipeline GitHub Actions Workflow, build-scan job (image build, provenance check, tz boot check, Trivy image scan), coverage-comment job (report-only sticky PR comment), frontend-test job (prettier --check + Vitest), gitleaks job (+10 more) -### Community 50 - "Events sqlc Queries" +### Community 36 - "CONCERNS doc" Cohesion: 0.13 -Nodes (10): Event, recordingQuerier, HasOlderEventsParams, InsertEventParams, ListEventsParams, ListEventsRow, Queries, AdvanceGroupTrackCountBaselineParams (+2 more) +Nodes (18): docker-compose vs .env.example port drift, CONCERNS.md (codebase concerns), DSN password redaction, decodeJSONBody helper fragility, Migration ordering and idempotency, Non-root container user UID 10001, Notifier DB timeout bounds (fixed in 479c781), Poll cycle hangs without upstream timeouts (+10 more) -### Community 51 - "Digest Chunking Tests (51)" +### Community 37 - "dialog.tsx" Cohesion: 0.15 -Nodes (22): buildDigestChunks(), digestWindowHeader(), remainderMarker(), syntheticEvents(), TestBuildDigestChunks_CapLastChunkCarriesRemainderMarker(), TestBuildDigestChunks_DeluxeTrackCountSuffix(), TestBuildDigestChunks_DuplicateSourcesRenderAsTwoLines(), TestBuildDigestChunks_EmptyGroupOmitsHeadingEntirely() (+14 more) - -### Community 52 - "Tag Schema Tests" -Cohesion: 0.28 -Nodes (20): detachRaceResult, assertDetachRaceRefused(), insertTag(), linkTag(), runDetachRace(), seedArtist(), seedArtistNamed(), TestSchema_Migration000011_DownUpRoundTrip() (+12 more) - -### Community 53 - "SQL Lexer (Dollar Quoting)" -Cohesion: 0.16 -Nodes (17): strings.Builder, TestCopyDollarQuoted_NoClosingTagCopiesRemainder(), TestDollarTagAt(), TestFindFromJoinTables_AdjacentFromJoinBothFound(), copyDollarQuoted(), copySingleQuoted(), dollarTagAt(), RawStatement (+9 more) - -### Community 54 - "Readiness Endpoint Tests" -Cohesion: 0.28 -Nodes (18): fakeSchemaVersioner, readyBody, getReady(), schemaAt(), TestReady_AheadOfSource(), TestReady_DBUnreachable(), TestReady_EmptySchemaMigrations(), TestReady_ExactPathOnly() (+10 more) - -### Community 55 - "Discord Embed Formatting Tests" -Cohesion: 0.19 -Nodes (19): TestDigestLine_RendersLabelURLAndDeluxeSuffix(), formatEmbed(), emojiPrefix(), i32Ptr(), TestFormatEmbed_AllNilOptionalFields_NoEmptyFieldsNoThumbnail(), TestFormatEmbed_DeluxeChange_BothCountsPresent(), TestFormatEmbed_DeluxeChange_NilBothCounts(), TestFormatEmbed_DeluxeChange_NilPreviousTrackCount() (+11 more) +Nodes (12): Dialog(), DialogContent(), DialogDescription(), DialogFooter(), DialogHeader(), DialogOverlay(), DialogTitle(), Skeleton() (+4 more) -### Community 56 - "ConfirmDialog & AlertDialog" -Cohesion: 0.15 +### Community 38 - "alert-dialog.tsx" +Cohesion: 0.18 Nodes (12): ConfirmDialog(), ConfirmDialogProps, AlertDialog(), AlertDialogAction(), AlertDialogCancel(), AlertDialogContent(), AlertDialogDescription(), AlertDialogFooter() (+4 more) -### Community 57 - "HTTP Handlers & JSON Helpers (57)" -Cohesion: 0.17 -Nodes (12): deezerSource, musicBrainzSource, searchResponse, searchResponseBody, sourceResult, stubSearchSource, ArtistSearcher, SearchArtist (+4 more) +### Community 39 - "server.go" +Cohesion: 0.23 +Nodes (12): chi.Router, net/http.Handler, Pinger, SearchSource, Store, echoRequestID(), Server, New() (+4 more) -### Community 58 - "Tags & Notes Migration" -Cohesion: 0.12 -Nodes (13): artists, watchlist, events, artist_tags, artist_tags_cap_trigger, check_artist_tags_max_per_artist(), check_artist_tags_max_per_artist, tags (+5 more) +### Community 40 - "CONTEXT doc" +Cohesion: 0.13 +Nodes (15): Digest, Digest mode, Digest window, Flush, Outbox, Pending event, Detection engine (internal/detection), Idempotent event recording (ON CONFLICT DO NOTHING) (+7 more) -### Community 59 - "Artist Backfill" -Cohesion: 0.26 -Nodes (15): Stats, Store, Backfill(), matchingCandidate(), TestBackfill_ActivityGate_DelaysThenProceeds(), TestBackfill_AllMatch_WritesUpsertAndRecordsAttemptForEach(), TestBackfill_ContextCancelledPartway_StopsPromptly(), TestBackfill_ListArtistsMissingImageErrors_ReturnsErrNoWrites() (+7 more) +### Community 41 - "detector.go" +Cohesion: 0.21 +Nodes (10): notifyGate, Option, RecordingSource, ReleaseDetailSource, github.com/danielrpof/drop-tracker/internal/db/sqlc.InsertEventParams, github.com/jackc/pgx/v5/pgtype.Timestamptz, Detector, New() (+2 more) -### Community 60 - "Notifier DB Timeout Tests" -Cohesion: 0.17 -Nodes (13): sync/atomic.Int32, callNotifyPending(), discardLogger(), shrinkDBOpTimeout(), TestNotifyPending_ParentCancellationStillPropagates(), TestNotifyPending_RecoversAfterUnresponsiveDatabase(), TestNotifyPending_SettingsReadUnresponsive_ReturnsInsteadOfWedgingLock(), TestNotifyPending_UnresponsiveDatabase_ReturnsInsteadOfWedging() (+5 more) +### Community 42 - "settings.go" +Cohesion: 0.27 +Nodes (12): github.com/danielrpof/drop-tracker/internal/db/sqlc.NotificationSetting, time.Location, Settings, NewService(), ParseCadence(), toSettings(), WithClock(), Cadence (+4 more) -### Community 61 - "Phase 25 Tag Filter Plans" +### Community 43 - "search.go" Cohesion: 0.22 -Nodes (18): Plan 25-05: Watchlist sorts with stable ties, Plan 25-07: Watchlist multi-tag any-of filter, Plan 25-08: preference toggles and chip click-to-filter, Phase 25 Context: Find & Filter, D-05: multi-tag filter is any-of (OR), D-07: Watchlist tag options derived from payload, D-08: card tag chip toggles filter, D-09: Has muted events / Custom release types toggles (+10 more) +Nodes (10): deezerSource, musicBrainzSource, SearchArtist, searchResponse, sourceResult, ArtistSearcher, NewDeezerSource(), NewMusicBrainzSource() (+2 more) -### Community 62 - "Deezer Client Tests (62)" -Cohesion: 0.24 -Nodes (13): golang.org/x/time/rate.Limiter, net/http.Client, Client, NewClient(), Do(), TestDo_CloseCancelsTheDerivedContext(), TestDo_LimiterWaitErrorOnCancelledContext(), TestDo_Success() (+5 more) - -### Community 63 - "Notification Settings Service" -Cohesion: 0.23 -Nodes (10): stubDigestOff, fakeSettingsStore, Cadence, Settings, UpdateParams, ParseCadence(), toSettings(), Option (+2 more) - -### Community 64 - "DB Pool Config" -Cohesion: 0.23 -Nodes (14): github.com/jackc/pgx/v5/pgxpool.Config, dsnSetsMaxConns(), NewPool(), PoolConfig(), poolMaxConnsForWorkers(), redactedTarget(), blackHoleAddr(), TestPoolConfig_AppliesExplicitBounds() (+6 more) - -### Community 65 - "Phase 24 Optimistic UI & Notes" +### Community 44 - "STRUCTURE doc" Cohesion: 0.16 -Nodes (16): D-03: Optimistic chip add/remove with rollback, D-05: Note clamped to 2 lines with more expander, D-06: Note edited in place with Save/Cancel and counter, D-10: Notes on watchlist row, tags on artists.id, D-11: Counts include only watched artists (carriers), D-20: Attach/detach routes under /watchlist/{id}/tags, D-22: GET/PATCH/DELETE /tags and POST /tags/{id}/merge; 409 on collision, D-24: Functional addTag/removeTag updaters with per-row pending set (+8 more) - -### Community 66 - "Phase 25 Release-Date Enrichment" -Cohesion: 0.19 -Nodes (15): Plan 25-01: latest/next release dates on GET /watchlist, Plan 25-03: card shows latest and upcoming release, G1: latest_release_date ignores retention, G2: upcoming = strictly after UTC today at own precision, latest_release_date field, next_release_date field (G3), WLVW-03 Sort by latest release, Phase 25 Research (+7 more) - -### Community 67 - "Artist Search Stubs" -Cohesion: 0.19 -Nodes (8): perArtistOutcome, perArtistSearcher, stubArtistFetcher, stubSearcher, stubSearcherByQuery, artistSearchResponse, stubDeezerArtistSearcher, Artist +Nodes (15): Poller CAS guard release fragility, Where to add new code guide, cmd/server entry point, STRUCTURE.md (directory layout), internal/ package layout, internal/db/migrations (immutable once committed), Naming conventions, Narrow interface pattern (+7 more) -### Community 68 - "Digest Chunk Builder" -Cohesion: 0.21 -Nodes (13): continuationHeading(), continuationNote(), positionIndicator(), renderGroup(), splitOversizedGroup(), TestContinuationHeading_ExactWording(), TestContinuationNote_NoDigit(), TestPositionIndicator_EmptyAtTotalOne() (+5 more) - -### Community 69 - "Phase 24 UI Contract Concepts" -Cohesion: 0.16 -Nodes (14): D-07: Manage tags dialog from Watchlist header, D-16: Reusable AlertDialog confirm for merge/delete, G-24-4: Long chip truncation (fixture issue, not a defect), Phase 24 UI Review (6-pillar audit), 6-pillar UI audit: 17/24, Phase 24 UI Design Contract, Tag chip treatment distinct from status/event badges [R1], ConfirmDialog (reusable AlertDialog wrapper) (+6 more) +### Community 45 - "v1.0 MILESTONE AUDIT doc" +Cohesion: 0.20 +Nodes (15): Discord embed formatting constraints, internal/notifier and discord packages, Markdown escaping for real-time embeds (NTFY-07), Phase 1 debt: SIGTERM and race human verification, Phase 2 debt: review warnings WR-01..05, Phase 3 debt: Lucene injection, Deezer pagination, MusicBrainz unreachable, Phase 4 debt: unmute backlog flood, blank external id guard, Phase 5 debt: live Discord rendering and embed budget checks (+7 more) -### Community 70 - "Events Service Tests (70)" -Cohesion: 0.24 -Nodes (10): stubEventsStore, Service, eventsResponse, eventsResponseBody, stubEventsStore, Event, ListParams, Page (+2 more) +### Community 46 - "ROADMAP doc (2)" +Cohesion: 0.14 +Nodes (14): Project overview: built outward from the data layer, Phase 17 Automated VPS Deploy (deferred), Backlog Phase 999.1 Terraform + Azure ephemeral deploy, Milestone v1.0 MVP (Phases 1-7), Milestone v1.1 Hardening & Scale Readiness (Phases 8-11.1), Milestone v1.2 Cleanup & Display Fixes (Phases 12-13), Milestone v1.3 Continuous Deployment (Phases 14-17, partial), Milestone v1.4 Operator Observability (Phases 18, 18.1, 19) (+6 more) -### Community 71 - "MusicBrainz Releases" -Cohesion: 0.21 -Nodes (6): noReleaseDetailSource, Client, Release, Medium, releaseEnvelope, fakeReleaseDetailSource +### Community 47 - "musicbrainz.go" +Cohesion: 0.30 +Nodes (8): Detector, newNotifyGate(), nullableString(), deluxeDetectionEnabled(), eventTypeMuted(), releaseTypeAllowed(), Detector, releaseTypeForStorage() -### Community 72 - "Phase 25 URL State & Name Filter" -Cohesion: 0.21 -Nodes (13): Plan 25-04: name filter, count, URL state, sticky cards, D-04: accent-insensitive name substring search, D-10: three distinct empty states + Clear filters, D-12: filter state in URL query params, G4: sticky cards, G5: name fold table ($ to s, o-slash to o, ae, ss), useUrlParams hook (no code analog), foldName() helper (+5 more) +### Community 48 - "REQUIREMENTS doc" +Cohesion: 0.23 +Nodes (14): Bulk Edit requirements BULK-01..05, Client-side sort/filter decision, REQUIREMENTS.md (v1.6 Watchlist Organization), Future Requirements (deferred), History tag filter HIST-02, Bulk Add requirements IMPT-01..05, Notification requirements NTFY-05..07, Out of Scope decisions (+6 more) -### Community 73 - "Server Entrypoint" -Cohesion: 0.27 -Nodes (9): logInstanceGateStatus(), main(), run(), decodeRecord(), nonEmptyLines(), recordMentions(), TestLogInstanceGateStatus(), TestRun_BootServesHealthThenGracefulShutdownOnCancel() (+1 more) +### Community 49 - "useBulkSelection.ts (2)" +Cohesion: 0.30 +Nodes (12): bulkAddTag(), showToast(), useBulkSelection(), addTag(), applyPreferences(), clearSelection(), finishSuccess(), onRemoveDialogOpenChange() (+4 more) -### Community 74 - "v1.6 Phases & Requirements (74)" +### Community 50 - "INTEGRATIONS doc" Cohesion: 0.17 -Nodes (12): Filter vs Search, Latest Release, Upcoming Release, Option D: Upcoming Releases Calendar, BULK-01..05 Bulk Edit Requirements, IMPT-01..05 Paste-a-List Bulk Add Requirements, WLVW-01..06 + HIST-02 Find & Filter Requirements, ROADMAP.md (v1.6 Phases 24-28) (+4 more) +Nodes (13): MusicBrainz TLS handshake failure (environmental), GitHub Actions CI/CD pipeline, INTEGRATIONS.md (external integrations), Deezer API integration, Three-stage Dockerfile image, MusicBrainz API integration, No user authentication, Logging and observability (+5 more) -### Community 75 - "Discord Webhook Client" -Cohesion: 0.29 -Nodes (8): allowedMentions, EmbedImage, retry429Body, webhookPayload, Client, Embed, EmbedField, fakeSender - -### Community 76 - "Discord Client Retry Tests" +### Community 51 - "status.go" Cohesion: 0.30 -Nodes (11): NewClient(), TestSend_400_ReturnsErrorNotMatchingSentinel(), TestSend_429Exhausted_ErrorNeverLeaksBodyOrToken(), TestSend_429RetryAfterClamped(), TestSend_429ThenSuccess_HonorsRetryAfter(), TestSend_429Twice_ReturnsErrorAfterSingleRetry(), TestSend_AllowedMentionsAlwaysSuppressed(), TestSend_ErrorPaths_NeverLeakTokenOrBody() (+3 more) +Nodes (11): serverConfig, StatusDeps, statusInstance, statusResponse, statusRun, statusSource, StatusStore, WatchlistCounter (+3 more) -### Community 77 - "Activity Gate" -Cohesion: 0.27 -Nodes (7): ActivityGate, NewActivityGate(), TestActivityGate_ActiveWhileBegunNotEnded(), TestActivityGate_ConcurrentUse(), TestActivityGate_DoubleEndDoesNotCorruptState(), TestActivityGate_FreshGateIsNotActive(), TestActivityGate_TwoConcurrentBeginsBothMustEnd() - -### Community 78 - "v1.6 Phases & Requirements (78)" -Cohesion: 0.18 -Nodes (11): Carrier, Tag Merge, Note (watchlist entry annotation), Tag, Tag Link (belongs to artist), Per-artist 10-tag Cap via Locking Trigger (ADR 0004), NOTE-01 Artist Note, NTFY-05..07 Tags on Discord Notifications (+3 more) - -### Community 79 - "Phase 24 Pattern Map Concepts" -Cohesion: 0.20 -Nodes (11): D-21: Idempotent attach (200) and detach (204), D-26: POST/PATCH /watchlist return tags+note via same enrichment; tags never null, Three-layer validation (handler, service, DB CHECK), Phase 24 Pattern Map, apiFetch as single client call path, :execrows idempotent delete, Handler skeleton: parse id, bound body, decode, validate, service, switch sentinel, Non-nil slice guarantee (+3 more) - -### Community 80 - "Phase 24 Postgres Research Findings" -Cohesion: 0.22 -Nodes (11): D-29: One-statement get-or-create via ON CONFLICT (lower(name)) DO UPDATE, D-31: Non-ASCII case identity tests and NFC normalization, Constraint-name error mapping (pgErr.ConstraintName), Phase 24 Research, BEFORE INSERT trigger fires before ON CONFLICT arbitration, Postgres lower() non-ASCII folding depends on collation (assumption), Expression-index ON CONFLICT target and RAISE USING CONSTRAINT, Parallel array_agg tag enrichment, not json_agg (+3 more) - -### Community 81 - "Phase 24 Review, UAT & Verification" -Cohesion: 0.18 -Nodes (11): Requirements TAG-01..07, NOTE-01, Phase 24 Code Review (24-14 increment), IN-01/02: tailwind-merge misclassifies text-label as colour, IN-03/04: Test hygiene and missing G-24-10 branch tests, WR-01: Hand-off lands on stale rename row's Cancel, Phase 24 UAT, G-24-14: Textarea 14px reading from stale go:embed build, Phase 24 Validation Strategy (+3 more) +### Community 52 - "musicbrainz.go (2)" +Cohesion: 0.24 +Nodes (9): coverArtURLForReleaseGroup(), earlierDate(), earliestReleaseDate(), guestFeatureArt(), withinDeluxeRecheckWindow(), Client, RecordingRelease, recordingLookupResponse (+1 more) -### Community 82 - "Phase 25 History Tag Filter Plans" -Cohesion: 0.35 -Nodes (10): Plan 25-02: GET /events tag_id filter with retention, Plan 25-06: History Tag control, Plan 25-09: History filters move to URL params, Plan 25-10: rebuild embedded SPA and phase DoD gate, D-11: History single-select tag via scalar tag_id narg, G6: History URL state as its own plan, HIST-02 History tag filter, Pattern 2: tag_id narg EXISTS over artist_tags in retention-aware ListEvents/HasOlderEvents (+2 more) +### Community 53 - "STATE doc" +Cohesion: 0.21 +Nodes (12): Event history disk growth, -race testing broken on Windows dev machine, Frontend XSS via untrusted data, Phase 15 coverage-report tool and PR comment, Accumulated decision log, v1.5 digest notifications design, Grilling sessions override earlier decisions, Phase 18.1 poll-cycle instrumentation (+4 more) -### Community 83 - "Web package.json Scripts" -Cohesion: 0.18 -Nodes (10): name, private, scripts, build, dev, format, test, test:watch (+2 more) +### Community 54 - "STACK doc" +Cohesion: 0.21 +Nodes (12): Environment configuration variables, go-chi/chi v5 router, caarlos0/env v11 config, STACK.md (technology stack), React 19 + React Router 7 + Vite + Tailwind 4 SPA, Go 1.26 backend, golang-migrate v4, pgx/v5 and sqlc codegen (+4 more) -### Community 84 - "Search Cancellation Stubs" +### Community 55 - "WatchlistBulkStore" Cohesion: 0.22 -Nodes (6): cancelingSearcher, recordingTimeSearcher, ArtistSearcher, context.CancelFunc, io.ReadCloser, cancelReadCloser - -### Community 85 - "Migration-Check Git Diff" -Cohesion: 0.27 -Nodes (10): appendGithubOutput(), diffRange(), filterMigrationUpFiles(), runChangedFiles(), TestDiffRange(), TestFilterMigrationUpFiles_KeepsOnlyTheGlob(), TestValidCommitishAndValidBranchRef(), withStubCommitExists() (+2 more) +Nodes (8): logInstanceGateStatus(), main(), run(), WatchlistBulkStore, IsWeakPassphrase(), Short(), Option, WithWatchlistBulk() -### Community 86 - "Codebase Architecture Map" -Cohesion: 0.36 -Nodes (8): deezer.Client (internal/deezer/client.go), Detector (internal/detection/detector.go), discord.Client (internal/discord/client.go), events.Service.List (internal/events/service.go), handleSearch (internal/httpserver/search.go), musicbrainz.Client (internal/musicbrainz/client.go), RunDeezerCycle (internal/poller/poller.go), RunMusicBrainzCycle (internal/poller/poller.go) - -### Community 87 - "MusicBrainz Artist Lookup" +### Community 56 - "bulk.go" Cohesion: 0.33 -Nodes (6): stubArtistDetailLookup, ArtistDetail, Client, ArtistAlias, ArtistRelation, ArtistRelationURL - -### Community 88 - "MusicBrainz Artist Search" -Cohesion: 0.31 -Nodes (6): stubMusicBrainzArtistSearcher, clampLimit(), escapeLucene(), Artist, Client, artistSearchResponse - -### Community 89 - "Per-Artist Tag Cap Decisions" -Cohesion: 0.28 -Nodes (9): Phase 24 Context: Artist Tags & Notes, ADR 0004: Per-artist tag cap trigger, D-18: 10-per-artist cap as BEFORE INSERT trigger, D-19: Merge deletes duplicates then UPDATEs, never inserts, Phase 24 Discussion Log, Claude's discretion: cap mechanism, collision API shape, enrichment form, FOR NO KEY UPDATE does not block FK FOR KEY SHARE, Row visibility within multi-row INSERT (Phase 26 bulk attach) (+1 more) - -### Community 90 - "Phase 24 Security Threats" -Cohesion: 0.22 -Nodes (9): D-09: Inline rename; collision opens merge confirmation, D-30: Tag vocabulary loads on first use, Phase 24 Security Threat Register, Generation-guarded loads (vocabGen, loadGen) T-24-54/58, Migrations 000011/000012: cap trigger UPDATE path and CR-01 lock fix, Stored XSS mitigated by plain JSX text (no dangerouslySetInnerHTML), T-24-12: Silent merge on rename prevented by 409 CollisionError, T-24-63: Confirm titles overflowing hide tag names (+1 more) +Nodes (8): axesOverlap(), Service, missingIDs(), normalizeAxis(), AxisDelta, BulkRemoveResult, BulkUpdateResult, PreferencesDelta -### Community 91 - "Release Detector" -Cohesion: 0.36 -Nodes (5): Option, RecordingSource, ReleaseDetailSource, Detector, WithNotifyMaxReleaseAgeDays() - -### Community 92 - "Single-Instance ADRs (92)" +### Community 57 - "tags.go" Cohesion: 0.25 -Nodes (8): Single-instance constraint (no leader election in robfig/cron), ADR 0002: One outbox, one sender lock, CAS-skip on held lock, Digest mode (v1.5), Notification outbox (pending events), Notifier.notifying shared CAS sender lock, Postgres advisory lock (rejected option), ADR 0003: Digest ack splits event-ack from completion +Nodes (8): attachTagRequest, deleteTagResponse, mergeTagRequest, renameTagRequest, tagResponse, TagStore, Option, WithTags() -### Community 93 - "Query Refs Tests" -Cohesion: 0.46 -Nodes (7): mustHigh(), mustNotHigh(), readPrevReleaseFixture(), refsFor(), TestQueryColumnRefs(), TestQueryColumnRefs_IdentifierCaseFolding(), TestRefSet_MergeAndLookups() +### Community 58 - "recordings.go" +Cohesion: 0.33 +Nodes (6): displayArtistName(), isGuestFeature(), Client, Recording, ArtistCreditEntry, recordingEnvelope -### Community 94 - "SPA Embed Handler" +### Community 59 - "logging.go" Cohesion: 0.39 -Nodes (6): Handler(), firstJSAsset(), TestHandler_MissingAssetPathFallsBackInsteadOf404(), TestHandler_RootPathServesIndexHTML(), TestHandler_ServesRealEmbeddedAssetDirectly(), TestHandler_UnknownPathFallsBackToIndexHTML() - -### Community 95 - "End-to-End Boot Tests" -Cohesion: 0.60 -Nodes (4): RowQuerier, SchemaVersionReader, NewSchemaVersionReader(), SchemaVersion() +Nodes (6): log/slog.Level, Config, Load(), New(), NewWithWriter(), parseLevel() -### Community 96 - "Single-Instance ADRs (96)" -Cohesion: 0.33 -Nodes (6): ADR 0001: In-process ring buffer for poll-run history, poll_runs table (rejected option), CONTEXT.md glossary vocabulary, Domain Docs agent guide, Flag ADR conflicts rule, n1-boot CI job +### Community 60 - "releases.go" +Cohesion: 0.39 +Nodes (4): Client, Medium, Release, releaseEnvelope -### Community 97 - "Status Snapshot & Ring Buffer (97)" -Cohesion: 0.40 -Nodes (6): In-process poll-run ring buffer (pollruns.Store), poller.RunRecorder seam, httpserver.StatusStore seam, handleStatus (internal/httpserver/status.go), gate.Authenticate passphrase gate (X-Instance-Gated), pollruns.Snapshot() +### Community 61 - "CONTEXT doc (2)" +Cohesion: 0.29 +Nodes (7): Carrier, Merge (tags), Note, Tag, Tag link, Tag vocabulary, Watched artist -### Community 98 - "Digest Ack Split" -Cohesion: 0.40 -Nodes (6): AckDigestBatch query, AckEventsOnly query, digest_last_slot_at / digest_last_sent_at, AND notified_at IS NULL idempotence predicate, Split event-ack from digest completion, context.WithoutCancel ack bounded by dbOpTimeout +### Community 62 - "artist_lookup.go" +Cohesion: 0.43 +Nodes (5): ArtistDetail, Client, ArtistAlias, ArtistRelation, ArtistRelationURL -### Community 99 - "Tag Cap Locking & Merge" -Cohesion: 0.33 -Nodes (6): ADR 0004: Per-artist tag cap locking trigger, artist_tags_max_per_artist constraint name, Migration 000011 (cap trigger on UPDATE OF artist_id), Migration 000012 (FOR KEY SHARE vs concurrent detach), TAG-04 (10 tags per artist cap), artist_tags cap trigger (locking, skip-existing) +### Community 63 - "terraform always on env doc" +Cohesion: 0.52 +Nodes (7): Always-on B1ms Flexible Server cost, Drift detection via scheduled plan, Long-lived Terraform environment seed, Provider and version upgrades, Seed: Promote Ephemeral Terraform Env, Separate prod-like workspace, State-recovery drill -### Community 100 - "Migration Rollback Rules" +### Community 64 - "activity.go" Cohesion: 0.40 -Nodes (6): internal/sqlscan dollar-quoted body handling, migration-check:allow-destructive annotation, Rule 1: backward-incompatible changes break rollback, cmd/migration-check CI guard, Migrations rollback-safety rules, Rule 2: unsafe-forward changes break or lock the deploy +Nodes (3): sync/atomic.Int32, ActivityGate, NewActivityGate() -### Community 101 - "Status Snapshot & Ring Buffer (101)" -Cohesion: 0.33 -Nodes (6): Tag merge via UPDATE tag_id (never insert), schema_applied / schema_expected version seam, internal/db ahead-of-source no-op test, Expand / backfill / contract pattern (000006, 000007), N-1 rollback invariant, RunMigrations (boot-time embedded migrations) - -### Community 102 - "Web Template & Status Client" -Cohesion: 0.33 -Nodes (6): /status run object, SPA API client (web/app/lib/api.ts), GET /status frozen response contract, pnpm allowBuilds (esbuild yes, msw no), pnpm transitive CVE overrides (nanoid, postcss, browserslist, fast-uri, js-yaml), React Router + shadcn/ui web template +### Community 65 - "backfill.go" +Cohesion: 0.60 +Nodes (4): Stats, Store, Backfill(), waitForActivityGate() -### Community 103 - "Login Throttle Tests (103)" +### Community 66 - "alerter.go" Cohesion: 0.47 -Nodes (4): IsWeakPassphrase(), TestIsWeakPassphrase(), TestWeakPassphraseBootWarn_EmptyOrStrongLogsNothing(), TestWeakPassphraseBootWarn_OneWarnLineNeverContainsValue() +Nodes (4): noopAlerter, Alerter, NoOpAlerter(), SelectAlerter() -### Community 104 - "Build Info" +### Community 67 - "CONTEXT doc (3)" Cohesion: 0.33 -Nodes (4): Short(), TestShort_Truncates(), TestVersion_DefaultsToDev(), TestVersion_Injected() +Nodes (6): Cadence, Digest slot, Grace window, Slot record, Watermark, Calendar-based slot math over duration math -### Community 105 - "Combobox Interaction" -Cohesion: 0.47 -Nodes (5): G7: keep both comboboxes, Combobox(), commitSelection(), handleTriggerKeyDown(), openAt() +### Community 68 - "schema_version.go" +Cohesion: 0.60 +Nodes (4): RowQuerier, SchemaVersionReader, NewSchemaVersionReader(), SchemaVersion() -### Community 106 - "Digest Mode Concepts" -Cohesion: 0.40 -Nodes (5): Digest Mode, Digest Window, Flush, Outbox, SettingsReader Required Constructor Arg, Fail-closed Gate +### Community 69 - "server.go (2)" +Cohesion: 0.33 +Nodes (5): Option, readyResponse, SchemaVersioner, WithAuthGate(), WithReadiness() -### Community 107 - "Digest Slot & Watermark" +### Community 70 - "settings.go (2)" Cohesion: 0.40 -Nodes (5): Digest Slot (00:05 America/New_York), Grace Window (12h daily / 48h weekly), Slot Record (digest_last_slot_at), Watermark (last digest sent), Group-preferred Digest Chunking with Per-chunk Ack +Nodes (5): settingsResponse, SettingsStore, updateSettingsRequest, WithSettings(), toSettingsResponse() + +### Community 71 - "releasegroups.go" +Cohesion: 0.53 +Nodes (3): Client, ReleaseGroup, releaseGroupEnvelope + +### Community 72 - "HistoryFilters.tsx" +Cohesion: 0.60 +Nodes (4): Combobox(), commitSelection(), handleTriggerKeyDown(), openAt() -### Community 108 - "Deezer Client" -Cohesion: 0.50 -Nodes (3): AlbumLister, APIError, errorProbe +### Community 73 - "pnpm workspace doc" +Cohesion: 0.70 +Nodes (4): allowBuilds esbuild and msw, pnpm workspace config, Transitive CVE overrides, Trivy fs HIGH CVE findings -### Community 109 - "v1.4 Observability Milestone (109)" +### Community 74 - "CONTEXT doc (4)" Cohesion: 0.67 -Nodes (3): Option C: Operator Status Panel + /ready, Don't Roadmap Phases Gated on Uncontrolled Infra, Phase 17: Automated VPS Deploy (deferred) +Nodes (4): Latest release, Upcoming release, Option D: Upcoming Releases Calendar, latest_release_date ignores retention (G1) ## Knowledge Gaps -- **208 isolated node(s):** `vitestSummary`, `github.com/danielrpof/drop-tracker`, `loginRequest`, `notification_settings`, `Queries` (+203 more) - These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 417 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) -- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **175 isolated node(s):** `vitestSummary`, `github.com/danielrpof/drop-tracker`, `loginRequest`, `notification_settings`, `Client` (+170 more) + These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 336 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) +- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `Phase 25 Pattern Map` connect `Frontend API Client & Watchlist Route` to `Phase 25 Release-Date Enrichment`, `shadcn UI Primitives`, `Phase 25 URL State & Name Filter`, `History Route & Event Cards`, `Tag Chips & Combobox UI`, `Phase 25 History Tag Filter Plans`, `Artist Note & Preference Toggles`, `Watchlist Service`, `Phase 25 Tag Filter Plans`?** - _High betweenness centrality (0.219) - this node is a cross-community bridge._ -- **Why does `NewService()` connect `Watchlist & Detector DB Tests` to `Notifier Core`, `Server Entrypoint`, `Watchlist HTTP Tests`, `Watchlist Service`?** - _High betweenness centrality (0.159) - this node is a cross-community bridge._ -- **Why does `Phase 25 Context: Find & Filter` connect `Phase 25 Tag Filter Plans` to `Phase 25 Release-Date Enrichment`, `HTTP Handlers & JSON Helpers (3)`, `Frontend API Client & Watchlist Route`, `Phase 25 URL State & Name Filter`, `Combobox Interaction`, `Phase 25 History Tag Filter Plans`, `Per-Artist Tag Cap Decisions`, `Watchlist Service`?** - _High betweenness centrality (0.115) - this node is a cross-community bridge._ +- **Why does `run()` connect `WatchlistBulkStore` to `writeError`, `.HandleLogin`, `deezer/client.go`, `notifier.go`, `migrate.go`, `match.go`, `service.go`, `poller.go`, `service.go (2)`, `server.go`, `detector.go`, `settings.go`, `search.go`, `status.go`, `tags.go`, `logging.go`, `activity.go`, `backfill.go`, `alerter.go`, `schema_version.go`, `server.go (2)`, `settings.go (2)`?** + _High betweenness centrality (0.046) - this node is a cross-community bridge._ +- **Why does `NewWithWriter()` connect `logging.go` to `main.go`, `notifier.go`?** + _High betweenness centrality (0.036) - this node is a cross-community bridge._ +- **Why does `runScan()` connect `main.go` to `parse.go`?** + _High betweenness centrality (0.023) - this node is a cross-community bridge._ +- **Are the 6 inferred relationships involving `Watchlist()` (e.g. with `addTag()` and `announce()`) actually correct?** + _`Watchlist()` has 6 INFERRED edges - model-reasoned connections that need verification._ - **What connects `vitestSummary`, `github.com/danielrpof/drop-tracker`, `loginRequest` to the rest of the system?** - _208 weakly-connected nodes found - possible documentation gaps or missing edges._ -- **Should `Watchlist & Detector DB Tests` be split into smaller, more focused modules?** - _Cohesion score 0.053376623376623376 - nodes in this community are weakly interconnected._ -- **Should `Poller Tests` be split into smaller, more focused modules?** - _Cohesion score 0.06086956521739131 - nodes in this community are weakly interconnected._ -- **Should `Watchlist HTTP Tests` be split into smaller, more focused modules?** - _Cohesion score 0.05929989550679206 - nodes in this community are weakly interconnected._ \ No newline at end of file + _175 weakly-connected nodes found - possible documentation gaps or missing edges._ +- **Should `writeError` be split into smaller, more focused modules?** + _Cohesion score 0.06621226874391431 - nodes in this community are weakly interconnected._ +- **Should `26 RESEARCH doc` be split into smaller, more focused modules?** + _Cohesion score 0.05608322026232474 - nodes in this community are weakly interconnected._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index b3431391..9377004c 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@

Communities

-
3075 nodes · 9590 edges · 173 communities
+
1784 nodes · 3773 edges · 128 communities