feat: add past task push presentation state

This commit is contained in:
Ashley Venn 2026-07-02 12:33:24 -07:00
parent d4c85494c0
commit f3795e8be1
11 changed files with 232 additions and 9 deletions

View file

@ -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

View file

@ -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();
}

View file

@ -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) {

View file

@ -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);
}

View file

@ -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(

View file

@ -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);

View file

@ -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}',
);
}

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -23,6 +23,7 @@ class TimelineItem {
this.actualStart,
this.actualEnd,
this.completedAt,
this.updatedAt,
this.durationMinutes,
}) : quickActions = List<TimelineQuickAction>.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;