diff --git a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md index ac7a1456d..0e76c4839 100644 --- a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md +++ b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md @@ -68,7 +68,7 @@ SCSS, ngx-translate. - Delete: `libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts` -- [ ] **Step 1: Write failing migration and service tests** +- [x] **Step 1: Write failing migration and service tests** Cover missing/invalid storage, valid legacy seeding, partial new-key migration, new-key precedence, idempotence, independent persistence, local action clearing @@ -94,7 +94,7 @@ expect(service.masterSuppressed()).toBe(false); expect(service.channelsIntent()).toBe('expanded'); ``` -- [ ] **Step 2: Run the target and verify RED** +- [x] **Step 2: Run the target and verify RED** Run: @@ -105,7 +105,7 @@ pnpm nx test portal-shared-data-access --runInBand Expected: FAIL because `LiveLayoutPanelStateService` and its storage constants do not exist. -- [ ] **Step 3: Implement the minimal signal service** +- [x] **Step 3: Implement the minimal signal service** Use a const-backed panel kind, readonly public signals, and explicit commands: @@ -151,13 +151,13 @@ Migration reads valid new keys first, falls back per missing key to valid `live-sidebar-state`, defaults to expanded, writes both new keys, and never writes the legacy key. -- [ ] **Step 4: Replace exports and remove the legacy service** +- [x] **Step 4: Replace exports and stage legacy service removal** Export the new service from `@iptvnator/portal/shared/data-access`. Remove only the injectable legacy service export/file; retain `restoreLiveSidebarState()` for migration compatibility. -- [ ] **Step 5: Run the target and verify GREEN** +- [x] **Step 5: Run the target and verify GREEN** Run: @@ -169,7 +169,7 @@ pnpm nx lint portal-shared-util Expected: all commands exit 0. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add libs/portal/shared/data-access libs/portal/shared/util diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index 4f245dfcb..aae46031a 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/collection'; +export * from './lib/live-layout-panel-state.service'; export * from './lib/multi-source'; diff --git a/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.spec.ts b/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.spec.ts new file mode 100644 index 000000000..561380b62 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.spec.ts @@ -0,0 +1,172 @@ +import { TestBed } from '@angular/core/testing'; +import { LIVE_SIDEBAR_STATE_STORAGE_KEY } from '@iptvnator/portal/shared/util'; +import { + LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, + LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, + LiveLayoutPanelStateService, +} from './live-layout-panel-state.service'; + +describe('LiveLayoutPanelStateService', () => { + afterEach(() => { + TestBed.resetTestingModule(); + localStorage.clear(); + }); + + it('defaults both panel intents to expanded and persists the migration', () => { + const service = createService(); + + expect(service.groupsIntent()).toBe('expanded'); + expect(service.channelsIntent()).toBe('expanded'); + expect( + localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY) + ).toBe('expanded'); + expect( + localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY) + ).toBe('expanded'); + }); + + it('seeds missing panel intents from a valid legacy sidebar state', () => { + localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed'); + + const service = createService(); + + expect(service.groupsIntent()).toBe('collapsed'); + expect(service.channelsIntent()).toBe('collapsed'); + expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + }); + + it('keeps valid new keys and migrates only a missing or invalid sibling', () => { + localStorage.setItem( + LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, + 'expanded' + ); + localStorage.setItem( + LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, + 'hidden' + ); + localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed'); + + const service = createService(); + + expect(service.groupsIntent()).toBe('expanded'); + expect(service.channelsIntent()).toBe('collapsed'); + expect( + localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY) + ).toBe('collapsed'); + }); + + it('does not let a changed legacy value overwrite migrated new keys', () => { + localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed'); + createService(); + TestBed.resetTestingModule(); + + localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'expanded'); + const service = createService(); + + expect(service.groupsIntent()).toBe('collapsed'); + expect(service.channelsIntent()).toBe('collapsed'); + }); + + it('persists Groups and Channels intents independently', () => { + const service = createService(); + + service.hidePanel('groups'); + + expect(service.groupsIntent()).toBe('collapsed'); + expect(service.channelsIntent()).toBe('expanded'); + expect( + localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY) + ).toBe('collapsed'); + expect( + localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY) + ).toBe('expanded'); + + service.hidePanel('channels'); + service.showPanel('groups'); + + expect(service.groupsIntent()).toBe('expanded'); + expect(service.channelsIntent()).toBe('collapsed'); + }); + + it('resolves effective visibility without persisting applicability or responsive suppression', () => { + const service = createService(); + + expect( + service.isPanelExpanded('groups', { + applicable: true, + }) + ).toBe(true); + expect( + service.isPanelExpanded('groups', { + applicable: false, + }) + ).toBe(false); + expect( + service.isPanelExpanded('groups', { + applicable: true, + responsiveSuppressed: true, + }) + ).toBe(false); + expect(service.groupsIntent()).toBe('expanded'); + expect( + localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY) + ).toBe('expanded'); + }); + + it('temporarily suppresses applicable visible panels and restores their persisted intents', () => { + const service = createService(); + service.hidePanel('channels'); + + service.toggleMasterSuppression(['groups', 'channels']); + + expect(service.masterSuppressed()).toBe(true); + expect( + service.isPanelExpanded('groups', { applicable: true }) + ).toBe(false); + expect(service.groupsIntent()).toBe('expanded'); + expect(service.channelsIntent()).toBe('collapsed'); + + service.toggleMasterSuppression(['groups', 'channels']); + + expect(service.masterSuppressed()).toBe(false); + expect( + service.isPanelExpanded('groups', { applicable: true }) + ).toBe(true); + expect( + service.isPanelExpanded('channels', { applicable: true }) + ).toBe(false); + }); + + it('exits master suppression before applying a panel-local action', () => { + const service = createService(); + service.toggleMasterSuppression(['groups', 'channels']); + + service.showPanel('channels'); + + expect(service.masterSuppressed()).toBe(false); + expect(service.channelsIntent()).toBe('expanded'); + + service.toggleMasterSuppression(['groups', 'channels']); + service.hidePanel('groups'); + + expect(service.masterSuppressed()).toBe(false); + expect(service.groupsIntent()).toBe('collapsed'); + }); + + it('leaves master suppression off when no applicable intent is visible', () => { + const service = createService(); + service.hidePanel('groups'); + service.hidePanel('channels'); + + service.toggleMasterSuppression(['groups', 'channels']); + + expect(service.masterSuppressed()).toBe(false); + }); +}); + +function createService(): LiveLayoutPanelStateService { + TestBed.configureTestingModule({}); + return TestBed.inject(LiveLayoutPanelStateService); +} diff --git a/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.ts b/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.ts new file mode 100644 index 000000000..48b423597 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.ts @@ -0,0 +1,133 @@ +import { Injectable, signal } from '@angular/core'; +import { + DEFAULT_LIVE_SIDEBAR_STATE, + LIVE_SIDEBAR_STATE_STORAGE_KEY, + LiveSidebarState, + isLiveSidebarState, +} from '@iptvnator/portal/shared/util'; + +export const LIVE_LAYOUT_PANEL = { + GROUPS: 'groups', + CHANNELS: 'channels', +} as const; + +export type LiveLayoutPanel = + (typeof LIVE_LAYOUT_PANEL)[keyof typeof LIVE_LAYOUT_PANEL]; + +export type LivePanelState = LiveSidebarState; + +export const LIVE_GROUPS_PANEL_STATE_STORAGE_KEY = + 'live-groups-panel-state'; +export const LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY = + 'live-channels-panel-state'; + +export interface LivePanelEffectiveContext { + readonly applicable: boolean; + readonly responsiveSuppressed?: boolean; +} + +interface LiveLeftPanelIntents { + readonly groups: LivePanelState; + readonly channels: LivePanelState; +} + +@Injectable({ providedIn: 'root' }) +export class LiveLayoutPanelStateService { + private readonly restoredIntents = restoreLiveLeftPanelIntents(); + private readonly _groupsIntent = signal(this.restoredIntents.groups); + private readonly _channelsIntent = signal(this.restoredIntents.channels); + private readonly _masterSuppressed = signal(false); + + readonly groupsIntent = this._groupsIntent.asReadonly(); + readonly channelsIntent = this._channelsIntent.asReadonly(); + readonly masterSuppressed = this._masterSuppressed.asReadonly(); + + isPanelExpanded( + panel: LiveLayoutPanel, + context: LivePanelEffectiveContext + ): boolean { + return ( + context.applicable && + !context.responsiveSuppressed && + !this._masterSuppressed() && + this.intentFor(panel)() === 'expanded' + ); + } + + hidePanel(panel: LiveLayoutPanel): void { + this.setPanelIntent(panel, 'collapsed'); + } + + showPanel(panel: LiveLayoutPanel): void { + this.setPanelIntent(panel, 'expanded'); + } + + toggleMasterSuppression( + applicablePanels: readonly LiveLayoutPanel[] + ): void { + if (this._masterSuppressed()) { + this._masterSuppressed.set(false); + return; + } + + const hasVisiblePanel = applicablePanels.some( + (panel) => this.intentFor(panel)() === 'expanded' + ); + this._masterSuppressed.set(hasVisiblePanel); + } + + private setPanelIntent( + panel: LiveLayoutPanel, + state: LivePanelState + ): void { + this._masterSuppressed.set(false); + const target = + panel === LIVE_LAYOUT_PANEL.GROUPS + ? this._groupsIntent + : this._channelsIntent; + target.set(state); + localStorage.setItem(storageKeyFor(panel), state); + } + + private intentFor(panel: LiveLayoutPanel) { + return panel === LIVE_LAYOUT_PANEL.GROUPS + ? this._groupsIntent + : this._channelsIntent; + } +} + +function restoreLiveLeftPanelIntents(): LiveLeftPanelIntents { + const legacyValue = localStorage.getItem( + LIVE_SIDEBAR_STATE_STORAGE_KEY + ); + const legacyFallback = isLiveSidebarState(legacyValue) + ? legacyValue + : DEFAULT_LIVE_SIDEBAR_STATE; + const groups = restorePanelIntent( + LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, + legacyFallback + ); + const channels = restorePanelIntent( + LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, + legacyFallback + ); + + localStorage.setItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, groups); + localStorage.setItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, channels); + + return { groups, channels }; +} + +function restorePanelIntent( + storageKey: string, + fallback: LivePanelState +): LivePanelState { + const storedValue = localStorage.getItem(storageKey); + return isLiveSidebarState(storedValue) ? storedValue : fallback; +} + +function storageKeyFor(panel: LiveLayoutPanel): string { + return panel === LIVE_LAYOUT_PANEL.GROUPS + ? LIVE_GROUPS_PANEL_STATE_STORAGE_KEY + : LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY; +} diff --git a/libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts b/libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts index 36a60ed9d..d4e47a4c8 100644 --- a/libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts +++ b/libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts @@ -6,11 +6,9 @@ import { } from './live-sidebar-state'; /** - * Shared collapse state for the live-TV sidebar across the workspace shell - * categories rail (Xtream/Stalker), the inline channels rail, and the - * unified-collection live tab. A single signal keeps all surfaces in sync - * within a session; localStorage persistence is delegated to the existing - * `live-sidebar-state` helpers so the storage key stays unchanged. + * @deprecated Migrate consumers to `LiveLayoutPanelStateService` from + * `@iptvnator/portal/shared/data-access`. This compatibility service remains + * until every existing live layout has moved in the current change. */ @Injectable({ providedIn: 'root' }) export class LiveLayoutSidebarStateService {