feat: show cross-date completion context

This commit is contained in:
Ashley Venn 2026-07-02 08:31:13 -07:00
parent a47bdc67bb
commit a82b58167e
7 changed files with 240 additions and 17 deletions

View file

@ -23,6 +23,35 @@ String _dateLabel(CivilDate date) {
return '${months[date.month - 1]} ${date.day}, ${date.year}'; 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. /// 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. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
int _minutesSinceMidnight(DateTime? value) { int _minutesSinceMidnight(DateTime? value) {

View file

@ -21,6 +21,7 @@ class TimelineCardModel {
required this.isCompleted, required this.isCompleted,
required this.isSelectable, required this.isSelectable,
required this.showsQuickActions, required this.showsQuickActions,
this.completionDateContextRequired = false,
this.completedTimeText, this.completedTimeText,
this.durationMinutes, this.durationMinutes,
}); });
@ -38,12 +39,18 @@ class TimelineCardModel {
? '' ? ''
: '$duration min' : '$duration min'
: '${_formatTime(start)} - ${_formatTime(end)}'; : '${_formatTime(start)} - ${_formatTime(end)}';
final completedAt = item.item.completedAt ?? item.item.end ?? item.end; final completionDateContextRequired =
isCompleted && _completionDateContextRequired(item);
return TimelineCardModel( return TimelineCardModel(
id: item.id, id: item.id,
title: title, title: title,
typeLabel: _typeLabelFor(item.taskType, visualKind), typeLabel: _typeLabelFor(item.taskType, visualKind),
subtitle: _subtitleFor(item, visualKind, timeText), subtitle: _subtitleFor(
item,
visualKind,
timeText,
completionDateContextRequired: completionDateContextRequired,
),
timeText: timeText, timeText: timeText,
startMinutes: _minutesSinceMidnight(start), startMinutes: _minutesSinceMidnight(start),
endMinutes: _minutesSinceMidnight(end), endMinutes: _minutesSinceMidnight(end),
@ -52,7 +59,13 @@ class TimelineCardModel {
rewardIconToken: item.item.rewardIconToken, rewardIconToken: item.item.rewardIconToken,
difficultyIconToken: item.item.difficultyIconToken, difficultyIconToken: item.item.difficultyIconToken,
durationMinutes: duration, durationMinutes: duration,
completedTimeText: isCompleted ? _formatTime(completedAt) : null, completionDateContextRequired: completionDateContextRequired,
completedTimeText: isCompleted
? _completionDisplayText(
item,
includeDate: completionDateContextRequired,
)
: null,
isCompleted: isCompleted, isCompleted: isCompleted,
isSelectable: true, isSelectable: true,
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
@ -74,9 +87,12 @@ class TimelineCardModel {
/// Display-ready time or duration text. /// Display-ready time or duration text.
final String timeText; 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; final String? completedTimeText;
/// Whether completion text includes date context because completion crossed dates.
final bool completionDateContextRequired;
/// Card start position in minutes since midnight. /// Card start position in minutes since midnight.
final int startMinutes; final int startMinutes;

View file

@ -42,18 +42,62 @@ String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) {
String _subtitleFor( String _subtitleFor(
TodayTimelineItem item, TodayTimelineItem item,
TaskVisualKind visualKind, TaskVisualKind visualKind,
String timeText, String timeText, {
) { required bool completionDateContextRequired,
}) {
if (visualKind == TaskVisualKind.freeSlot) { if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest'; return 'Intentional rest';
} }
if (item.taskStatus == TaskStatus.completed || if (item.taskStatus == TaskStatus.completed ||
visualKind == TaskVisualKind.completedSurprise) { visualKind == TaskVisualKind.completedSurprise) {
return 'Completed at: ' final completedText = _completionDisplayText(
'${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}'; item,
includeDate: completionDateContextRequired,
);
if (completedText == null || completedText.isEmpty) {
return 'Completed';
}
return 'Completed at: $completedText';
} }
if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) {
return '${item.item.durationMinutes} min'; return '${item.item.durationMinutes} min';
} }
return timeText; 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;
}

View file

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

View file

@ -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/focus_flow_app.dart';
import 'package:focus_flow_flutter/app/persistent_scheduler_composition.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/controllers/scheduler_command_controller.dart';
import 'package:focus_flow_flutter/models/today_screen_models.dart';
/// Runs persistent composition smoke tests. /// Runs persistent composition smoke tests.
void main() { void main() {
@ -95,9 +96,11 @@ void main() {
selectedDate: selectedDate, selectedDate: selectedDate,
clock: FixedClock(_instant(8)), clock: FixedClock(_instant(8)),
); );
var commandController = composition.createCommandController( var commandController = _commandController(
refreshReads: () async {}, composition,
selectedDate: () => selectedDate, selectedDate: selectedDate,
operationIdPrefix: 'future-schedule',
now: () => _instant(8),
); );
await tester.runAsync( await tester.runAsync(
() => commandController.quickCaptureToBacklog(title), () => commandController.quickCaptureToBacklog(title),
@ -134,9 +137,11 @@ void main() {
expect(scheduled.taskStatus, TaskStatus.planned); expect(scheduled.taskStatus, TaskStatus.planned);
expect(CivilDate.fromDateTime(scheduled.start!.toUtc()), tomorrow); expect(CivilDate.fromDateTime(scheduled.start!.toUtc()), tomorrow);
commandController = composition.createCommandController( commandController = _commandController(
refreshReads: () async {}, composition,
selectedDate: () => selectedDate, selectedDate: selectedDate,
operationIdPrefix: 'future-complete',
now: () => DateTime.utc(2026, 1, 4, 8, 5),
); );
await tester.runAsync( await tester.runAsync(
() => commandController.completeTask( () => commandController.completeTask(
@ -162,10 +167,21 @@ void main() {
); );
expect(completed.taskStatus, TaskStatus.completed); expect(completed.taskStatus, TaskStatus.completed);
expect(completed.item.completedAt, isNotNull); 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( commandController = _commandController(
refreshReads: () async {}, composition,
selectedDate: () => selectedDate, selectedDate: selectedDate,
operationIdPrefix: 'future-uncomplete',
now: () => DateTime.utc(2026, 1, 4, 8, 10),
); );
await tester.runAsync( await tester.runAsync(
() => commandController.uncompleteTask(taskId: completed.id), () => commandController.uncompleteTask(taskId: completed.id),
@ -253,6 +269,24 @@ Future<BacklogQueryResult> _readBacklog(
return result.requireValue; 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]. /// Reads one Today timeline item by [title].
Future<TodayTimelineItem> _readTodayItem( Future<TodayTimelineItem> _readTodayItem(
WidgetTester tester, WidgetTester tester,

View file

@ -37,6 +37,8 @@ class TimelineItemMapper {
showsExplicitTime: _showsExplicitTime(task.type), showsExplicitTime: _showsExplicitTime(task.type),
start: task.scheduledStart, start: task.scheduledStart,
end: task.scheduledEnd, end: task.scheduledEnd,
actualStart: task.actualStart,
actualEnd: task.actualEnd,
completedAt: task.completedAt, completedAt: task.completedAt,
durationMinutes: _durationMinutesFor(task), durationMinutes: _durationMinutesFor(task),
quickActions: _quickActionsFor(task.type), quickActions: _quickActionsFor(task.type),

View file

@ -20,6 +20,8 @@ class TimelineItem {
required this.category, required this.category,
this.start, this.start,
this.end, this.end,
this.actualStart,
this.actualEnd,
this.completedAt, this.completedAt,
this.durationMinutes, this.durationMinutes,
}) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions); }) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
@ -54,6 +56,12 @@ class TimelineItem {
/// Timeline placement end, if the source item has one. /// Timeline placement end, if the source item has one.
final DateTime? end; 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. /// Completion instant, if the source task has been completed.
final DateTime? completedAt; final DateTime? completedAt;