diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md index ba954a6..fd23eb9 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md +++ b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 02 — Past Task Read Model -**Status:** Planned. +**Status:** Complete. **Goal:** Add presentation-model state that tells widgets when to show the full Push button. @@ -15,6 +15,17 @@ Push button. After this block, `TimelineCardModel` or its successor exposes a clear push-state field. Leaf widgets should not recompute past/incomplete scheduling conditions. +## Implementation notes + +1. `TimelineItem` carries the source task `updatedAt` timestamp for stale-write + protection. +2. `TodayScreenData` and `TimelineCardModel` receive an injected clock for + deterministic past/incomplete detection. +3. `TimelineCardModel.showPastTaskPushButton` identifies past incomplete planned + flexible cards and suppresses hover quick actions while true. +4. Mapper tests cover earlier today, previous day, future day, completed, + unsupported task types, and missing schedule placement. + --- ## Chunk 2.1 — Add push-state presentation fields diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 25927a3..0282ee3 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -71,7 +71,9 @@ class TodayScreenController extends ChangeNotifier { required this.read, required this.selectedDates, required this.dateLabelFor, - }) : _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) { + DateTime Function()? now, + }) : now = now ?? _utcNow, + _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) { selectedDates.addListener(_handleSelectedDateChanged); } @@ -84,6 +86,9 @@ class TodayScreenController extends ChangeNotifier { /// Formats dates before a backend read has returned screen data. final TodayDateLabelFormatter dateLabelFor; + /// Clock used while mapping time-sensitive presentation state. + final DateTime Function() now; + /// Private state stored as `_state` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. TodayScreenState _state; @@ -130,7 +135,10 @@ class TodayScreenController extends ChangeNotifier { return; } - final data = TodayScreenData.fromTodayState(result.requireValue); + final data = TodayScreenData.fromTodayState( + result.requireValue, + now: now(), + ); _syncSelectedCard(data); _state = data.cards.isEmpty ? TodayScreenEmpty(data) @@ -215,4 +223,8 @@ class TodayScreenController extends ChangeNotifier { } _selectedCard = null; } + + /// Runs the `_utcNow` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + static DateTime _utcNow() => DateTime.now().toUtc(); } diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart index 8d5de8d..385ae9e 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -21,17 +21,23 @@ class TimelineCardModel { required this.isCompleted, required this.isSelectable, required this.showsQuickActions, + this.showPastTaskPushButton = false, this.completionDateContextRequired = false, this.completedTimeText, + this.expectedUpdatedAt, this.durationMinutes, }); /// Maps a scheduler timeline item into card presentation data. - factory TimelineCardModel.fromItem(TodayTimelineItem item) { + factory TimelineCardModel.fromItem( + TodayTimelineItem item, { + required DateTime now, + }) { final start = item.start; final end = item.end; final visualKind = _visualKindFor(item); final isCompleted = item.taskStatus == TaskStatus.completed; + final showPastTaskPushButton = _showPastTaskPushButton(item, now: now); final title = item.item.displayTitle; final duration = item.item.durationMinutes; final timeText = start == null || end == null @@ -66,9 +72,14 @@ class TimelineCardModel { includeDate: completionDateContextRequired, ) : null, + expectedUpdatedAt: item.item.updatedAt, isCompleted: isCompleted, isSelectable: true, - showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, + showPastTaskPushButton: showPastTaskPushButton, + showsQuickActions: + !showPastTaskPushButton && + !isCompleted && + visualKind != TaskVisualKind.freeSlot, ); } @@ -90,6 +101,9 @@ class TimelineCardModel { /// Display-ready completion time or date-time, if the task has been completed. final String? completedTimeText; + /// Expected update timestamp for stale-write protection. + final DateTime? expectedUpdatedAt; + /// Whether completion text includes date context because completion crossed dates. final bool completionDateContextRequired; @@ -123,6 +137,12 @@ class TimelineCardModel { /// Whether the card should show compact quick-action controls. final bool showsQuickActions; + /// Whether the card should show the full overdue-task push control. + final bool showPastTaskPushButton; + + /// Accessible label for the full overdue-task push control. + String get pastTaskPushSemanticLabel => 'Push overdue task'; + /// Whether the left status control can toggle this task's completion. bool get canToggleCompletion { return switch (taskType) { diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart index 6d764d6..3cc954c 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -101,3 +101,19 @@ bool _completionDateContextRequired(TodayTimelineItem item) { actualStartDate != null && actualStartDate != scheduledDate || actualEndDate != null && actualEndDate != scheduledDate; } + +/// Top-level helper that performs the `_showPastTaskPushButton` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +bool _showPastTaskPushButton(TodayTimelineItem item, {required DateTime now}) { + if (item.source != TodayTimelineItemSource.task) { + return false; + } + if (item.taskType != TaskType.flexible) { + return false; + } + if (item.taskStatus != TaskStatus.planned) { + return false; + } + final end = item.end; + return end != null && end.isBefore(now); +} diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart index 4455c8e..7c149e2 100644 --- a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -23,9 +23,13 @@ class TodayScreenData { } /// Maps scheduler core [TodayState] into UI presentation data. - factory TodayScreenData.fromTodayState(TodayState state) { + factory TodayScreenData.fromTodayState( + TodayState state, { + required DateTime now, + }) { final cards = [ - for (final item in state.timelineItems) TimelineCardModel.fromItem(item), + for (final item in state.timelineItems) + TimelineCardModel.fromItem(item, now: now), ]; final nextRequired = state.nextRequiredItem; return TodayScreenData( diff --git a/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart b/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart index 0d9e235..de6ccc2 100644 --- a/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart +++ b/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart @@ -18,6 +18,7 @@ void main() { scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), completedAt: DateTime.utc(2026, 1, 3, 9, 25), ), + now: DateTime.utc(2026, 1, 3, 10), ); expect(card.completionDateContextRequired, isFalse); @@ -32,6 +33,7 @@ void main() { scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), completedAt: DateTime.utc(2026, 1, 4, 8, 5), ), + now: DateTime.utc(2026, 1, 4, 9), ); expect(card.completionDateContextRequired, isTrue); @@ -48,6 +50,7 @@ void main() { actualStart: DateTime.utc(2026, 1, 4, 0, 10), actualEnd: DateTime.utc(2026, 1, 4, 0, 20), ), + now: DateTime.utc(2026, 1, 4, 1), ); expect(card.completionDateContextRequired, isTrue); diff --git a/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart new file mode 100644 index 0000000..76c05ff --- /dev/null +++ b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests past-task push presentation mapping. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/models/today_screen_models.dart'; + +/// Runs past-task push presentation tests. +void main() { + final now = DateTime.utc(2026, 1, 3, 12); + + test('planned flexible task earlier today shows Push state', () { + final updatedAt = DateTime.utc(2026, 1, 3, 8, 45); + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + updatedAt: updatedAt, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isTrue); + expect(card.showsQuickActions, isFalse); + expect(card.expectedUpdatedAt, updatedAt); + expect(card.pastTaskPushSemanticLabel, 'Push overdue task'); + }); + + test('planned flexible task later today does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 14), + end: DateTime.utc(2026, 1, 3, 14, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isTrue); + }); + + test('planned flexible task on a previous date shows Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 2, 14), + end: DateTime.utc(2026, 1, 2, 14, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isTrue); + expect(card.showsQuickActions, isFalse); + }); + + test('planned flexible task on a future date does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 4, 9), + end: DateTime.utc(2026, 1, 4, 9, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isTrue); + }); + + test('completed flexible task in the past does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + status: TaskStatus.completed, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isFalse); + }); + + test('unsupported task types do not show Push state', () { + for (final type in [ + TaskType.critical, + TaskType.inflexible, + TaskType.locked, + TaskType.surprise, + TaskType.freeSlot, + ]) { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + type: type, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse, reason: type.name); + } + }); + + test('task without schedule placement does not show Push state', () { + final card = TimelineCardModel.fromItem(_item(), now: now); + + expect(card.showPastTaskPushButton, isFalse); + }); +} + +/// Builds a timeline item for push-state presentation tests. +TodayTimelineItem _item({ + DateTime? start, + DateTime? end, + DateTime? updatedAt, + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.planned, +}) { + return TodayTimelineItem( + item: TimelineItem( + id: 'task-${type.name}-${status.name}', + displayTitle: 'Task', + taskType: type, + projectColorToken: 'project-home', + backgroundToken: TimelineBackgroundToken.flexible, + rewardIconToken: TimelineRewardIconToken.medium, + difficultyIconToken: TimelineDifficultyIconToken.medium, + showsExplicitTime: true, + quickActions: const [], + category: TimelineItemCategory.taskCard, + start: start, + end: end, + updatedAt: updatedAt, + durationMinutes: start == null || end == null + ? null + : end.difference(start).inMinutes, + ), + source: TodayTimelineItemSource.task, + taskStatus: status, + taskId: 'task-${type.name}-${status.name}', + ); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index c536293..9d51b19 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -169,6 +169,7 @@ void main() { expect(completed.item.completedAt, isNotNull); final completedData = TodayScreenData.fromTodayState( await _readToday(tester, composition, tomorrow), + now: DateTime.utc(2026, 1, 4, 8, 20), ); final completedCard = completedData.cards.singleWhere( (card) => card.title == title, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 6eb790b..aa0aef9 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -65,7 +65,10 @@ void main() { 'visual token adapter maps seeded items to expected visual kinds', () async { final state = await _readSeededToday(); - final data = TodayScreenData.fromTodayState(state); + final data = TodayScreenData.fromTodayState( + state, + now: DateTime.utc(2025, 5, 20, 15, 20), + ); expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible); expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible); @@ -357,7 +360,10 @@ void main() { tester, ) async { final state = await _readSeededToday(); - final data = TodayScreenData.fromTodayState(state); + final data = TodayScreenData.fromTodayState( + state, + now: DateTime.utc(2025, 5, 20, 15, 20), + ); await _pumpConstrainedWidget( tester, diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart index 82f33dd..5325aa4 100644 --- a/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart @@ -40,6 +40,7 @@ class TimelineItemMapper { actualStart: task.actualStart, actualEnd: task.actualEnd, completedAt: task.completedAt, + updatedAt: task.updatedAt, durationMinutes: _durationMinutesFor(task), quickActions: _quickActionsFor(task.type), category: task.isLocked diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart index 977c350..4b2abbc 100644 --- a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart @@ -23,6 +23,7 @@ class TimelineItem { this.actualStart, this.actualEnd, this.completedAt, + this.updatedAt, this.durationMinutes, }) : quickActions = List.unmodifiable(quickActions); @@ -65,6 +66,9 @@ class TimelineItem { /// Completion instant, if the source task has been completed. final DateTime? completedAt; + /// Source task update timestamp, when the item was mapped from a task. + final DateTime? updatedAt; + /// Duration display value for flexible task cards. final int? durationMinutes;