forked from eva/focus-flow
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}';
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue