feat: show cross-date completion context
This commit is contained in:
parent
a47bdc67bb
commit
a82b58167e
7 changed files with 240 additions and 17 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue