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