feat(timeline): add timeline item view model

This commit is contained in:
Ashley Venn 2026-06-24 12:18:40 -07:00
parent 89faecdee9
commit 6f31290397
5 changed files with 427 additions and 2 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 08 — Today Timeline State
Status: Planned
Status: In progress — Chunk 8.1 complete
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
@ -40,6 +40,13 @@ Acceptance criteria:
- Tests cover flexible duration display.
- Tests cover explicit time display for inflexible, critical, and locked items.
Completed:
- Added UI-independent timeline item and mapper models in `lib/src/timeline_state.dart`.
- Exported timeline state through `lib/scheduler_core.dart`.
- Added tests for task field/token mapping, flexible duration display, explicit time display, and locked overlay category/no-actions behavior.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
## Chunk 8.2 — Hidden locked block overlay state
Recommended Codex level: medium

View file

@ -1,6 +1,6 @@
# V1 Block 08 — Today Timeline State
Status: Planned
Status: In progress — Chunk 8.1 complete
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
@ -40,6 +40,13 @@ Acceptance criteria:
- Tests cover flexible duration display.
- Tests cover explicit time display for inflexible, critical, and locked items.
Completed:
- Added UI-independent timeline item and mapper models in `lib/src/timeline_state.dart`.
- Exported timeline state through `lib/scheduler_core.dart`.
- Added tests for task field/token mapping, flexible duration display, explicit time display, and locked overlay category/no-actions behavior.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
## Chunk 8.2 — Hidden locked block overlay state
Recommended Codex level: medium

View file

@ -24,3 +24,4 @@ export 'src/quick_capture.dart';
export 'src/scheduling_engine.dart';
export 'src/task_actions.dart';
export 'src/task_statistics.dart';
export 'src/timeline_state.dart';

254
lib/src/timeline_state.dart Normal file
View file

@ -0,0 +1,254 @@
// UI-independent Today timeline view state.
//
// This file translates domain tasks into small display-ready value objects
// without importing Flutter or choosing visual assets. UI layers can map these
// tokens to colors, icons, text, or widgets later.
import 'models.dart';
/// Broad display category for an item placed on the Today timeline.
enum TimelineItemCategory {
/// Normal visible task card.
taskCard,
/// Non-task timeline layer such as locked time.
overlay,
}
/// Background style token derived from the scheduling behavior type.
enum TimelineBackgroundToken {
flexible,
inflexible,
critical,
locked,
surprise,
freeSlot,
}
/// Reward icon token for the first timeline card icon.
enum TimelineRewardIconToken {
notSet,
veryLow,
low,
medium,
high,
veryHigh,
}
/// Difficulty icon token for the second timeline card icon.
enum TimelineDifficultyIconToken {
notSet,
veryEasy,
easy,
medium,
hard,
veryHard,
}
/// Quick actions the timeline can expose without depending on a UI framework.
enum TimelineQuickAction {
done,
push,
backlog,
breakUp,
missed,
cancel,
noLongerRelevant,
}
/// Display-ready timeline item derived from a domain [Task].
class TimelineItem {
const TimelineItem({
required this.id,
required this.displayTitle,
required this.taskType,
required this.projectColorToken,
required this.backgroundToken,
required this.rewardIconToken,
required this.difficultyIconToken,
required this.showsExplicitTime,
required this.quickActions,
required this.category,
this.start,
this.end,
this.durationMinutes,
});
/// Stable id of the source item.
final String id;
/// Text shown as the item name.
final String displayTitle;
/// Source scheduling behavior type.
final TaskType taskType;
/// Border/project color token. This is a key, not a raw color.
final String projectColorToken;
/// Translucent background token derived from [taskType].
final TimelineBackgroundToken backgroundToken;
/// First icon token.
final TimelineRewardIconToken rewardIconToken;
/// Second icon token.
final TimelineDifficultyIconToken difficultyIconToken;
/// Whether the item should render explicit start/end time text.
final bool showsExplicitTime;
/// Timeline placement start, if the source item has one.
final DateTime? start;
/// Timeline placement end, if the source item has one.
final DateTime? end;
/// Duration display value for flexible task cards.
final int? durationMinutes;
/// Quick actions available from this item.
final List<TimelineQuickAction> quickActions;
/// Whether this is a normal task card or a non-task overlay.
final TimelineItemCategory category;
}
/// Converts domain tasks into Today timeline items.
class TimelineItemMapper {
const TimelineItemMapper({
this.projectColorTokensById = const <String, String>{},
});
/// Project id to border color token lookup.
final Map<String, String> projectColorTokensById;
/// Build a mapper from project profiles.
factory TimelineItemMapper.fromProjects(Iterable<ProjectProfile> projects) {
return TimelineItemMapper(
projectColorTokensById: {
for (final project in projects) project.id: project.colorKey,
},
);
}
/// Convert [task] into framework-neutral timeline view state.
TimelineItem fromTask(Task task) {
return TimelineItem(
id: task.id,
displayTitle: task.title,
taskType: task.type,
projectColorToken:
projectColorTokensById[task.projectId] ?? task.projectId,
backgroundToken: _backgroundTokenFor(task.type),
rewardIconToken: _rewardIconTokenFor(task.reward),
difficultyIconToken: _difficultyIconTokenFor(task.difficulty),
showsExplicitTime: _showsExplicitTime(task.type),
start: task.scheduledStart,
end: task.scheduledEnd,
durationMinutes: _durationMinutesFor(task),
quickActions: _quickActionsFor(task.type),
category: task.isLocked
? TimelineItemCategory.overlay
: TimelineItemCategory.taskCard,
);
}
bool _showsExplicitTime(TaskType type) {
return type == TaskType.inflexible ||
type == TaskType.critical ||
type == TaskType.locked;
}
int? _durationMinutesFor(Task task) {
if (!task.isFlexible) {
return null;
}
if (task.durationMinutes != null) {
return task.durationMinutes;
}
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return null;
}
return end.difference(start).inMinutes;
}
List<TimelineQuickAction> _quickActionsFor(TaskType type) {
switch (type) {
case TaskType.flexible:
return const [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
];
case TaskType.inflexible:
case TaskType.critical:
return const [
TimelineQuickAction.done,
TimelineQuickAction.missed,
TimelineQuickAction.cancel,
TimelineQuickAction.noLongerRelevant,
];
case TaskType.locked:
case TaskType.surprise:
case TaskType.freeSlot:
return const [];
}
}
TimelineBackgroundToken _backgroundTokenFor(TaskType type) {
switch (type) {
case TaskType.flexible:
return TimelineBackgroundToken.flexible;
case TaskType.inflexible:
return TimelineBackgroundToken.inflexible;
case TaskType.critical:
return TimelineBackgroundToken.critical;
case TaskType.locked:
return TimelineBackgroundToken.locked;
case TaskType.surprise:
return TimelineBackgroundToken.surprise;
case TaskType.freeSlot:
return TimelineBackgroundToken.freeSlot;
}
}
TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) {
switch (reward) {
case RewardLevel.notSet:
return TimelineRewardIconToken.notSet;
case RewardLevel.veryLow:
return TimelineRewardIconToken.veryLow;
case RewardLevel.low:
return TimelineRewardIconToken.low;
case RewardLevel.medium:
return TimelineRewardIconToken.medium;
case RewardLevel.high:
return TimelineRewardIconToken.high;
case RewardLevel.veryHigh:
return TimelineRewardIconToken.veryHigh;
}
}
TimelineDifficultyIconToken _difficultyIconTokenFor(
DifficultyLevel difficulty,
) {
switch (difficulty) {
case DifficultyLevel.notSet:
return TimelineDifficultyIconToken.notSet;
case DifficultyLevel.veryEasy:
return TimelineDifficultyIconToken.veryEasy;
case DifficultyLevel.easy:
return TimelineDifficultyIconToken.easy;
case DifficultyLevel.medium:
return TimelineDifficultyIconToken.medium;
case DifficultyLevel.hard:
return TimelineDifficultyIconToken.hard;
case DifficultyLevel.veryHard:
return TimelineDifficultyIconToken.veryHard;
}
}
}

View file

@ -0,0 +1,156 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Timeline item view model', () {
final createdAt = DateTime(2026, 6, 20, 8);
final start = DateTime(2026, 6, 20, 9);
final end = DateTime(2026, 6, 20, 9, 30);
const project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
final mapper = TimelineItemMapper.fromProjects(const [project]);
test('maps task fields to display tokens', () {
final task = scheduledTask(
id: 'dishes',
title: 'Wash dishes',
projectId: project.id,
type: TaskType.flexible,
reward: RewardLevel.high,
difficulty: DifficultyLevel.hard,
createdAt: createdAt,
start: start,
end: end,
);
final item = mapper.fromTask(task);
expect(item.id, 'dishes');
expect(item.displayTitle, 'Wash dishes');
expect(item.taskType, TaskType.flexible);
expect(item.projectColorToken, 'project-home');
expect(item.backgroundToken, TimelineBackgroundToken.flexible);
expect(item.rewardIconToken, TimelineRewardIconToken.high);
expect(item.difficultyIconToken, TimelineDifficultyIconToken.hard);
expect(item.category, TimelineItemCategory.taskCard);
expect(item.quickActions, [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
]);
});
test('flexible task cards display duration while retaining position', () {
final task = scheduledTask(
id: 'laundry',
title: 'Fold laundry',
projectId: project.id,
type: TaskType.flexible,
durationMinutes: 25,
createdAt: createdAt,
start: start,
end: start.add(const Duration(minutes: 25)),
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isFalse);
expect(item.durationMinutes, 25);
expect(item.start, start);
expect(item.end, start.add(const Duration(minutes: 25)));
});
test('flexible duration can fall back to scheduled interval', () {
final task = scheduledTask(
id: 'admin',
title: 'Handle admin',
projectId: project.id,
type: TaskType.flexible,
createdAt: createdAt,
start: start,
end: start.add(const Duration(minutes: 45)),
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isFalse);
expect(item.durationMinutes, 45);
});
test('inflexible critical and locked items show explicit times', () {
for (final type in [
TaskType.inflexible,
TaskType.critical,
TaskType.locked,
]) {
final task = scheduledTask(
id: type.name,
title: type.name,
projectId: project.id,
type: type,
createdAt: createdAt,
start: start,
end: end,
);
final item = mapper.fromTask(task);
expect(item.showsExplicitTime, isTrue, reason: type.name);
expect(item.start, start, reason: type.name);
expect(item.end, end, reason: type.name);
expect(item.durationMinutes, isNull, reason: type.name);
}
});
test('locked item is an overlay without normal task quick actions', () {
final task = scheduledTask(
id: 'sleep',
title: 'Sleep',
projectId: 'locked',
type: TaskType.locked,
createdAt: createdAt,
start: DateTime(2026, 6, 20, 0),
end: DateTime(2026, 6, 20, 7),
);
final item = mapper.fromTask(task);
expect(item.category, TimelineItemCategory.overlay);
expect(item.projectColorToken, 'locked');
expect(item.backgroundToken, TimelineBackgroundToken.locked);
expect(item.quickActions, isEmpty);
});
});
}
Task scheduledTask({
required String id,
required String title,
required String projectId,
required TaskType type,
required DateTime createdAt,
required DateTime start,
required DateTime end,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
int? durationMinutes,
}) {
return Task(
id: id,
title: title,
projectId: projectId,
type: type,
status: TaskStatus.planned,
reward: reward,
difficulty: difficulty,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: end,
createdAt: createdAt,
updatedAt: createdAt,
);
}