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
@@ -128,6 +128,25 @@
@click="toggleDesc"
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- #388 E5: the accepted announcement link, both directions. Only
ACCEPTED ones reach the payload, so anything rendered here is
something the operator agreed to — never a proposal. -->
<!-- A location with no name/path is "the current route, with these
query params" — so this works from Latest or Browse alike without
the card reaching for useRoute(), which it has no other reason to
know about. -->
<div v-if="associations.length" class="fc-post-card__assoc">
<RouterLink
v-for="a in associations" :key="a.id"
:to="{ query: { post_id: a.post_id } }"
class="fc-post-card__assoc-link"
>
<v-icon icon="mdi-link-variant" size="x-small" />
{{ a.role === 'announces'
? 'The full set is in Discord' : 'Announced on Patreon' }}
</RouterLink>
</div>
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
Shown once expanded; clickable now, auto-downloaded by the worker
slice. -->
@@ -234,6 +253,10 @@ const sortDateIso = computed(() => props.post.post_date || props.post.downloaded
// Tuesday but gained images this morning" is the whole signal that chat
// content is trickling in.
const grewAt = computed(() => (synthesized.value ? props.post.last_grew_at : null))
// #388 E5. Accepted links only — a pending proposal lives in the review queue,
// never beside the artwork. Defaults to [] so a post dict from before the
// feature (or composed by hand) renders without a link rather than throwing.
const associations = computed(() => props.post.associations || [])
const grewRelative = computed(() => (grewAt.value ? relativeFrom(grewAt.value) : ''))
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
function relativeFrom (iso) {
@@ -394,6 +417,17 @@ function formatBytes (n) {
/* Growth is news, so it gets the accent the rest of the meta line doesn't —
but it is still the meta line, not a badge competing with the artwork. */
.fc-post-card__grew { color: rgb(var(--v-theme-accent)); }
.fc-post-card__assoc { margin-top: 8px; }
.fc-post-card__assoc-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.8125rem;
color: rgb(var(--v-theme-accent));
text-decoration: none;
}
.fc-post-card__assoc-link:hover { text-decoration: underline; }
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
@@ -15,6 +15,7 @@
<ImportFiltersForm />
<TranslationCard />
<DiscordGroupingCard />
<PostAssociationsCard />
</div>
</section>
@@ -82,6 +83,7 @@ import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import DiscordGroupingCard from './DiscordGroupingCard.vue'
import PostAssociationsCard from './PostAssociationsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
@@ -0,0 +1,128 @@
<template>
<MaintenanceTile
icon="mdi-link-variant"
title="Announcement links"
blurb="Pair a Patreon teaser with the Discord drop it announced."
>
<div class="text-caption fc-muted mb-3">
Some creators post a cropped fragment on Patreon to say the real thing has
landed in their Discord. FC proposes those pairs; nothing is linked until
you accept one. A wrong link would tell you two different pieces are the
same, so this stays a suggestion.
</div>
<v-switch
v-model="enabled" color="accent" hide-details density="compact"
label="Look for announcement pairs"
@update:model-value="store.setEnabled"
/>
<v-row class="mt-2">
<v-col cols="12" sm="6">
<SettingNumberField
v-model="threshold" label="Confidence needed"
:min="0" :max="1" :step="0.05"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setThreshold(Number(threshold))"
/>
<div class="text-caption fc-muted mt-1">
A pair always needs <strong>two</strong> reasons being close in time
and the post itself mentioning Discord. Neither is enough alone at any
setting at or above 0.55, which is what stops a busy posting day from
producing false pairs.
</div>
</v-col>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="windowHours" label="How far apart (hours)"
:min="1" :step="1"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setWindowHours(Number(windowHours))"
/>
<div class="text-caption fc-muted mt-1">
The announcement exists in order to point at the drop, so the two are
usually minutes to hours apart.
</div>
</v-col>
</v-row>
<div class="d-flex align-center mt-4 mb-2" style="gap:12px">
<strong class="text-body-2">
{{ store.proposals.length }} waiting for review
</strong>
<v-spacer />
<v-btn size="small" variant="text" :loading="store.loading" @click="store.rescan">
Scan now
</v-btn>
</div>
<div v-if="!store.proposals.length" class="text-caption fc-muted">
Nothing proposed. That is the expected state most of the time pairs only
appear when a post both lands near a drop and says it is about Discord.
</div>
<div
v-for="p in store.proposals" :key="p.id"
class="fc-assoc"
>
<div class="fc-assoc__body">
<RouterLink :to="{ name: 'latest', query: { post_id: p.announcement_post_id } }">
post {{ p.announcement_post_id }}
</RouterLink>
<span class="fc-assoc__arrow">announced</span>
<RouterLink :to="{ name: 'latest', query: { post_id: p.payload_post_id } }">
drop {{ p.payload_post_id }}
</RouterLink>
<!-- The per-signal breakdown, not just the total: "why did it suggest
this" is the question the operator actually has, and a lone score
cannot answer it. -->
<div class="text-caption fc-muted">
{{ Math.round(p.score * 100) }}% ·
timing {{ Math.round((p.signals?.proximity ?? 0) * 100) }}% ·
says so {{ Math.round((p.signals?.declared ?? 0) * 100) }}%
</div>
</div>
<v-btn size="small" variant="tonal" @click="store.accept(p.id)">Link</v-btn>
<v-btn size="small" variant="text" @click="store.dismiss(p.id)">Dismiss</v-btn>
</div>
</MaintenanceTile>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostAssociationsStore } from '../../stores/postAssociations.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
const store = usePostAssociationsStore()
const enabled = ref(true)
const threshold = ref(0.6)
const windowHours = ref(24)
watch(() => store.enabled, (v) => { enabled.value = v }, { immediate: true })
watch(() => store.threshold, (v) => { threshold.value = v }, { immediate: true })
watch(() => store.windowHours, (v) => { windowHours.value = v }, { immediate: true })
onMounted(async () => {
// Both swallow their own failures: a settings read that fails should not
// leave the queue unrendered, and vice versa.
await Promise.allSettled([store.loadSettings(), store.load()])
})
</script>
<style scoped>
.fc-assoc {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.fc-assoc__body { flex: 1; min-width: 0; }
.fc-assoc__arrow {
color: rgb(var(--v-theme-on-surface-variant));
margin: 0 6px;
}
</style>
+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
}
})
+34
View File
@@ -114,6 +114,40 @@ describe('PostCard', () => {
expect(w.text()).not.toContain('updated')
})
it('shows an accepted announcement link from either end', () => {
const teaser = mountComponent(PostCard, {
props: {
post: {
...BASE,
associations: [{ id: 7, role: 'announces', post_id: 42 }],
},
},
pinia: freshPinia(),
})
expect(teaser.text()).toContain('The full set is in Discord')
const drop = mountComponent(PostCard, {
props: {
post: {
...SYNTH,
associations: [{ id: 7, role: 'announced_by', post_id: 1 }],
},
},
pinia: freshPinia(),
})
expect(drop.text()).toContain('Announced on Patreon')
})
it('shows no link when there is no accepted association', () => {
// Pending proposals never reach the payload, so an empty list here is
// the normal case and must render as silence, not an empty row.
const w = mountComponent(PostCard, {
props: { post: { ...BASE, associations: [] } },
pinia: freshPinia(),
})
expect(w.find('.fc-post-card__assoc').exists()).toBe(false)
})
it('singularises a one-message drop', () => {
const w = mountComponent(PostCard, {
props: { post: { ...SYNTH, synthesis: { message_count: 1 } } },