diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md index 8dc9588..2159d78 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_08_Today_Timeline_State.md @@ -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 diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md b/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md index 8dc9588..2159d78 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_08_Today_Timeline_State.md @@ -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 diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index dd9f472..f001895 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -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'; diff --git a/lib/src/timeline_state.dart b/lib/src/timeline_state.dart new file mode 100644 index 0000000..c01e177 --- /dev/null +++ b/lib/src/timeline_state.dart @@ -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 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 {}, + }); + + /// Project id to border color token lookup. + final Map projectColorTokensById; + + /// Build a mapper from project profiles. + factory TimelineItemMapper.fromProjects(Iterable 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 _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; + } + } +} diff --git a/test/timeline_state_test.dart b/test/timeline_state_test.dart new file mode 100644 index 0000000..9768ad8 --- /dev/null +++ b/test/timeline_state_test.dart @@ -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, + ); +}