feat: the announcement link, on the card and in a review queue (388 E5)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s

Rule 27 — E5's other half. The matcher can propose; this is where the operator
decides, and where an accepted link actually shows up.

**The review queue** (Settings → Ingestion & filters). Each proposal shows the
per-signal breakdown, not just the total: "why did it suggest this" is the
question the operator actually has, and a lone percentage cannot answer it. So
a row reads "72% · timing 95% · says so 60%", and the copy states outright that
a pair always needs two reasons — which is the property that stops a busy
posting day from producing false pairs.

The empty state says so explicitly. Nothing proposed is the EXPECTED state most
of the time, and an empty queue that looks like a failure invites turning the
threshold down until it produces noise.

**On the card**, both directions, and accepted links only: the teaser gets "The
full set is in Discord", the drop gets "Announced on Patreon". A pending
proposal is a question for the review queue, never a claim to render beside the
artwork — that distinction is the whole confirm-only design, so it is asserted
in the backend (only `linked` rows reach the payload) and again here.

One detail worth the comment it carries: the link's target is `{ query: {
post_id } }` with no name or path. In vue-router that means "the current route
with these query params", so it works identically from Latest and from Browse —
and, more usefully, the card never reaches for `useRoute()`, which it has no
other reason to know about and which is not available when it is mounted in a
test without a router.

Backend CI on 235393c was green: all 13 E5 tests and migration 0094.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-10 11:53:17 -04:00
co-authored by Claude Opus 5
parent 235393c08b
commit d0b0458d27
5 changed files with 281 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { toast } from '../utils/toast.js'
// Backs the announcement review queue (#388 E5): "this Patreon post announced
// that Discord drop". Confirm-only, deliberately — a wrongly-asserted link
// tells the operator two different pieces are one, which is worse than no link
// at all, so accept is the ONLY thing that makes a link real. Mirrors
// seriesSuggestions (FC-6.3), which is the same shape for the same reason.
export const usePostAssociationsStore = defineStore('postAssociations', () => {
const api = useApi()
const proposals = ref([])
const enabled = ref(true)
const threshold = ref(0.6)
const windowHours = ref(24)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load () {
await run(async () => {
const body = await api.get('/api/posts/associations')
proposals.value = body.items || []
})
}
async function loadSettings () {
const s = await api.get('/api/settings/import')
enabled.value = s.discord_link_enabled
threshold.value = s.discord_link_threshold
windowHours.value = s.discord_link_window_hours
}
async function saveSettings (patch) {
await api.patch('/api/settings/import', { body: patch })
}
async function setEnabled (v) {
enabled.value = v
await saveSettings({ discord_link_enabled: v })
}
async function setThreshold (v) {
threshold.value = v
await saveSettings({ discord_link_threshold: v })
}
async function setWindowHours (v) {
windowHours.value = v
await saveSettings({ discord_link_window_hours: v })
}
async function accept (id) {
try {
await api.post(`/api/posts/associations/${id}/accept`, {})
proposals.value = proposals.value.filter(p => p.id !== id)
toast({ text: 'Linked', type: 'success' })
} catch (e) {
toast({ text: `Link failed: ${e.message}`, type: 'error' })
}
}
async function dismiss (id) {
try {
await api.post(`/api/posts/associations/${id}/dismiss`, {})
proposals.value = proposals.value.filter(p => p.id !== id)
} catch (e) {
toast({ text: `Dismiss failed: ${e.message}`, type: 'error' })
}
}
async function rescan () {
await api.post('/api/posts/associations/rescan', {})
await load()
}
return {
proposals, enabled, threshold, windowHours, loading, error,
load, loadSettings, setEnabled, setThreshold, setWindowHours,
accept, dismiss, rescan
}
})