Lapsed subscriptions stop pulling, a quieter Subscriptions card, and working feed filters #254

Merged
bvandeusen merged 3 commits from dev into main 2026-09-13 22:45:04 -04:00
2 changed files with 79 additions and 96 deletions
Showing only changes of commit 4d84ab2816 - Show all commits
@@ -1,12 +1,20 @@
<template>
<!-- Renders nothing when there is nothing to say same posture as
NeedsAttentionCard. A reconciliation card that always shows would train
the operator to scroll past it. -->
<v-card v-if="anythingToSay" variant="tonal" class="mb-4 fc-recon">
the operator to scroll past it. It can also be DISMISSED, and stays
dismissed until what it would say changes. -->
<v-card v-if="visible" variant="tonal" class="mb-4 fc-recon">
<v-card-text>
<v-btn
icon="mdi-close" size="small" variant="text" class="fc-recon__dismiss"
title="Hide until something changes" aria-label="Hide until something changes"
@click="dismiss"
/>
<div v-for="p in interesting" :key="p.platform" class="fc-recon__platform">
<!-- Bucket 1: the adoption win. The only direction with an action,
because adding a source is the reversible half. -->
<!-- The adoption win: subscriptions FC doesn't follow yet. The only
bucket shown. Sources you follow but no longer pay for are not
listed here (operator, 2026-09-13) the membership sweep stops
pulling those instead (#3995). -->
<template v-if="p.subscribed_not_tracked.length">
<div class="fc-recon__head">
<v-icon icon="mdi-account-plus-outline" size="small" class="me-2" />
@@ -36,37 +44,13 @@
</div>
</template>
<!-- Bucket 2: REPORT ONLY. No disable control here by design — the
source list on this same page is where that decision belongs. -->
<template v-if="p.tracked_not_subscribed.length">
<div class="fc-recon__head">
<v-icon icon="mdi-help-circle-outline" size="small" class="me-2" />
<strong>
{{ p.tracked_not_subscribed.length }}
{{ p.platform }}
{{ p.tracked_not_subscribed.length === 1 ? 'source' : 'sources' }}
your roster doesn't account for
</strong>
</div>
<div v-for="s in p.tracked_not_subscribed" :key="s.id" class="fc-recon__row">
<div class="fc-recon__body">
<strong>{{ s.artist.name }}</strong>
<div class="fc-recon__dim">{{ reasonFor(s) }}</div>
</div>
</div>
<div class="fc-recon__dim fc-recon__note">
Nothing has been changed. If you want one of these to stop checking,
disable it in the list below.
</div>
</template>
<!-- The roster could not be trusted, so bucket 2 was not computed. Said
in words: an empty list here must never read as "all clear". -->
<!-- The roster could not be trusted. Said in words: an empty list here
must never read as "all clear". -->
<div v-if="!p.fresh" class="fc-recon__dim fc-recon__note">
<v-icon icon="mdi-clock-alert-outline" size="small" class="me-1" />
<template v-if="!p.last_success_at">
Your {{ p.platform }} roster has never synced, so FC can't tell which
sources you still subscribe to.
creators you subscribe to.
</template>
<template v-else>
Your {{ p.platform }} roster last synced
@@ -81,44 +65,62 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { formatRelative } from '../../utils/date.js'
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
// #387 C4. Two directions, deliberately unequal: subscriptions FC doesn't
// follow get a one-click add, while sources the roster doesn't account for are
// REPORTED ONLY (operator decision, 2026-09-11). The asymmetry is the point —
// adding a source is trivially undone, and "you no longer subscribe to this" is
// computed from an absence that has three possible causes.
// #387 C4. Offers the subscriptions FC doesn't follow yet. The other direction
// (sources FC follows that the roster says you no longer pay for) used to be
// listed here as a report. The operator didn't want it listed (2026-09-13); the
// membership sweep stops pulling those sources instead (#3995).
const store = useMembershipReconcileStore()
// An untrustworthy roster is only worth mentioning when there is something it
// would have reconciled — otherwise a failed sweep on a platform with no
// sources yet would put a warning on a page with nothing to warn about.
const interesting = computed(() => store.platforms.filter(
p => p.subscribed_not_tracked.length
|| p.tracked_not_subscribed.length
|| (!p.fresh && p.tracked_total)
p => p.subscribed_not_tracked.length || (!p.fresh && p.tracked_total)
))
const anythingToSay = computed(() => interesting.value.length > 0)
function reasonFor (s) {
if (s.basis === 'lapsed') {
return `Your membership reads "${s.membership?.status}" — you're not a paying supporter of this creator right now.`
}
if (s.basis === 'absent_exact') {
return "FC knows this creator's id on the platform, and it isn't in your roster."
}
// absent_handle — the weakest claim, and it says so. A renamed creator looks
// exactly like this, which is why it is not phrased as a conclusion.
return "No membership matched this source's address. It may simply have been renamed."
// Dismissal is keyed to WHAT the card says, not to the card: a fingerprint of
// the offered memberships and any stale-roster warnings. Dismissing hides this
// exact set; a new subscription (or a roster going stale) changes the
// fingerprint and brings the card back. Per-browser, which is enough for a
// single-operator instance.
const DISMISS_KEY = 'fc.recon.dismissed'
const fingerprint = computed(() => interesting.value
.flatMap(p => [
...p.subscribed_not_tracked.map(m => `${p.platform}:${m.id}`),
...(p.fresh ? [] : [`${p.platform}:stale`]),
])
.sort()
.join('|'))
function readDismissed () {
try { return localStorage.getItem(DISMISS_KEY) } catch { return null }
}
const dismissed = ref(readDismissed())
function dismiss () {
dismissed.value = fingerprint.value
try { localStorage.setItem(DISMISS_KEY, fingerprint.value) } catch { /* private mode */ }
}
const visible = computed(
() => interesting.value.length > 0 && fingerprint.value !== dismissed.value,
)
onMounted(() => { store.load() })
</script>
<style scoped>
.fc-recon { position: relative; }
.fc-recon__dismiss {
position: absolute;
top: 6px;
right: 6px;
}
.fc-recon__platform + .fc-recon__platform {
margin-top: 16px;
}
+26 -45
View File
@@ -1,7 +1,7 @@
<template>
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
today's 900px column, and above it the feed widens and gains the rail
and day gutter (milestone #407). -->
today's 900px column, and above it the feed widens and gains the day
gutter (milestone #407). -->
<v-container fluid class="pt-2 pb-6 fc-posts">
<!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll — newer posts load above, older posts below. -->
@@ -51,19 +51,20 @@
</template>
<!-- Normal feed -->
<div v-else class="fc-posts__layout">
<!-- On a wide window this is a sticky left rail (#407 E); below the
breakpoint it lays out exactly as the old inline header did. -->
<aside class="fc-posts__rail">
<div v-else class="fc-posts__main">
<!-- Filters and status sit in one row above the feed. #407 option E put
them in a sticky left rail; the operator found it wasted space
(2026-09-13), so on a wide window this row lines up with the feed
column instead, clear of the day gutter. -->
<div class="fc-posts__toolbar">
<FeedStatusRibbon v-if="statusRibbon" class="fc-posts__status" />
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<FeedStatusRibbon v-if="statusRibbon" />
</aside>
</div>
<div class="fc-posts__main">
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
@@ -101,7 +102,6 @@
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div>
</div>
</div>
</div>
</v-container>
</template>
@@ -313,44 +313,25 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
}
.fc-posts__day-count { font-size: 0.78rem; }
/* Wide window (#407 D + E). The rail holds filters and status; each day's
heading moves into a sticky gutter beside its posts; the column widens and
the cards switch to their filmstrip layout on their own (PostCard measures
itself). 1600px is where a 280px rail and a 150px gutter still leave a card
wide enough to be worth the change. */
/* Wide window (#407 A + D). Each day's heading moves into a sticky gutter
beside its posts; the column widens and the cards switch to their filmstrip
layout on their own (PostCard measures itself). The toolbar lines up with the
feed column, clear of the gutter. */
@media (min-width: 1600px) {
.fc-posts { max-width: 2360px; }
.fc-posts__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 40px;
align-items: start;
}
.fc-posts__rail {
position: sticky;
top: calc(var(--fc-nav-h, 64px) + 16px);
.fc-posts { max-width: 2080px; }
.fc-posts__main { max-width: 2080px; margin: 0 auto; }
.fc-posts__toolbar {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
flex-wrap: wrap;
gap: 8px 24px;
margin-left: calc(150px + 24px);
margin-bottom: 8px;
}
.fc-posts__rail :deep(.fc-posts-filters) {
flex-direction: column;
align-items: stretch;
padding-bottom: 0;
}
.fc-posts__rail :deep(.fc-posts-filters__artist),
.fc-posts__rail :deep(.fc-posts-filters__platform) {
flex: none;
width: 100%;
min-width: 0;
max-width: none;
}
.fc-posts__rail :deep(.fc-ribbon) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.fc-posts__main { max-width: 1900px; }
/* Status reads after the filters on a wide row, but stays first when the
narrow layout stacks them (it is the front door's headline). */
.fc-posts__status { order: 2; padding: 0; }
.fc-posts__toolbar :deep(.fc-posts-filters) { padding-bottom: 0; }
.fc-posts__day {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);