ui: VOD sort dropdown with persisted key + locale labels

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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-03 15:54:53 +02:00
parent 020f3dacf1
commit 832b606701
7 changed files with 157 additions and 5 deletions

View File

@ -2,6 +2,37 @@
Dated entries from improvement cycles. Newest at top. 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 ## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix
Three independent improvements landed this cycle. Three independent improvements landed this cycle.

View File

@ -239,9 +239,17 @@
<div class="content"> <div class="content">
<!-- VODs Tab --> <!-- VODs Tab -->
<div class="tab-content active" id="vodsTab"> <div class="tab-content active" id="vodsTab">
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px;"> <div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;"> <input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button> <button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary,#888); font-size:12px; margin-left:8px;">Sort:</label>
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:7px 10px; color: var(--text-primary,#fff); font-size:13px;">
<option value="date_desc">Newest first</option>
<option value="date_asc">Oldest first</option>
<option value="views_desc">Most viewed</option>
<option value="duration_desc">Longest first</option>
<option value="duration_asc">Shortest first</option>
</select>
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span> <span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
</div> </div>
<div class="vod-grid" id="vodGrid"> <div class="vod-grid" id="vodGrid">

View File

@ -167,7 +167,13 @@ const UI_TEXT_DE = {
filterClearTitle: 'Filter loeschen (Esc)', filterClearTitle: 'Filter loeschen (Esc)',
filterNoMatchTitle: 'Keine Treffer', filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', 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: { clips: {
dialogTitle: 'Clip zuschneiden', dialogTitle: 'Clip zuschneiden',

View File

@ -167,7 +167,13 @@ const UI_TEXT_EN = {
filterClearTitle: 'Clear filter (Esc)', filterClearTitle: 'Clear filter (Esc)',
filterNoMatchTitle: 'No matches', filterNoMatchTitle: 'No matches',
filterNoMatchText: 'No VODs match the current filter.', 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: { clips: {
dialogTitle: 'Trim clip', dialogTitle: 'Trim clip',

View File

@ -10,6 +10,95 @@ let lastLoadedStreamer: string | null = null;
let vodFilterQuery = ''; let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; 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<VodSortKey> = ['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<HTMLSelectElement>('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<VodSortKey, string> = {
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 { function loadPersistedVodFilter(): string {
try { try {
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? ''; return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
@ -222,7 +311,8 @@ function renderVodGridFromCurrentState(): void {
return; return;
} }
const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery); const sorted = sortVods(lastLoadedVods, vodSortKey);
const filtered = filterVodsByQuery(sorted, vodFilterQuery);
if (filtered.length === 0 && vodFilterQuery.trim()) { if (filtered.length === 0 && vodFilterQuery.trim()) {
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);

View File

@ -145,6 +145,10 @@ function applyLanguageToStaticUI(): void {
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder); setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels();
}
const status = document.getElementById('statusText')?.textContent?.trim() || ''; const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {

View File

@ -52,6 +52,13 @@ async function init(): Promise<void> {
if (vodFilterInput) vodFilterInput.value = vodFilterQuery; if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
syncVodFilterClearButton(); syncVodFilterClearButton();
// Restore persisted VOD sort key. Apply localized labels to <option>s
// before syncing the select value so the right option is preselected
// even on first load before any language change fires.
vodSortKey = loadPersistedVodSort();
refreshVodSortSelectLabels();
syncVodSortSelect();
// Restore last active tab from previous session (default 'vods') // Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab()); showTab(loadPersistedActiveTab());