feat(timeline): add manual compact mode state

This commit is contained in:
Ashley Venn 2026-06-24 12:41:21 -07:00
parent 9c19b85f03
commit 88434e3805
4 changed files with 306 additions and 2 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 08 — Today Timeline State
Status: In progress — Chunks 8.1 and 8.2 complete
Status: In progress — Chunks 8.1, 8.2, and 8.3 complete
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
@ -105,6 +105,14 @@ Acceptance criteria:
- Tests cover compact mode not activating automatically after pushed/missed tasks.
- Tests cover compact mode showing the next required item when one exists.
Completed:
- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state.
- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping.
- Kept locked items out of compact selection unless separately revealed through overlay state.
- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
## Chunk 8.4 — Timeline state regression test suite

View file

@ -1,6 +1,6 @@
# V1 Block 08 — Today Timeline State
Status: In progress — Chunks 8.1 and 8.2 complete
Status: In progress — Chunks 8.1, 8.2, and 8.3 complete
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
@ -105,6 +105,14 @@ Acceptance criteria:
- Tests cover compact mode not activating automatically after pushed/missed tasks.
- Tests cover compact mode showing the next required item when one exists.
Completed:
- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state.
- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping.
- Kept locked items out of compact selection unless separately revealed through overlay state.
- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
## Chunk 8.4 — Timeline state regression test suite

View file

@ -115,6 +115,53 @@ class TimelineItem {
final TimelineItemCategory category;
}
/// Reduced Today timeline state for manual compact mode.
class CompactTimelineState {
const CompactTimelineState({
required this.manualCompactModeEnabled,
required this.fullTimelineExpanded,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
});
/// User-controlled compact mode flag.
final bool manualCompactModeEnabled;
/// Whether the full timeline is expanded while compact mode is active.
final bool fullTimelineExpanded;
/// Current visible task, if one is active or occupies [now].
final TimelineItem? currentItem;
/// Next critical or inflexible task after the current moment.
final TimelineItem? nextRequiredItem;
/// Optional next flexible task after the current moment.
final TimelineItem? nextFlexibleItem;
/// Whether callers should show the full timeline.
bool get fullTimelineVisible {
return !manualCompactModeEnabled || fullTimelineExpanded;
}
/// Items selected for the compact view.
List<TimelineItem> get compactItems {
if (!manualCompactModeEnabled) {
return const [];
}
return [
if (currentItem != null) currentItem!,
if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id)
nextRequiredItem!,
if (nextFlexibleItem != null &&
nextFlexibleItem!.id != currentItem?.id &&
nextFlexibleItem!.id != nextRequiredItem?.id)
nextFlexibleItem!,
];
}
}
/// Converts domain tasks into Today timeline items.
class TimelineItemMapper {
const TimelineItemMapper({
@ -183,6 +230,103 @@ class TimelineItemMapper {
);
}
/// Build manual compact-mode state from scheduled task data.
CompactTimelineState compactStateForTasks({
required Iterable<Task> tasks,
required DateTime now,
required bool manualCompactModeEnabled,
bool includeNextFlexibleTask = false,
bool fullTimelineExpanded = false,
}) {
if (!manualCompactModeEnabled) {
return CompactTimelineState(
manualCompactModeEnabled: false,
fullTimelineExpanded: fullTimelineExpanded,
);
}
final timelineTasks = tasks.where(_canAppearInCompactMode).toList()
..sort(_compareTasksByStart);
final currentTask = timelineTasks
.where((task) {
return task.status == TaskStatus.active || _containsTime(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
);
final nextRequiredTask = timelineTasks
.where((task) {
return task.isRequiredVisible && _startsAtOrAfter(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
);
final nextFlexibleTask = includeNextFlexibleTask
? timelineTasks
.where((task) {
return task.isFlexible && _startsAtOrAfter(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
)
: null;
return CompactTimelineState(
manualCompactModeEnabled: true,
fullTimelineExpanded: fullTimelineExpanded,
currentItem: currentTask == null ? null : fromTask(currentTask),
nextRequiredItem:
nextRequiredTask == null ? null : fromTask(nextRequiredTask),
nextFlexibleItem:
nextFlexibleTask == null ? null : fromTask(nextFlexibleTask),
);
}
bool _canAppearInCompactMode(Task task) {
return !task.isLocked &&
task.status != TaskStatus.backlog &&
task.status != TaskStatus.completed &&
task.status != TaskStatus.cancelled &&
task.status != TaskStatus.noLongerRelevant &&
task.scheduledStart != null &&
task.scheduledEnd != null;
}
bool _containsTime(Task task, DateTime now) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return false;
}
return !now.isBefore(start) && now.isBefore(end);
}
bool _startsAtOrAfter(Task task, DateTime now) {
final start = task.scheduledStart;
return start != null && !start.isBefore(now);
}
int _compareTasksByStart(Task left, Task right) {
final leftStart = left.scheduledStart;
final rightStart = right.scheduledStart;
if (leftStart == null && rightStart == null) {
return 0;
}
if (leftStart == null) {
return 1;
}
if (rightStart == null) {
return -1;
}
return leftStart.compareTo(rightStart);
}
String _lockedOccurrenceId(LockedBlockOccurrence occurrence) {
final lockedBlockId = occurrence.lockedBlockId;
if (lockedBlockId != null) {

View file

@ -187,6 +187,150 @@ void main() {
expect(item.showsExplicitTime, isTrue);
expect(item.quickActions, isEmpty);
});
test('manual compact mode flag controls compact output selection', () {
final current = scheduledTask(
id: 'current',
title: 'Current task',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
);
final nextRequired = scheduledTask(
id: 'next-required',
title: 'Next appointment',
projectId: project.id,
type: TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 11),
end: DateTime(2026, 6, 20, 11, 30),
);
final disabled = mapper.compactStateForTasks(
tasks: [current, nextRequired],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: false,
);
final enabled = mapper.compactStateForTasks(
tasks: [current, nextRequired],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: true,
);
expect(disabled.manualCompactModeEnabled, isFalse);
expect(disabled.compactItems, isEmpty);
expect(disabled.fullTimelineVisible, isTrue);
expect(enabled.manualCompactModeEnabled, isTrue);
expect(enabled.currentItem!.id, 'current');
expect(enabled.nextRequiredItem!.id, 'next-required');
expect(enabled.compactItems.map((item) => item.id), [
'current',
'next-required',
]);
expect(enabled.fullTimelineVisible, isFalse);
});
test('compact mode can include the next flexible task when requested', () {
final required = scheduledTask(
id: 'required',
title: 'Required',
projectId: project.id,
type: TaskType.critical,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 10),
end: DateTime(2026, 6, 20, 10, 30),
);
final flexible = scheduledTask(
id: 'flexible',
title: 'Flexible',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 11),
end: DateTime(2026, 6, 20, 11, 30),
);
final state = mapper.compactStateForTasks(
tasks: [flexible, required],
now: DateTime(2026, 6, 20, 9),
manualCompactModeEnabled: true,
includeNextFlexibleTask: true,
);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem!.id, 'required');
expect(state.nextFlexibleItem!.id, 'flexible');
expect(state.compactItems.map((item) => item.id), [
'required',
'flexible',
]);
});
test('compact mode does not activate after pushed or missed tasks', () {
final pushed = scheduledTask(
id: 'pushed',
title: 'Pushed task',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 14),
end: DateTime(2026, 6, 20, 14, 30),
);
final missed = scheduledTask(
id: 'missed',
title: 'Missed appointment',
projectId: project.id,
type: TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 8),
end: DateTime(2026, 6, 20, 8, 30),
).copyWith(status: TaskStatus.missed);
final state = mapper.compactStateForTasks(
tasks: [pushed, missed],
now: DateTime(2026, 6, 20, 9),
manualCompactModeEnabled: false,
);
expect(state.manualCompactModeEnabled, isFalse);
expect(state.compactItems, isEmpty);
expect(state.fullTimelineVisible, isTrue);
});
test('compact mode ignores locked items unless overlay reveal is separate',
() {
final locked = scheduledTask(
id: 'locked',
title: 'Hidden blocked time',
projectId: 'locked',
type: TaskType.locked,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
);
final required = scheduledTask(
id: 'required',
title: 'Visible required task',
projectId: project.id,
type: TaskType.critical,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 10),
end: DateTime(2026, 6, 20, 10, 30),
);
final state = mapper.compactStateForTasks(
tasks: [locked, required],
now: DateTime(2026, 6, 20, 9, 15),
manualCompactModeEnabled: true,
);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem!.id, 'required');
expect(state.compactItems.map((item) => item.id), ['required']);
});
});
}