feat(tasks): implement surprise task logging

This commit is contained in:
Ashley Venn 2026-06-24 11:47:40 -07:00
parent 00b9347477
commit 6612a5629a
7 changed files with 607 additions and 5 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 06 — Task Actions and State Transitions
Status: Planned
Status: Completed
Purpose: Define and implement the safe, low-friction actions available from task cards.
@ -306,7 +306,7 @@ BREAKPOINT: Stop here. Confirm `high` mode before implementing Chunk 6.5 surpris
Recommended Codex level: high
Status: Planned
Status: Completed
Tasks:
@ -335,3 +335,16 @@ Commit suggestion:
```text
feat(tasks): implement surprise task logging
```
Execution notes:
- Added `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, and `SurpriseTaskLogService`.
- Surprise logging creates a completed `TaskType.surprise` task and trims/validates the title.
- Optional fields can be omitted; omitted time data saves an unplaced completed surprise task without blocking the log.
- When start time and time-used are supplied, the surprise task occupies that interval.
- Overlapping planned flexible tasks are moved using the existing `pushFlexibleTaskToNextAvailableSlot` behavior with the surprise interval treated as blocked time.
- Surprise-caused flexible movement increments auto-push statistics rather than manual-push statistics.
- Inflexible and critical overlaps are reported through visible overlap data and are not moved.
- Locked overlaps are tracked separately from normal task/notice output so locked time remains hidden by default.
- Added `test/surprise_task_logging_test.dart` for surprise creation, flexible movement, fixed overlap reporting, locked overlap tracking, optional fields, and cascade movement safety.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.

View file

@ -1,6 +1,7 @@
# Current Software Plan
Execute V1 block documents in numeric order.
Execute active V1 block documents in numeric order. Blocks 01-06 are completed
and archived; the next active block is Block 07.
## Execution rules
@ -30,7 +31,7 @@ Do not infer a new level name.
3. `V1_BLOCK_03_Scheduling_Engine.md`
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
5. `V1_BLOCK_05_Recurring_Locked_Blocks.md`
6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md`
6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` — archived
7. `V1_BLOCK_07_Child_Tasks.md`
8. `V1_BLOCK_08_Today_Timeline_State.md`
9. `V1_BLOCK_09_Persistence_Preparation.md`

View file

@ -40,6 +40,11 @@ the stale filter and green/blue/purple marker. A later MongoDB-backed
persistence pass may add a separate backlog-entered timestamp, but the current
core keeps the timestamp semantics consistent and database-independent.
Surprise task logging is a domain action, not UI behavior. A logged surprise task
is completed immediately. If it has a time interval, overlapping flexible tasks
are pushed by the normal scheduler, required visible overlaps are reported, and
locked overlaps are tracked as hidden data rather than rendered as normal tasks.
## Persistence direction
MongoDB is the committed persistence target. The V1 scheduling core should still

View file

@ -353,6 +353,7 @@ class SchedulingEngine {
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
bool countAsManualPush = true,
}) {
// Resolve the selected task by id so UI code only needs to pass a stable
// identifier, not object references.
@ -425,6 +426,7 @@ class SchedulingEngine {
placement: placement,
pushedTaskMessage: 'Flexible task pushed to next available slot.',
updatedAt: updatedAt ?? DateTime.now(),
countPushedTaskAsManual: countAsManualPush,
);
}
@ -1140,6 +1142,7 @@ SchedulingResult _applyPushPlacement({
required _BacklogInsertionPlan placement,
required String pushedTaskMessage,
required DateTime updatedAt,
bool countPushedTaskAsManual = true,
}) {
final changes = <SchedulingChange>[];
final notices = <SchedulingNotice>[];
@ -1165,7 +1168,7 @@ SchedulingResult _applyPushPlacement({
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
stats: isPushedTask
stats: isPushedTask && countPushedTaskAsManual
? task.stats.incrementManualPush()
: task.stats.incrementAutoPush(),
);

View file

@ -137,6 +137,78 @@ class RequiredTaskActionResult {
final bool removedFromActivePlan;
}
/// Input for logging work the user already did outside the plan.
///
/// The user may know only a title, or may also know when it happened and how
/// long it took. When time data is present, the resulting surprise task occupies
/// that interval and flexible work overlapping it is pushed out of the way.
class SurpriseTaskLogRequest {
const SurpriseTaskLogRequest({
required this.id,
required this.title,
required this.createdAt,
this.startedAt,
this.timeUsedMinutes,
this.projectId = 'inbox',
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
});
/// Caller-generated id for the new surprise task.
final String id;
/// User-entered title.
final String title;
/// Creation/log timestamp supplied by the caller for testability.
final DateTime createdAt;
/// When the surprise work started, if known.
final DateTime? startedAt;
/// Minutes spent on the surprise work, if known.
final int? timeUsedMinutes;
/// Project id to assign; defaults to inbox when omitted.
final String projectId;
/// Optional priority. Null means the user did not set one.
final PriorityLevel? priority;
/// Optional reward estimate.
final RewardLevel reward;
/// Optional difficulty estimate.
final DifficultyLevel difficulty;
}
/// Result from logging a surprise task.
class SurpriseTaskLogResult {
const SurpriseTaskLogResult({
required this.surpriseTask,
required this.schedulingResult,
this.requiredOverlaps = const <SchedulingOverlap>[],
this.lockedOverlaps = const <TimeInterval>[],
this.usedNormalPushRules = false,
});
/// Newly created completed surprise task.
final Task surpriseTask;
/// Updated task list, movement notices, changes, and visible overlaps.
final SchedulingResult schedulingResult;
/// Critical/inflexible overlaps with the surprise interval.
final List<SchedulingOverlap> requiredOverlaps;
/// Locked overlaps tracked separately so they stay hidden by default.
final List<TimeInterval> lockedOverlaps;
/// Whether flexible movement was delegated to normal push behavior.
final bool usedNormalPushRules;
}
/// Applies low-friction quick actions for flexible task cards.
///
/// This service is the adapter between small UI button presses and domain logic.
@ -386,6 +458,150 @@ class RequiredTaskActionService {
}
}
/// Logs surprise completed work and repairs affected flexible tasks.
///
/// Surprise work is already done, so the new task is created as completed. When
/// it has a concrete interval, overlapping planned flexible tasks are moved by
/// the existing push scheduler with the surprise interval treated as blocked
/// time. Required visible and locked time is never moved.
class SurpriseTaskLogService {
const SurpriseTaskLogService({
this.schedulingEngine = const SchedulingEngine(),
});
/// Scheduling dependency used to move overlapping flexible tasks.
final SchedulingEngine schedulingEngine;
/// Log one surprise task against a scheduling snapshot.
SurpriseTaskLogResult log({
required SurpriseTaskLogRequest request,
required SchedulingInput input,
DateTime? updatedAt,
}) {
final now = updatedAt ?? DateTime.now();
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
var currentTasks = <Task>[surpriseTask, ...input.tasks];
if (surpriseInterval == null) {
return SurpriseTaskLogResult(
surpriseTask: surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
notices: const [
SchedulingNotice('Surprise task logged.'),
],
),
);
}
final requiredOverlaps = _requiredOverlaps(
tasks: input.requiredVisibleTasks,
surpriseInterval: surpriseInterval,
);
final lockedOverlaps = _lockedOverlaps(
input: input,
surpriseInterval: surpriseInterval,
);
final flexibleTaskIds = _overlappingFlexibleTaskIds(
input.flexibleTasks,
surpriseInterval,
);
final changes = <SchedulingChange>[];
final movementNotices = <SchedulingNotice>[];
var usedNormalPushRules = false;
for (final taskId in flexibleTaskIds) {
final currentTask = _taskById(currentTasks, taskId);
final currentInterval =
currentTask == null ? null : _scheduledIntervalForTask(currentTask);
if (currentTask == null ||
currentInterval == null ||
!currentInterval.overlaps(surpriseInterval)) {
continue;
}
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: SchedulingInput(
tasks: currentTasks,
window: input.window,
lockedIntervals: [
...input.lockedIntervals,
surpriseInterval,
],
requiredVisibleIntervals: input.requiredVisibleIntervals,
),
taskId: taskId,
updatedAt: now,
countAsManualPush: false,
);
usedNormalPushRules = true;
currentTasks = pushResult.tasks;
changes.addAll(pushResult.changes);
movementNotices.addAll(pushResult.notices);
}
final overlapNotices = requiredOverlaps
.map(
(overlap) => SchedulingNotice(
'Surprise task overlaps required visible time.',
type: SchedulingNoticeType.overlap,
taskId: overlap.taskId,
),
)
.toList(growable: false);
return SurpriseTaskLogResult(
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
notices: List<SchedulingNotice>.unmodifiable([
const SchedulingNotice('Surprise task logged.'),
...movementNotices,
...overlapNotices,
]),
changes: List<SchedulingChange>.unmodifiable(changes),
overlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
),
requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps),
usedNormalPushRules: usedNormalPushRules,
);
}
Task _createSurpriseTask(
SurpriseTaskLogRequest request, {
required DateTime updatedAt,
}) {
final trimmedTitle = request.title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(request.title, 'title', 'Title is required.');
}
final duration = request.timeUsedMinutes;
final start = request.startedAt;
final hasScheduledTime = start != null && duration != null && duration > 0;
return Task(
id: request.id,
title: trimmedTitle,
projectId: request.projectId,
type: TaskType.surprise,
status: TaskStatus.completed,
priority: request.priority,
reward: request.reward,
difficulty: request.difficulty,
durationMinutes: duration != null && duration > 0 ? duration : null,
scheduledStart: hasScheduledTime ? start : null,
scheduledEnd:
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
createdAt: request.createdAt,
updatedAt: updatedAt,
);
}
}
/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
@ -396,3 +612,80 @@ Task? _taskById(List<Task> tasks, String taskId) {
return null;
}
/// Build an interval for a task, or null when it has no usable placement.
TimeInterval? _scheduledIntervalForTask(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null || !start.isBefore(end)) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
/// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({
required List<Task> tasks,
required TimeInterval surpriseInterval,
}) {
final overlaps = <SchedulingOverlap>[];
for (final task in tasks) {
final interval = _scheduledIntervalForTask(task);
if (interval == null || !interval.overlaps(surpriseInterval)) {
continue;
}
overlaps.add(
SchedulingOverlap(
taskId: task.id,
taskInterval: interval,
blockedInterval: surpriseInterval,
),
);
}
return overlaps;
}
/// Locked overlaps with a surprise interval, kept hidden from normal notices.
List<TimeInterval> _lockedOverlaps({
required SchedulingInput input,
required TimeInterval surpriseInterval,
}) {
final lockedIntervals = <TimeInterval>[
...input.lockedIntervals,
...input.lockedTasks
.map(_scheduledIntervalForTask)
.whereType<TimeInterval>(),
];
return lockedIntervals
.where((interval) => interval.overlaps(surpriseInterval))
.toList(growable: false);
}
/// Planned flexible task ids overlapping a surprise interval in timeline order.
List<String> _overlappingFlexibleTaskIds(
List<Task> tasks,
TimeInterval surpriseInterval,
) {
final overlappingTasks = tasks.where((task) {
if (task.status != TaskStatus.planned) {
return false;
}
final interval = _scheduledIntervalForTask(task);
return interval != null && interval.overlaps(surpriseInterval);
}).toList(growable: false)
..sort((a, b) {
final aStart = a.scheduledStart ?? surpriseInterval.end;
final bStart = b.scheduledStart ?? surpriseInterval.end;
return aStart.compareTo(bStart);
});
return overlappingTasks.map((task) => task.id).toList(growable: false);
}

View file

@ -0,0 +1,287 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Surprise task logging', () {
final now = DateTime(2026, 6, 19, 12);
const service = SurpriseTaskLogService();
test('creates a completed surprise task that occupies the logged time', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Answered urgent text',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 20,
projectId: 'relationships',
priority: PriorityLevel.high,
reward: RewardLevel.medium,
),
input: emptyInput(),
updatedAt: now,
);
expect(result.surpriseTask.type, TaskType.surprise);
expect(result.surpriseTask.status, TaskStatus.completed);
expect(result.surpriseTask.title, 'Answered urgent text');
expect(result.surpriseTask.projectId, 'relationships');
expect(result.surpriseTask.priority, PriorityLevel.high);
expect(result.surpriseTask.reward, RewardLevel.medium);
expect(result.surpriseTask.durationMinutes, 20);
expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20));
expect(
result.schedulingResult.tasks.map((task) => task.id),
['surprise-1'],
);
});
test('pushes overlapping flexible tasks using normal push rules', () {
final flexible = plannedFlexibleTask(
id: 'flexible',
title: 'Write notes',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: [flexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
final moved = taskById(result.schedulingResult.tasks, 'flexible');
expect(moved.scheduledStart, DateTime(2026, 6, 19, 9, 30));
expect(moved.scheduledEnd, DateTime(2026, 6, 19, 10));
expect(moved.stats.autoPushedCount, 1);
expect(moved.stats.manuallyPushedCount, 0);
expect(result.schedulingResult.changes.single.taskId, 'flexible');
expect(result.usedNormalPushRules, isTrue);
});
test('does not move inflexible or critical overlaps and reports them', () {
final inflexible = fixedTask(
id: 'appointment',
type: TaskType.inflexible,
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final critical = fixedTask(
id: 'meds',
type: TaskType.critical,
createdAt: now,
start: DateTime(2026, 6, 19, 9, 20),
durationMinutes: 20,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: [inflexible, critical],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
),
updatedAt: now,
);
expect(taskById(result.schedulingResult.tasks, 'appointment'),
sameScheduleAs(inflexible));
expect(taskById(result.schedulingResult.tasks, 'meds'),
sameScheduleAs(critical));
expect(
result.requiredOverlaps.map((overlap) => overlap.taskId),
['appointment', 'meds'],
);
expect(
result.schedulingResult.overlaps.map((overlap) => overlap.taskId),
['appointment', 'meds'],
);
});
test('tracks locked overlap without rendering locked time as a task', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 11),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 10),
label: 'hidden-work',
),
],
),
updatedAt: now,
);
expect(result.lockedOverlaps.single.label, 'hidden-work');
expect(result.schedulingResult.tasks.map((task) => task.type), [
TaskType.surprise,
]);
expect(result.schedulingResult.overlaps, isEmpty);
});
test('optional fields can be omitted without blocking save', () {
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: ' Took a break ',
createdAt: now,
),
input: emptyInput(),
updatedAt: now,
);
expect(result.surpriseTask.title, 'Took a break');
expect(result.surpriseTask.projectId, 'inbox');
expect(result.surpriseTask.priority, isNull);
expect(result.surpriseTask.reward, RewardLevel.notSet);
expect(result.surpriseTask.durationMinutes, isNull);
expect(result.surpriseTask.scheduledStart, isNull);
expect(result.surpriseTask.scheduledEnd, isNull);
expect(result.schedulingResult.tasks.single.id, 'surprise-1');
});
test('does not push the same flexible task twice after cascade movement',
() {
final first = plannedFlexibleTask(
id: 'first',
title: 'First',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final second = plannedFlexibleTask(
id: 'second',
title: 'Second',
createdAt: now,
start: DateTime(2026, 6, 19, 9, 30),
durationMinutes: 30,
);
final result = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9, 10),
timeUsedMinutes: 40,
),
input: SchedulingInput(
tasks: [first, second],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 12),
),
),
updatedAt: now,
);
expect(taskById(result.schedulingResult.tasks, 'first').scheduledStart,
DateTime(2026, 6, 19, 9, 50));
expect(taskById(result.schedulingResult.tasks, 'second').scheduledStart,
DateTime(2026, 6, 19, 10, 20));
expect(
result.schedulingResult.changes.map((change) => change.taskId),
['first', 'second'],
);
});
});
}
SchedulingInput emptyInput() {
return SchedulingInput(
tasks: const [],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 17),
),
);
}
Task plannedFlexibleTask({
required String id,
required String title,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task.quickCapture(
id: id,
title: title,
createdAt: createdAt,
).copyWith(
status: TaskStatus.planned,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
);
}
Task fixedTask({
required String id,
required TaskType type,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task(
id: id,
title: id,
projectId: 'fixed',
type: type,
status: TaskStatus.planned,
priority: PriorityLevel.high,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
createdAt: createdAt,
updatedAt: createdAt,
);
}
Task taskById(List<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}
Matcher sameScheduleAs(Task expected) {
return predicate<Task>((actual) {
return actual.scheduledStart == expected.scheduledStart &&
actual.scheduledEnd == expected.scheduledEnd &&
actual.status == expected.status &&
actual.type == expected.type;
}, 'same schedule and state as ${expected.id}');
}