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