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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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