forked from eva/focus-flow
feat(tasks): implement surprise task logging
This commit is contained in:
parent
00b9347477
commit
6612a5629a
7 changed files with 607 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 06 — Task Actions and State Transitions
|
# 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.
|
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
|
Recommended Codex level: high
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
|
|
@ -335,3 +335,16 @@ Commit suggestion:
|
||||||
```text
|
```text
|
||||||
feat(tasks): implement surprise task logging
|
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.
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Current Software Plan
|
# 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
|
## Execution rules
|
||||||
|
|
||||||
|
|
@ -30,7 +31,7 @@ Do not infer a new level name.
|
||||||
3. `V1_BLOCK_03_Scheduling_Engine.md`
|
3. `V1_BLOCK_03_Scheduling_Engine.md`
|
||||||
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
||||||
5. `V1_BLOCK_05_Recurring_Locked_Blocks.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`
|
7. `V1_BLOCK_07_Child_Tasks.md`
|
||||||
8. `V1_BLOCK_08_Today_Timeline_State.md`
|
8. `V1_BLOCK_08_Today_Timeline_State.md`
|
||||||
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
||||||
|
|
|
||||||
|
|
@ -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
|
persistence pass may add a separate backlog-entered timestamp, but the current
|
||||||
core keeps the timestamp semantics consistent and database-independent.
|
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
|
## Persistence direction
|
||||||
|
|
||||||
MongoDB is the committed persistence target. The V1 scheduling core should still
|
MongoDB is the committed persistence target. The V1 scheduling core should still
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ class SchedulingEngine {
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
bool countAsManualPush = true,
|
||||||
}) {
|
}) {
|
||||||
// Resolve the selected task by id so UI code only needs to pass a stable
|
// Resolve the selected task by id so UI code only needs to pass a stable
|
||||||
// identifier, not object references.
|
// identifier, not object references.
|
||||||
|
|
@ -425,6 +426,7 @@ class SchedulingEngine {
|
||||||
placement: placement,
|
placement: placement,
|
||||||
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? DateTime.now(),
|
||||||
|
countPushedTaskAsManual: countAsManualPush,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1140,6 +1142,7 @@ SchedulingResult _applyPushPlacement({
|
||||||
required _BacklogInsertionPlan placement,
|
required _BacklogInsertionPlan placement,
|
||||||
required String pushedTaskMessage,
|
required String pushedTaskMessage,
|
||||||
required DateTime updatedAt,
|
required DateTime updatedAt,
|
||||||
|
bool countPushedTaskAsManual = true,
|
||||||
}) {
|
}) {
|
||||||
final changes = <SchedulingChange>[];
|
final changes = <SchedulingChange>[];
|
||||||
final notices = <SchedulingNotice>[];
|
final notices = <SchedulingNotice>[];
|
||||||
|
|
@ -1165,7 +1168,7 @@ SchedulingResult _applyPushPlacement({
|
||||||
scheduledStart: interval.start,
|
scheduledStart: interval.start,
|
||||||
scheduledEnd: interval.end,
|
scheduledEnd: interval.end,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
stats: isPushedTask
|
stats: isPushedTask && countPushedTaskAsManual
|
||||||
? task.stats.incrementManualPush()
|
? task.stats.incrementManualPush()
|
||||||
: task.stats.incrementAutoPush(),
|
: task.stats.incrementAutoPush(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,78 @@ class RequiredTaskActionResult {
|
||||||
final bool removedFromActivePlan;
|
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.
|
/// 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.
|
||||||
|
|
@ -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.
|
/// 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) {
|
||||||
|
|
@ -396,3 +612,80 @@ Task? _taskById(List<Task> tasks, String taskId) {
|
||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
287
test/surprise_task_logging_test.dart
Normal file
287
test/surprise_task_logging_test.dart
Normal 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}');
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue