feat(scheduling): add starter domain and placement engine

This commit is contained in:
Ashley Venn 2026-06-19 15:45:11 -07:00
parent 67c8c846c5
commit 26db079846
28 changed files with 3062 additions and 17 deletions

29
.gitignore vendored
View file

@ -1,21 +1,18 @@
# See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub
# Dart
.dart_tool/
.packages
build/
# If you're building an application, you may want to check-in your pubspec.lock
pubspec.lock
build/
coverage/
# Directory created by dartdoc
# If you don't generate documentation locally you can remove this line.
doc/api/
# IDE
.idea/
.vscode/
*.iml
# Avoid committing generated Javascript files:
*.dart.js
*.info.json # Produced by the --dump-info flag.
*.js # When generated by dart2js. Don't specify *.js if your
# project includes source files written in JavaScript.
*.js_
*.js.deps
*.js.map
# OS
.DS_Store
Thumbs.db
# Generated archives
*.zip

151
AGENTS.md Normal file
View file

@ -0,0 +1,151 @@
# AGENTS.md — Codex Project Rules
This file is persistent project guidance. Codex must treat it as standing instructions for this repository.
## User and product context
The product owner is a software engineer building a personal scheduling app for difficult ADHD/executive-dysfunction scheduling problems. The app must reduce planning friction, preserve task order, prevent forgotten tasks, and recover gracefully when the user misses work, burns out, or cannot manually reorganize a plan.
Design priorities:
1. Low-friction interactions.
2. Reliable scheduling behavior.
3. No shame, guilt, punishment, or productivity-slogan framing.
4. Preserve flexible task order unless the user explicitly chooses otherwise.
5. Never move locked or inflexible time during automatic rescheduling.
6. Keep hidden locked time hidden by default.
7. Make every scheduling rule testable.
8. Prefer explicit state transitions over vague magic behavior.
9. Do not add non-MVP features unless the active plan asks for them.
10. Update documentation when behavior changes.
The user prefers practical, concrete implementation work. Avoid broad redesigns unless the active plan calls for them.
## Documentation structure rules
Human-facing design/product documentation belongs in:
```text
Human Documentation/
```
Codex execution planning belongs in:
```text
Codex Documentation/
```
Current, active implementation plans belong in:
```text
Codex Documentation/Current Software Plan/
```
Completed plans must be moved to:
```text
Codex Documentation/Archived plans/
```
This is firm. New plans may be uploaded, generated, or pointed at from elsewhere, but once Codex completes a plan, it must move the completed plan document into `Codex Documentation/Archived plans/`. Do not leave completed plans in `Current Software Plan/`.
## Planning document execution rules
Planning documents are organized into blocks, chunks, and optional stages.
- Blocks are groups of related work.
- Chunks are actionable pieces of work inside a block.
- Stages may be used inside larger chunks when helpful.
- Blocks do **not** receive a Codex thinking-level classification.
- Chunks and stages must include one of these classifications:
- `low`
- `medium`
- `high`
- `extra high`
Codex should rely on the planning documents as much as possible to avoid excess token use. Before doing exploratory work, check whether the current plan already answers the question.
## Break point rules
Planning documents may contain `BREAKPOINT` markers.
When the user says to do the next chunk:
1. Execute only the next permitted chunk or stage.
2. Do not proceed past a `BREAKPOINT`.
3. If the next chunk or stage has a different recommended thinking level, stop and tell the user the next required level.
4. Wait for user confirmation that the mode has been switched before continuing.
A breakpoint is a hard stop. Do not skip it because the next task seems small.
## Commit rules
When finishing a work block or a clearly bounded chunk that leaves the project in a working state:
1. Run relevant formatting/analyze/tests when possible.
2. Commit the work.
3. Use a descriptive conventional commit message.
Examples:
```text
feat(scheduling): add flexible task push behavior
fix(backlog): preserve stale age marker after task update
test(engine): cover locked block overlap rules
docs(plan): archive completed foundation block
```
Do not make vague commits such as `update files`, `changes`, or `work`.
## Engineering rules
- Keep scheduling/domain logic separate from UI.
- Start with pure Dart domain logic and tests.
- Add Flutter UI only when the plan calls for it.
- Avoid network/sync/background behavior unless explicitly planned.
- Prefer immutable models or copy/update patterns.
- Avoid hidden side effects in scheduling functions.
- Every rule that changes task placement should have tests.
- Use clear names over clever abstractions.
- If a rule is ambiguous, add a TODO comment and a small safe default rather than inventing broad behavior.
## MVP boundaries
MVP includes:
- Today view data model.
- Backlog/wishlist model.
- Quick capture to backlog.
- Optional quick-capture scheduling into next available flexible slot.
- Flexible/inflexible/critical/locked/surprise task types.
- Recurring locked blocks, hidden by default.
- One-day overrides for locked blocks.
- Flexible task push behavior.
- Push options: next available slot, tomorrow/top of queue, backlog.
- End-of-day rollover notice.
- Manual compact mode state.
- Task actions: done, push, backlog, break up.
- Child task ownership and parent auto-completion logic.
- Internal statistics needed for future reporting.
MVP excludes unless explicitly added:
- Week view.
- Month view.
- Weekly reports.
- Overwhelm shield.
- Drag-and-drop reordering.
- Per-task history panel.
- Task dependencies.
- Context tags.
- Full sync.
- Long-running task auto-extension behavior.
## UX language rules
Use calm, non-punitive terminology:
- Prefer: `missed`, `pushed`, `moved to backlog`, `no longer relevant`, `cancelled`.
- Avoid: `failed`, `late again`, `bad`, `unproductive`, `overdue pile`, `punishment`.
The app should treat disruption as expected, not exceptional.

View file

@ -0,0 +1,10 @@
# Archived plans
Completed Codex implementation plans go here.
Rules:
- Move completed plans here from `Codex Documentation/Current Software Plan/`.
- Do not delete completed plans.
- Commit the archive move with a conventional commit message.
- If a completed plan later needs revision, create a new current plan rather than editing archived history unless the user explicitly asks.

View file

@ -0,0 +1,102 @@
# V1 Block 01 — Project Foundation
Status: Completed
Purpose: Establish a clean Dart package foundation, repository conventions, and starter test loop. This block should leave the repository easy for Codex to modify safely.
## Chunk 1.1 — Verify project scaffold
Recommended Codex level: low
Status: Completed with environment blocker noted
Tasks:
- Inspect `pubspec.yaml`, `analysis_options.yaml`, `README.md`, and `.gitignore`.
- Confirm the repo is a pure Dart package, not a Flutter app yet.
- Confirm `lib/scheduler_core.dart` exports only stable public entry points.
- Confirm `lib/src/` contains implementation files.
- Confirm `test/` contains at least one starter test.
Acceptance criteria:
- `dart pub get` succeeds, or any dependency issue is documented.
- `dart analyze` succeeds, or initial scaffold issues are fixed.
- `dart test` succeeds, or the failure is fixed.
Execution notes:
- Verified `pubspec.yaml`, `analysis_options.yaml`, `README.md`, and `.gitignore`.
- Confirmed the repository is currently structured as a pure Dart package, not a Flutter app.
- Confirmed `lib/scheduler_core.dart` exports the starter public entry points from `lib/src/`.
- Confirmed `lib/src/` contains implementation files and `test/` contains a starter scheduling engine test.
- Could not run `dart pub get`, `dart analyze`, or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
Commit suggestion:
```text
chore(project): verify starter dart scaffold
```
## Chunk 1.2 — Stabilize public API boundaries
Recommended Codex level: medium
Status: Completed with environment blocker noted
Tasks:
- Keep public exports in `lib/scheduler_core.dart` minimal.
- Ensure internal helpers remain under `lib/src/`.
- Add comments that identify which APIs are starter placeholders.
- Do not add Flutter UI code in this block.
Acceptance criteria:
- Public API is small and understandable.
- Internal implementation is not accidentally exported beyond current needs.
- Analyzer and tests pass.
Execution notes:
- Kept `lib/scheduler_core.dart` exports limited to the existing starter model, scheduling engine, and statistics surfaces.
- Confirmed implementation remains under `lib/src/`.
- Added public documentation comments that identify the starter placeholder APIs without changing scheduling behavior.
- Did not add Flutter UI code.
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
BREAKPOINT: Stop here if the next requested chunk changes Codex level.
## Chunk 1.3 — Document local development commands
Recommended Codex level: low
Status: Completed with environment blocker noted
Tasks:
- Update `README.md` with setup, analyze, test, and project layout notes if missing.
- Do not duplicate the full product spec in README.
- Keep human/product detail in `Human Documentation/`.
- Keep execution detail in `Codex Documentation/`.
Acceptance criteria:
- README tells a new contributor how to run checks.
- README points Codex/humans to the correct documentation folders.
Execution notes:
- Updated `README.md` with the Dart SDK prerequisite, local setup/check commands, and when to run them.
- Added documentation boundary notes for `Human Documentation/`, `Codex Documentation/Current Software Plan/`, and `Codex Documentation/Archived plans/`.
- Did not duplicate the full product spec in `README.md`.
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
Commit suggestion:
```text
docs(project): document starter development workflow
```

View file

@ -0,0 +1,172 @@
# V1 Block 02 — Domain Model
Status: Completed
Purpose: Define the core data vocabulary for the scheduling app. This model should be UI-independent and testable.
## Chunk 2.1 — Define core enums
Recommended Codex level: medium
Status: Completed with environment blocker noted
Tasks:
Add or verify enums for:
- `TaskType`: flexible, inflexible, critical, locked, surprise, freeSlot.
- `TaskStatus`: planned, active, completed, missed, cancelled, noLongerRelevant, backlog.
- `PriorityLevel`: veryLow, low, medium, high, veryHigh.
- `RewardLevel`: veryLow, low, medium, high, veryHigh, notSet.
- `DifficultyLevel`: veryEasy, easy, medium, hard, veryHard, notSet.
- `ReminderProfile`: silent, gentle, persistent, strict.
Rules:
- Reward `notSet` is its own category and must not be treated as low.
- Difficulty may have `notSet` for raw capture/default states.
- Critical and inflexible are required visible items.
- Locked blocks are hidden scheduling constraints, not ordinary tasks.
Acceptance criteria:
- Enums exist in the domain model.
- Comments document the business meaning of each value.
- Tests can import and use the enum values.
Execution notes:
- Verified all required enum values exist in `lib/src/models.dart`.
- Added business-meaning documentation comments for status, priority, reward, difficulty, and reminder values.
- Preserved `RewardLevel.notSet` and `DifficultyLevel.notSet` as neutral capture/default states.
- Added a test that imports and references core enum values through the public package export.
## Chunk 2.2 — Define task identity and fields
Recommended Codex level: high
Status: Completed with environment blocker noted
Tasks:
Create or refine a `Task` model with fields for:
- `id`
- `title`
- `projectId`
- `type`
- `status`
- `priority`
- `reward`
- `difficulty`
- `durationMinutes`
- `scheduledStart`
- `scheduledEnd`
- `parentTaskId`
- `createdAt`
- `updatedAt`
- `stats`
Rules:
- Use a copy/update pattern rather than mutating shared state unexpectedly.
- Allow optional fields for quick capture.
- Do not require every task to have reward/difficulty/time.
- Include validation helper(s) only if needed.
Acceptance criteria:
- A task can represent backlog, planned, critical, locked, and surprise items.
- A quick-capture task can be created with minimal fields.
- Tests cover default/neutral quick-capture values.
Execution notes:
- Kept the immutable `Task` model and `copyWith` update pattern.
- Added `Task.quickCapture` for minimal capture with neutral defaults and no required schedule details.
- Added tests for quick-capture defaults, including `RewardLevel.notSet`, `DifficultyLevel.notSet`, and absent schedule/duration.
BREAKPOINT: Stop here. The next chunk remains high but may expand model scope.
## Chunk 2.3 — Define project and project defaults
Recommended Codex level: high
Status: Completed with environment blocker noted
Tasks:
Create a minimal `ProjectProfile` or equivalent model with:
- `id`
- `name`
- `colorKey` or theme token
- default priority
- default reward
- default difficulty
- default reminder profile
- default duration minutes
Rules:
- Project defaults are suggestions/defaults, not hard requirements.
- Individual tasks can override project defaults.
- Learned usage statistics are future-facing; do not overbuild now.
Acceptance criteria:
- Tasks can reference a project.
- Project defaults can be applied when creating a task.
- Overrides remain possible.
Execution notes:
- Kept `ProjectProfile` minimal with id, name, color key, default priority, default reward, default difficulty, default reminder profile, and default duration.
- Added `ProjectProfile.createTask` to apply project defaults while allowing explicit task overrides.
- Added tests for default application and per-task overrides.
## Chunk 2.4 — Define task statistics model
Recommended Codex level: medium
Status: Completed with environment blocker noted
Tasks:
Add or verify a `TaskStatistics` model with counters for:
- skippedDuringBurnoutCount
- manuallyPushedCount
- autoPushedCount
- movedToBacklogCount
- restoredFromBacklogCount
- missedCount
- cancelledCount
- completedLateCount
- completedDuringLockedHoursCount
- completedDuringLockedHoursMinutes
Rules:
- Stats are internal metadata.
- Stats should support future filtering/reporting.
- Do not build report UI in V1.
Acceptance criteria:
- Statistics can be incremented through copy/update helpers.
- Tests cover at least one increment path.
Execution notes:
- Verified all required statistics counters exist.
- Added immutable increment helpers for remaining counters.
- Added a test covering a statistics increment path and preservation of existing values.
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
Commit suggestion:
```text
feat(domain): define scheduling task model
```

View file

@ -0,0 +1,37 @@
# Current Software Plan
Execute V1 block documents in numeric order.
## Execution rules
1. Read `AGENTS.md` first.
2. Read the relevant block document.
3. Execute only the next chunk/stage requested by the user.
4. Respect all `BREAKPOINT` markers.
5. If the next chunk/stage changes recommended Codex level, stop and ask the user to confirm they switched mode.
6. Commit completed work blocks/chunks with conventional commits.
7. Move completed plan files to `Codex Documentation/Archived plans/` when the plan is complete.
## Recommended mode labels
Chunks and stages use:
- `low`
- `medium`
- `high`
- `extra high`
Do not infer a new level name.
## V1 plan index
1. `V1_BLOCK_01_Project_Foundation.md`
2. `V1_BLOCK_02_Domain_Model.md`
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.md`
7. `V1_BLOCK_07_Child_Tasks.md`
8. `V1_BLOCK_08_Today_Timeline_State.md`
9. `V1_BLOCK_09_Persistence_Preparation.md`
10. `V1_BLOCK_10_Testing_Documentation_Handoff.md`

View file

@ -0,0 +1,151 @@
# V1 Block 03 — Scheduling Engine
Status: Planned
Purpose: Implement the core behavior that makes this product valuable: flexible tasks move safely without destroying the user's intended order.
## Chunk 3.1 — Define scheduling inputs and outputs
Recommended Codex level: high
Status: Completed with environment blocker noted
Tasks:
Create clear models/functions for scheduling operations:
- Current task list.
- Locked intervals.
- Required visible intervals.
- Flexible intervals/tasks.
- Scheduling window.
- Operation result with changed tasks and notices.
Rules:
- Scheduling functions should be deterministic.
- Avoid direct database access inside the engine.
- Return results; do not hide side effects.
Acceptance criteria:
- Scheduling engine can be called with in-memory data.
- Result object can describe moved tasks and overlaps.
- Tests can assert exact task placement results.
Execution notes:
- Added `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, and `SchedulingOverlap` models.
- Expanded `SchedulingResult` and `SchedulingNotice` so operation results can report changed task placements, overlap details, and typed notices.
- Added `SchedulingEngine.analyzeSchedule` for deterministic in-memory schedule analysis without moving tasks or touching persistence.
- Added tests for in-memory task grouping, overlap reporting, and exact movement-change representation.
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
## Chunk 3.2 — Insert backlog task into next available flexible slot
Recommended Codex level: extra high
Status: Completed with environment blocker noted
Tasks:
Implement insertion behavior:
- A backlog item can be inserted into the soonest flexible slot where it fits.
- Later flexible tasks shift forward using normal bump rules.
- Locked, inflexible, and critical blocks do not move.
- If no slot exists today, return a clear overflow result.
Acceptance criteria:
- Test: insert 15-minute task into an open 20-minute slot.
- Test: insert task before flexible task and push later flexible task.
- Test: insertion skips locked time.
- Test: insertion does not move inflexible/critical blocks.
- Test: no available slot returns an overflow/no-fit result.
Execution notes:
- Added `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot`.
- Backlog insertion now requires a flexible backlog task with positive duration.
- The insertion planner schedules the task into the earliest available in-window slot, skips locked/required visible time, and preserves planned flexible task order while pushing affected flexible tasks later.
- Locked, inflexible, critical, and non-planned scheduled flexible intervals are treated as fixed for this operation.
- Successful insertion returns updated tasks, movement notices, and exact `SchedulingChange` records.
- No-fit and overflow cases return the original task list with a typed notice.
- Added tests for open-slot insertion, bumping a later flexible task, skipping locked time, preserving inflexible/critical blocks, and overflow/no-fit.
- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment.
- Commit was deferred until the repository was initialized and repaired.
BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if not already set.
## Chunk 3.3 — Push flexible task to next available slot
Recommended Codex level: extra high
Tasks:
Implement push behavior:
- `Push -> Next available slot` moves the selected flexible task to the next slot where it fits.
- Later flexible tasks shift forward.
- Preserve order among affected flexible tasks.
- Do not move locked, inflexible, or critical blocks.
Acceptance criteria:
- Test: pushed flexible task moves after current slot.
- Test: downstream flexible tasks are bumped in order.
- Test: locked intervals are respected.
- Test: required items remain fixed and visible.
## Chunk 3.4 — Push flexible task to tomorrow top of queue
Recommended Codex level: high
Tasks:
Implement tomorrow behavior:
- `Push -> Tomorrow` places the task at the top of tomorrow's flexible queue.
- Preserve order among multiple rolled/pushed tasks where applicable.
- Do not require manual scheduling.
Acceptance criteria:
- Test: task receives tomorrow date/queue marker.
- Test: task is before other flexible tomorrow tasks unless required/locked time blocks it.
BREAKPOINT: Stop here. The next chunk drops from high/extra high to medium.
## Chunk 3.5 — End-of-day rollover
Recommended Codex level: medium
Tasks:
Implement rollover behavior:
- At end of day, unfinished flexible tasks move to tomorrow.
- Rolled tasks go to top of tomorrow's flexible queue.
- Preserve order among rolled-over tasks.
- Produce a small notice such as `3 unfinished flexible tasks were moved to tomorrow`.
Do not roll over:
- Locked blocks.
- Inflexible tasks.
- Critical tasks without explicit missed/backlog handling.
- Cancelled tasks.
- Completed tasks.
Acceptance criteria:
- Tests cover all included and excluded task types.
- Notice text/count is generated.
Commit suggestion:
```text
feat(scheduling): implement flexible task movement rules
```

View file

@ -0,0 +1,101 @@
# V1 Block 04 — Backlog and Quick Capture
Status: Planned
Purpose: Support low-friction task capture and a unified backlog/wishlist model.
## Chunk 4.1 — Backlog model and filters
Recommended Codex level: medium
Tasks:
Implement backlog-friendly task metadata:
- unified backlog storage/state
- filter tags or derived filters for inbox, pushed, critical missed, wishlist, stale, no reward set
- sorting keys for priority, reward vs effort, age, project, times pushed
Rules:
- Do not create separate hard backlog sections.
- Use unified backlog with filters/sorts.
- Do not preserve original schedule/order metadata when moved to backlog.
Acceptance criteria:
- Backlog tasks can be filtered by derived category.
- Moving to backlog clears active schedule placement.
- Tests verify original schedule order is not used to restore backlog items.
## Chunk 4.2 — Quick capture defaults
Recommended Codex level: medium
Tasks:
Implement quick capture creation rules:
- Title is required.
- Default destination is backlog.
- Priority defaults to medium.
- Reward defaults to notSet.
- Difficulty may default to notSet or medium depending on model decision.
- Project defaults to Inbox/Unsorted.
- Time can remain unset.
Acceptance criteria:
- Test: minimal quick capture creates backlog task.
- Test: neutral defaults are applied.
- Test: reward notSet is preserved distinctly.
## Chunk 4.3 — Quick capture schedule checkbox behavior
Recommended Codex level: high
Tasks:
Implement behavior for `Add to next available schedule slot`:
- When unchecked, task enters backlog.
- When checked, optional scheduling fields become relevant:
- duration
- priority
- reward
- project
- flexibility
- If duration is missing, return a validation result or use a documented default.
- Insert into soonest flexible slot using the scheduling engine.
Acceptance criteria:
- Test: unchecked quick capture goes to backlog.
- Test: checked quick capture with duration schedules into next available slot.
- Test: checked quick capture without required scheduling fields is handled predictably.
BREAKPOINT: Stop here. Confirm `high` mode before implementing scheduling integration.
## Chunk 4.4 — Backlog staleness marker calculation
Recommended Codex level: low
Tasks:
Implement age/staleness calculation for backlog display:
- Green: 07 days by default.
- Blue: 830 days by default.
- Purple: 31+ days by default.
- Thresholds must be configurable constants/settings model, not hardcoded everywhere.
- No automatic prompts per stale task.
Acceptance criteria:
- Tests cover fresh, aging, stale, and configurable thresholds.
Commit suggestion:
```text
feat(backlog): add unified backlog and quick capture rules
```

View file

@ -0,0 +1,99 @@
# V1 Block 05 — Recurring Locked Blocks
Status: Planned
Purpose: Locked blocks are critical MVP. They reserve time, stay hidden by default, and are not tasks.
## Chunk 5.1 — Locked block model
Recommended Codex level: high
Tasks:
Create a model for locked blocks with:
- id
- name
- start/end time
- recurrence rule or simple recurrence model
- hiddenByDefault flag
- project/category optional field
- created/updated timestamps
Rules:
- Locked blocks are scheduling constraints.
- They must not render as ordinary tasks.
- They may have names in overlay mode.
Acceptance criteria:
- Model supports one-off and recurring locked blocks.
- Work-hours style recurrence can be represented.
## Chunk 5.2 — One-day override model
Recommended Codex level: high
Tasks:
Support one-day overrides for recurring locked blocks:
- remove a locked occurrence for a date
- shorten/extend an occurrence for a date
- add surprise locked time for a date
- avoid changing the base recurrence rule
Acceptance criteria:
- Test: Friday off removes only Friday occurrence.
- Test: half-day modifies only one date.
- Test: recurring rule remains unchanged.
BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override expansion.
## Chunk 5.3 — Expand locked blocks into schedule constraints
Recommended Codex level: extra high
Tasks:
Implement expansion from recurring locked blocks + overrides into concrete intervals for a day.
Rules:
- Hidden-by-default affects UI only, not scheduling.
- The scheduling engine must always respect locked intervals.
- Overlay mode can reveal names and times.
Acceptance criteria:
- Test: recurring work block appears on weekdays.
- Test: one-day override removes/modifies the occurrence.
- Test: scheduling engine sees the final locked intervals.
## Chunk 5.4 — Track completed during locked hours
Recommended Codex level: medium
Tasks:
Add helper logic to detect when a completed/surprise task overlaps locked time.
Track:
- completedDuringLockedHoursCount
- completedDuringLockedHoursMinutes
- projects completed during locked hours, if project stats exist
Acceptance criteria:
- Tests cover task completed entirely inside locked time.
- Tests cover partial overlap.
- Tests cover no overlap.
Commit suggestion:
```text
feat(locked-time): support recurring hidden scheduling blocks
```

View file

@ -0,0 +1,108 @@
# V1 Block 06 — Task Actions and State Transitions
Status: Planned
Purpose: Define and implement the safe, low-friction actions available from task cards.
## Chunk 6.1 — Flexible task quick actions
Recommended Codex level: medium
Tasks:
Support these quick actions for flexible task cards:
- Done
- Push
- Backlog
- Break up
Rules:
- Done marks completed.
- Push opens simple push destinations.
- Backlog moves to unified backlog and does not preserve original schedule order.
- Break up starts child task flow; full implementation in Block 07.
Acceptance criteria:
- Tests cover done and backlog state transitions.
- Push destination selection is represented in domain layer.
## Chunk 6.2 — Push destination behavior
Recommended Codex level: high
Tasks:
Implement push destinations:
- Next available slot.
- Tomorrow/top of queue.
- Backlog.
Explicitly exclude:
- Later today.
Acceptance criteria:
- Test: next available uses scheduling engine.
- Test: tomorrow sets tomorrow/top-of-queue metadata.
- Test: backlog clears schedule placement.
BREAKPOINT: Stop here. Confirm `high` mode before integrating push behavior.
## Chunk 6.3 — Required task states
Recommended Codex level: medium
Tasks:
Implement required task state meanings:
- Done: completed.
- Missed: did not happen; visible as missed but not aggressively styled.
- Cancel: did not/will not happen; remove from active plan.
- No longer relevant: separate from cancelled.
Rules:
- Critical missed tasks move to backlog.
- Required/inflexible missed tasks remain in place/history and are marked missed.
- Cancelled and noLongerRelevant are distinct statuses.
Acceptance criteria:
- Tests cover critical missed to backlog.
- Tests cover inflexible missed stays in schedule/history.
- Tests cover cancelled vs noLongerRelevant distinction.
## Chunk 6.4 — Surprise task logging
Recommended Codex level: high
Tasks:
Implement `I did something unplanned` behavior:
- Create surprise completed task.
- Optional fields: time used, project, reward, priority.
- Surprise task occupies the time it happened.
- Flexible tasks in that time are pushed using normal rules.
- Inflexible/critical/locked blocks are not moved.
- Inflexible/critical overlaps are reported.
- Locked overlaps remain hidden by default unless reveal mode exists.
Acceptance criteria:
- Test: surprise task creates completed task.
- Test: overlapping flexible task is pushed.
- Test: overlapping inflexible/critical task is reported, not moved.
- Test: locked overlap can be tracked without rendering as task.
Commit suggestion:
```text
feat(tasks): implement core task actions and transitions
```

View file

@ -0,0 +1,78 @@
# V1 Block 07 — Child Tasks
Status: Planned
Purpose: Support breaking a large task into smaller owned child tasks without adding dependency complexity.
## Chunk 7.1 — Parent/child model rules
Recommended Codex level: medium
Tasks:
Implement parent/child ownership fields and helpers:
- parent task id on child tasks
- list/query children by parent
- parent can be incomplete while children are planned/completed
Rules:
- This is not full task dependency support.
- Do not add arbitrary DAG/dependency logic.
- Children are owned by parent only.
Acceptance criteria:
- Test: child references parent.
- Test: parent can query/aggregate children through helper.
## Chunk 7.2 — Child entry defaults
Recommended Codex level: medium
Tasks:
Support row-style child entry data:
- child title
- priority up/down/dropdown value
- reward up/down/dropdown value
- time required
Rules:
- If no priority is set, children are inserted in the order added.
- Children can have their own reward, priority, and time.
- Children inherit project from parent unless overridden.
Acceptance criteria:
- Tests cover child creation with explicit fields.
- Tests cover no priority preserving insertion order.
## Chunk 7.3 — Parent auto-completion
Recommended Codex level: high
Tasks:
Implement completion rules:
- Parent auto-completes when all children complete.
- Marking parent complete force-completes remaining children.
- Completing from any child can provide a domain-level option/result to mark entire parent complete.
Acceptance criteria:
- Test: all children completed completes parent.
- Test: parent complete force-completes children.
- Test: partial child completion does not complete parent.
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation.
Commit suggestion:
```text
feat(tasks): support parent-owned child tasks
```

View file

@ -0,0 +1,84 @@
# V1 Block 08 — Today Timeline State
Status: Planned
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
## Chunk 8.1 — Timeline item view model
Recommended Codex level: medium
Tasks:
Create a UI-independent timeline item model with fields for:
- display title
- start/end or duration
- task type
- project color token
- background type token
- reward icon token
- difficulty icon token
- explicit time display flag
- quick actions available
Rules:
- Thick border color is project class.
- Translucent background is task type.
- Name is text.
- Icon 1 is reward.
- Icon 2 is difficulty.
- Flexible task cards show duration; timeline position shows start/stop.
- Inflexible, critical, and locked show explicit start/end times.
Acceptance criteria:
- Tests or snapshots can verify field mapping from task to timeline item.
## Chunk 8.2 — Hidden locked block overlay state
Recommended Codex level: medium
Tasks:
Create overlay state for locked blocks:
- hidden by default
- temporary reveal mode
- named blocked-time overlay
- not a normal task block
Acceptance criteria:
- Locked block can produce an overlay item only when reveal mode is active.
- Overlay item is distinguishable from task card item.
## Chunk 8.3 — Compact mode state
Recommended Codex level: low
Tasks:
Add domain/app state for manual compact mode:
- Manual toggle only.
- Show current task.
- Show next required task.
- Optionally show next flexible task.
- Hide full timeline unless expanded.
Rules:
- The app must not automatically switch into compact mode after missed/pushed tasks.
Acceptance criteria:
- Compact mode flag controls compact output selection.
- Tests cover manual toggle behavior.
Commit suggestion:
```text
feat(timeline): add today timeline view state models
```

View file

@ -0,0 +1,74 @@
# V1 Block 09 — Persistence Preparation
Status: Planned
Purpose: Prepare the domain layer for local persistence without overbuilding sync or database implementation too early.
## Chunk 9.1 — Repository interface
Recommended Codex level: medium
Tasks:
Define repository interfaces for:
- tasks
- projects
- locked blocks
- scheduling operations/state snapshots
Rules:
- Do not couple scheduling engine directly to SQLite.
- Use interfaces that a future Drift/SQLite layer can implement.
- Keep current implementation in-memory if needed.
Acceptance criteria:
- Domain services can depend on interfaces.
- Tests can use fake/in-memory repositories.
## Chunk 9.2 — Serialization-safe models
Recommended Codex level: high
Tasks:
Make models persistence-friendly:
- stable IDs
- enum serialization helpers or clear mapping plan
- DateTime handling convention
- nullable fields documented
- migration-safe field names where possible
Acceptance criteria:
- Models can round-trip through map/json-like structures or have a clear TODO for Drift mappings.
- Tests cover at least task serialization if implemented.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — Explicit non-sync boundary
Recommended Codex level: low
Tasks:
Document that V1 persistence is local-first and sync is out of scope.
Rules:
- No network sync code.
- No cloud assumptions.
- No background service implementation.
Acceptance criteria:
- README or architecture doc states sync is future work.
Commit suggestion:
```text
feat(data): prepare persistence interfaces for scheduling core
```

View file

@ -0,0 +1,75 @@
# V1 Block 10 — Testing, Documentation, and Handoff
Status: Planned
Purpose: Ensure the V1 scheduling core is understandable, tested, and ready for future UI work.
## Chunk 10.1 — Scheduling rule test matrix
Recommended Codex level: high
Tasks:
Create a test matrix covering:
- flexible insert into free slot
- flexible insert pushing other flexible tasks
- locked blocks never moved
- inflexible tasks never moved
- critical tasks remain visible/required
- push to next available
- push to tomorrow top of queue
- push to backlog
- end-of-day rollover
- surprise task overlaps
- child task parent completion
Acceptance criteria:
- Tests clearly name the business rule being verified.
- Avoid brittle tests that only check implementation details.
## Chunk 10.2 — Documentation sync
Recommended Codex level: medium
Tasks:
Update docs to reflect implemented behavior:
- README summary.
- Relevant current plan statuses.
- Any architecture notes.
- Any TODOs moved to wishlist/future notes.
Acceptance criteria:
- No docs claim V2 features are implemented in V1.
- MVP exclusions remain clear.
BREAKPOINT: Stop here. Confirm mode switch if needed before final cleanup.
## Chunk 10.3 — Archive completed plans
Recommended Codex level: low
Tasks:
For every completed block plan:
- Mark status complete.
- Move completed plan to `Codex Documentation/Archived plans/`.
- Leave active/incomplete plans in Current Software Plan.
- Commit archive moves.
Acceptance criteria:
- Completed plans are not left in Current Software Plan.
- Archive folder contains completed plans.
- Commit message follows conventional commit style.
Commit suggestion:
```text
docs(plan): archive completed v1 planning blocks
```

View file

@ -0,0 +1,43 @@
# Codex Documentation
This folder contains Codex-facing execution plans and implementation guidance.
## Required structure
```text
Codex Documentation/
├── README.md
├── Current Software Plan/
└── Archived plans/
```
## Current Software Plan
Active implementation plans live in:
```text
Codex Documentation/Current Software Plan/
```
Codex should execute the current plans in numbered order unless the user explicitly says otherwise.
## Archived plans
Completed implementation plans must be moved to:
```text
Codex Documentation/Archived plans/
```
This is not optional. Completed plans must not remain in Current Software Plan.
## Plan status convention
Each plan file should clearly indicate whether it is:
- `Planned`
- `In progress`
- `Complete`
- `Archived`
When finished, mark complete, move to archive, and commit the move.

View file

@ -0,0 +1,13 @@
# ADHD-Friendly Scheduling Application
Unified Product and Design Specification
Draft compiled from user requirements - June 19, 2026
## Executive Summary
This application is a failure-tolerant scheduling system for people who struggle with executive dysfunction, task migration, reminders, volatile schedules, and missed logging. It is not intended to be a generic productivity app. Its core job is to preserve intent when the plan breaks: move flexible tasks forward, protect required commitments, keep locked unavailable time out of the way visually, and make recovery simple instead of punitive.
The MVP focuses on Today, Backlog, recurring hidden locked blocks, push/rollover behavior, task capture, and simple state management. Version 2.0 adds week/month views, reports, drag-and-drop, task history, and the full overwhelm shield/recovery system.
See the DOCX file for the complete formatted specification.

Binary file not shown.

View file

@ -0,0 +1,10 @@
# Human Documentation
This folder contains human-facing product documentation.
Start with:
- `Overall App Design Spec.docx`
- `Original Chat-Compiled Design Spec.md`
These documents define the product intent, terminology, major features, and version boundaries. Codex should use these files for product context but should execute from the files in `Codex Documentation/Current Software Plan/`.

View file

@ -0,0 +1,32 @@
# Starter Architecture Notes
## Initial architecture decision
Start as a pure Dart scheduling-core package. Do not begin with UI. The scheduling rules are the hardest and most important part of the product, and they should be testable without a Flutter app running.
## Future shape
```text
Flutter UI
View/application state
Pure Dart scheduling core
Repository interfaces
Local persistence, likely SQLite/Drift later
Future sync layer
```
## Why pure Dart first
- Easier to test scheduling rules.
- Less UI noise for Codex.
- Cleaner migration into Flutter later.
- Avoids premature sync/background complexity.
## Key invariant
The scheduling core must never move locked or inflexible blocks during automatic rescheduling.

View file

@ -0,0 +1,154 @@
# ADHD Scheduling App — Unified Product Design Summary
## Product purpose
This app is a failure-tolerant scheduling system for ADHD/executive dysfunction, volatile schedules, missed tasks, and low-capacity days. It is not a generic productivity app. Its core job is to preserve intent and order while reducing the amount of manual planning the user must do.
The app should assume that life will interrupt the plan. When that happens, the app should keep tasks safe, shift what can move, protect what cannot move, and avoid creating guilt or organizational overhead.
## Core product principles
1. The user should not need to reorganize the whole plan after a disruption.
2. Flexible tasks move; inflexible and locked blocks do not.
3. Backlog is a safe holding area, not a trash pile.
4. Critical tasks remain visible and actionable.
5. Locked time blocks scheduling but is hidden by default.
6. The app should use learned/default behavior wherever possible.
7. Manual editing exists for high-capacity moments, but one-tap actions are required for low-capacity moments.
## V1/MVP scope
V1 focuses on:
- Today timeline.
- Backlog/wishlist.
- Quick task capture.
- Flexible task scheduling and pushing.
- Recurring hidden locked blocks.
- One-day locked-block overrides.
- End-of-day rollover.
- Basic child task splitting.
- Internal statistics for future reports.
## V2.0 scope
V2.0 is planned for:
- Week view.
- Month view.
- Weekly reports.
- Overwhelm shield.
- Drag-and-drop reordering.
- Per-task history panel.
## Wishlist/future scope
Wishlist/future items include:
- Task dependencies.
- Context tags.
- Advanced sync.
- Long-running task auto-extension decision.
- Additional intelligent assistant features.
## Task types
- Flexible: movable planned task.
- Inflexible: required visible item that should not move automatically.
- Critical: required visible item that remains actionable if missed.
- Locked: hidden scheduling block that reserves unavailable time.
- Surprise: unplanned completed task logged after the fact.
- Free Slot: intentional rest time.
## Backlog/wishlist
The backlog is unified and supports filtering/sorting rather than hard sections. Items can be added directly, pushed there, or moved back into the next available schedule slot.
Backlog items have an ambient staleness indicator only in backlog views:
- Green: newest, default under 7 days.
- Blue: aging, default over 7 days.
- Purple: stale, default over 30 days.
These thresholds should be configurable. The app should not nag per stale task.
## Scheduling behavior
Flexible tasks can be inserted into the soonest slot where they fit. Later flexible tasks shift forward. Locked, inflexible, and critical blocks do not move automatically.
Push options:
- Next available slot.
- Tomorrow, top of queue.
- Backlog.
No `later today` option for now.
## Locked blocks
Locked blocks are critical to MVP. They can repeat, block scheduling, and remain hidden by default. They may be revealed temporarily as a named blocked-time overlay, not rendered as normal task cards.
Examples:
- Work hours.
- Unavailable time.
- Surprise weekend work.
One-day overrides must be supported without changing the recurring rule.
## Today timeline
Today view is a timeline.
Visual encoding:
- Thick border color: project class.
- Translucent background: task type.
- Text: task name.
- Icon 1: reward level.
- Icon 2: difficulty level.
Flexible tasks show duration. Inflexible, critical, and locked blocks show explicit start/end times.
Compact mode is manual only.
## Quick capture
Quick capture should default to backlog with neutral/default fields:
- Priority: Medium.
- Reward: Not set.
- Time: Not set or default estimate.
- Project: Inbox/Unsorted.
A checkbox can schedule the item into the next available slot. Selecting that expands optional fields like duration, project, priority, reward, and flexibility.
## Child tasks
Large tasks can be broken up through a checklist-style form. Each child row includes:
- Child task title.
- Priority.
- Reward.
- Time required.
If no priority is set, children are inserted in the order they were added. Child tasks belong to a parent. Parent completes when all children complete. Completing the parent force-completes remaining children.
## Statistics
The app should track internal statistics quietly for future reports:
- Skipped during burnout.
- Manually pushed count.
- Auto pushed count.
- Moved to backlog count.
- Restored from backlog count.
- Missed count.
- Cancelled count.
- Completed during locked hours count/minutes.
- Completed late count.
- Parent/child completion patterns.
## UX tone
The app must avoid guilt. It should not punish missed tasks or use shame language. The system should calmly record what happened and help recover.

View file

@ -1 +1,68 @@
# adhd_scheduling_starter_project
# ADHD Scheduling App Starter Project
This is a starter Dart project for an ADHD-focused scheduling application.
The repository intentionally starts with a **pure Dart scheduling core** before adding a full Flutter UI. The hardest part of the product is the scheduling behavior: flexible task shifting, locked time, backlog recovery, recurring availability blocks, and task-state correctness. Keeping that logic independent makes it easier to test and safer to hand off to Codex.
## Intended product direction
- V1/MVP: Today view, backlog/wishlist, quick capture, flexible task pushing, recurring hidden locked blocks, task-state transitions, and a testable scheduling core.
- V2.0: Week/month views, reports, overwhelm shield, drag-and-drop, task history panels.
- Wishlist/future: Dependencies, context tags, advanced sync, long-running task behavior decisions.
## Repository layout
```text
.
├── AGENTS.md
├── README.md
├── pubspec.yaml
├── analysis_options.yaml
├── lib/
│ ├── scheduler_core.dart
│ └── src/
│ ├── models.dart
│ ├── scheduling_engine.dart
│ └── task_statistics.dart
├── test/
│ └── scheduling_engine_test.dart
├── Human Documentation/
│ ├── Overall App Design Spec.docx
│ └── Original Chat-Compiled Design Spec.md
└── Codex Documentation/
├── README.md
├── Current Software Plan/
└── Archived plans/
```
## Basic commands
Install a Dart SDK that satisfies `pubspec.yaml` first. This starter is a pure
Dart package, so Flutter is not required for the current core/test loop.
```bash
dart pub get
dart analyze
dart test
```
Run these before committing changes whenever the local environment has Dart
available.
## Documentation
Product and design context belongs in `Human Documentation/`. Codex execution
plans belong in `Codex Documentation/`, with active work under
`Codex Documentation/Current Software Plan/` and finished plans moved to
`Codex Documentation/Archived plans/`.
## Codex handoff
Codex should begin by reading:
1. `AGENTS.md`
2. `Human Documentation/Overall App Design Spec.docx` or the Markdown companion
3. `Codex Documentation/Current Software Plan/README.md`
4. The next numbered block document in `Codex Documentation/Current Software Plan/`
Each completed work block should be committed with a conventional commit message.

13
analysis_options.yaml Normal file
View file

@ -0,0 +1,13 @@
include: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
prefer_final_locals: true
prefer_final_fields: true
avoid_print: true

8
lib/scheduler_core.dart Normal file
View file

@ -0,0 +1,8 @@
/// Public exports for the ADHD scheduling core.
///
/// Keep this file small. Implementation details belong in `lib/src/`.
library adhd_scheduler_core;
export 'src/models.dart';
export 'src/scheduling_engine.dart';
export 'src/task_statistics.dart';

318
lib/src/models.dart Normal file
View file

@ -0,0 +1,318 @@
import 'task_statistics.dart';
/// Scheduling behavior category.
enum TaskType {
/// Movable planned work.
flexible,
/// Required visible block that should not be moved automatically.
inflexible,
/// Required visible task that remains actionable if missed.
critical,
/// Hidden scheduling constraint, not a task card.
locked,
/// Unplanned completed/logged task.
surprise,
/// Intentional rest time.
freeSlot,
}
/// Current lifecycle state of a task.
enum TaskStatus {
/// Scheduled or queued work that has not started.
planned,
/// Work currently in progress.
active,
/// Work finished by the user.
completed,
/// Work that was not completed in its intended time.
missed,
/// Work intentionally removed from the plan.
cancelled,
/// Work intentionally dismissed because it no longer applies.
noLongerRelevant,
/// Unscheduled work kept for later planning.
backlog,
}
/// User-facing importance level.
enum PriorityLevel {
/// Lowest priority.
veryLow,
/// Low priority.
low,
/// Default middle priority.
medium,
/// High priority.
high,
/// Highest priority.
veryHigh,
}
/// Expected reward or payoff from completing a task.
enum RewardLevel {
/// No reward level has been captured; this is not equivalent to low reward.
notSet,
/// Very low expected reward.
veryLow,
/// Low expected reward.
low,
/// Medium expected reward.
medium,
/// High expected reward.
high,
/// Very high expected reward.
veryHigh,
}
/// Expected effort or activation difficulty for a task.
enum DifficultyLevel {
/// No difficulty has been captured yet.
notSet,
/// Very easy to start or complete.
veryEasy,
/// Easy to start or complete.
easy,
/// Medium effort.
medium,
/// Hard to start or complete.
hard,
/// Very hard to start or complete.
veryHard,
}
/// Reminder intensity preference.
enum ReminderProfile {
/// No reminder nudges.
silent,
/// Low-friction reminder nudges.
gentle,
/// Repeated reminder nudges.
persistent,
/// Strong reminder behavior for required items.
strict,
}
/// Starter task model for the scheduling core.
///
/// This is a public placeholder API for early V1 chunks. Keep behavior changes
/// explicit and covered by tests as the model becomes more complete.
class Task {
const Task({
required this.id,
required this.title,
required this.projectId,
required this.type,
required this.status,
required this.createdAt,
required this.updatedAt,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.scheduledStart,
this.scheduledEnd,
this.parentTaskId,
this.stats = const TaskStatistics(),
});
/// Create a minimal captured task without requiring planning details.
factory Task.quickCapture({
required String id,
required String title,
required DateTime createdAt,
String projectId = 'inbox',
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.backlog,
DateTime? updatedAt,
}) {
return Task(
id: id,
title: title,
projectId: projectId,
type: type,
status: status,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
final String id;
final String title;
final String projectId;
final TaskType type;
final TaskStatus status;
final PriorityLevel? priority;
final RewardLevel reward;
final DifficultyLevel difficulty;
final int? durationMinutes;
final DateTime? scheduledStart;
final DateTime? scheduledEnd;
final String? parentTaskId;
final DateTime createdAt;
final DateTime updatedAt;
final TaskStatistics stats;
bool get isFlexible => type == TaskType.flexible;
bool get isRequiredVisible => type == TaskType.critical || type == TaskType.inflexible;
bool get isLocked => type == TaskType.locked;
bool get isBacklog => status == TaskStatus.backlog;
Task copyWith({
String? id,
String? title,
String? projectId,
TaskType? type,
TaskStatus? status,
PriorityLevel? priority,
RewardLevel? reward,
DifficultyLevel? difficulty,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
String? parentTaskId,
DateTime? createdAt,
DateTime? updatedAt,
TaskStatistics? stats,
bool clearSchedule = false,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
projectId: projectId ?? this.projectId,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
reward: reward ?? this.reward,
difficulty: difficulty ?? this.difficulty,
durationMinutes: durationMinutes ?? this.durationMinutes,
scheduledStart: clearSchedule ? null : (scheduledStart ?? this.scheduledStart),
scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd),
parentTaskId: parentTaskId ?? this.parentTaskId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
stats: stats ?? this.stats,
);
}
}
/// Starter project defaults used when creating or scheduling tasks.
class ProjectProfile {
const ProjectProfile({
required this.id,
required this.name,
required this.colorKey,
this.defaultPriority = PriorityLevel.medium,
this.defaultReward = RewardLevel.notSet,
this.defaultDifficulty = DifficultyLevel.notSet,
this.defaultReminderProfile = ReminderProfile.gentle,
this.defaultDurationMinutes,
});
final String id;
final String name;
final String colorKey;
final PriorityLevel defaultPriority;
final RewardLevel defaultReward;
final DifficultyLevel defaultDifficulty;
final ReminderProfile defaultReminderProfile;
final int? defaultDurationMinutes;
/// Create a task using project defaults while allowing explicit overrides.
Task createTask({
required String id,
required String title,
required DateTime createdAt,
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.backlog,
PriorityLevel? priority,
RewardLevel? reward,
DifficultyLevel? difficulty,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
String? parentTaskId,
DateTime? updatedAt,
}) {
return Task(
id: id,
title: title,
projectId: this.id,
type: type,
status: status,
priority: priority ?? defaultPriority,
reward: reward ?? defaultReward,
difficulty: difficulty ?? defaultDifficulty,
durationMinutes: durationMinutes ?? defaultDurationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
parentTaskId: parentTaskId,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
}
/// Starter time range value used by scheduling helpers.
class TimeInterval {
const TimeInterval({
required this.start,
required this.end,
this.label,
}) : assert(!end.isBefore(start), 'end must not be before start');
final DateTime start;
final DateTime end;
final String? label;
Duration get duration => end.difference(start);
bool overlaps(TimeInterval other) {
return start.isBefore(other.end) && end.isAfter(other.start);
}
}
/// Starter representation of locked time.
///
/// Later recurring-block chunks should expand this without exposing hidden
/// locked time in UI-facing defaults.
class LockedBlock {
const LockedBlock({
required this.id,
required this.name,
required this.interval,
this.hiddenByDefault = true,
});
final String id;
final String name;
final TimeInterval interval;
final bool hiddenByDefault;
}

View file

@ -0,0 +1,609 @@
import 'models.dart';
/// Category for scheduler notices.
enum SchedulingNoticeType {
/// General informational notice.
info,
/// A task was moved by a scheduling operation.
moved,
/// A scheduled task overlaps blocked time.
overlap,
/// A task could not fit in the requested window.
noFit,
/// A task would need to move outside the requested window.
overflow,
}
/// Window of time available to a scheduling operation.
class SchedulingWindow {
const SchedulingWindow({
required this.start,
required this.end,
}) : assert(!end.isBefore(start), 'end must not be before start');
final DateTime start;
final DateTime end;
TimeInterval get interval => TimeInterval(start: start, end: end);
bool contains(TimeInterval interval) {
final startsInWindow =
interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start);
final endsInWindow =
interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end);
return startsInWindow && endsInWindow;
}
}
/// In-memory input for scheduling operations.
class SchedulingInput {
const SchedulingInput({
required this.tasks,
required this.window,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
});
final List<Task> tasks;
final SchedulingWindow window;
final List<TimeInterval> lockedIntervals;
final List<TimeInterval> requiredVisibleIntervals;
List<Task> get flexibleTasks {
return tasks.where((task) => task.isFlexible).toList(growable: false);
}
List<Task> get lockedTasks {
return tasks.where((task) => task.isLocked).toList(growable: false);
}
List<Task> get requiredVisibleTasks {
return tasks.where((task) => task.isRequiredVisible).toList(growable: false);
}
List<TimeInterval> get flexibleIntervals {
return _scheduledIntervalsFor(flexibleTasks);
}
List<TimeInterval> get blockedIntervals {
final intervals = <TimeInterval>[
...lockedIntervals,
..._scheduledIntervalsFor(lockedTasks),
...requiredVisibleIntervals,
..._scheduledIntervalsFor(requiredVisibleTasks),
]..sort((a, b) => a.start.compareTo(b.start));
return List<TimeInterval>.unmodifiable(intervals);
}
}
/// Exact placement change made by a scheduling operation.
class SchedulingChange {
const SchedulingChange({
required this.taskId,
required this.previousStart,
required this.previousEnd,
required this.nextStart,
required this.nextEnd,
});
final String taskId;
final DateTime? previousStart;
final DateTime? previousEnd;
final DateTime? nextStart;
final DateTime? nextEnd;
}
/// Overlap between a scheduled task and blocked time.
class SchedulingOverlap {
const SchedulingOverlap({
required this.taskId,
required this.taskInterval,
required this.blockedInterval,
});
final String taskId;
final TimeInterval taskInterval;
final TimeInterval blockedInterval;
}
/// Starter notice type returned by scheduling operations.
class SchedulingNotice {
const SchedulingNotice(
this.message, {
this.type = SchedulingNoticeType.info,
this.taskId,
});
final String message;
final SchedulingNoticeType type;
final String? taskId;
}
/// Starter result wrapper for scheduling operations.
class SchedulingResult {
const SchedulingResult({
required this.tasks,
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
});
final List<Task> tasks;
final List<SchedulingNotice> notices;
final List<SchedulingChange> changes;
final List<SchedulingOverlap> overlaps;
}
/// Starter scheduling engine.
///
/// This is intentionally small. Codex should expand this according to the V1
/// plan documents and add tests for every scheduling rule.
class SchedulingEngine {
const SchedulingEngine();
/// Insert a backlog task into the earliest available slot today.
///
/// Locked, inflexible, and critical time is treated as fixed. Planned
/// flexible tasks at or after the insertion point may shift later, preserving
/// their relative order.
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
final task = _taskById(input.tasks, taskId);
if (task == null) {
return _unchangedResult(
input,
SchedulingNotice(
'Task was not found.',
type: SchedulingNoticeType.noFit,
taskId: taskId,
),
);
}
if (!task.isFlexible || !task.isBacklog) {
return _unchangedResult(
input,
SchedulingNotice(
'Only backlog flexible tasks can be inserted.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final taskDuration = _durationFromMinutes(task.durationMinutes);
if (taskDuration == null) {
return _unchangedResult(
input,
SchedulingNotice(
'Task needs a positive duration before scheduling.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final placement = _planBacklogInsertion(
input: input,
task: task,
taskDuration: taskDuration,
);
if (placement == null) {
return _unchangedResult(
input,
SchedulingNotice(
'No available flexible slot today.',
type: SchedulingNoticeType.overflow,
taskId: task.id,
),
);
}
return _applyPlacement(
input: input,
insertedTask: task,
placement: placement,
updatedAt: updatedAt ?? DateTime.now(),
);
}
/// Analyze the current in-memory schedule without moving tasks.
SchedulingResult analyzeSchedule(SchedulingInput input) {
final overlaps = <SchedulingOverlap>[];
final notices = <SchedulingNotice>[];
final blockedIntervals = input.blockedIntervals;
for (final task in input.flexibleTasks) {
final taskInterval = _scheduledIntervalFor(task);
if (taskInterval == null || !input.window.contains(taskInterval)) {
continue;
}
for (final blockedInterval in blockedIntervals) {
if (!taskInterval.overlaps(blockedInterval)) {
continue;
}
overlaps.add(
SchedulingOverlap(
taskId: task.id,
taskInterval: taskInterval,
blockedInterval: blockedInterval,
),
);
notices.add(
SchedulingNotice(
'Flexible task overlaps blocked time.',
type: SchedulingNoticeType.overlap,
taskId: task.id,
),
);
}
}
return SchedulingResult(
tasks: input.tasks,
notices: List<SchedulingNotice>.unmodifiable(notices),
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
);
}
/// Move a task to backlog.
///
/// Backlog does not preserve original schedule/order placement.
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
return task.copyWith(
status: TaskStatus.backlog,
updatedAt: updatedAt ?? DateTime.now(),
stats: task.stats.incrementMovedToBacklog(),
clearSchedule: true,
);
}
/// Mark a flexible task pushed manually.
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
return task.copyWith(
updatedAt: updatedAt ?? DateTime.now(),
stats: task.stats.incrementManualPush(),
);
}
/// Mark missed according to the current MVP rules.
///
/// Critical missed tasks go to backlog. Inflexible missed tasks stay in place.
Task markMissed(Task task, {DateTime? updatedAt}) {
final nextStats = task.stats.incrementMissed();
final now = updatedAt ?? DateTime.now();
if (task.type == TaskType.critical) {
return task.copyWith(
status: TaskStatus.backlog,
updatedAt: now,
stats: nextStats.incrementMovedToBacklog(),
clearSchedule: true,
);
}
return task.copyWith(
status: TaskStatus.missed,
updatedAt: now,
stats: nextStats,
);
}
/// Finds the first interval that can fit the requested duration while avoiding
/// blocked intervals.
///
/// This helper is deliberately simple. Full flexible bump behavior belongs in
/// later plan chunks.
TimeInterval? findFirstOpenInterval({
required DateTime windowStart,
required DateTime windowEnd,
required Duration duration,
required List<TimeInterval> blocked,
}) {
final sortedBlocked = [...blocked]..sort((a, b) => a.start.compareTo(b.start));
var cursor = windowStart;
for (final interval in sortedBlocked) {
if (cursor.add(duration).isBefore(interval.start) ||
cursor.add(duration).isAtSameMomentAs(interval.start)) {
return TimeInterval(start: cursor, end: cursor.add(duration));
}
if (interval.end.isAfter(cursor)) {
cursor = interval.end;
}
}
final candidateEnd = cursor.add(duration);
if (candidateEnd.isBefore(windowEnd) || candidateEnd.isAtSameMomentAs(windowEnd)) {
return TimeInterval(start: cursor, end: candidateEnd);
}
return null;
}
}
TimeInterval? _scheduledIntervalFor(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
final intervals = <TimeInterval>[];
for (final task in tasks) {
final interval = _scheduledIntervalFor(task);
if (interval != null) {
intervals.add(interval);
}
}
return List<TimeInterval>.unmodifiable(intervals);
}
SchedulingResult _unchangedResult(
SchedulingInput input,
SchedulingNotice notice,
) {
return SchedulingResult(
tasks: input.tasks,
notices: [notice],
);
}
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
Duration? _durationFromMinutes(int? minutes) {
if (minutes == null || minutes <= 0) {
return null;
}
return Duration(minutes: minutes);
}
_BacklogInsertionPlan? _planBacklogInsertion({
required SchedulingInput input,
required Task task,
required Duration taskDuration,
}) {
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final queue = <_PlacementItem>[
_PlacementItem(
task: task,
duration: taskDuration,
earliestStart: input.window.start,
),
];
final scheduledFlexibleTasks = input.flexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id)
.toList(growable: false)
..sort((a, b) {
final aStart = a.scheduledStart ?? input.window.end;
final bStart = b.scheduledStart ?? input.window.end;
return aStart.compareTo(bStart);
});
for (final flexibleTask in scheduledFlexibleTasks) {
final interval = _scheduledIntervalFor(flexibleTask);
if (interval == null) {
continue;
}
final startsBeforeWindow = interval.start.isBefore(input.window.start);
final startsAfterWindow =
interval.start.isAfter(input.window.end) ||
interval.start.isAtSameMomentAs(input.window.end);
if (startsBeforeWindow || startsAfterWindow) {
fixedBlocks.add(interval);
continue;
}
if (flexibleTask.status != TaskStatus.planned) {
fixedBlocks.add(interval);
continue;
}
queue.add(
_PlacementItem(
task: flexibleTask,
duration: interval.duration,
earliestStart: interval.start,
),
);
}
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
var cursor = input.window.start;
final placements = <String, TimeInterval>{};
for (final item in queue) {
final earliestStart = _laterOf(cursor, item.earliestStart);
final interval = _firstOpenIntervalFrom(
earliestStart: earliestStart,
windowEnd: input.window.end,
duration: item.duration,
blocked: fixedBlocks,
);
if (interval == null) {
return null;
}
placements[item.task.id] = interval;
cursor = interval.end;
}
return _BacklogInsertionPlan(placements: placements);
}
SchedulingResult _applyPlacement({
required SchedulingInput input,
required Task insertedTask,
required _BacklogInsertionPlan placement,
required DateTime updatedAt,
}) {
final changes = <SchedulingChange>[];
final notices = <SchedulingNotice>[];
final updatedTasks = <Task>[];
for (final task in input.tasks) {
final interval = placement.placements[task.id];
if (interval == null) {
updatedTasks.add(task);
continue;
}
final isInsertedTask = task.id == insertedTask.id;
final moved = isInsertedTask ||
!_sameDateTime(task.scheduledStart, interval.start) ||
!_sameDateTime(task.scheduledEnd, interval.end);
if (!moved) {
updatedTasks.add(task);
continue;
}
final updatedTask = task.copyWith(
status: isInsertedTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
stats: isInsertedTask
? task.stats.incrementRestoredFromBacklog()
: task.stats.incrementAutoPush(),
);
updatedTasks.add(updatedTask);
changes.add(
SchedulingChange(
taskId: task.id,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: interval.start,
nextEnd: interval.end,
),
);
notices.add(
SchedulingNotice(
isInsertedTask
? 'Backlog task inserted into schedule.'
: 'Flexible task moved to make room.',
type: SchedulingNoticeType.moved,
taskId: task.id,
),
);
}
return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks),
notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes),
);
}
DateTime _laterOf(DateTime first, DateTime second) {
if (first.isAfter(second)) {
return first;
}
return second;
}
bool _sameDateTime(DateTime? first, DateTime? second) {
if (first == null || second == null) {
return first == null && second == null;
}
return first.isAtSameMomentAs(second);
}
TimeInterval? _firstOpenIntervalFrom({
required DateTime earliestStart,
required DateTime windowEnd,
required Duration duration,
required List<TimeInterval> blocked,
}) {
var cursor = earliestStart;
while (true) {
final candidateEnd = cursor.add(duration);
if (candidateEnd.isAfter(windowEnd)) {
return null;
}
final candidate = TimeInterval(start: cursor, end: candidateEnd);
TimeInterval? overlappingBlock;
for (final block in blocked) {
if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) {
continue;
}
if (block.start.isAfter(candidate.end) ||
block.start.isAtSameMomentAs(candidate.end)) {
break;
}
if (candidate.overlaps(block)) {
overlappingBlock = block;
break;
}
}
if (overlappingBlock == null) {
return candidate;
}
cursor = overlappingBlock.end;
}
}
class _PlacementItem {
const _PlacementItem({
required this.task,
required this.duration,
required this.earliestStart,
});
final Task task;
final Duration duration;
final DateTime earliestStart;
}
class _BacklogInsertionPlan {
const _BacklogInsertionPlan({required this.placements});
final Map<String, TimeInterval> placements;
}

View file

@ -0,0 +1,100 @@
/// Internal counters used for future filtering, reporting, and scheduling hints.
///
/// These are intentionally quiet metadata. They should not become noisy UI by
/// default. This is a public starter placeholder while persistence and reporting
/// needs are still being shaped by the V1 plan.
class TaskStatistics {
const TaskStatistics({
this.skippedDuringBurnoutCount = 0,
this.manuallyPushedCount = 0,
this.autoPushedCount = 0,
this.movedToBacklogCount = 0,
this.restoredFromBacklogCount = 0,
this.missedCount = 0,
this.cancelledCount = 0,
this.completedLateCount = 0,
this.completedDuringLockedHoursCount = 0,
this.completedDuringLockedHoursMinutes = 0,
});
final int skippedDuringBurnoutCount;
final int manuallyPushedCount;
final int autoPushedCount;
final int movedToBacklogCount;
final int restoredFromBacklogCount;
final int missedCount;
final int cancelledCount;
final int completedLateCount;
final int completedDuringLockedHoursCount;
final int completedDuringLockedHoursMinutes;
TaskStatistics copyWith({
int? skippedDuringBurnoutCount,
int? manuallyPushedCount,
int? autoPushedCount,
int? movedToBacklogCount,
int? restoredFromBacklogCount,
int? missedCount,
int? cancelledCount,
int? completedLateCount,
int? completedDuringLockedHoursCount,
int? completedDuringLockedHoursMinutes,
}) {
return TaskStatistics(
skippedDuringBurnoutCount:
skippedDuringBurnoutCount ?? this.skippedDuringBurnoutCount,
manuallyPushedCount: manuallyPushedCount ?? this.manuallyPushedCount,
autoPushedCount: autoPushedCount ?? this.autoPushedCount,
movedToBacklogCount: movedToBacklogCount ?? this.movedToBacklogCount,
restoredFromBacklogCount:
restoredFromBacklogCount ?? this.restoredFromBacklogCount,
missedCount: missedCount ?? this.missedCount,
cancelledCount: cancelledCount ?? this.cancelledCount,
completedLateCount: completedLateCount ?? this.completedLateCount,
completedDuringLockedHoursCount: completedDuringLockedHoursCount ??
this.completedDuringLockedHoursCount,
completedDuringLockedHoursMinutes: completedDuringLockedHoursMinutes ??
this.completedDuringLockedHoursMinutes,
);
}
TaskStatistics incrementMovedToBacklog() {
return copyWith(movedToBacklogCount: movedToBacklogCount + 1);
}
TaskStatistics incrementSkippedDuringBurnout() {
return copyWith(skippedDuringBurnoutCount: skippedDuringBurnoutCount + 1);
}
TaskStatistics incrementManualPush() {
return copyWith(manuallyPushedCount: manuallyPushedCount + 1);
}
TaskStatistics incrementAutoPush() {
return copyWith(autoPushedCount: autoPushedCount + 1);
}
TaskStatistics incrementRestoredFromBacklog() {
return copyWith(restoredFromBacklogCount: restoredFromBacklogCount + 1);
}
TaskStatistics incrementMissed() {
return copyWith(missedCount: missedCount + 1);
}
TaskStatistics incrementCancelled() {
return copyWith(cancelledCount: cancelledCount + 1);
}
TaskStatistics incrementCompletedLate() {
return copyWith(completedLateCount: completedLateCount + 1);
}
TaskStatistics incrementCompletedDuringLockedHours(int minutes) {
return copyWith(
completedDuringLockedHoursCount: completedDuringLockedHoursCount + 1,
completedDuringLockedHoursMinutes:
completedDuringLockedHoursMinutes + minutes,
);
}
}

11
pubspec.yaml Normal file
View file

@ -0,0 +1,11 @@
name: adhd_scheduler_core
description: A pure Dart scheduling core starter for an ADHD-focused scheduling app.
version: 0.1.0
publish_to: none
environment:
sdk: '>=3.4.0 <4.0.0'
dev_dependencies:
lints: ^5.0.0
test: ^1.25.0

View file

@ -0,0 +1,428 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Domain model', () {
final now = DateTime(2026, 6, 19, 12);
test('core enum values are importable', () {
expect(TaskType.locked, TaskType.locked);
expect(TaskStatus.noLongerRelevant, TaskStatus.noLongerRelevant);
expect(PriorityLevel.veryHigh, PriorityLevel.veryHigh);
expect(RewardLevel.notSet, RewardLevel.notSet);
expect(DifficultyLevel.notSet, DifficultyLevel.notSet);
expect(ReminderProfile.gentle, ReminderProfile.gentle);
});
test('quick capture uses neutral defaults without schedule details', () {
final task = Task.quickCapture(
id: 'capture-1',
title: 'Buy stamps',
createdAt: now,
);
expect(task.projectId, 'inbox');
expect(task.type, TaskType.flexible);
expect(task.status, TaskStatus.backlog);
expect(task.priority, isNull);
expect(task.reward, RewardLevel.notSet);
expect(task.difficulty, DifficultyLevel.notSet);
expect(task.durationMinutes, isNull);
expect(task.scheduledStart, isNull);
expect(task.scheduledEnd, isNull);
expect(task.createdAt, now);
expect(task.updatedAt, now);
});
test('project defaults apply while task overrides remain possible', () {
const project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'green',
defaultPriority: PriorityLevel.high,
defaultReward: RewardLevel.medium,
defaultDifficulty: DifficultyLevel.easy,
defaultReminderProfile: ReminderProfile.persistent,
defaultDurationMinutes: 25,
);
final defaulted = project.createTask(
id: 'task-1',
title: 'Clean desk',
createdAt: now,
);
final overridden = project.createTask(
id: 'task-2',
title: 'Fold laundry',
createdAt: now,
priority: PriorityLevel.low,
reward: RewardLevel.high,
difficulty: DifficultyLevel.hard,
durationMinutes: 10,
);
expect(defaulted.projectId, 'home');
expect(defaulted.priority, PriorityLevel.high);
expect(defaulted.reward, RewardLevel.medium);
expect(defaulted.difficulty, DifficultyLevel.easy);
expect(defaulted.durationMinutes, 25);
expect(overridden.priority, PriorityLevel.low);
expect(overridden.reward, RewardLevel.high);
expect(overridden.difficulty, DifficultyLevel.hard);
expect(overridden.durationMinutes, 10);
});
test('statistics increments preserve existing values immutably', () {
const stats = TaskStatistics(missedCount: 2);
final updated = stats.incrementCompletedDuringLockedHours(15);
expect(stats.completedDuringLockedHoursCount, 0);
expect(stats.completedDuringLockedHoursMinutes, 0);
expect(updated.missedCount, 2);
expect(updated.completedDuringLockedHoursCount, 1);
expect(updated.completedDuringLockedHoursMinutes, 15);
});
});
group('SchedulingEngine starter behavior', () {
final now = DateTime(2026, 6, 19, 12);
const engine = SchedulingEngine();
Task flexibleTask() {
return Task(
id: 'task-1',
title: 'Clean coffee maker',
projectId: 'home',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
durationMinutes: 15,
createdAt: now,
updatedAt: now,
);
}
Task taskById(List<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}
test('scheduling input classifies in-memory task groups', () {
final flexible = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
);
final locked = flexibleTask().copyWith(
id: 'locked-1',
type: TaskType.locked,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 15),
);
final critical = flexibleTask().copyWith(
id: 'critical-1',
type: TaskType.critical,
scheduledStart: DateTime(2026, 6, 19, 16),
scheduledEnd: DateTime(2026, 6, 19, 16, 30),
);
final input = SchedulingInput(
tasks: [flexible, locked, critical],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 12),
end: DateTime(2026, 6, 19, 18),
),
);
expect(input.flexibleTasks.map((task) => task.id), ['task-1']);
expect(input.lockedTasks.map((task) => task.id), ['locked-1']);
expect(input.requiredVisibleTasks.map((task) => task.id), ['critical-1']);
expect(input.blockedIntervals.map((interval) => interval.label), [
'locked-1',
'critical-1',
]);
});
test('analyzeSchedule reports flexible overlaps without moving tasks', () {
final flexible = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final tasks = [flexible];
final result = engine.analyzeSchedule(
SchedulingInput(
tasks: tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 12),
end: DateTime(2026, 6, 19, 18),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13, 15),
end: DateTime(2026, 6, 19, 14),
label: 'locked-block',
),
],
),
);
expect(result.tasks, same(tasks));
expect(result.changes, isEmpty);
expect(result.overlaps.single.taskId, 'task-1');
expect(result.overlaps.single.blockedInterval.label, 'locked-block');
expect(result.notices.single.type, SchedulingNoticeType.overlap);
expect(result.notices.single.taskId, 'task-1');
});
test('scheduling result can describe exact task movement', () {
final moved = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final result = SchedulingResult(
tasks: [moved],
notices: const [
SchedulingNotice(
'Flexible task moved.',
type: SchedulingNoticeType.moved,
taskId: 'task-1',
),
],
changes: [
SchedulingChange(
taskId: 'task-1',
previousStart: DateTime(2026, 6, 19, 13),
previousEnd: DateTime(2026, 6, 19, 13, 15),
nextStart: DateTime(2026, 6, 19, 14),
nextEnd: DateTime(2026, 6, 19, 14, 15),
),
],
);
expect(result.tasks.single.scheduledStart, DateTime(2026, 6, 19, 14));
expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13));
expect(result.changes.single.nextEnd, DateTime(2026, 6, 19, 14, 15));
expect(result.notices.single.type, SchedulingNoticeType.moved);
});
test('insert backlog task into an open 20-minute slot', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 20),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = result.tasks.single;
expect(inserted.status, TaskStatus.planned);
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
expect(inserted.stats.restoredFromBacklogCount, 1);
expect(result.changes.single.taskId, 'backlog-1');
expect(result.notices.single.type, SchedulingNoticeType.moved);
});
test('insert backlog task before flexible task and push later task', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final plannedFlexible = flexibleTask().copyWith(
id: 'flexible-1',
durationMinutes: 30,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask, plannedFlexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = taskById(result.tasks, 'backlog-1');
final pushed = taskById(result.tasks, 'flexible-1');
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 15));
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15));
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
expect(pushed.stats.autoPushedCount, 1);
expect(result.changes.map((change) => change.taskId), [
'backlog-1',
'flexible-1',
]);
});
test('insert backlog task skips locked time', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 20),
label: 'appointment',
),
],
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = result.tasks.single;
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 20));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35));
});
test('insert backlog task does not move inflexible or critical blocks', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final inflexibleTask = flexibleTask().copyWith(
id: 'inflexible-1',
type: TaskType.inflexible,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final criticalTask = flexibleTask().copyWith(
id: 'critical-1',
type: TaskType.critical,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
);
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlogTask, inflexibleTask, criticalTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 15),
),
),
taskId: 'backlog-1',
updatedAt: now,
);
final inserted = taskById(result.tasks, 'backlog-1');
final inflexible = taskById(result.tasks, 'inflexible-1');
final critical = taskById(result.tasks, 'critical-1');
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13));
expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
expect(critical.scheduledStart, DateTime(2026, 6, 19, 14));
expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30));
expect(result.changes.map((change) => change.taskId), ['backlog-1']);
});
test('insert backlog task returns overflow when no slot fits today', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final tasks = [backlogTask];
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: tasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 30),
),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 30),
label: 'appointment',
),
],
),
taskId: 'backlog-1',
updatedAt: now,
);
expect(result.tasks, same(tasks));
expect(result.changes, isEmpty);
expect(result.notices.single.type, SchedulingNoticeType.overflow);
expect(result.notices.single.taskId, 'backlog-1');
expect(backlogTask.status, TaskStatus.backlog);
});
test('moveToBacklog clears active schedule placement', () {
final task = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 18),
scheduledEnd: DateTime(2026, 6, 19, 18, 15),
);
final moved = engine.moveToBacklog(task, updatedAt: now);
expect(moved.status, TaskStatus.backlog);
expect(moved.scheduledStart, isNull);
expect(moved.scheduledEnd, isNull);
expect(moved.stats.movedToBacklogCount, 1);
});
test('critical missed task goes to backlog', () {
final task = flexibleTask().copyWith(type: TaskType.critical);
final missed = engine.markMissed(task, updatedAt: now);
expect(missed.status, TaskStatus.backlog);
expect(missed.stats.missedCount, 1);
expect(missed.stats.movedToBacklogCount, 1);
});
test('inflexible missed task remains missed in place', () {
final task = flexibleTask().copyWith(type: TaskType.inflexible);
final missed = engine.markMissed(task, updatedAt: now);
expect(missed.status, TaskStatus.missed);
expect(missed.stats.missedCount, 1);
});
test('findFirstOpenInterval skips blocked time', () {
final slot = engine.findFirstOpenInterval(
windowStart: DateTime(2026, 6, 19, 17),
windowEnd: DateTime(2026, 6, 19, 20),
duration: const Duration(minutes: 30),
blocked: [
TimeInterval(
start: DateTime(2026, 6, 19, 17),
end: DateTime(2026, 6, 19, 18),
label: 'Work overflow',
),
],
);
expect(slot, isNotNull);
expect(slot!.start, DateTime(2026, 6, 19, 18));
expect(slot.end, DateTime(2026, 6, 19, 18, 30));
});
});
}