From 88434e3805ad02fe75a9f54e55202b1d1f1276ca Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 12:41:21 -0700 Subject: [PATCH] feat(timeline): add manual compact mode state --- .../V1_BLOCK_08_Today_Timeline_State.md | 10 +- .../V1_BLOCK_08_Today_Timeline_State.md | 10 +- lib/src/timeline_state.dart | 144 ++++++++++++++++++ test/timeline_state_test.dart | 144 ++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md index d438ab5..66d2ebb 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md @@ -1,6 +1,6 @@ # V1 Block 08 — Today Timeline State -Status: In progress — Chunks 8.1 and 8.2 complete +Status: In progress — Chunks 8.1, 8.2, and 8.3 complete Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested. @@ -105,6 +105,14 @@ Acceptance criteria: - Tests cover compact mode not activating automatically after pushed/missed tasks. - Tests cover compact mode showing the next required item when one exists. +Completed: + +- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state. +- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping. +- Kept locked items out of compact selection unless separately revealed through overlay state. +- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite. ## Chunk 8.4 — Timeline state regression test suite diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md b/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md index d438ab5..66d2ebb 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md @@ -1,6 +1,6 @@ # V1 Block 08 — Today Timeline State -Status: In progress — Chunks 8.1 and 8.2 complete +Status: In progress — Chunks 8.1, 8.2, and 8.3 complete Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested. @@ -105,6 +105,14 @@ Acceptance criteria: - Tests cover compact mode not activating automatically after pushed/missed tasks. - Tests cover compact mode showing the next required item when one exists. +Completed: + +- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state. +- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping. +- Kept locked items out of compact selection unless separately revealed through overlay state. +- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite. ## Chunk 8.4 — Timeline state regression test suite diff --git a/lib/src/timeline_state.dart b/lib/src/timeline_state.dart index 500a514..9588ed8 100644 --- a/lib/src/timeline_state.dart +++ b/lib/src/timeline_state.dart @@ -115,6 +115,53 @@ class TimelineItem { final TimelineItemCategory category; } +/// Reduced Today timeline state for manual compact mode. +class CompactTimelineState { + const CompactTimelineState({ + required this.manualCompactModeEnabled, + required this.fullTimelineExpanded, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }); + + /// User-controlled compact mode flag. + final bool manualCompactModeEnabled; + + /// Whether the full timeline is expanded while compact mode is active. + final bool fullTimelineExpanded; + + /// Current visible task, if one is active or occupies [now]. + final TimelineItem? currentItem; + + /// Next critical or inflexible task after the current moment. + final TimelineItem? nextRequiredItem; + + /// Optional next flexible task after the current moment. + final TimelineItem? nextFlexibleItem; + + /// Whether callers should show the full timeline. + bool get fullTimelineVisible { + return !manualCompactModeEnabled || fullTimelineExpanded; + } + + /// Items selected for the compact view. + List get compactItems { + if (!manualCompactModeEnabled) { + return const []; + } + return [ + if (currentItem != null) currentItem!, + if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) + nextRequiredItem!, + if (nextFlexibleItem != null && + nextFlexibleItem!.id != currentItem?.id && + nextFlexibleItem!.id != nextRequiredItem?.id) + nextFlexibleItem!, + ]; + } +} + /// Converts domain tasks into Today timeline items. class TimelineItemMapper { const TimelineItemMapper({ @@ -183,6 +230,103 @@ class TimelineItemMapper { ); } + /// Build manual compact-mode state from scheduled task data. + CompactTimelineState compactStateForTasks({ + required Iterable tasks, + required DateTime now, + required bool manualCompactModeEnabled, + bool includeNextFlexibleTask = false, + bool fullTimelineExpanded = false, + }) { + if (!manualCompactModeEnabled) { + return CompactTimelineState( + manualCompactModeEnabled: false, + fullTimelineExpanded: fullTimelineExpanded, + ); + } + + final timelineTasks = tasks.where(_canAppearInCompactMode).toList() + ..sort(_compareTasksByStart); + final currentTask = timelineTasks + .where((task) { + return task.status == TaskStatus.active || _containsTime(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextRequiredTask = timelineTasks + .where((task) { + return task.isRequiredVisible && _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextFlexibleTask = includeNextFlexibleTask + ? timelineTasks + .where((task) { + return task.isFlexible && _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ) + : null; + + return CompactTimelineState( + manualCompactModeEnabled: true, + fullTimelineExpanded: fullTimelineExpanded, + currentItem: currentTask == null ? null : fromTask(currentTask), + nextRequiredItem: + nextRequiredTask == null ? null : fromTask(nextRequiredTask), + nextFlexibleItem: + nextFlexibleTask == null ? null : fromTask(nextFlexibleTask), + ); + } + + bool _canAppearInCompactMode(Task task) { + return !task.isLocked && + task.status != TaskStatus.backlog && + task.status != TaskStatus.completed && + task.status != TaskStatus.cancelled && + task.status != TaskStatus.noLongerRelevant && + task.scheduledStart != null && + task.scheduledEnd != null; + } + + bool _containsTime(Task task, DateTime now) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return !now.isBefore(start) && now.isBefore(end); + } + + bool _startsAtOrAfter(Task task, DateTime now) { + final start = task.scheduledStart; + return start != null && !start.isBefore(now); + } + + int _compareTasksByStart(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart == null && rightStart == null) { + return 0; + } + if (leftStart == null) { + return 1; + } + if (rightStart == null) { + return -1; + } + return leftStart.compareTo(rightStart); + } + String _lockedOccurrenceId(LockedBlockOccurrence occurrence) { final lockedBlockId = occurrence.lockedBlockId; if (lockedBlockId != null) { diff --git a/test/timeline_state_test.dart b/test/timeline_state_test.dart index 4c478c2..acbf181 100644 --- a/test/timeline_state_test.dart +++ b/test/timeline_state_test.dart @@ -187,6 +187,150 @@ void main() { expect(item.showsExplicitTime, isTrue); expect(item.quickActions, isEmpty); }); + + test('manual compact mode flag controls compact output selection', () { + final current = scheduledTask( + id: 'current', + title: 'Current task', + projectId: project.id, + type: TaskType.flexible, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10), + ); + final nextRequired = scheduledTask( + id: 'next-required', + title: 'Next appointment', + projectId: project.id, + type: TaskType.inflexible, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 11), + end: DateTime(2026, 6, 20, 11, 30), + ); + + final disabled = mapper.compactStateForTasks( + tasks: [current, nextRequired], + now: DateTime(2026, 6, 20, 9, 15), + manualCompactModeEnabled: false, + ); + final enabled = mapper.compactStateForTasks( + tasks: [current, nextRequired], + now: DateTime(2026, 6, 20, 9, 15), + manualCompactModeEnabled: true, + ); + + expect(disabled.manualCompactModeEnabled, isFalse); + expect(disabled.compactItems, isEmpty); + expect(disabled.fullTimelineVisible, isTrue); + + expect(enabled.manualCompactModeEnabled, isTrue); + expect(enabled.currentItem!.id, 'current'); + expect(enabled.nextRequiredItem!.id, 'next-required'); + expect(enabled.compactItems.map((item) => item.id), [ + 'current', + 'next-required', + ]); + expect(enabled.fullTimelineVisible, isFalse); + }); + + test('compact mode can include the next flexible task when requested', () { + final required = scheduledTask( + id: 'required', + title: 'Required', + projectId: project.id, + type: TaskType.critical, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 10), + end: DateTime(2026, 6, 20, 10, 30), + ); + final flexible = scheduledTask( + id: 'flexible', + title: 'Flexible', + projectId: project.id, + type: TaskType.flexible, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 11), + end: DateTime(2026, 6, 20, 11, 30), + ); + + final state = mapper.compactStateForTasks( + tasks: [flexible, required], + now: DateTime(2026, 6, 20, 9), + manualCompactModeEnabled: true, + includeNextFlexibleTask: true, + ); + + expect(state.currentItem, isNull); + expect(state.nextRequiredItem!.id, 'required'); + expect(state.nextFlexibleItem!.id, 'flexible'); + expect(state.compactItems.map((item) => item.id), [ + 'required', + 'flexible', + ]); + }); + + test('compact mode does not activate after pushed or missed tasks', () { + final pushed = scheduledTask( + id: 'pushed', + title: 'Pushed task', + projectId: project.id, + type: TaskType.flexible, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 14), + end: DateTime(2026, 6, 20, 14, 30), + ); + final missed = scheduledTask( + id: 'missed', + title: 'Missed appointment', + projectId: project.id, + type: TaskType.inflexible, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 8), + end: DateTime(2026, 6, 20, 8, 30), + ).copyWith(status: TaskStatus.missed); + + final state = mapper.compactStateForTasks( + tasks: [pushed, missed], + now: DateTime(2026, 6, 20, 9), + manualCompactModeEnabled: false, + ); + + expect(state.manualCompactModeEnabled, isFalse); + expect(state.compactItems, isEmpty); + expect(state.fullTimelineVisible, isTrue); + }); + + test('compact mode ignores locked items unless overlay reveal is separate', + () { + final locked = scheduledTask( + id: 'locked', + title: 'Hidden blocked time', + projectId: 'locked', + type: TaskType.locked, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10), + ); + final required = scheduledTask( + id: 'required', + title: 'Visible required task', + projectId: project.id, + type: TaskType.critical, + createdAt: createdAt, + start: DateTime(2026, 6, 20, 10), + end: DateTime(2026, 6, 20, 10, 30), + ); + + final state = mapper.compactStateForTasks( + tasks: [locked, required], + now: DateTime(2026, 6, 20, 9, 15), + manualCompactModeEnabled: true, + ); + + expect(state.currentItem, isNull); + expect(state.nextRequiredItem!.id, 'required'); + expect(state.compactItems.map((item) => item.id), ['required']); + }); }); }