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}';
}
/// 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) {

View file

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

View file

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

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/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<BacklogQueryResult> _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<TodayTimelineItem> _readTodayItem(
WidgetTester tester,

View file

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

View file

@ -20,6 +20,8 @@ class TimelineItem {
required this.category,
this.start,
this.end,
this.actualStart,
this.actualEnd,
this.completedAt,
this.durationMinutes,
}) : quickActions = List<TimelineQuickAction>.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;