From 832b6067019d03bb63bd154cb4dd43a0cd0b94c0 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 3 May 2026 15:54:53 +0200 Subject: [PATCH] ui: VOD sort dropdown with persisted key + locale labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sort selector next to the existing filter input. Five modes: newest first (default), oldest first, most viewed, longest first, shortest first. Concrete user pain — long archives previously had no way to find the longest stream, the most-watched, or to scroll back to the start chronologically. - vodSortKey state persisted to localStorage as twitch-vod-manager:vod-sort and validated against an enum on load, so an unknown stored value falls back to date_desc - renderVodGridFromCurrentState now applies sortVods before filterVodsByQuery so the filter sees the sort and the match counter is consistent - sortVods uses created_at timestamps for date sorts, view_count for views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration - DE + EN labels for both the "Sort:" prefix and the five option texts; refreshVodSortSelectLabels re-runs on language switch - syncVodSortSelect on init preselects the persisted value before any VOD load so the dropdown reflects state immediately Browser-default keyboard nav (arrows, type-ahead) covers keyboard access for the select. docs/IMPROVEMENT_LOG.md: Cycle 4 dated section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/IMPROVEMENT_LOG.md | 31 +++++++++++++ src/index.html | 12 ++++- src/renderer-locale-de.ts | 8 +++- src/renderer-locale-en.ts | 8 +++- src/renderer-streamers.ts | 92 ++++++++++++++++++++++++++++++++++++++- src/renderer-texts.ts | 4 ++ src/renderer.ts | 7 +++ 7 files changed, 157 insertions(+), 5 deletions(-) diff --git a/docs/IMPROVEMENT_LOG.md b/docs/IMPROVEMENT_LOG.md index 19bc6c9..2d6f323 100644 --- a/docs/IMPROVEMENT_LOG.md +++ b/docs/IMPROVEMENT_LOG.md @@ -2,6 +2,37 @@ Dated entries from improvement cycles. Newest at top. +## 2026-05-03 — Cycle 4: GQL retry + VOD sort + shutdown consolidation + +Three independent improvements landed this cycle. + +### 1. Public Twitch GQL fallback retries on transient failures (defensive error handling) + +- **File**: `src/main.ts` — new `isTransientAxiosError` + retry loop in `fetchPublicTwitchGql`. +- **Problem**: `fetchPublicTwitchGql` swallowed every network error with `catch (e) { console.error(...); return null; }`. The public-API fallback path is what users without a Twitch client_id/secret hit on every VOD list load — a single TCP RST or a transient `503` from `gql.twitch.tv` produced an empty list and the user had to click refresh. +- **Fix**: Up to 3 attempts with exponential backoff (`400ms × 2^(attempt-1)` + jitter, capped by attempt count). Retries cover transient HTTP (`408`, `429`, `5xx`) and pure network failures (no response). GraphQL errors in `errors[]` are still returned without retry — those are application-level rejections of the query itself. Recovery is logged via `appendDebugLog('public-gql-recovered', ...)` so we can later see in logs whether the retries actually pay off. + +### 2. VOD list sort dropdown with persistence (client feature: VM/state + UI + persistence) + +- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`. +- **Problem**: VODs always rendered in the order Twitch returned them (`sort:TIME` desc). With long archives users had no way to find the longest stream, the most-watched, or the oldest. +- **Fix**: `vodSortSelect` dropdown next to the filter input. Five sort modes: newest first, oldest first, most viewed, longest first, shortest first. State (`vodSortKey`) persisted to `localStorage` under `twitch-vod-manager:vod-sort` and validated against an enum on load — an unknown stored value falls back to `date_desc` so a future rename can't strand the user. `renderVodGridFromCurrentState` now applies `sortVods` before `filterVodsByQuery` so the filter sees the sort order and the match-counter is consistent. Sort labels and the "Sort:" prefix label are localized (DE + EN), and `refreshVodSortSelectLabels` re-runs on language switch so the option labels stay in the active language. Browser-default keyboard nav on the select (arrow keys, type-ahead) covers keyboard access. + +### 3. `shutdownCleanup()` consolidates `window-all-closed` + `before-quit` (cleanup of meaningful size) + +- **File**: `src/main.ts`. +- **Problem**: Both lifecycle handlers ran nearly identical cleanup blocks but had drifted: `window-all-closed` killed children and was platform-aware (`app.quit()` on non-darwin), `before-quit` only stopped timers and saved state. There was no single place to add a new "must run on exit" step — every future addition had to be pasted into both handlers and inevitably one would diverge. +- **Fix**: Single `shutdownCleanup(reason)` helper, gated by an idempotent `shutdownCleanupDone` flag so a `before-quit` immediately following a `window-all-closed` is a no-op. The helper kills `activeDownloads`, `activeClipProcesses`, and `currentEditorProcess` (with try/catch so an already-exited proc doesn't throw), persists config + queue, then stops timers. Debug-log flush is reordered to run AFTER `saveConfig` / `flushQueueSave` so any error in those persistence calls actually reaches the log file before the flush timer is gone. Both `app.on(...)` handlers shrank to one line each. + +### Regression + +- `npm run build` — clean (TypeScript strict, 0 errors). +- `npm run test:e2e:update-logic` — passed. +- `npm run test:merge-split` — passed. +- `npm run test:e2e` — passed (`issues: []`). +- `npm run test:e2e:guide` — passed (`failures: []`). +- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`). + ## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix Three independent improvements landed this cycle. diff --git a/src/index.html b/src/index.html index e502863..2de1a02 100644 --- a/src/index.html +++ b/src/index.html @@ -239,9 +239,17 @@
-
- +
+ + +
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index fb03b6c..7f45407 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -167,7 +167,13 @@ const UI_TEXT_DE = { filterClearTitle: 'Filter loeschen (Esc)', filterNoMatchTitle: 'Keine Treffer', filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', - filterMatchCount: '{shown} von {total} VODs' + filterMatchCount: '{shown} von {total} VODs', + sortLabel: 'Sortierung:', + sortDateDesc: 'Neueste zuerst', + sortDateAsc: 'Aelteste zuerst', + sortViewsDesc: 'Meiste Aufrufe', + sortDurationDesc: 'Laengste zuerst', + sortDurationAsc: 'Kuerzeste zuerst' }, clips: { dialogTitle: 'Clip zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 9b47d5b..6d1672b 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -167,7 +167,13 @@ const UI_TEXT_EN = { filterClearTitle: 'Clear filter (Esc)', filterNoMatchTitle: 'No matches', filterNoMatchText: 'No VODs match the current filter.', - filterMatchCount: '{shown} of {total} VODs' + filterMatchCount: '{shown} of {total} VODs', + sortLabel: 'Sort:', + sortDateDesc: 'Newest first', + sortDateAsc: 'Oldest first', + sortViewsDesc: 'Most viewed', + sortDurationDesc: 'Longest first', + sortDurationAsc: 'Shortest first' }, clips: { dialogTitle: 'Trim clip', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 1aac7d1..c97931c 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -10,6 +10,95 @@ let lastLoadedStreamer: string | null = null; let vodFilterQuery = ''; const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; +type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc'; +const VALID_VOD_SORTS: ReadonlyArray = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc']; +const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort'; +let vodSortKey: VodSortKey = 'date_desc'; + +function loadPersistedVodSort(): VodSortKey { + try { + const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY); + if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) { + return stored as VodSortKey; + } + } catch { /* localStorage may be unavailable */ } + return 'date_desc'; +} + +function persistVodSort(key: VodSortKey): void { + try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ } +} + +function vodDurationToSeconds(durationStr: string): number { + let total = 0; + const h = durationStr.match(/(\d+)h/); + const m = durationStr.match(/(\d+)m/); + const s = durationStr.match(/(\d+)s/); + if (h) total += parseInt(h[1], 10) * 3600; + if (m) total += parseInt(m[1], 10) * 60; + if (s) total += parseInt(s[1], 10); + return total; +} + +function sortVods(vods: VOD[], key: VodSortKey): VOD[] { + const sorted = [...vods]; + const ts = (s: string): number => { + const n = new Date(s).getTime(); + return Number.isFinite(n) ? n : 0; + }; + switch (key) { + case 'date_desc': + sorted.sort((a, b) => ts(b.created_at) - ts(a.created_at)); + break; + case 'date_asc': + sorted.sort((a, b) => ts(a.created_at) - ts(b.created_at)); + break; + case 'views_desc': + sorted.sort((a, b) => (b.view_count || 0) - (a.view_count || 0)); + break; + case 'duration_desc': + sorted.sort((a, b) => vodDurationToSeconds(b.duration) - vodDurationToSeconds(a.duration)); + break; + case 'duration_asc': + sorted.sort((a, b) => vodDurationToSeconds(a.duration) - vodDurationToSeconds(b.duration)); + break; + } + return sorted; +} + +function onVodSortChange(): void { + const select = byId('vodSortSelect'); + const value = select.value; + if ((VALID_VOD_SORTS as readonly string[]).includes(value)) { + vodSortKey = value as VodSortKey; + persistVodSort(vodSortKey); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } +} + +function syncVodSortSelect(): void { + const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; + if (select) select.value = vodSortKey; +} + +function refreshVodSortSelectLabels(): void { + const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; + if (!select) return; + const labels: Record = { + date_desc: UI_TEXT.vods.sortDateDesc, + date_asc: UI_TEXT.vods.sortDateAsc, + views_desc: UI_TEXT.vods.sortViewsDesc, + duration_desc: UI_TEXT.vods.sortDurationDesc, + duration_asc: UI_TEXT.vods.sortDurationAsc + }; + for (const opt of Array.from(select.options)) { + const k = opt.value as VodSortKey; + if (labels[k]) opt.textContent = labels[k]; + } +} + function loadPersistedVodFilter(): string { try { return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? ''; @@ -222,7 +311,8 @@ function renderVodGridFromCurrentState(): void { return; } - const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery); + const sorted = sortVods(lastLoadedVods, vodSortKey); + const filtered = filterVodsByQuery(sorted, vodFilterQuery); if (filtered.length === 0 && vodFilterQuery.trim()) { setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index ceb7b04..09cb5f8 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -145,6 +145,10 @@ function applyLanguageToStaticUI(): void { setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder); setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); + setText('vodSortLabel', UI_TEXT.vods.sortLabel); + if (typeof refreshVodSortSelectLabels === 'function') { + refreshVodSortSelectLabels(); + } const status = document.getElementById('statusText')?.textContent?.trim() || ''; if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { diff --git a/src/renderer.ts b/src/renderer.ts index 434a392..050a8d6 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -52,6 +52,13 @@ async function init(): Promise { if (vodFilterInput) vodFilterInput.value = vodFilterQuery; syncVodFilterClearButton(); + // Restore persisted VOD sort key. Apply localized labels to