feat(tasks): implement required task state transitions

This commit is contained in:
Ashley Venn 2026-06-24 11:38:33 -07:00
parent 5b49d4e8a6
commit 00b9347477
3 changed files with 315 additions and 1 deletions

View file

@ -254,7 +254,7 @@ BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 requi
Recommended Codex level: medium Recommended Codex level: medium
Status: Planned Status: Completed
Tasks: Tasks:
@ -287,6 +287,19 @@ Commit suggestion:
feat(tasks): implement required task state transitions 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. BREAKPOINT: Stop here. Confirm `high` mode before implementing Chunk 6.5 surprise task logging.
## Chunk 6.5 — Surprise task logging ## Chunk 6.5 — Surprise task logging

View file

@ -27,6 +27,25 @@ enum FlexibleTaskQuickAction {
breakUp, 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. /// Explicit push destinations shown after choosing the push quick action.
/// ///
/// Push starts as a simple quick action, but the actual destination requires one /// 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. /// Applies low-friction quick actions for flexible task cards.
/// ///
/// This service is the adapter between small UI button presses and domain logic. /// 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. /// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) { Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) { for (final task in tasks) {

View file

@ -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,
);
}