From 00b9347477c9f6cdd00b16fc3e137ac849beceaa Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 11:38:33 -0700 Subject: [PATCH] feat(tasks): implement required task state transitions --- ..._Task_Actions_State_Transitions_UPDATED.md | 15 +- lib/src/task_actions.dart | 121 ++++++++++++ test/required_task_actions_test.dart | 180 ++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 test/required_task_actions_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md index 8f60499..0f0db51 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md @@ -254,7 +254,7 @@ BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 requi Recommended Codex level: medium -Status: Planned +Status: Completed Tasks: @@ -287,6 +287,19 @@ Commit suggestion: feat(tasks): implement required task state transitions ``` +Execution notes: + +- Added `RequiredTaskAction`, `RequiredTaskActionResult`, and `RequiredTaskActionService`. +- Required visible cards now support explicit done, missed, cancel, and no-longer-relevant transitions. +- Done marks critical/inflexible tasks completed without clearing their schedule history. +- Missed critical tasks use the scheduler miss rule and move to backlog with schedule cleared. +- Missed inflexible tasks remain scheduled and marked missed for history. +- Cancelled and no-longer-relevant are distinct statuses; cancellation increments the cancellation counter. +- The service rejects flexible and locked task types so locked blocks remain outside normal task-card transitions. +- Added `test/required_task_actions_test.dart` for the required-task action rules. +- No Surprise Task Logging implementation was added in this chunk. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + BREAKPOINT: Stop here. Confirm `high` mode before implementing Chunk 6.5 surprise task logging. ## Chunk 6.5 — Surprise task logging diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 9b4ad33..c9cdbbc 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -27,6 +27,25 @@ enum FlexibleTaskQuickAction { breakUp, } +/// State transitions available from required visible task cards. +/// +/// Required visible tasks are critical or inflexible items. They are not moved by +/// flexible scheduling, but the user still needs simple lifecycle actions for +/// what happened to the commitment. +enum RequiredTaskAction { + /// Mark the required task completed. + done, + + /// Mark that the required task did not happen. + missed, + + /// Intentionally remove it from the active plan. + cancel, + + /// Dismiss it because it no longer applies, distinct from cancellation. + noLongerRelevant, +} + /// Explicit push destinations shown after choosing the push quick action. /// /// Push starts as a simple quick action, but the actual destination requires one @@ -96,6 +115,28 @@ class PushDestinationResult { } } +/// Result from applying a required task state transition. +class RequiredTaskActionResult { + const RequiredTaskActionResult({ + required this.action, + required this.task, + required this.removedFromActivePlan, + }); + + /// Action the user selected. + final RequiredTaskAction action; + + /// Updated task after the transition. + final Task task; + + /// Whether the transition removes the task from the active timeline. + /// + /// Critical missed tasks return true because they move to backlog. Cancelled + /// and no-longer-relevant tasks also return true because they are no longer + /// active planned work. + final bool removedFromActivePlan; +} + /// Applies low-friction quick actions for flexible task cards. /// /// This service is the adapter between small UI button presses and domain logic. @@ -265,6 +306,86 @@ class FlexibleTaskActionService { } } +/// Applies lifecycle actions for required visible task cards. +/// +/// This service only handles critical and inflexible tasks. Locked blocks are +/// scheduling constraints, not normal task cards, and flexible tasks use +/// [FlexibleTaskActionService]. +class RequiredTaskActionService { + const RequiredTaskActionService({ + this.schedulingEngine = const SchedulingEngine(), + }); + + /// Scheduling dependency used for required missed behavior. + final SchedulingEngine schedulingEngine; + + /// Apply one required-card state transition. + RequiredTaskActionResult apply({ + required Task task, + required RequiredTaskAction action, + DateTime? updatedAt, + }) { + if (!task.isRequiredVisible) { + throw ArgumentError.value( + task.type, + 'task.type', + 'Task must be critical or inflexible.', + ); + } + + final now = updatedAt ?? DateTime.now(); + + return switch (action) { + RequiredTaskAction.done => RequiredTaskActionResult( + action: action, + task: task.copyWith( + status: TaskStatus.completed, + updatedAt: now, + ), + removedFromActivePlan: false, + ), + RequiredTaskAction.missed => _markMissed( + task: task, + action: action, + updatedAt: now, + ), + RequiredTaskAction.cancel => RequiredTaskActionResult( + action: action, + task: task.copyWith( + status: TaskStatus.cancelled, + updatedAt: now, + stats: task.stats.incrementCancelled(), + clearSchedule: true, + ), + removedFromActivePlan: true, + ), + RequiredTaskAction.noLongerRelevant => RequiredTaskActionResult( + action: action, + task: task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: now, + clearSchedule: true, + ), + removedFromActivePlan: true, + ), + }; + } + + RequiredTaskActionResult _markMissed({ + required Task task, + required RequiredTaskAction action, + required DateTime updatedAt, + }) { + final missed = schedulingEngine.markMissed(task, updatedAt: updatedAt); + + return RequiredTaskActionResult( + action: action, + task: missed, + removedFromActivePlan: missed.status == TaskStatus.backlog, + ); + } +} + /// Find one task by id in a list. Task? _taskById(List tasks, String taskId) { for (final task in tasks) { diff --git a/test/required_task_actions_test.dart b/test/required_task_actions_test.dart new file mode 100644 index 0000000..5e191e9 --- /dev/null +++ b/test/required_task_actions_test.dart @@ -0,0 +1,180 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Required task action service', () { + final now = DateTime(2026, 6, 19, 12); + const service = RequiredTaskActionService(); + + test('done marks inflexible task completed without clearing history', () { + final task = requiredTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + + final result = service.apply( + task: task, + action: RequiredTaskAction.done, + updatedAt: now, + ); + + expect(result.action, RequiredTaskAction.done); + expect(result.task.status, TaskStatus.completed); + expect(result.task.scheduledStart, task.scheduledStart); + expect(result.task.scheduledEnd, task.scheduledEnd); + expect(result.removedFromActivePlan, isFalse); + }); + + test('done marks critical task completed without moving it', () { + final task = requiredTask( + id: 'meds', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 10), + ); + + final result = service.apply( + task: task, + action: RequiredTaskAction.done, + updatedAt: now, + ); + + expect(result.task.status, TaskStatus.completed); + expect(result.task.scheduledStart, task.scheduledStart); + expect(result.task.scheduledEnd, task.scheduledEnd); + }); + + test('missed critical task moves to backlog and clears schedule', () { + final task = requiredTask( + id: 'critical', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + + final result = service.apply( + task: task, + action: RequiredTaskAction.missed, + updatedAt: now, + ); + + expect(result.task.status, TaskStatus.backlog); + expect(result.task.scheduledStart, isNull); + expect(result.task.scheduledEnd, isNull); + expect(result.task.stats.missedCount, 1); + expect(result.task.stats.movedToBacklogCount, 1); + expect(result.removedFromActivePlan, isTrue); + }); + + test('missed inflexible task remains scheduled as history', () { + final task = requiredTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + + final result = service.apply( + task: task, + action: RequiredTaskAction.missed, + updatedAt: now, + ); + + expect(result.task.status, TaskStatus.missed); + expect(result.task.scheduledStart, task.scheduledStart); + expect(result.task.scheduledEnd, task.scheduledEnd); + expect(result.task.stats.missedCount, 1); + expect(result.removedFromActivePlan, isFalse); + }); + + test('cancel and no-longer-relevant remain distinct states', () { + final task = requiredTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + + final cancelled = service.apply( + task: task, + action: RequiredTaskAction.cancel, + updatedAt: now, + ); + final noLongerRelevant = service.apply( + task: task, + action: RequiredTaskAction.noLongerRelevant, + updatedAt: now, + ); + + expect(cancelled.task.status, TaskStatus.cancelled); + expect(cancelled.task.scheduledStart, isNull); + expect(cancelled.task.scheduledEnd, isNull); + expect(cancelled.task.stats.cancelledCount, 1); + expect(cancelled.removedFromActivePlan, isTrue); + + expect(noLongerRelevant.task.status, TaskStatus.noLongerRelevant); + expect(noLongerRelevant.task.scheduledStart, isNull); + expect(noLongerRelevant.task.scheduledEnd, isNull); + expect(noLongerRelevant.task.stats.cancelledCount, 0); + expect(noLongerRelevant.removedFromActivePlan, isTrue); + }); + + test('required action service rejects flexible tasks', () { + final task = Task.quickCapture( + id: 'flexible', + title: 'Flexible', + createdAt: now, + ).copyWith(status: TaskStatus.planned); + + expect( + () => service.apply( + task: task, + action: RequiredTaskAction.done, + updatedAt: now, + ), + throwsArgumentError, + ); + }); + + test('required action service rejects locked tasks', () { + final task = requiredTask( + id: 'locked', + type: TaskType.locked, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + ); + + expect( + () => service.apply( + task: task, + action: RequiredTaskAction.missed, + updatedAt: now, + ), + throwsArgumentError, + ); + }); + }); +} + +Task requiredTask({ + required String id, + required TaskType type, + required DateTime createdAt, + required DateTime start, +}) { + return Task( + id: id, + title: id, + projectId: 'required', + type: type, + status: TaskStatus.planned, + priority: PriorityLevel.high, + durationMinutes: 30, + scheduledStart: start, + scheduledEnd: start.add(const Duration(minutes: 30)), + createdAt: createdAt, + updatedAt: createdAt, + ); +}