Fixes a bug live since 0.2.0 (ea317c0): every relative timestamp named the
tier below its own range, so minutes read "Ns ago" and hours "N min ago".
Brings the repo its first unit-test runner along with it.
The tier table kept a divisor and a translation key as two independently
maintained halves, and a mispairing there rendered minutes as "Ns ago" for 59
releases. The table now pairs a divisor with the Intl unit it produces and the
platform supplies the words, so the wrong half is at least self-evident on
sight — "minute" next to 3600 reads wrong in a way "time.minutesAgo" never
did. It also removes 12 hand-written strings and hands plural rules to CLDR
for any language added later.
Hungarian output is byte-identical to the strings it replaces. English gets
shorter: "59 min ago" to "59m ago", "1 wk ago" to "1w ago", "1 mo ago" to
"1mo ago", "1 yr ago" to "1y ago"; "2h ago" and "1d ago" are unchanged.
style narrow matches the app's compact timestamps and numeric always keeps it
counting, rather than "yesterday"/"last week". time.justNow stays because Intl
has no wording for the sub-minute case. Formatters are memoized per language,
since this runs once per rendered timestamp across a virtualized feed.
Review follow-ups on the tier-shift fix. The table moves to a module-level
RELATIVE_UNITS: it is constant, so rebuilding it per call was pure garbage on
a path that runs once per rendered timestamp across a virtualized feed.
Its ascending order is load-bearing and was unstated — sorting it largest-first
would make every timestamp under a year read "just now", silently, the same
shape as the bug this branch fixes. It is now documented and asserted, and the
suite derives its tier coverage and translation checks from the table itself
rather than from its own case list, so a new tier cannot slip in uncovered.
The audit log read the empty string for an unreadable date as a value and
rendered a blank cell; it now uses the `|| "—"` idiom the channel table
already uses, so missing and unreadable dates look alike.
The repo had no unit-test runner, so this adds the minimum: vitest (3.x, as
4.x needs Vite 6), a node-env config deliberately separate from vite.config.ts
so the production build never loads it, npm test / test:watch, and a knip
entry so the config and specs are not reported unused. Browser flows stay in
e2e/ under Playwright.
The suite samples every tier at its exact opening second as well as inside it,
and stubs i18n so each assertion lands on the key rather than the copy. An
interior-only suite still passes when the range comparison flips to <=, which
is how the tier shift went unnoticed for so long.
The units table paired each divisor with the NEXT row's label, so the count
came out right and the word wrong, all the way up the scale: minutes rendered
as "Ns ago", hours as "N min ago", days as "N hours ago", months as "N weeks
ago". Most visible on the audit log, where scheduler rows three minutes apart
read "1s / 4s / 7s ago".
Each row now carries its own label and is read whole, so a divisor can no
longer drift from its word. Also guard a non-finite age, which rendered
"NaN mo ago", and drop time.secondsAgo now that nothing emits it.
Present since ea317c0 swapped the hardcoded English strings for i18n keys;
shipped in every release from 0.2.0 on.
- The tag menu's flip was one-way: once anchored above it stayed there, so a row
scrolling toward the top dragged the menu off the screen. It now re-decides both
ways each pass — prefer the side it is on (no flapping), leave it as soon as that
side stops fitting and the other does. Verified: trigger at y=229 (no room above)
flips back below, menuTop 253 = btnBottom 249 + 4, nothing off-screen.
- Extract the card pagination into lib/useCardPager: size (persisted, validated),
page, the out-of-range clamp-commit, the slice and the Pager props lived twice,
and every review round had to fix both copies in lockstep. The two tabs now keep
only what differs — which rows, which storage key, and when to reset.
The clamp is gated on the card layout too, so it no longer rewrites card state
while the table is the one on screen.
- The tag menu's "trigger is gone" test now measures against the SCROLLER, not the
window. The page scroller starts ~230px down (measured), so a row that slid under
the fixed toolbar was still "on screen" by window coords and the menu floated over
the chrome, anchored to something invisible.
- Scroll-repositioning keeps whichever side the menu is anchored to instead of
resetting it to "below" every frame — that reset made the layout effect flip it
back, costing two renders and two forced layouts per frame near the bottom edge.
- One placement rule (`coordsFrom`) now serves the initial open, the layout
correction and every reposition, so those can no longer drift apart.
- Card page: reset on the filter/sort INPUTS only, and separately COMMIT the clamp
when the page falls out of range. Keying the reset on the row count (round 2) was
too blunt — a background refetch that merely added a channel threw the reader off
page 7 for something they didn't do.
- Pager derives `pageCount()` once and `hasPagerContent` reuses it, instead of the
same expression twice in one file.
- Re-indent the pagerNode JSX left dangling by round 2's ternary edit.
- The portalled tag picker now FOLLOWS its trigger on scroll (rAF-coalesced)
instead of closing. Closing on every wheel tick was a regression for the table,
where the menu used to travel with its row; it still closes once the trigger
leaves the viewport (nothing left to anchor to) or on resize.
- Place the menu by MEASUREMENT, not a px estimate: it opens below, then the
layout effect flips it above only if the real box overflows. The old
`rows * 30px` guess read short at font-scale 1.3 (the rows are rem), which
could leave a fixed menu hanging off the bottom with no way to reach it.
- `toggle` only opens once a position exists, so the `open && coords` gate can
never show a stale anchor.
- One owner for "is there anything to page": `hasPagerContent()` lives in Pager
and DataTable calls it, instead of both re-deriving the rule — that drift is
what left empty control bands behind in the first place.
- Reset the card page on the row COUNT too, not just the filter inputs, so an
unsubscribe or a shrinking refetch can't leave a stale page to spring back.
- `parseCardSize` clamps to the sizes the pager actually offers (like clampView /
clampStatus), so the select can't display one value while the grid pages by another.
- VirtualGrid's docblock now carries the stacking-context constraint: its rows are
transformed, so anything floating an item renders must portal out.
In the card layout every VirtualGrid row carries a `transform`, which makes it a
stacking context — so the tag menu's z-index was scoped to its own row and the
NEXT card (a later DOM sibling) painted over it. Same hazard that buried the
players in E3, so the same blessed fix: render the menu through <Overlay> (i.e.
at <body>) with fixed coords instead of `absolute` inside the cell.
The flip-up case anchors the menu's BOTTOM to just above the button so it lands
exactly whatever its real height is, and the horizontal clamp measures the
rendered width rather than assuming px — `w-44` grows with the text-size slider.
Closes on outside click (dismiss now watches the cell AND the portalled menu),
and on page scroll/resize, where a fixed menu would otherwise strand at a stale
anchor. Verified in both layouts by hit-testing the menu's own pixels.
- DataTable: keep `pagerNode` NULLABLE. Extracting Pager made it an always-truthy
element, so the `controls` / `bottomControls` guards always fired and a table with
nothing to page rendered empty `my-3` bands (plus an empty in-band PageToolbar).
- ChannelsProvider: `setView` bails when the tab is unchanged (re-selecting the
active tab, or goToFullHistory forcing "subscribed", stacked dead Back entries),
and pushes a FRESH state like setPage does instead of spreading `_chan`/`_yt`
markers into the tab entry.
- Lift the layout into its own narrow context (`useChannelLayout`): App read the
whole ChannelsState for the fade, so every keystroke in the manager's search box
re-rendered the app root.
- Reset the card page when the row set or sort changes (both tabs) — the clamp only
hid a stale page, which came back when the filter was cleared.
- Sort + slice the card rows only in cards mode; table mode was copy-sorting ~300
rows per render for a result nothing read.
- Validate persisted `cardSize` / `sort` (`parseCardSize`, `parseSortState`), matching
the clamp-on-read convention the module's other persisted values already follow;
a corrupt size made the page count NaN and emptied the grid.
- Pager: page-size changes scroll to the top too (they re-slice from a new first item).
- ColumnSortControl: a stored sort naming a column this table no longer offers is
treated as no sort, instead of showing a direction arrow for a sort that isn't applied.
- Drop the stale "rows" layout comments (that variant was retired this sprint).
UAT round 4:
- Discovery table: move Actions to the first column, mirroring the Subscriptions
table (where it sits right after Prio) so both tabs' actions line up.
- The manager's tabs now ride in history.state (`_chTab`), the same way the channel
page (`_chan`) and the live search (`_yt`) already do — so Back steps back through
the tabs you visited before it leaves the module. Arriving from elsewhere stamps
the entry setPage pushed (one navigation never costs two Back presses).
- Turning a page now scrolls the list back to its top (both the tables and the card
grid, since they share Pager) — landing mid-list in an unseen page is disorienting.
UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
Extracted the pager cluster out of DataTable into a reusable Pager component
(the table now renders it too — no behaviour change); the card view slices the
sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
fadeTop:false for its sticky-header table; the card layout has no sticky header,
so App now re-enables the top fade when the channel layout is cards.
Cards have no column headers to sort by, so lift the sort out of DataTable into
one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds
the comparator + click-cycle (one source of truth); DataTable gains an optional
controlled sort (sort + onSortChange) and falls back to its internal persistKey
sort when they're omitted, so every other table is untouched. The subscribed
sort lives in ChannelsProvider, discovery keeps its own (its columns differ);
a new ColumnSortControl dropdown (options derived from the columns' sortable
metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account,
persisted, per-tab.
UAT round 1 feedback:
- Retire the `rows` layout — it barely differed from the table; keep Table + Cards.
- The card now shows the About cell (+ its hover overlay) and the priority stepper
in every layout, not just the table: ChannelLayoutGrid gains named slots
(lead / about / actions) and stops hiding the `hideInCard` columns, since the
roomy desktop card has space the cramped mobile fallback did not.
- Fix the table losing its centering with "Rows per page: All": a long-titled
channel stretched the uncapped Channel column (~289→429px) and overflowed the
centered table. Cap it with a max-w element inside the cell (table-layout:auto
ignores a cell max-width), the same trick AboutCell already uses. Gaps now 163/162.
Self-review follow-ups: the `rows` layout mapped the primary column without a
React key (single-element today, but a warning waiting to happen); wrap it in a
keyed Fragment. Also replace the guessed card/row rowEst with browser-measured
values (176 / 62) so the scrollbar doesn't jump on first scroll.
A generic ViewSwitcher (reused from the feed) drives a per-account layout
shared by the Subscriptions + Discovery tabs. `table` keeps the DataTable
(numbered pager); `cards` and `rows` render a virtualized grid over the same
client-side list via the new ChannelLayoutGrid, which composes cells straight
from each table's Column<T> render closures (cardPrimary/hideInCard/cardLabel),
so both tabs — with their different columns — get the views for free. Status
chips surface in the fixed band when the table's own controls row is gone.
VirtualFeed becomes a thin wrapper that maps a FeedView to VirtualGrid's
layout spec and renders a VideoCard per item; all measure/chunk/place/
infinite-scroll mechanics move to VirtualGrid so the channel manager (E4 S3)
can reuse them. Behaviour-preserving: e2e 17/17, all render families smoked.
Platform-prefixed handles (x/twitter/instagram/facebook/tiktok/youtube/twitch/threads/
telegram/soundcloud) now link to the platform's profile URL — only the @handle is
linked, the '<platform>:' label stays as text. A required colon/arrow separator + the
known-platform list keep false positives out; a bare @handle with no platform stays plain.
The description linkifier only caught http(s):// URLs; extend it to www.* hosts and
bare domain.tld/path URLs (both get an https:// href prefix), so links like
www.amelielens.com and facebook.com/amelielensmusic become clickable too. A required
path + letters-only TLD keep false positives (version numbers, file names) out; bare
@handles and #hashtags stay plain (the platform is ambiguous).
- linkify: bare http(s) URLs inside channel descriptions become clickable links
(new tab, rel=noopener noreferrer) — new lib/linkify.tsx, used in the shared
ChannelAboutContent so both the About overlay and the channel detail page get it
- Country flag: the flag EMOJI never renders on Windows/Chrome (showed the small-caps
'hu' fallback), so swap it for a real self-hosted SVG flag via the flag-icons package;
applies to the About overlay + the channel detail page
- The About overlay now shows the FULL channel About (description, external links,
country, language, topics, keywords) — the same content as the detail page's About
tab, via a new shared ChannelAboutContent component (reused by ChannelPage + AboutCell)
- AboutCell fetches the channel detail ON DEMAND on hover (shared [channel,id] react-query
cache with the channel page), so the list stays light; the overlay is interactive
(scrollable + clickable links) via a small hover-bridge, and closes on page scroll/resize
- Center the content: max-w-7xl -> max-w-[96rem] on the channel tables + header, so the
wider About table stays symmetric with the header (mx-auto handles the responsive centering)
- Topic chips are display-only for now; the future search epic makes them clickable
- Backend: add channel description to the shared _channel_summary projection, so both
the subscribed list and the discovery list carry it (no migration; description is
already stored from the channels.list call)
- New AboutCell: one truncated line in the table + full text in a portalled, always-on
glass hover/focus overlay (wider than Tooltip, flips above the anchor near the bottom,
closes on scroll/resize)
- About column (after Channel) on both the subscribed and discovery tables; hidden in the
mobile card fallback
- Drop the intro blurb (+ its orphan channels.intro i18n key and Trans import)
- Header is now two tight rows: tabs + stats (right), your-tags + sync/backfill (right),
leaving room on the tab row for future tabs
- Add a funnel filter on the Prio / Channel / Sync / Tags columns (client-side, like
the discovery table's Channel filter)
- Move the Actions column to 2nd position (after Prio, before Channel), left-aligned
DataTable gains a working controlsPosition="both": the leading controls
(e.g. the status chips) stay ONLY at the top; the bottom row is pager-only
(no duplicated chips). The subscribed + discovery channel tables opt in, so
the 33-page list has a pager at the bottom too — no scroll back up.
- Plex player fills the screen in native fullscreen (was 720p-capped, centred)
- YouTube modal reverted to its smaller base size
- iOS-safe clipboard copy helper, adopted across all 5 copy sites
Bug 5: in browser fullscreen the video stayed small and centred instead
of filling the screen. Two causes:
1. The wrapper carries 80vw/80vh + transform:scale(1.25) for the windowed
HTPC look; in native FS that scaled layer never filled. Size the
wrapper to 100vw/100vh with NO transform while fs is on.
2. The real culprit: the <video> was `w-auto h-auto max-h-full max-w-full`,
which caps at the stream's intrinsic resolution (720p -> 1280x720) and
never scales UP. The windowed scale(1.25) hid this. Switch to
`w-full h-full object-contain` so it fills the wrapper by aspect.
Verified visually in real Chrome native fullscreen: video rect 1920x1024
(fills), was 1280x720 centred.
navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the
download share-link copy 'often does neither'). Add lib/clipboard.ts
copyText(): a gesture-safe execCommand path FIRST (before any await can
spend the transient user activation), async Clipboard API as fallback.
Migrate all five copy call sites to it.
The max-w-6xl bump was a quality lever (bigger pixel box → higher ABR);
the user prefers the smaller max-w-4xl base back. A user-selectable size
preset stays a future item.
Cuts the release for the unshipped stack since 0.44.0: E3 feed view modes, the
Platform refactor (S1–S6b, internal), and the Plex cast-explore epic. VERSION
0.44.0 -> 0.45.0; user-facing releaseNotes entry.
Fixes the pre-existing Plex bug where clicking a lower-billed cast member emptied
the grid (the sync only stored the top ~3 cast, AND-matched). Now: the full cast
is persisted on info-view (lazy) + a batched rich re-sync backfills the whole
library (marker 0057); the actor filter defaults to OR with an AND toggle
(feed-rail pill style); the filtered actors show as person cards (photo + count +
remove); orphaned headshot-cache rows are pruned each sync. Shared app/plex/people.py
backs both enrichment paths. E2E 17/17; /code-review clean (2 fixed). UAT passed.
Lazy enrichment only covers viewed titles, so an actor's count/results were
partial. Add a background backfill: each plex_sync rich-fetches a bounded batch
(60) of not-yet-enriched movies/shows and persists their FULL cast, stamping
people_enriched_at so each is fetched once — the whole library is covered over
successive runs (migration 0057 adds the marker). Extracted the enrichment into a
shared app/plex/people.py (cast_from_meta / enrich_row_people / prune_orphan_people
+ the person-image host/proxy) so the info-page lazy path and the batch share ONE
implementation — no duplication; the info page also stamps the marker so the batch
skips it. Verified: an 8-item batch grows those movies' cast_names 3->24 and fills
plex_people (32->224); the info-page cast is intact after the _rich_meta refactor.
(Plex cast-explore S1b)
The genre + actor MATCH toggles were a boxed button row (rounded-lg border, no
track); restyle them as the feed rail's tag-match toggle — a rounded-full track
(border + bg-card + p-0.5) with an inset rounded-full active pill, plus a MATCH
label. Extracted a shared MatchToggle so both read identically. i18n
plex.filter.matchLabel (en/hu). (Plex cast-explore polish)
Guard the prune's NOT IN against the classic NULL trap: a JSON-null cast array
element would surface as SQL NULL in the referenced set, making name NOT IN
(…, NULL) evaluate NULL for every row → silently prune nothing. Filter NULLs out
of the subquery. (Plex cast-explore S4 review)
The plex_people headshot cache can outlive its subjects — when a title leaves the
Plex library, cast/crew who appear in nothing else are orphaned. prune_orphan_people
deletes rows whose name no longer appears in ANY movie/show cast_names/directors,
run at the end of every plex_sync (best-effort; a jsonb_typeof='array' guard skips
scalar/null JSONB values). The filterable cast_names self-clean with their row;
this cleans the only per-person cache. Verified: an injected orphan is dropped, real
people kept. (Plex cast-explore S4)
The actors currently in the filter now render as cards above the poster grid —
headshot + name + in-scope title count + a remove x — so you SEE who you're
exploring, not just chips in the rail (generalises the old search-only person
cards to the applied filter). New GET /plex/people/cards (photo from the
plex_people cache, count live against cast_names); PlexBrowse fetches + renders
them; i18n en/hu. Verified: John Goodman (11) / Douglas M. Griffin (1) / Bradley
Cooper (14) with photos + counts. (Plex cast-explore S3)
Multiple selected actors now combine as OR (any) by default — click several cast
and see films featuring ANY of them — instead of the old hardcoded AND that
emptied the grid. An AND (all) toggle in the ACTIVE filter section (shown for 2+
actors, mirroring the genre match toggle) narrows to films with ALL of them.
Backend actor_mode param mirrors genre_mode. Verified: OR John Goodman|Douglas
M. Griffin = 11 films; AND = 1 (10 Cloverfield Lane). (Plex cast-explore S2)
The cheap section listing that drives the main sync only stores the top ~3 cast,
so any cast member below that was unfilterable (clicking them AND-emptied the
grid). Now the movie/show info fetch (/item, /show) — which already pulls the full
rich cast — persists it onto cast_names + people_text (so every displayed cast
member becomes filterable) and caches each headshot in the new plex_people table
(migration 0056). Idempotent (only grows a thinner stored set); best-effort (never
500s a read). Verified: 10 Cloverfield Lane 3->8 cast after viewing; plex_people
populated with photos. (Plex cast-explore S1a)
Closes the Platform epic DoD. Three cross-cutting App states move into scoped
providers (WizardProvider, FeedViewProvider, PrefsProvider) and the module
registry (components/moduleRegistry) drives the render: App looks up
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate — the page
ternary and the three side-rail page conditionals are gone. Content modules and
rails are prop-free (read me via useMe(), module state from providers).
App.tsx 796->431 lines, zero page===X chrome conditionals. E2E 17/17;
/code-review clean; UAT passed.
Replace App's page-render ternary and the three side-rail page conditionals with
a data-driven lookup: components/moduleRegistry maps each page to a prop-free
content component (+ optional side rail + shell fadeTop), and App renders
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate (an unreachable
gated page falls back to the feed, as the old ternary did). Content modules and
the two rails are now prop-free: they read `me` via the new useMe() hook and
their module-local state from the scoped providers; FeedPage/MessagesPage are the
only wrappers (they wire the global feed scope / meId). App.tsx 796->431 lines,
zero `page===X` chrome conditionals — closes the Platform epic DoD. (Platform S6b.3)
Lift the four editable Settings prefs (theme/perf/hints/notifications), their
live-apply effects, the debounced auto-save + baseline echo-guard, and the
server-adopt-on-login into PrefsProvider, exposing the PrefsController via
usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props;
App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse,
language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3)
Lift the feed's view-mode state (server-synced: persists on pick, adopted from
prefs on login) out of App into FeedViewProvider. App reads it via useFeedView()
to hand to Feed/ChannelPage; the view adopt lines leave App's big prefs-adopt
effect. Removes another App-owned cross-cutting state ahead of the render flip.
(Platform S6b.3)