feat(tasks): implement required task state transitions
This commit is contained in:
parent
5b49d4e8a6
commit
00b9347477
3 changed files with 315 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Task> tasks, String taskId) {
|
||||
for (final task in tasks) {
|
||||
|
|
|
|||
180
test/required_task_actions_test.dart
Normal file
180
test/required_task_actions_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue