From a82b58167e70f9a97de74bd33a9f43dbba3bf642 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 2 Jul 2026 08:31:13 -0700 Subject: [PATCH] feat: show cross-date completion context --- .../today/time/time_string_formatter.dart | 29 ++++++ .../lib/models/today/timeline_card_model.dart | 24 ++++- .../timeline_item_presentation_mapper.dart | 52 ++++++++++- .../completion_date_presentation_test.dart | 90 +++++++++++++++++++ .../test/persistent_composition_test.dart | 52 +++++++++-- .../mapping/timeline_item_mapper.dart | 2 + .../timeline_state/models/timeline_item.dart | 8 ++ 7 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart index 1ed136d..9225cec 100644 --- a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -23,6 +23,35 @@ String _dateLabel(CivilDate date) { return '${months[date.month - 1]} ${date.day}, ${date.year}'; } +/// Top-level helper that performs the `_shortDateTimeLabel` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _shortDateTimeLabel(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[value.month - 1]} ${value.day}, ${_formatTime(value)}'; +} + +/// Top-level helper that performs the `_civilDateForDateTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +CivilDate? _civilDateForDateTime(DateTime? value) { + if (value == null) { + return null; + } + return CivilDate(value.year, value.month, value.day); +} + /// Top-level helper that performs the `_minutesSinceMidnight` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int _minutesSinceMidnight(DateTime? value) { 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 bf1baa1..8d5de8d 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,6 +21,7 @@ class TimelineCardModel { required this.isCompleted, required this.isSelectable, required this.showsQuickActions, + this.completionDateContextRequired = false, this.completedTimeText, this.durationMinutes, }); @@ -38,12 +39,18 @@ class TimelineCardModel { ? '' : '$duration min' : '${_formatTime(start)} - ${_formatTime(end)}'; - final completedAt = item.item.completedAt ?? item.item.end ?? item.end; + final completionDateContextRequired = + isCompleted && _completionDateContextRequired(item); return TimelineCardModel( id: item.id, title: title, typeLabel: _typeLabelFor(item.taskType, visualKind), - subtitle: _subtitleFor(item, visualKind, timeText), + subtitle: _subtitleFor( + item, + visualKind, + timeText, + completionDateContextRequired: completionDateContextRequired, + ), timeText: timeText, startMinutes: _minutesSinceMidnight(start), endMinutes: _minutesSinceMidnight(end), @@ -52,7 +59,13 @@ class TimelineCardModel { rewardIconToken: item.item.rewardIconToken, difficultyIconToken: item.item.difficultyIconToken, durationMinutes: duration, - completedTimeText: isCompleted ? _formatTime(completedAt) : null, + completionDateContextRequired: completionDateContextRequired, + completedTimeText: isCompleted + ? _completionDisplayText( + item, + includeDate: completionDateContextRequired, + ) + : null, isCompleted: isCompleted, isSelectable: true, showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, @@ -74,9 +87,12 @@ class TimelineCardModel { /// Display-ready time or duration text. final String timeText; - /// Display-ready completion time, if the task has been completed. + /// Display-ready completion time or date-time, if the task has been completed. final String? completedTimeText; + /// Whether completion text includes date context because completion crossed dates. + final bool completionDateContextRequired; + /// Card start position in minutes since midnight. final int startMinutes; 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 edb96c4..6d764d6 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 @@ -42,18 +42,62 @@ String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) { String _subtitleFor( TodayTimelineItem item, TaskVisualKind visualKind, - String timeText, -) { + String timeText, { + required bool completionDateContextRequired, +}) { if (visualKind == TaskVisualKind.freeSlot) { return 'Intentional rest'; } if (item.taskStatus == TaskStatus.completed || visualKind == TaskVisualKind.completedSurprise) { - return 'Completed at: ' - '${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}'; + final completedText = _completionDisplayText( + item, + includeDate: completionDateContextRequired, + ); + if (completedText == null || completedText.isEmpty) { + return 'Completed'; + } + return 'Completed at: $completedText'; } if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { return '${item.item.durationMinutes} min'; } return timeText; } + +/// Top-level helper that performs the `_completionDisplayText` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _completionDisplayText( + TodayTimelineItem item, { + required bool includeDate, +}) { + final completionInstant = _completionDisplayInstant(item); + if (completionInstant == null) { + return null; + } + if (includeDate) { + return _shortDateTimeLabel(completionInstant); + } + return _formatTime(completionInstant); +} + +/// Top-level helper that performs the `_completionDisplayInstant` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DateTime? _completionDisplayInstant(TodayTimelineItem item) { + return item.item.completedAt ?? item.item.actualEnd ?? item.item.end; +} + +/// Top-level helper that performs the `_completionDateContextRequired` 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 _completionDateContextRequired(TodayTimelineItem item) { + final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start); + if (scheduledDate == null) { + return false; + } + final completedAtDate = _civilDateForDateTime(item.item.completedAt); + final actualStartDate = _civilDateForDateTime(item.item.actualStart); + final actualEndDate = _civilDateForDateTime(item.item.actualEnd); + return completedAtDate != null && completedAtDate != scheduledDate || + actualStartDate != null && actualStartDate != scheduledDate || + actualEndDate != null && actualEndDate != scheduledDate; +} 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 new file mode 100644 index 0000000..0d9e235 --- /dev/null +++ b/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests completion date presentation models. +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 completion date presentation tests. +void main() { + test('same-day completion keeps compact time-only text', () { + final card = TimelineCardModel.fromItem( + _completedItem( + scheduledStart: DateTime.utc(2026, 1, 3, 9), + scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), + completedAt: DateTime.utc(2026, 1, 3, 9, 25), + ), + ); + + expect(card.completionDateContextRequired, isFalse); + expect(card.completedTimeText, '9:25 AM'); + expect(card.subtitle, 'Completed at: 9:25 AM'); + }); + + test('completedAt on a different date includes date context', () { + final card = TimelineCardModel.fromItem( + _completedItem( + scheduledStart: DateTime.utc(2026, 1, 3, 9), + scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), + completedAt: DateTime.utc(2026, 1, 4, 8, 5), + ), + ); + + expect(card.completionDateContextRequired, isTrue); + expect(card.completedTimeText, 'Jan 4, 8:05 AM'); + expect(card.subtitle, 'Completed at: Jan 4, 8:05 AM'); + }); + + test('actual interval on a different date includes date context', () { + final card = TimelineCardModel.fromItem( + _completedItem( + scheduledStart: DateTime.utc(2026, 1, 3, 23, 30), + scheduledEnd: DateTime.utc(2026, 1, 4), + completedAt: DateTime.utc(2026, 1, 3, 23, 45), + actualStart: DateTime.utc(2026, 1, 4, 0, 10), + actualEnd: DateTime.utc(2026, 1, 4, 0, 20), + ), + ); + + expect(card.completionDateContextRequired, isTrue); + expect(card.completedTimeText, 'Jan 3, 11:45 PM'); + expect(card.subtitle, 'Completed at: Jan 3, 11:45 PM'); + }); +} + +/// Builds a completed timeline item for presentation tests. +TodayTimelineItem _completedItem({ + required DateTime scheduledStart, + required DateTime scheduledEnd, + DateTime? completedAt, + DateTime? actualStart, + DateTime? actualEnd, +}) { + return TodayTimelineItem( + item: TimelineItem( + id: 'completed-task', + displayTitle: 'Completed task', + taskType: TaskType.flexible, + projectColorToken: 'project-home', + backgroundToken: TimelineBackgroundToken.flexible, + rewardIconToken: TimelineRewardIconToken.medium, + difficultyIconToken: TimelineDifficultyIconToken.medium, + showsExplicitTime: true, + quickActions: const [], + category: TimelineItemCategory.taskCard, + start: scheduledStart, + end: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, + completedAt: completedAt, + durationMinutes: scheduledEnd.difference(scheduledStart).inMinutes, + ), + source: TodayTimelineItemSource.task, + taskStatus: TaskStatus.completed, + taskId: 'completed-task', + ); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 1070f3d..c536293 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -13,6 +13,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart'; import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart'; +import 'package:focus_flow_flutter/models/today_screen_models.dart'; /// Runs persistent composition smoke tests. void main() { @@ -95,9 +96,11 @@ void main() { selectedDate: selectedDate, clock: FixedClock(_instant(8)), ); - var commandController = composition.createCommandController( - refreshReads: () async {}, - selectedDate: () => selectedDate, + var commandController = _commandController( + composition, + selectedDate: selectedDate, + operationIdPrefix: 'future-schedule', + now: () => _instant(8), ); await tester.runAsync( () => commandController.quickCaptureToBacklog(title), @@ -134,9 +137,11 @@ void main() { expect(scheduled.taskStatus, TaskStatus.planned); expect(CivilDate.fromDateTime(scheduled.start!.toUtc()), tomorrow); - commandController = composition.createCommandController( - refreshReads: () async {}, - selectedDate: () => selectedDate, + commandController = _commandController( + composition, + selectedDate: selectedDate, + operationIdPrefix: 'future-complete', + now: () => DateTime.utc(2026, 1, 4, 8, 5), ); await tester.runAsync( () => commandController.completeTask( @@ -162,10 +167,21 @@ void main() { ); expect(completed.taskStatus, TaskStatus.completed); expect(completed.item.completedAt, isNotNull); + final completedData = TodayScreenData.fromTodayState( + await _readToday(tester, composition, tomorrow), + ); + final completedCard = completedData.cards.singleWhere( + (card) => card.title == title, + ); + expect(completedCard.completionDateContextRequired, isTrue); + expect(completedCard.completedTimeText, 'Jan 4, 8:05 AM'); + expect(completedCard.subtitle, 'Completed at: Jan 4, 8:05 AM'); - commandController = composition.createCommandController( - refreshReads: () async {}, - selectedDate: () => selectedDate, + commandController = _commandController( + composition, + selectedDate: selectedDate, + operationIdPrefix: 'future-uncomplete', + now: () => DateTime.utc(2026, 1, 4, 8, 10), ); await tester.runAsync( () => commandController.uncompleteTask(taskId: completed.id), @@ -253,6 +269,24 @@ Future _readBacklog( return result.requireValue; } +/// Builds a deterministic command controller for persistence lifecycle tests. +SchedulerCommandController _commandController( + PersistentSchedulerComposition composition, { + required CivilDate selectedDate, + required String operationIdPrefix, + required DateTime Function() now, +}) { + return SchedulerCommandController( + commands: composition.commandUseCases, + contextFor: composition.context, + selectedDate: () => selectedDate, + refreshReads: () async {}, + quickCaptureProjectId: composition.defaultProjectId, + operationIdPrefix: operationIdPrefix, + now: now, + ); +} + /// Reads one Today timeline item by [title]. Future _readTodayItem( WidgetTester 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 4d4a927..82f33dd 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 @@ -37,6 +37,8 @@ class TimelineItemMapper { showsExplicitTime: _showsExplicitTime(task.type), start: task.scheduledStart, end: task.scheduledEnd, + actualStart: task.actualStart, + actualEnd: task.actualEnd, completedAt: task.completedAt, durationMinutes: _durationMinutesFor(task), quickActions: _quickActionsFor(task.type), 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 79913f9..977c350 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 @@ -20,6 +20,8 @@ class TimelineItem { required this.category, this.start, this.end, + this.actualStart, + this.actualEnd, this.completedAt, this.durationMinutes, }) : quickActions = List.unmodifiable(quickActions); @@ -54,6 +56,12 @@ class TimelineItem { /// Timeline placement end, if the source item has one. final DateTime? end; + /// Actual work start instant, if the source task recorded one. + final DateTime? actualStart; + + /// Actual work end instant, if the source task recorded one. + final DateTime? actualEnd; + /// Completion instant, if the source task has been completed. final DateTime? completedAt;