fix(epg): honor the controlled selectedDate in the timeline

The timeline declared a `selectedDate` input but never read it: `viewDayKey`
seeded to today and only local `commitDay` navigation mutated it, so a
programmatic host change was ignored and a preserved non-today date reset to
today on (re)mount — the panel showed today/empty-day while the host pointed at
another day (the old EpgList honored the controlled input). Flagged by Codex.

Seed `viewDayKey` from `selectedDate` via `linkedSignal` so it initializes from
and follows the host, while local day navigation still overrides via `.set()`.
Steady state is unchanged: the host echoes back the emitted key, so the signal
recomputes to the same value (no loop, no flicker).

Regression coverage: two specs assert a controlled non-today `selectedDate`
renders the ribbon on that day and follows a programmatic change. ui-epg 80,
portal-shared-ui + playlist-m3u green; lint clean; component 399 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-01 18:04:32 +02:00
1 parent f8931f0ded
commit 30696d037d
2 files changed
+35 -2

No files matched your search

@@ -23,6 +23,13 @@ function programAt(
};
}
/** Local yyyy-MM-dd day-key for a programme start (matches the component). */
function localDateKey(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
describe('EpgTimelineComponent', () => {
let fixture: ComponentFixture<EpgTimelineComponent>;
let component: EpgTimelineComponent;
@@ -86,6 +93,26 @@ describe('EpgTimelineComponent', () => {
expect(component.renderState()).toBe('empty-day');
});
it('honours a controlled non-today selectedDate instead of resetting to today', () => {
// Data three days out (today empty). Without honouring the controlled
// input the panel would show empty-day for today; with it, the ribbon
// renders on the host-selected day.
const future = programAt(3 * 1440, 60);
setInputs({
programs: [future],
selectedDate: localDateKey(future.start),
});
expect(component.renderState()).toBe('ribbon');
});
it('follows a programmatic selectedDate change from the host', () => {
const future = programAt(3 * 1440, 60);
setInputs({ programs: [future] });
expect(component.renderState()).toBe('empty-day'); // viewing today
setInputs({ selectedDate: localDateKey(future.start) });
expect(component.renderState()).toBe('ribbon'); // followed the host
});
it('shows the ribbon controls (Now + zoom) only while a ribbon renders', () => {
setInputs({ programs: [programAt(0, 120, 'Now')] });
expect(component.showRibbonControls()).toBe(true);
@@ -7,6 +7,7 @@ import {
ElementRef,
inject,
input,
linkedSignal,
output,
signal,
untracked,
@@ -120,8 +121,13 @@ export class EpgTimelineComponent {
private readonly nowMs = signal(Date.now());
readonly selectedKey = signal<string | null>(null);
/** Day currently centred in the ribbon viewport. */
private readonly viewDayKey = signal(getTodayEpgDateKey());
/** Day centred in the ribbon. Seeded from the controlled `selectedDate` so a
* non-today date survives (re)mount and follows host changes; local
* navigation overrides via `commitDay`. Falls back to today when unset. */
private readonly viewDayKey = linkedSignal(() => {
const key = this.selectedDate()?.trim();
return key ? key : getTodayEpgDateKey();
});
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
private readonly scroll = new TimelineScrollController({