docs(project): commit current repository state

This commit is contained in:
Ashley Venn 2026-06-24 11:11:15 -07:00
parent f7f239c3f7
commit b4e321da02
22 changed files with 2015 additions and 38 deletions

View file

@ -49,6 +49,18 @@ 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/`. 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/`.
## Persistence target rules
MongoDB is the committed persistence target for this project. Treat MongoDB document storage as the future database direction when designing repository interfaces, serialization helpers, and persistence boundaries.
Rules:
- Do not introduce alternative database assumptions unless the user explicitly reverses this decision.
- V1 planning may prepare MongoDB-friendly repository interfaces and document-shaped model mappings without adding a MongoDB driver yet.
- Do not add MongoDB connection strings, Atlas/cloud setup, local server requirements, sync behavior, accounts, or network/background behavior unless an active plan explicitly calls for that implementation work.
- Keep the scheduling core independent from MongoDB APIs; persistence adapters should sit behind repository interfaces.
## Planning document execution rules ## Planning document execution rules
Planning documents are organized into blocks, chunks, and optional stages. Planning documents are organized into blocks, chunks, and optional stages.
@ -103,6 +115,7 @@ Do not make vague commits such as `update files`, `changes`, or `work`.
- Start with pure Dart domain logic and tests. - Start with pure Dart domain logic and tests.
- Add Flutter UI only when the plan calls for it. - Add Flutter UI only when the plan calls for it.
- Avoid network/sync/background behavior unless explicitly planned. - Avoid network/sync/background behavior unless explicitly planned.
- Use MongoDB as the committed future persistence target; do not add alternative database assumptions.
- Prefer immutable models or copy/update patterns. - Prefer immutable models or copy/update patterns.
- Avoid hidden side effects in scheduling functions. - Avoid hidden side effects in scheduling functions.
- Every rule that changes task placement should have tests. - Every rule that changes task placement should have tests.

View file

@ -0,0 +1,132 @@
# 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
- parent/child helpers must remain domain-only and UI-independent
Rules:
- This is not full task dependency support.
- Do not add arbitrary DAG/dependency logic.
- Children are owned by parent only.
- Child ownership does not block scheduling unless a later planned feature explicitly adds dependency behavior.
Acceptance criteria:
- Test: child references parent.
- Test: parent can query/aggregate children through helper.
- Test: child ownership does not create dependency/blocking behavior.
## 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
- optional project override
Rules:
- If no priority is set, children are inserted in the order added.
- Children can have their own reward, priority, difficulty, and time.
- Children inherit project from parent unless overridden.
- Reward can remain `not set`; do not treat missing reward as very low reward.
- Preserve original entry order for children that have no explicit priority.
Acceptance criteria:
- Tests cover child creation with explicit fields.
- Tests cover no priority preserving insertion order.
- Tests cover inherited project and overridden project.
- Tests cover reward `not set` remaining distinct from very low reward.
BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-task edge-case regression suite.
## Chunk 7.3 — Child-task edge-case regression test suite
Recommended Codex level: extra high
Tasks:
Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic.
Cover these cases:
- parent with zero children does not auto-complete by accident
- parent with planned children remains incomplete
- child completion updates child state without forcing parent completion until all children are complete
- parent completion can force-complete remaining children only through the explicit parent-complete action
- child tasks keep their parent id when scheduled, pushed, moved to backlog, or marked complete
- children without priority preserve row insertion order
- children with priority can be sorted by priority without losing stable insertion order within same-priority groups
- parent project inheritance works
- child project override works
- child task reward `not set` remains distinct from very low reward
- no arbitrary dependency/DAG fields or scheduling-blocking behavior are introduced
Rules:
- Tests should name the business rule being protected.
- Prefer small fixtures over large scenario setup.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
- Do not implement new user-facing behavior in this chunk unless needed to make the edge-case tests compile against planned domain APIs.
Acceptance criteria:
- A dedicated child-task test group exists.
- Tests cover ownership, ordering, inheritance, and non-dependency behavior.
- Existing scheduling and backlog tests still pass.
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation.
## Chunk 7.4 — 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.
- Parent/child completion should update relevant task statistics without duplicating events.
Rules:
- Do not silently complete sibling child tasks when one child completes.
- Do not complete parent until every child is complete unless the explicit parent-complete action is selected.
- Do not add generalized dependency resolution.
Acceptance criteria:
- Test: all children completed completes parent.
- Test: parent complete force-completes children.
- Test: partial child completion does not complete parent.
- Test: completing one child does not complete siblings.
- Test: explicit parent-complete action records the correct completion state for parent and remaining children.
Commit suggestion:
```text
feat(tasks): support parent-owned child tasks
```

View file

@ -0,0 +1,133 @@
# 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
- item category that distinguishes task cards from overlays
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.
- Timeline state must remain UI-framework-independent.
Acceptance criteria:
- Tests or snapshots can verify field mapping from task to timeline item.
- Tests cover flexible duration display.
- Tests cover explicit time display for inflexible, critical, and locked items.
## 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
- overlay item category distinct from task-card category
Acceptance criteria:
- Locked block can produce an overlay item only when reveal mode is active.
- Overlay item is distinguishable from task card item.
- Overlay item shows a name and explicit start/end times.
- Locked overlay does not expose normal task quick actions.
BREAKPOINT: Stop here. Confirm `low` mode before implementing compact mode state.
## 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.
- Locked blocks stay hidden in compact mode unless explicitly revealed by a separate overlay action.
- Compact mode should use existing timeline item mapping instead of duplicate display logic where practical.
Acceptance criteria:
- Compact mode flag controls compact output selection.
- Tests cover manual toggle behavior.
- Tests cover compact mode not activating automatically after pushed/missed tasks.
- Tests cover compact mode showing the next required item when one exists.
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
## Chunk 8.4 — Timeline state regression test suite
Recommended Codex level: medium
Tasks:
Add or expand tests that protect the timeline mapping rules before Flutter UI work begins.
Cover these cases:
- flexible task card exposes duration, not forced explicit start/end text
- inflexible task card exposes explicit start/end text
- critical task card exposes explicit start/end text
- locked block overlay exposes explicit start/end text only when reveal mode is active
- locked block overlay is not treated as a task card
- project color token maps from project class/border token
- background token maps from task type
- reward icon token handles all five reward levels plus `not set`
- difficulty icon token handles all five difficulty levels
- quick actions map correctly for flexible tasks
- compact mode returns only the intended reduced item set
Rules:
- Do not implement Flutter widgets in this block unless explicitly requested later.
- Timeline models should be easy for Flutter to consume but must not import Flutter.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
Acceptance criteria:
- Timeline test group exists.
- Mapping rules are tested without depending on a UI framework.
- Existing scheduling, backlog, locked-block, and task-action tests still pass.
Commit suggestion:
```text
feat(timeline): add today timeline view state models
```

View file

@ -0,0 +1,122 @@
# 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.
- Repository interfaces should preserve domain rules rather than bypassing services.
Acceptance criteria:
- Domain services can depend on interfaces.
- Tests can use fake/in-memory repositories.
- Interfaces do not import Drift, SQLite, Flutter, or platform-specific APIs.
BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite.
## Chunk 9.2 — Persistence edge-case regression test suite
Recommended Codex level: extra high
Tasks:
Before model serialization changes, add or expand tests that define persistence-sensitive behavior clearly.
Cover these cases:
- task IDs remain stable across copy/update operations
- project IDs remain stable across copy/update operations
- locked block IDs remain stable across copy/update operations
- DateTime values are stored and compared using the documented convention
- nullable fields can be intentionally preserved
- nullable fields that need clearing have explicit clear behavior or documented mapping behavior
- enum values have stable persistence names or a clearly tested mapping plan
- `RewardLevel.notSet` remains distinct from `RewardLevel.veryLow`
- backlog age behavior has a documented source timestamp or TODO before database work
- in-memory fake repositories can round-trip saved records without losing scheduling fields
- repository operations do not mutate input objects unexpectedly
Rules:
- This chunk may add tests and small model helpers needed to make persistence behavior testable.
- Do not add Drift/SQLite implementation yet.
- Do not add sync, networking, cloud accounts, or background services.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
Acceptance criteria:
- Persistence-sensitive test group exists.
- Tests capture stable IDs, enum mapping, nullable-field behavior, and DateTime convention.
- Any unresolved persistence detail is documented as a TODO in the plan or architecture notes.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — 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
- nullable field clear helpers where needed
- migration-safe field names where possible
- map/json-like conversion helpers only if they do not over-couple the domain layer
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.
- Tests cover enum persistence mapping if implemented.
- Tests cover intentional clearing of nullable fields if helper behavior is added.
BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary.
## Chunk 9.4 — 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.
- No mobile notification or background reconciliation implementation in this block.
Acceptance criteria:
- README or architecture doc states sync is future work.
- Docs distinguish local persistence preparation from actual sync.
- Wishlist/future notes contain sync as a later feature, not a V1 requirement.
Commit suggestion:
```text
feat(data): prepare persistence interfaces for scheduling core
```

View file

@ -0,0 +1,127 @@
# 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 — Final scheduling rule coverage audit
Recommended Codex level: high
Tasks:
Audit the full V1 test suite and fill remaining coverage gaps for:
- 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
- required task states
- surprise task overlaps
- child task parent completion
- timeline state mapping
- persistence-sensitive model behavior
Rules:
- This is a final coverage audit, not a place to add large new features.
- If a missing behavior is found, implement the smallest domain fix needed and test it.
- Do not duplicate tests already added in earlier block-specific regression chunks unless the duplicate provides clearer final coverage.
- Tests should name the business rule being verified.
- Avoid brittle tests that only check implementation details.
Acceptance criteria:
- Test matrix exists as either a Markdown checklist or clearly grouped test names.
- Every V1 implemented behavior has at least one meaningful test.
- Edge-case tests added in Blocks 0609 are represented in the final coverage audit.
- `dart analyze` and `dart test` pass.
BREAKPOINT: Stop here. Confirm `medium` mode before final verification and documentation sync.
## Chunk 10.2 — Full verification pass
Recommended Codex level: medium
Tasks:
Run and record the standard verification commands:
```bash
dart pub get
dart format lib test
dart analyze
dart test
git diff --check
```
Rules:
- Do not mark this chunk complete unless all commands pass.
- If formatting changes files, include those changes in the same work block.
- If any command cannot run because of the local environment, document the exact reason and do not claim it passed.
Acceptance criteria:
- Verification results are recorded in the completed plan note or commit summary.
- No failing analyze/test/format/diff-check issues remain.
## Chunk 10.3 — Documentation sync
Recommended Codex level: medium
Tasks:
Update docs to reflect implemented behavior:
- README summary.
- Human Documentation product/design notes if affected.
- Relevant current plan statuses.
- Any architecture notes.
- Any TODOs moved to wishlist/future notes.
- Any known limitations that remain after V1.
Acceptance criteria:
- No docs claim V2 features are implemented in V1.
- MVP exclusions remain clear.
- Plan files accurately distinguish complete, active, and future work.
- Breakpoints and recommended Codex levels remain accurate after any renumbering.
BREAKPOINT: Stop here. Confirm `low` mode before archiving completed plans.
## Chunk 10.4 — 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.
Rules:
- Completed plans must not remain in `Codex Documentation/Current Software Plan/`.
- Incomplete or partially complete plans must not be archived.
- All completed work must be committed with a conventional commit message.
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,312 @@
# V1 Block 06 — Task Actions and State Transitions
Status: Planned
Purpose: Define and implement the safe, low-friction actions available from task cards.
This block must keep task actions predictable, testable, and aligned with the MVP product rules. Do not add UI, sync, persistence, reports, child-task implementation, or timeline rendering here unless a chunk explicitly asks for it.
## Chunk 6.1 — Flexible task quick actions
Recommended Codex level: medium
Status: Completed
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.
Execution notes:
- Added `FlexibleTaskActionService` for flexible card quick actions.
- Added `FlexibleTaskQuickAction` and `PushDestination` domain enums.
- Done marks a flexible task completed without changing its schedule placement.
- Backlog uses the scheduling engine backlog transition and clears schedule placement.
- Push returns explicit domain destinations: next available slot, tomorrow/top of queue, and backlog.
- Break up returns an explicit child-task-flow intent without creating children yet.
- Added tests for done, backlog, push destination representation, and break-up flow intent.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.
## Chunk 6.2 — Push destination behavior
Recommended Codex level: high
Status: Completed
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.
Execution notes:
- Added `PushDestinationResult` to carry selected destination metadata with the scheduling result.
- Added `FlexibleTaskActionService.applyPushDestination`.
- Next available delegates to `SchedulingEngine.pushFlexibleTaskToNextAvailableSlot`.
- Tomorrow/top-of-queue delegates to `SchedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue` and reports `placesAtTomorrowTopOfQueue`.
- Backlog destination updates the task list with `SchedulingEngine.moveToBacklog`, clears placement, and records a scheduling change.
- `PushDestination` remains limited to next available slot, tomorrow/top of queue, and backlog; later today is not represented.
- Added tests for next available, tomorrow/top-of-queue metadata, backlog clearing schedule placement, and the excluded later-today destination.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.
BREAKPOINT: Stop here. Confirm `extra high` mode before implementing Chunk 6.3, because the next chunk expands the regression and edge-case test suite and may require small correctness fixes.
## Chunk 6.3 — Edge-case regression test suite
Recommended Codex level: extra high
Status: Planned
Purpose:
Before adding more state-transition behavior, harden the existing implementation with a dedicated regression test suite. The goal is to prove that Blocks 02 through 06.2 are reliable before layering required-task states, surprise-task logging, child tasks, timeline state, or persistence on top of them.
Scope:
- Add tests first where possible.
- Make the smallest code changes needed to satisfy tests when tests expose real behavior gaps.
- Do not implement Required Task States from Chunk 6.4.
- Do not implement Surprise Task Logging from Chunk 6.5.
- Do not add Flutter UI.
- Do not add persistence/database code.
- Do not add sync/network behavior.
Required test organization:
- Keep existing tests passing.
- Split tests into clearer files if useful, for example:
- `test/models_test.dart`
- `test/scheduling_engine_test.dart`
- `test/backlog_test.dart`
- `test/quick_capture_test.dart`
- `test/locked_time_test.dart`
- `test/task_actions_test.dart`
- If tests remain in one file temporarily, group them with clear `group(...)` blocks.
Required edge-case coverage:
1. Domain model and copy behavior
Add or confirm tests for:
- `Task.quickCapture` trims titles.
- `Task.quickCapture` rejects empty/whitespace-only titles.
- `Task.copyWith(clearSchedule: true)` clears both `scheduledStart` and `scheduledEnd`.
- `Task.copyWith` preserves existing values when fields are not supplied.
- Any new nullable-field clear behavior, if added, is explicitly tested.
- `ProjectProfile.createTask` rejects empty/whitespace-only task titles, matching quick-capture behavior.
2. Flexible scheduling and push behavior
Add or confirm tests for:
- Next-available insertion uses the earliest flexible slot where the task fits.
- Inserting a backlog task pushes later flexible tasks forward in order.
- Flexible task order is preserved after pushing.
- Locked intervals are never moved.
- Inflexible intervals are never moved.
- Critical intervals are never moved.
- If no available slot exists, the result leaves the task unscheduled/backlogged rather than corrupting the plan.
- `PushDestination.nextAvailableSlot` delegates to the scheduling engine.
- `PushDestination.tomorrowTopOfQueue` places the task at the top of tomorrows flexible queue.
- `PushDestination.backlog` clears schedule placement and does not preserve original active-plan order.
3. End-of-day rollover safety
Add tests that specifically prevent accidental rollover corruption:
- Only unfinished flexible tasks from the intended source day roll over.
- Completed flexible tasks do not roll over.
- Cancelled tasks do not roll over.
- Backlogged tasks do not roll over.
- Critical tasks do not roll over through the flexible rollover path.
- Inflexible tasks do not roll over through the flexible rollover path.
- Future flexible tasks outside the source-day window are not pulled into tomorrow accidentally.
- Rolled-over tasks appear at the top of tomorrows flexible queue and preserve relative order among themselves.
If the current rollover API lacks enough source-day context, adjust the API minimally so the source-day boundary is explicit and covered by tests.
4. Backlog behavior and staleness
Add or confirm tests for:
- Unified backlog filtering by status/category works as expected.
- Pushed, critical-missed, wishlist, inbox, stale, and no-reward-set filters do not overlap incorrectly.
- Sorting by priority is stable and deterministic.
- Sorting by reward-vs-effort treats `RewardLevel.notSet` as its own neutral/unknown category, not as equivalent to very low reward.
- Sorting by age is deterministic when multiple tasks have the same age marker.
- Staleness marker thresholds match the configured/default rules:
- younger than/equal to 7 days = fresh/green
- older than 7 days through 30 days = aging/blue
- older than 30 days = stale/purple
- Stale filtering and staleness marker behavior use consistent timestamp semantics.
If the current model cannot distinguish task creation age from backlog age, do not add database persistence yet. Either document the limitation in code comments/tests or add a minimal in-memory/domain field only if necessary and safe for MVP.
5. Quick capture behavior
Add or confirm tests for:
- Quick capture defaults to backlog/inbox/unsorted behavior.
- Priority defaults to medium.
- Reward defaults to not set.
- Duration can remain unset when the task is not immediately scheduled.
- Checking “add to next available slot” requires or collects enough scheduling data to place the task.
- Quick capture with immediate scheduling uses normal flexible insertion/push rules.
- Quick capture cannot create blank tasks.
6. Locked block and overlay-domain behavior
Add or confirm tests for:
- Recurring locked blocks expand into concrete blocked intervals for the requested day.
- One-day remove override removes only that days instance.
- One-day replace override changes only that days interval.
- One-day add override adds surprise locked time without changing the recurring rule.
- Locked blocks are hidden by default in normal task-style output/domain views.
- Overlay/reveal data can expose the locked block name as blocked time, not as a normal task.
- Completed-during-locked-hours counters increment correctly.
- Tasks completed outside locked hours do not increment locked-hour counters.
7. Flexible action service behavior
Add or confirm tests for:
- `Done` marks flexible task completed.
- `Backlog` moves the task to backlog and clears schedule.
- `Push` returns only supported destinations.
- `Break up` returns an intent only and does not create child tasks in Block 06.
- Unsupported action/type combinations fail safely and predictably.
8. Regression and safety checks
Add tests or assertions for:
- No duplicate task IDs are created by task-action helpers unless explicitly intended.
- Scheduling results include enough change metadata for later UI/reporting.
- Required/inflexible/locked blocks are never silently converted into flexible tasks.
- All public domain services avoid mutating input lists in place unless explicitly documented.
Acceptance criteria:
- Existing test count increases meaningfully beyond the current baseline.
- The new tests cover edge cases from Blocks 02 through 06.2.
- Any code changes are minimal and directly tied to failing edge-case tests.
- No Required Task States implementation is added in this chunk.
- No Surprise Task Logging implementation is added in this chunk.
- `dart format lib test` passes.
- `dart analyze` passes.
- `dart test` passes.
- `git diff --check` passes.
- Commit with a conventional commit message.
Commit suggestion:
```text
test(tasks): add edge-case regression coverage before state transitions
```
BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 required task state transitions.
## Chunk 6.4 — Required task states
Recommended Codex level: medium
Status: Planned
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.
- Locked blocks are not normal tasks and should not be handled by this state-transition service as task cards.
Acceptance criteria:
- Tests cover critical missed to backlog.
- Tests cover inflexible missed stays in schedule/history.
- Tests cover cancelled vs noLongerRelevant distinction.
- Tests cover done state for required/inflexible task cards.
- Tests cover safe handling of unsupported task types.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` pass.
Commit suggestion:
```text
feat(tasks): implement required task state transitions
```
BREAKPOINT: Stop here. Confirm `high` mode before implementing Chunk 6.5 surprise task logging.
## Chunk 6.5 — Surprise task logging
Recommended Codex level: high
Status: Planned
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.
- Test: optional fields can be omitted without blocking save.
- Test: normal push rules are reused rather than duplicated.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` pass.
Commit suggestion:
```text
feat(tasks): implement surprise task logging
```

View file

@ -15,17 +15,20 @@ Implement parent/child ownership fields and helpers:
- parent task id on child tasks - parent task id on child tasks
- list/query children by parent - list/query children by parent
- parent can be incomplete while children are planned/completed - parent can be incomplete while children are planned/completed
- parent/child helpers must remain domain-only and UI-independent
Rules: Rules:
- This is not full task dependency support. - This is not full task dependency support.
- Do not add arbitrary DAG/dependency logic. - Do not add arbitrary DAG/dependency logic.
- Children are owned by parent only. - Children are owned by parent only.
- Child ownership does not block scheduling unless a later planned feature explicitly adds dependency behavior.
Acceptance criteria: Acceptance criteria:
- Test: child references parent. - Test: child references parent.
- Test: parent can query/aggregate children through helper. - Test: parent can query/aggregate children through helper.
- Test: child ownership does not create dependency/blocking behavior.
## Chunk 7.2 — Child entry defaults ## Chunk 7.2 — Child entry defaults
@ -39,19 +42,63 @@ Support row-style child entry data:
- priority up/down/dropdown value - priority up/down/dropdown value
- reward up/down/dropdown value - reward up/down/dropdown value
- time required - time required
- optional project override
Rules: Rules:
- If no priority is set, children are inserted in the order added. - If no priority is set, children are inserted in the order added.
- Children can have their own reward, priority, and time. - Children can have their own reward, priority, difficulty, and time.
- Children inherit project from parent unless overridden. - Children inherit project from parent unless overridden.
- Reward can remain `not set`; do not treat missing reward as very low reward.
- Preserve original entry order for children that have no explicit priority.
Acceptance criteria: Acceptance criteria:
- Tests cover child creation with explicit fields. - Tests cover child creation with explicit fields.
- Tests cover no priority preserving insertion order. - Tests cover no priority preserving insertion order.
- Tests cover inherited project and overridden project.
- Tests cover reward `not set` remaining distinct from very low reward.
## Chunk 7.3 — Parent auto-completion BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the child-task edge-case regression suite.
## Chunk 7.3 — Child-task edge-case regression test suite
Recommended Codex level: extra high
Tasks:
Before implementing parent auto-completion, add or expand tests for edge cases that could make child tasks behave like unwanted dependency logic.
Cover these cases:
- parent with zero children does not auto-complete by accident
- parent with planned children remains incomplete
- child completion updates child state without forcing parent completion until all children are complete
- parent completion can force-complete remaining children only through the explicit parent-complete action
- child tasks keep their parent id when scheduled, pushed, moved to backlog, or marked complete
- children without priority preserve row insertion order
- children with priority can be sorted by priority without losing stable insertion order within same-priority groups
- parent project inheritance works
- child project override works
- child task reward `not set` remains distinct from very low reward
- no arbitrary dependency/DAG fields or scheduling-blocking behavior are introduced
Rules:
- Tests should name the business rule being protected.
- Prefer small fixtures over large scenario setup.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
- Do not implement new user-facing behavior in this chunk unless needed to make the edge-case tests compile against planned domain APIs.
Acceptance criteria:
- A dedicated child-task test group exists.
- Tests cover ownership, ordering, inheritance, and non-dependency behavior.
- Existing scheduling and backlog tests still pass.
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation.
## Chunk 7.4 — Parent auto-completion
Recommended Codex level: high Recommended Codex level: high
@ -62,14 +109,21 @@ Implement completion rules:
- Parent auto-completes when all children complete. - Parent auto-completes when all children complete.
- Marking parent complete force-completes remaining children. - Marking parent complete force-completes remaining children.
- Completing from any child can provide a domain-level option/result to mark entire parent complete. - Completing from any child can provide a domain-level option/result to mark entire parent complete.
- Parent/child completion should update relevant task statistics without duplicating events.
Rules:
- Do not silently complete sibling child tasks when one child completes.
- Do not complete parent until every child is complete unless the explicit parent-complete action is selected.
- Do not add generalized dependency resolution.
Acceptance criteria: Acceptance criteria:
- Test: all children completed completes parent. - Test: all children completed completes parent.
- Test: parent complete force-completes children. - Test: parent complete force-completes children.
- Test: partial child completion does not complete parent. - Test: partial child completion does not complete parent.
- Test: completing one child does not complete siblings.
BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion propagation. - Test: explicit parent-complete action records the correct completion state for parent and remaining children.
Commit suggestion: Commit suggestion:

View file

@ -21,6 +21,7 @@ Create a UI-independent timeline item model with fields for:
- difficulty icon token - difficulty icon token
- explicit time display flag - explicit time display flag
- quick actions available - quick actions available
- item category that distinguishes task cards from overlays
Rules: Rules:
@ -31,10 +32,13 @@ Rules:
- Icon 2 is difficulty. - Icon 2 is difficulty.
- Flexible task cards show duration; timeline position shows start/stop. - Flexible task cards show duration; timeline position shows start/stop.
- Inflexible, critical, and locked show explicit start/end times. - Inflexible, critical, and locked show explicit start/end times.
- Timeline state must remain UI-framework-independent.
Acceptance criteria: Acceptance criteria:
- Tests or snapshots can verify field mapping from task to timeline item. - Tests or snapshots can verify field mapping from task to timeline item.
- Tests cover flexible duration display.
- Tests cover explicit time display for inflexible, critical, and locked items.
## Chunk 8.2 — Hidden locked block overlay state ## Chunk 8.2 — Hidden locked block overlay state
@ -48,11 +52,16 @@ Create overlay state for locked blocks:
- temporary reveal mode - temporary reveal mode
- named blocked-time overlay - named blocked-time overlay
- not a normal task block - not a normal task block
- overlay item category distinct from task-card category
Acceptance criteria: Acceptance criteria:
- Locked block can produce an overlay item only when reveal mode is active. - Locked block can produce an overlay item only when reveal mode is active.
- Overlay item is distinguishable from task card item. - Overlay item is distinguishable from task card item.
- Overlay item shows a name and explicit start/end times.
- Locked overlay does not expose normal task quick actions.
BREAKPOINT: Stop here. Confirm `low` mode before implementing compact mode state.
## Chunk 8.3 — Compact mode state ## Chunk 8.3 — Compact mode state
@ -71,11 +80,51 @@ Add domain/app state for manual compact mode:
Rules: Rules:
- The app must not automatically switch into compact mode after missed/pushed tasks. - The app must not automatically switch into compact mode after missed/pushed tasks.
- Locked blocks stay hidden in compact mode unless explicitly revealed by a separate overlay action.
- Compact mode should use existing timeline item mapping instead of duplicate display logic where practical.
Acceptance criteria: Acceptance criteria:
- Compact mode flag controls compact output selection. - Compact mode flag controls compact output selection.
- Tests cover manual toggle behavior. - Tests cover manual toggle behavior.
- Tests cover compact mode not activating automatically after pushed/missed tasks.
- Tests cover compact mode showing the next required item when one exists.
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
## Chunk 8.4 — Timeline state regression test suite
Recommended Codex level: medium
Tasks:
Add or expand tests that protect the timeline mapping rules before Flutter UI work begins.
Cover these cases:
- flexible task card exposes duration, not forced explicit start/end text
- inflexible task card exposes explicit start/end text
- critical task card exposes explicit start/end text
- locked block overlay exposes explicit start/end text only when reveal mode is active
- locked block overlay is not treated as a task card
- project color token maps from project class/border token
- background token maps from task type
- reward icon token handles all five reward levels plus `not set`
- difficulty icon token handles all five difficulty levels
- quick actions map correctly for flexible tasks
- compact mode returns only the intended reduced item set
Rules:
- Do not implement Flutter widgets in this block unless explicitly requested later.
- Timeline models should be easy for Flutter to consume but must not import Flutter.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
Acceptance criteria:
- Timeline test group exists.
- Mapping rules are tested without depending on a UI framework.
- Existing scheduling, backlog, locked-block, and task-action tests still pass.
Commit suggestion: Commit suggestion:

View file

@ -2,7 +2,9 @@
Status: Planned Status: Planned
Purpose: Prepare the domain layer for local persistence without overbuilding sync or database implementation too early. Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early.
MongoDB is the committed persistence target. This block should make the core persistence-friendly and MongoDB-document-friendly while keeping the scheduling engine independent from database APIs.
## Chunk 9.1 — Repository interface ## Chunk 9.1 — Repository interface
@ -19,56 +21,123 @@ Define repository interfaces for:
Rules: Rules:
- Do not couple scheduling engine directly to SQLite. - Do not couple the scheduling engine directly to MongoDB APIs.
- Use interfaces that a future Drift/SQLite layer can implement. - Use interfaces that a future MongoDB adapter can implement.
- Keep current implementation in-memory if needed. - Keep the current implementation in-memory if needed.
- Repository interfaces should preserve domain rules rather than bypassing services.
- Do not introduce alternative database assumptions.
Acceptance criteria: Acceptance criteria:
- Domain services can depend on interfaces. - Domain services can depend on interfaces.
- Tests can use fake/in-memory repositories. - Tests can use fake/in-memory repositories.
- Interfaces do not import MongoDB, Flutter, platform-specific APIs, or network/client APIs.
- Interface names and method shapes are compatible with document-style persistence.
## Chunk 9.2 — Serialization-safe models BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite.
## Chunk 9.2 — Persistence edge-case regression test suite
Recommended Codex level: extra high
Tasks:
Before model serialization changes, add or expand tests that define persistence-sensitive behavior clearly.
Cover these cases:
- task IDs remain stable across copy/update operations
- project IDs remain stable across copy/update operations
- locked block IDs remain stable across copy/update operations
- DateTime values are stored and compared using the documented convention
- nullable fields can be intentionally preserved
- nullable fields that need clearing have explicit clear behavior or documented mapping behavior
- enum values have stable persistence names or a clearly tested mapping plan
- `RewardLevel.notSet` remains distinct from `RewardLevel.veryLow`
- backlog age behavior has a documented source timestamp or TODO before database work
- in-memory fake repositories can round-trip saved records without losing scheduling fields
- repository operations do not mutate input objects unexpectedly
- document-shaped maps preserve all fields needed by future MongoDB persistence
- generated document field names are stable and migration-friendly
Rules:
- This chunk may add tests and small model helpers needed to make persistence behavior testable.
- Do not add a MongoDB driver, MongoDB client, Atlas setup, connection string, local MongoDB service requirement, or database adapter yet.
- Do not add alternative database mappings or SQL-specific terminology.
- Do not add sync, networking, cloud accounts, or background services.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
Acceptance criteria:
- Persistence-sensitive test group exists.
- Tests capture stable IDs, enum mapping, nullable-field behavior, DateTime convention, and document-shaped mapping expectations.
- Any unresolved persistence detail is documented as a TODO in the plan or architecture notes.
- No alternative-database references remain in the active persistence plan.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — MongoDB-document-safe models
Recommended Codex level: high Recommended Codex level: high
Tasks: Tasks:
Make models persistence-friendly: Make models persistence-friendly and MongoDB-document-friendly:
- stable IDs - stable IDs
- enum serialization helpers or clear mapping plan - enum serialization helpers or clear mapping plan
- DateTime handling convention - DateTime handling convention
- nullable fields documented - nullable fields documented
- migration-safe field names where possible - nullable field clear helpers where needed
- migration-safe document field names where possible
- document-shaped map conversion helpers only if they do not over-couple the domain layer
- clear mapping rules for nested task statistics and future child-task ownership fields
Rules:
- Keep model helpers independent from MongoDB client libraries.
- Prefer plain Dart map/document shapes that a later adapter can translate into MongoDB documents.
- Do not introduce database connection logic in this chunk.
- Do not add SQL-style table assumptions, joins, or relational schema requirements.
Acceptance criteria: Acceptance criteria:
- Models can round-trip through map/json-like structures or have a clear TODO for Drift mappings. - Models can round-trip through document-shaped map/json-like structures or have a clear TODO for MongoDB document mappings.
- Tests cover at least task serialization if implemented. - Tests cover at least task serialization if implemented.
- Tests cover enum persistence mapping if implemented.
- Tests cover intentional clearing of nullable fields if helper behavior is added.
- Tests confirm `not set` reward remains distinct from very low reward through mapping.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary.
## Chunk 9.3 — Explicit non-sync boundary ## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary
Recommended Codex level: low Recommended Codex level: low
Tasks: Tasks:
Document that V1 persistence is local-first and sync is out of scope. Document that V1 persistence work prepares for MongoDB but does not implement runtime database access, sync, accounts, or background services.
Rules: Rules:
- No network sync code. - No network sync code.
- No cloud assumptions. - No cloud assumptions.
- No MongoDB connection strings.
- No Atlas setup.
- No local MongoDB server requirement.
- No background service implementation. - No background service implementation.
- No mobile notification or background reconciliation implementation in this block.
Acceptance criteria: Acceptance criteria:
- README or architecture doc states sync is future work. - README or architecture doc states MongoDB is the committed persistence target.
- README or architecture doc states this block does not require a running MongoDB instance.
- Docs distinguish MongoDB persistence preparation from actual sync or database adapter implementation.
- Wishlist/future notes contain sync as a later feature, not a V1 requirement.
Commit suggestion: Commit suggestion:
```text ```text
feat(data): prepare persistence interfaces for scheduling core feat(data): prepare MongoDB-friendly persistence interfaces
``` ```

View file

@ -4,13 +4,13 @@ Status: Planned
Purpose: Ensure the V1 scheduling core is understandable, tested, and ready for future UI work. Purpose: Ensure the V1 scheduling core is understandable, tested, and ready for future UI work.
## Chunk 10.1 — Scheduling rule test matrix ## Chunk 10.1 — Final scheduling rule coverage audit
Recommended Codex level: high Recommended Codex level: high
Tasks: Tasks:
Create a test matrix covering: Audit the full V1 test suite and fill remaining coverage gaps for:
- flexible insert into free slot - flexible insert into free slot
- flexible insert pushing other flexible tasks - flexible insert pushing other flexible tasks
@ -21,15 +21,57 @@ Create a test matrix covering:
- push to tomorrow top of queue - push to tomorrow top of queue
- push to backlog - push to backlog
- end-of-day rollover - end-of-day rollover
- required task states
- surprise task overlaps - surprise task overlaps
- child task parent completion - child task parent completion
- timeline state mapping
- persistence-sensitive model behavior
Rules:
- This is a final coverage audit, not a place to add large new features.
- If a missing behavior is found, implement the smallest domain fix needed and test it.
- Do not duplicate tests already added in earlier block-specific regression chunks unless the duplicate provides clearer final coverage.
- Tests should name the business rule being verified.
- Avoid brittle tests that only check implementation details.
Acceptance criteria: Acceptance criteria:
- Tests clearly name the business rule being verified. - Test matrix exists as either a Markdown checklist or clearly grouped test names.
- Avoid brittle tests that only check implementation details. - Every V1 implemented behavior has at least one meaningful test.
- Edge-case tests added in Blocks 0609 are represented in the final coverage audit.
- `dart analyze` and `dart test` pass.
## Chunk 10.2 — Documentation sync BREAKPOINT: Stop here. Confirm `medium` mode before final verification and documentation sync.
## Chunk 10.2 — Full verification pass
Recommended Codex level: medium
Tasks:
Run and record the standard verification commands:
```bash
dart pub get
dart format lib test
dart analyze
dart test
git diff --check
```
Rules:
- Do not mark this chunk complete unless all commands pass.
- If formatting changes files, include those changes in the same work block.
- If any command cannot run because of the local environment, document the exact reason and do not claim it passed.
Acceptance criteria:
- Verification results are recorded in the completed plan note or commit summary.
- No failing analyze/test/format/diff-check issues remain.
## Chunk 10.3 — Documentation sync
Recommended Codex level: medium Recommended Codex level: medium
@ -38,18 +80,22 @@ Tasks:
Update docs to reflect implemented behavior: Update docs to reflect implemented behavior:
- README summary. - README summary.
- Human Documentation product/design notes if affected.
- Relevant current plan statuses. - Relevant current plan statuses.
- Any architecture notes. - Any architecture notes.
- Any TODOs moved to wishlist/future notes. - Any TODOs moved to wishlist/future notes.
- Any known limitations that remain after V1.
Acceptance criteria: Acceptance criteria:
- No docs claim V2 features are implemented in V1. - No docs claim V2 features are implemented in V1.
- MVP exclusions remain clear. - MVP exclusions remain clear.
- Plan files accurately distinguish complete, active, and future work.
- Breakpoints and recommended Codex levels remain accurate after any renumbering.
BREAKPOINT: Stop here. Confirm mode switch if needed before final cleanup. BREAKPOINT: Stop here. Confirm `low` mode before archiving completed plans.
## Chunk 10.3 — Archive completed plans ## Chunk 10.4 — Archive completed plans
Recommended Codex level: low Recommended Codex level: low
@ -62,6 +108,12 @@ For every completed block plan:
- Leave active/incomplete plans in Current Software Plan. - Leave active/incomplete plans in Current Software Plan.
- Commit archive moves. - Commit archive moves.
Rules:
- Completed plans must not remain in `Codex Documentation/Current Software Plan/`.
- Incomplete or partially complete plans must not be archived.
- All completed work must be committed with a conventional commit message.
Acceptance criteria: Acceptance criteria:
- Completed plans are not left in Current Software Plan. - Completed plans are not left in Current Software Plan.

View file

@ -0,0 +1,27 @@
# Documentation Pass Summary
This archive is the same starter Dart scheduling core with expanded inline documentation added to the code files under `lib/`.
## What changed
- Added file-level reading guides to each Dart file.
- Expanded class, enum, field, getter, method, and helper documentation.
- Added inline comments in the scheduling engine to explain validation, placement planning, and application flow.
- Documented how core concepts are meant to be used by UI, persistence, tests, and future planner features.
## Files documented
- `lib/scheduler_core.dart`
- `lib/src/models.dart`
- `lib/src/backlog.dart`
- `lib/src/locked_time.dart`
- `lib/src/quick_capture.dart`
- `lib/src/scheduling_engine.dart`
- `lib/src/task_actions.dart`
- `lib/src/task_statistics.dart`
## Behavior check
Only comments and documentation text were added to the Dart files. A comparison that strips comments and blank lines reports the non-comment code as unchanged from the uploaded archive.
`dart format` was not run because the Dart SDK is not installed in this execution environment. The comments were inserted to preserve the existing formatting style as closely as possible.

View file

@ -15,9 +15,9 @@ Pure Dart scheduling core
Repository interfaces Repository interfaces
Local persistence, likely SQLite/Drift later MongoDB persistence adapter (planned later)
Future sync layer Future sync layer, if explicitly planned
``` ```
## Why pure Dart first ## Why pure Dart first
@ -30,3 +30,14 @@ Future sync layer
## Key invariant ## Key invariant
The scheduling core must never move locked or inflexible blocks during automatic rescheduling. The scheduling core must never move locked or inflexible blocks during automatic rescheduling.
## Persistence direction
MongoDB is the committed persistence target. The V1 scheduling core should still
remain persistence-independent and testable without a running database. Repository
interfaces should be designed so a later MongoDB adapter can persist document-shaped
models without importing MongoDB APIs into scheduling logic.
Do not add alternative database assumptions to this project unless
the product owner explicitly changes the persistence decision.

View file

@ -8,6 +8,7 @@ The repository intentionally starts with a **pure Dart scheduling core** before
- V1/MVP: Today view, backlog/wishlist, quick capture, flexible task pushing, recurring hidden locked blocks, task-state transitions, and a testable scheduling core. - 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. - V2.0: Week/month views, reports, overwhelm shield, drag-and-drop, task history panels.
- Persistence direction: MongoDB is the committed database target, but the current core remains database-independent until the active plan adds a MongoDB adapter.
- Wishlist/future: Dependencies, context tags, advanced sync, long-running task behavior decisions. - Wishlist/future: Dependencies, context tags, advanced sync, long-running task behavior decisions.
## Repository layout ## Repository layout
@ -38,7 +39,9 @@ The repository intentionally starts with a **pure Dart scheduling core** before
## Basic commands ## Basic commands
Install a Dart SDK that satisfies `pubspec.yaml` first. This starter is a pure 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. Dart package, so Flutter is not required for the current core/test loop. MongoDB
is the planned persistence target, but no database service is required for the
current in-memory domain/test loop.
```bash ```bash
dart pub get dart pub get

Binary file not shown.

View file

@ -1,3 +1,17 @@
// Public library entry point for the ADHD scheduling core.
//
// Import this file from app/UI code instead of importing individual files from
// `lib/src/`. That keeps the public API explicit while allowing the internal
// file layout to change later. At the moment this library exposes pure domain
// objects and scheduling helpers only; it does not perform persistence, UI work,
// notifications, or network calls.
//
// Typical usage:
// 1. Build or load `Task`, `ProjectProfile`, and locked-time objects.
// 2. Create a `SchedulingInput` for the day being planned.
// 3. Call `SchedulingEngine` or one of the action services.
// 4. Store the returned immutable task list and surface notices/changes in UI.
// Public exports for the ADHD scheduling core. // Public exports for the ADHD scheduling core.
// //
// Keep this file small. Implementation details belong in `lib/src/`. // Keep this file small. Implementation details belong in `lib/src/`.

View file

@ -1,41 +1,100 @@
// Backlog query helpers for the ADHD scheduling core.
//
// The backlog is not a separate database table in this starter model. It is a
// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps
// display-oriented backlog filtering, sorting, and age markers out of the main
// scheduler so the engine can focus on moving tasks through time.
import 'models.dart'; import 'models.dart';
/// Derived backlog filters for a unified backlog list. /// Derived backlog filters for a unified backlog list.
///
/// These filters do not store separate task collections. They are projections
/// over the same master task list. That is important because a task can move
/// between today's timeline and backlog by changing [Task.status], without
/// needing to copy it between separate stores.
enum BacklogFilter { enum BacklogFilter {
/// Uncategorized captured tasks in the default inbox project.
inbox, inbox,
/// Tasks that have been manually or automatically pushed at least once.
pushed, pushed,
/// Critical tasks that have missed at least once and need recovery attention.
criticalMissed, criticalMissed,
/// Someday/maybe tasks that are intentionally kept out of normal pressure.
wishlist, wishlist,
/// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold.
stale, stale,
/// Tasks still missing a reward estimate. Useful during cleanup/review.
noRewardSet, noRewardSet,
} }
/// Sort options for a unified backlog list. /// Sort options for a unified backlog list.
///
/// Sort keys are intentionally product-facing rather than database-facing. For
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
/// field. Persistence can later index the underlying fields if needed.
enum BacklogSortKey { enum BacklogSortKey {
/// Highest priority first.
priority, priority,
/// Best simple reward-minus-difficulty score first.
rewardVsEffort, rewardVsEffort,
/// Oldest created task first.
age, age,
/// Lexicographic project id grouping. Future UI can replace this with project
/// display order while keeping the same public key.
project, project,
/// Most frequently pushed tasks first.
timesPushed, timesPushed,
} }
/// Visual age bucket for backlog display. /// Visual age bucket for backlog display.
///
/// This supports the design rule that old backlog items should visually age
/// from green to blue to purple. The enum names describe semantic buckets; UI
/// code should translate them into actual theme colors.
enum BacklogStalenessMarker { enum BacklogStalenessMarker {
/// Fresh backlog item. Default: created within seven days.
green, green,
/// Aging backlog item. Default: created within thirty days.
blue, blue,
/// Old/stale backlog item. Default: created more than thirty days ago.
purple, purple,
} }
/// Configurable thresholds for backlog age markers. /// Configurable thresholds for backlog age markers.
///
/// The defaults match the current design spec: less than a week is fresh, less
/// than a month is aging, and anything older is stale. Keeping the thresholds in
/// a value object makes future settings/preferences easy to inject in tests or
/// user configuration.
class BacklogStalenessSettings { class BacklogStalenessSettings {
const BacklogStalenessSettings({ const BacklogStalenessSettings({
this.greenMaxAge = const Duration(days: 7), this.greenMaxAge = const Duration(days: 7),
this.blueMaxAge = const Duration(days: 30), this.blueMaxAge = const Duration(days: 30),
}); });
/// Maximum age that still counts as fresh/green.
final Duration greenMaxAge; final Duration greenMaxAge;
/// Maximum age that still counts as aging/blue. Anything older is purple.
final Duration blueMaxAge; final Duration blueMaxAge;
/// Return the visual age marker for [task] relative to [now].
///
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
/// meant to show how long the idea has existed in the system. A task edited
/// yesterday but created two months ago should still feel old in the backlog.
BacklogStalenessMarker markerFor({ BacklogStalenessMarker markerFor({
required Task task, required Task task,
required DateTime now, required DateTime now,
@ -55,6 +114,11 @@ class BacklogStalenessSettings {
} }
/// Read-only backlog projection over the unified task list. /// Read-only backlog projection over the unified task list.
///
/// [BacklogView] is a query/helper object. It does not mutate tasks or own data;
/// it receives the current task list and exposes common backlog slices for UI.
/// That keeps backlog display logic out of widgets and avoids duplicating the
/// same filtering rules in multiple screens.
class BacklogView { class BacklogView {
const BacklogView({ const BacklogView({
required this.tasks, required this.tasks,
@ -63,21 +127,42 @@ class BacklogView {
this.stalenessSettings = const BacklogStalenessSettings(), this.stalenessSettings = const BacklogStalenessSettings(),
}); });
/// Master task list supplied by the caller. Only `status == backlog` items are
/// shown by this view.
final List<Task> tasks; final List<Task> tasks;
/// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now; final DateTime now;
/// Age since [Task.updatedAt] that qualifies for the `stale` filter.
final Duration staleAfter; final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators.
final BacklogStalenessSettings stalenessSettings; final BacklogStalenessSettings stalenessSettings;
/// All tasks currently in backlog status.
///
/// The returned list is a snapshot. It is not intended to be modified and then
/// written back; state changes should go through scheduling/action services.
List<Task> get backlogTasks { List<Task> get backlogTasks {
return tasks.where((task) => task.isBacklog).toList(growable: false); return tasks.where((task) => task.isBacklog).toList(growable: false);
} }
/// Return backlog tasks matching a single user-facing filter.
///
/// Filtering always starts from [backlogTasks], so a completed or planned task
/// will never appear here even if it has matching statistics.
List<Task> filter(BacklogFilter filter) { List<Task> filter(BacklogFilter filter) {
return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( return backlogTasks.where((task) => _matchesFilter(task, filter)).toList(
growable: false, growable: false,
); );
} }
/// Return all backlog tasks sorted by a user-facing ordering.
///
/// A new list is created before sorting so the original [tasks] list is never
/// reordered by a read operation. The final list is unmodifiable to make that
/// intent explicit to callers.
List<Task> sorted(BacklogSortKey sortKey) { List<Task> sorted(BacklogSortKey sortKey) {
final sortedTasks = [...backlogTasks]; final sortedTasks = [...backlogTasks];
sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey)); sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey));
@ -85,10 +170,15 @@ class BacklogView {
return List<Task>.unmodifiable(sortedTasks); return List<Task>.unmodifiable(sortedTasks);
} }
/// Return the green/blue/purple marker for one task.
BacklogStalenessMarker stalenessMarkerFor(Task task) { BacklogStalenessMarker stalenessMarkerFor(Task task) {
return stalenessSettings.markerFor(task: task, now: now); return stalenessSettings.markerFor(task: task, now: now);
} }
/// Private predicate implementing every [BacklogFilter] option.
///
/// Keeping this as a switch expression makes new filters obvious: add the enum
/// value and the compiler forces this method to handle it.
bool _matchesFilter(Task task, BacklogFilter filter) { bool _matchesFilter(Task task, BacklogFilter filter) {
return switch (filter) { return switch (filter) {
BacklogFilter.inbox => task.projectId == 'inbox', BacklogFilter.inbox => task.projectId == 'inbox',
@ -102,6 +192,10 @@ class BacklogView {
}; };
} }
/// Comparison callback used by [sorted].
///
/// Sort directions are encoded here. Higher priority/reward/push counts should
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
return switch (sortKey) { return switch (sortKey) {
BacklogSortKey.priority => BacklogSortKey.priority =>
@ -115,6 +209,10 @@ class BacklogView {
} }
} }
/// Convert nullable priority into a stable numeric rank for sorting.
///
/// Null priority is treated like medium so partially imported data behaves like
/// normal starter tasks instead of sinking to the bottom.
int _priorityRank(PriorityLevel? priority) { int _priorityRank(PriorityLevel? priority) {
return switch (priority) { return switch (priority) {
PriorityLevel.veryLow => 0, PriorityLevel.veryLow => 0,
@ -125,6 +223,7 @@ int _priorityRank(PriorityLevel? priority) {
}; };
} }
/// Convert reward enum values to numeric ranks for derived scoring.
int _rewardRank(RewardLevel reward) { int _rewardRank(RewardLevel reward) {
return switch (reward) { return switch (reward) {
RewardLevel.notSet => 0, RewardLevel.notSet => 0,
@ -136,6 +235,7 @@ int _rewardRank(RewardLevel reward) {
}; };
} }
/// Convert difficulty enum values to numeric ranks for derived scoring.
int _difficultyRank(DifficultyLevel difficulty) { int _difficultyRank(DifficultyLevel difficulty) {
return switch (difficulty) { return switch (difficulty) {
DifficultyLevel.notSet => 0, DifficultyLevel.notSet => 0,
@ -147,10 +247,17 @@ int _difficultyRank(DifficultyLevel difficulty) {
}; };
} }
/// Simple motivation score: reward minus difficulty.
///
/// Positive scores suggest high payoff for lower activation cost. Negative scores
/// suggest high effort for lower payoff. This is deliberately simple for V1 and
/// can be replaced by richer heuristics later without changing the public sort
/// key.
int _rewardVsEffortScore(Task task) { int _rewardVsEffortScore(Task task) {
return _rewardRank(task.reward) - _difficultyRank(task.difficulty); return _rewardRank(task.reward) - _difficultyRank(task.difficulty);
} }
/// Total manual and automatic pushes recorded on the task.
int _timesPushed(Task task) { int _timesPushed(Task task) {
return task.stats.manuallyPushedCount + task.stats.autoPushedCount; return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
} }

View file

@ -1,6 +1,17 @@
// Locked-time modeling and expansion.
//
// Locked time is anything the flexible scheduler should treat as unavailable:
// work, appointments, streams, sleep boundaries, relationship blocks, or one-off
// interruptions. This file converts human-friendly locked block definitions into
// concrete `TimeInterval`s that the scheduler can avoid.
import 'models.dart'; import 'models.dart';
/// Weekday value using DateTime's Monday-first convention. /// Weekday value using DateTime's Monday-first convention.
///
/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`.
/// This enum wraps those integers so the rest of the code can talk in readable
/// names while still comparing directly to [DateTime.weekday].
enum LockedWeekday { enum LockedWeekday {
monday(DateTime.monday), monday(DateTime.monday),
tuesday(DateTime.tuesday), tuesday(DateTime.tuesday),
@ -12,10 +23,15 @@ enum LockedWeekday {
const LockedWeekday(this.dateTimeValue); const LockedWeekday(this.dateTimeValue);
/// Matching [DateTime.weekday] integer value.
final int dateTimeValue; final int dateTimeValue;
} }
/// Time of day without a calendar date. /// Time of day without a calendar date.
///
/// Locked blocks are often defined as "every weekday from 8:00 to 17:00" rather
/// than as one specific timestamp. [ClockTime] stores just the hour/minute part
/// and can later be projected onto a concrete date with [onDate].
class ClockTime { class ClockTime {
const ClockTime({ const ClockTime({
required this.hour, required this.hour,
@ -23,28 +39,53 @@ class ClockTime {
}) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'), }) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'),
assert(minute >= 0 && minute < 60, 'minute must be 0-59'); assert(minute >= 0 && minute < 60, 'minute must be 0-59');
/// 24-hour clock hour, 0 through 23.
final int hour; final int hour;
/// Minute within the hour, 0 through 59.
final int minute; final int minute;
/// Combine this time-of-day with [date].
///
/// Only the year, month, and day from [date] are used. Seconds and smaller
/// units are intentionally reset because locked blocks in this starter project
/// operate at minute precision.
DateTime onDate(DateTime date) { DateTime onDate(DateTime date) {
return DateTime(date.year, date.month, date.day, hour, minute); return DateTime(date.year, date.month, date.day, hour, minute);
} }
} }
/// Recurrence rule for locked time. /// Recurrence rule for locked time.
///
/// The current starter implementation only supports weekly recurrence because
/// that covers the product's fixed work/stream/relationship blocks. This object
/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence
/// rules can be added later without changing the rest of the locked-time model.
class LockedBlockRecurrence { class LockedBlockRecurrence {
const LockedBlockRecurrence.weekly({ const LockedBlockRecurrence.weekly({
required this.weekdays, required this.weekdays,
}); });
/// Weekdays when this recurrence should produce an occurrence.
final Set<LockedWeekday> weekdays; final Set<LockedWeekday> weekdays;
/// Whether this recurrence applies to [date].
bool occursOn(DateTime date) { bool occursOn(DateTime date) {
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
} }
} }
/// Scheduling constraint that reserves time without becoming a task card. /// Scheduling constraint that reserves time without becoming a task card.
///
/// Locked blocks represent time the flexible scheduler should avoid: work hours,
/// appointments, sleep boundaries, streams, relationship blocks, or any other
/// commitment that should not be automatically rearranged. They are modeled
/// separately from normal tasks because the UI may show them as subtle overlays
/// rather than actionable task cards.
///
/// A block is either:
/// - one-off, with [date] set and [recurrence] null; or
/// - recurring, with [recurrence] set and [date] usually null.
class LockedBlock { class LockedBlock {
const LockedBlock({ const LockedBlock({
required this.id, required this.id,
@ -62,23 +103,46 @@ class LockedBlock {
'date is required for one-off locked blocks', 'date is required for one-off locked blocks',
); );
/// Stable id for persistence and one-day overrides.
final String id; final String id;
/// User-facing label, such as `Work`, `Stream`, or `Relationship block`.
final String name; final String name;
/// Start time-of-day. Combined with a date during expansion.
final ClockTime startTime; final ClockTime startTime;
/// End time-of-day. Combined with a date during expansion.
final ClockTime endTime; final ClockTime endTime;
/// Calendar date for one-off locked blocks. Recurring blocks leave this null. /// Calendar date for one-off locked blocks. Recurring blocks leave this null.
final DateTime? date; final DateTime? date;
/// Optional weekly recurrence. Null means this is a one-off block.
final LockedBlockRecurrence? recurrence; final LockedBlockRecurrence? recurrence;
/// Whether UI should keep this block visually quiet by default. Scheduling still
/// treats hidden blocks as blocked time.
final bool hiddenByDefault; final bool hiddenByDefault;
/// Optional project/category association for UI colors or reports.
final String? projectId; final String? projectId;
/// Creation timestamp for persistence/auditing.
final DateTime createdAt; final DateTime createdAt;
/// Last update timestamp for persistence/auditing.
final DateTime updatedAt; final DateTime updatedAt;
/// Convenience check for whether this block expands through recurrence.
bool get isRecurring => recurrence != null; bool get isRecurring => recurrence != null;
} }
/// Concrete locked-time occurrence for one calendar day. /// Concrete locked-time occurrence for one calendar day.
///
/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a
/// specific date. The scheduler only needs occurrences/time intervals, while UI
/// can use ids and visibility flags to explain where the blocked time came from.
class LockedBlockOccurrence { class LockedBlockOccurrence {
const LockedBlockOccurrence({ const LockedBlockOccurrence({
required this.name, required this.name,
@ -89,14 +153,29 @@ class LockedBlockOccurrence {
this.projectId, this.projectId,
}); });
/// Display/debug label for this occurrence.
final String name; final String name;
/// Concrete start/end on one calendar date.
final TimeInterval interval; final TimeInterval interval;
/// Visibility hint for UI; does not affect scheduling.
final bool hiddenByDefault; final bool hiddenByDefault;
/// Source recurring/one-off block id, when this came from a base block.
final String? lockedBlockId; final String? lockedBlockId;
/// Source override id, when this was replaced or added for one date.
final String? overrideId; final String? overrideId;
/// Optional project/category association for UI colors or reports.
final String? projectId; final String? projectId;
/// Scheduler-facing interval. Visibility only affects future UI overlays. /// Scheduler-facing interval. Visibility only affects future UI overlays.
///
/// A fresh [TimeInterval] is returned with [name] as the label so scheduling
/// notices/debugging can identify the blocked source without depending on the
/// richer occurrence object.
TimeInterval get schedulingInterval { TimeInterval get schedulingInterval {
return TimeInterval( return TimeInterval(
start: interval.start, start: interval.start,
@ -107,13 +186,30 @@ class LockedBlockOccurrence {
} }
/// Type of one-day override applied to locked time. /// Type of one-day override applied to locked time.
///
/// Overrides let the app handle holidays, one-off appointments, or changed work
/// hours without editing the base recurring rule.
enum LockedBlockOverrideType { enum LockedBlockOverrideType {
/// Suppress one occurrence of a recurring locked block.
remove, remove,
/// Replace one occurrence with different details.
replace, replace,
/// Add a one-off locked occurrence that has no base recurring block.
add, add,
} }
/// One-day change to locked time that leaves the base recurrence unchanged. /// One-day change to locked time that leaves the base recurrence unchanged.
///
/// Overrides are applied during expansion for a single date only. This is safer
/// than modifying the recurring block because the normal schedule remains intact
/// for every other day.
///
/// Use the named factories to create valid override shapes:
/// - [remove] references a base block and suppresses that occurrence.
/// - [replace] references a base block and swaps its details for one day.
/// - [add] creates an extra locked occurrence that has no base block.
class LockedBlockOverride { class LockedBlockOverride {
const LockedBlockOverride({ const LockedBlockOverride({
required this.id, required this.id,
@ -201,18 +297,40 @@ class LockedBlockOverride {
); );
} }
/// Stable id for persistence and debugging.
final String id; final String id;
/// Base block id affected by remove/replace overrides. Null for add overrides.
final String? lockedBlockId; final String? lockedBlockId;
/// Date the override applies to. Time components are ignored by date checks.
final DateTime date; final DateTime date;
/// Kind of override operation.
final LockedBlockOverrideType type; final LockedBlockOverrideType type;
/// Optional replacement/addition name.
final String? name; final String? name;
/// Optional replacement/addition start time.
final ClockTime? startTime; final ClockTime? startTime;
/// Optional replacement/addition end time.
final ClockTime? endTime; final ClockTime? endTime;
/// Visibility hint for UI; scheduling still blocks this time.
final bool hiddenByDefault; final bool hiddenByDefault;
/// Optional project/category association for UI colors or reports.
final String? projectId; final String? projectId;
/// Creation timestamp for persistence/auditing.
final DateTime createdAt; final DateTime createdAt;
/// Last update timestamp for persistence/auditing.
final DateTime updatedAt; final DateTime updatedAt;
/// Whether this override targets [blockId] on [occurrenceDate].
bool appliesTo({ bool appliesTo({
required String blockId, required String blockId,
required DateTime occurrenceDate, required DateTime occurrenceDate,
@ -220,6 +338,11 @@ class LockedBlockOverride {
return lockedBlockId == blockId && _sameDate(date, occurrenceDate); return lockedBlockId == blockId && _sameDate(date, occurrenceDate);
} }
/// Build a concrete interval for [targetDate] when this override has enough
/// time data and applies to that date.
///
/// Remove overrides intentionally return null because they do not create a new
/// interval. Replacement/add overrides need both [startTime] and [endTime].
TimeInterval? intervalForDate(DateTime targetDate) { TimeInterval? intervalForDate(DateTime targetDate) {
final start = startTime; final start = startTime;
final end = endTime; final end = endTime;
@ -237,15 +360,23 @@ class LockedBlockOverride {
} }
/// Expands locked blocks and one-day overrides into concrete intervals. /// Expands locked blocks and one-day overrides into concrete intervals.
///
/// This object is the boundary between human-friendly locked block definitions
/// and scheduler-friendly intervals. It retains the occurrence details for UI
/// while exposing [schedulingIntervals] for placement algorithms.
class LockedScheduleExpansion { class LockedScheduleExpansion {
const LockedScheduleExpansion({ const LockedScheduleExpansion({
required this.date, required this.date,
required this.occurrences, required this.occurrences,
}); });
/// Calendar date represented by this expansion, normalized to year/month/day.
final DateTime date; final DateTime date;
/// Concrete locked occurrences active on [date].
final List<LockedBlockOccurrence> occurrences; final List<LockedBlockOccurrence> occurrences;
/// Just the intervals the scheduler needs to avoid.
List<TimeInterval> get schedulingIntervals { List<TimeInterval> get schedulingIntervals {
return List<TimeInterval>.unmodifiable( return List<TimeInterval>.unmodifiable(
occurrences.map((occurrence) => occurrence.schedulingInterval), occurrences.map((occurrence) => occurrence.schedulingInterval),
@ -254,6 +385,14 @@ class LockedScheduleExpansion {
} }
/// Returns concrete locked-time occurrences for [date]. /// Returns concrete locked-time occurrences for [date].
///
/// Expansion order:
/// 1. Sort overrides deterministically by creation time, then id.
/// 2. For each base block that occurs on the date, collect its overrides.
/// 3. Skip the occurrence if any remove override applies.
/// 4. Use the latest replacement override if one exists.
/// 5. Add one-off `add` overrides for the date.
/// 6. Sort occurrences by start time for predictable UI/scheduler behavior.
LockedScheduleExpansion expandLockedBlocksForDay({ LockedScheduleExpansion expandLockedBlocksForDay({
required List<LockedBlock> blocks, required List<LockedBlock> blocks,
required List<LockedBlockOverride> overrides, required List<LockedBlockOverride> overrides,
@ -327,6 +466,10 @@ LockedScheduleExpansion expandLockedBlocksForDay({
); );
} }
/// Return the last replacement override from an already sorted override list.
///
/// Later-created replacements win, which lets a user correct a one-day override
/// without deleting older history first.
LockedBlockOverride? _lastReplacementOverride( LockedBlockOverride? _lastReplacementOverride(
List<LockedBlockOverride> overrides, List<LockedBlockOverride> overrides,
) { ) {
@ -339,6 +482,7 @@ LockedBlockOverride? _lastReplacementOverride(
return null; return null;
} }
/// Convenience wrapper when callers only need scheduler intervals.
List<TimeInterval> lockedSchedulingIntervalsForDay({ List<TimeInterval> lockedSchedulingIntervalsForDay({
required List<LockedBlock> blocks, required List<LockedBlock> blocks,
required List<LockedBlockOverride> overrides, required List<LockedBlockOverride> overrides,
@ -352,6 +496,10 @@ List<TimeInterval> lockedSchedulingIntervalsForDay({
} }
/// Returns [task] with locked-hour completion statistics applied when relevant. /// Returns [task] with locked-hour completion statistics applied when relevant.
///
/// This does not decide whether completion is allowed. It only records that a
/// completion overlapped locked time, which future reports can surface as a
/// boundary-leak signal.
Task trackCompletedDuringLockedHours({ Task trackCompletedDuringLockedHours({
required Task task, required Task task,
required List<TimeInterval> lockedIntervals, required List<TimeInterval> lockedIntervals,
@ -371,6 +519,9 @@ Task trackCompletedDuringLockedHours({
} }
/// Calculates how many scheduled task minutes overlap locked intervals. /// Calculates how many scheduled task minutes overlap locked intervals.
///
/// Multiple locked intervals may overlap each other. To avoid double-counting,
/// intersections are merged before minutes are summed.
int completedDuringLockedHoursMinutes({ int completedDuringLockedHoursMinutes({
required Task task, required Task task,
required List<TimeInterval> lockedIntervals, required List<TimeInterval> lockedIntervals,
@ -424,6 +575,7 @@ int completedDuringLockedHoursMinutes({
return total.inMinutes; return total.inMinutes;
} }
/// Whether a base block should produce an occurrence on [date].
bool _blockOccursOn(LockedBlock block, DateTime date) { bool _blockOccursOn(LockedBlock block, DateTime date) {
final recurrence = block.recurrence; final recurrence = block.recurrence;
if (recurrence != null) { if (recurrence != null) {
@ -433,6 +585,7 @@ bool _blockOccursOn(LockedBlock block, DateTime date) {
return _sameDate(block.date!, date); return _sameDate(block.date!, date);
} }
/// Convert a base block rule into a concrete occurrence for [date].
LockedBlockOccurrence _occurrenceFromBlock( LockedBlockOccurrence _occurrenceFromBlock(
LockedBlock block, LockedBlock block,
DateTime date, DateTime date,
@ -450,6 +603,10 @@ LockedBlockOccurrence _occurrenceFromBlock(
); );
} }
/// Convert a replacement override into a concrete occurrence.
///
/// Missing override fields fall back to the original block, so a one-day change
/// can replace only the time, only the name, or only the project association.
LockedBlockOccurrence _occurrenceFromReplacement({ LockedBlockOccurrence _occurrenceFromReplacement({
required LockedBlock block, required LockedBlock block,
required LockedBlockOverride override, required LockedBlockOverride override,
@ -473,6 +630,7 @@ LockedBlockOccurrence _occurrenceFromReplacement({
); );
} }
/// Convert an add override into a concrete occurrence when it is complete.
LockedBlockOccurrence? _occurrenceFromAddedOverride( LockedBlockOccurrence? _occurrenceFromAddedOverride(
LockedBlockOverride override, LockedBlockOverride override,
DateTime date, DateTime date,
@ -497,10 +655,12 @@ LockedBlockOccurrence? _occurrenceFromAddedOverride(
); );
} }
/// Whether this task state/type should be checked for locked-hour completion.
bool _shouldTrackLockedHourCompletion(Task task) { bool _shouldTrackLockedHourCompletion(Task task) {
return task.status == TaskStatus.completed || task.type == TaskType.surprise; return task.status == TaskStatus.completed || task.type == TaskType.surprise;
} }
/// Build a valid scheduled interval for a task, or null if the task is unplaced.
TimeInterval? _scheduledIntervalForTask(Task task) { TimeInterval? _scheduledIntervalForTask(Task task) {
final start = task.scheduledStart; final start = task.scheduledStart;
final end = task.scheduledEnd; final end = task.scheduledEnd;
@ -512,14 +672,17 @@ TimeInterval? _scheduledIntervalForTask(Task task) {
return TimeInterval(start: start, end: end, label: task.id); return TimeInterval(start: start, end: end, label: task.id);
} }
/// Return whichever timestamp is earlier.
DateTime _earliest(DateTime first, DateTime second) { DateTime _earliest(DateTime first, DateTime second) {
return first.isBefore(second) ? first : second; return first.isBefore(second) ? first : second;
} }
/// Return whichever timestamp is later.
DateTime _latest(DateTime first, DateTime second) { DateTime _latest(DateTime first, DateTime second) {
return first.isAfter(second) ? first : second; return first.isAfter(second) ? first : second;
} }
/// Compare only the calendar date parts of two timestamps.
bool _sameDate(DateTime first, DateTime second) { bool _sameDate(DateTime first, DateTime second) {
return first.year == second.year && return first.year == second.year &&
first.month == second.month && first.month == second.month &&

View file

@ -1,6 +1,28 @@
// Core domain model for the ADHD scheduling starter project.
//
// This file intentionally contains small immutable value objects and enums. It
// should stay free of UI, persistence, notification, and platform code. Keeping
// this layer plain makes the scheduler easy to test and easy to move between a
// command-line prototype, Flutter UI, or future backend service.
//
// Reading order for humans:
// 1. `TaskType` and `TaskStatus` explain the two main axes of a task.
// 2. `Task` shows the actual data carried through the scheduling engine.
// 3. `ProjectProfile` explains how project defaults create tasks.
// 4. `TimeInterval` is the shared time-span helper used by scheduling logic.
import 'task_statistics.dart'; import 'task_statistics.dart';
/// Scheduling behavior category. /// Scheduling behavior category.
///
/// This enum is one of the central concepts in the planner. The type answers
/// the question: "how should the scheduler treat this item?" It is separate
/// from [TaskStatus], which answers "where is this item in its lifecycle?"
///
/// For example, a flexible task can be planned, completed, missed, or moved to
/// backlog. A locked item, by contrast, acts more like a calendar constraint
/// than a normal task card. Keeping behavior and lifecycle separate makes later
/// UI and persistence logic easier to reason about.
enum TaskType { enum TaskType {
/// Movable planned work. /// Movable planned work.
flexible, flexible,
@ -22,6 +44,12 @@ enum TaskType {
} }
/// Current lifecycle state of a task. /// Current lifecycle state of a task.
///
/// Status is intentionally data-oriented: it describes what happened to a task,
/// not how important it is or how the scheduler should move it. Scheduler rules
/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task
/// can be pushed, while a planned critical task should remain visible and become
/// backlog if missed.
enum TaskStatus { enum TaskStatus {
/// Scheduled or queued work that has not started. /// Scheduled or queued work that has not started.
planned, planned,
@ -46,6 +74,12 @@ enum TaskStatus {
} }
/// User-facing importance level. /// User-facing importance level.
///
/// Priority is a relative ordering hint. It should help decide what rises to the
/// top of a queue, but it should not be treated as an absolute promise that the
/// task must happen at a specific time. The scheduling engine currently ranks
/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics
/// can build on the same enum later.
enum PriorityLevel { enum PriorityLevel {
/// Lowest priority. /// Lowest priority.
veryLow, veryLow,
@ -64,6 +98,10 @@ enum PriorityLevel {
} }
/// Expected reward or payoff from completing a task. /// Expected reward or payoff from completing a task.
///
/// Reward is meant to capture motivational payoff, not objective value. In this
/// app design it supports ADHD-friendly planning: a small, easy, high-reward task
/// may be a better momentum starter than a large low-reward task.
enum RewardLevel { enum RewardLevel {
/// No reward level has been captured; this is not equivalent to low reward. /// No reward level has been captured; this is not equivalent to low reward.
notSet, notSet,
@ -85,6 +123,11 @@ enum RewardLevel {
} }
/// Expected effort or activation difficulty for a task. /// Expected effort or activation difficulty for a task.
///
/// Difficulty is not the same as duration. A five-minute phone call might be
/// very hard to start, while an hour of familiar maintenance may be easy. The
/// backlog view uses this with [RewardLevel] to expose a simple
/// reward-versus-effort sort.
enum DifficultyLevel { enum DifficultyLevel {
/// No difficulty has been captured yet. /// No difficulty has been captured yet.
notSet, notSet,
@ -106,6 +149,10 @@ enum DifficultyLevel {
} }
/// Reminder intensity preference. /// Reminder intensity preference.
///
/// This is currently stored as project metadata rather than enforced by the core
/// scheduler. Future notification/UI layers can use it to decide how aggressive
/// reminders should be without adding reminder-specific logic to [Task].
enum ReminderProfile { enum ReminderProfile {
/// No reminder nudges. /// No reminder nudges.
silent, silent,
@ -121,6 +168,10 @@ enum ReminderProfile {
} }
/// Lightweight backlog-only metadata. /// Lightweight backlog-only metadata.
///
/// Tags here are deliberately narrow. They are not meant to replace a general
/// tagging system. They identify special backlog behavior that the planner needs
/// to understand, such as "wishlist/someday" items.
enum BacklogTag { enum BacklogTag {
/// Task is intentionally saved as a someday/wishlist item. /// Task is intentionally saved as a someday/wishlist item.
wishlist, wishlist,
@ -128,8 +179,25 @@ enum BacklogTag {
/// Starter task model for the scheduling core. /// Starter task model for the scheduling core.
/// ///
/// This is a public placeholder API for early V1 chunks. Keep behavior changes /// [Task] is the main domain object passed through the scheduler. It is written
/// explicit and covered by tests as the model becomes more complete. /// as an immutable value object: operations do not mutate an existing task, they
/// return a copied task with changed fields. That approach makes scheduling
/// actions easier to test because every function receives an input list and
/// returns a new output list.
///
/// Important modeling choices:
/// - [type] controls scheduling behavior: flexible, critical, locked, etc.
/// - [status] controls lifecycle state: planned, completed, backlog, etc.
/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and
/// unscheduled captures do not have timeline placement yet.
/// - [stats] records quiet metadata for future reports. It should not clutter
/// the everyday UI unless a report or filter specifically needs it.
/// - [parentTaskId] links child tasks to a larger parent task without requiring
/// a nested object graph. That keeps persistence simple and avoids recursive
/// scheduling structures.
///
/// This is still a starter V1 model, so behavior changes should be explicit and
/// backed by tests as the product rules settle.
class Task { class Task {
const Task({ const Task({
required this.id, required this.id,
@ -151,6 +219,14 @@ class Task {
}); });
/// Create a minimal captured task without requiring planning details. /// Create a minimal captured task without requiring planning details.
///
/// Quick capture is intentionally forgiving: the user can enter only a title
/// and the system can still create a valid backlog item. Defaults route the
/// item into the inbox project as a medium-priority flexible backlog task.
///
/// The only hard validation here is that [title] must contain non-whitespace
/// text. Scheduling validation, such as requiring a positive duration, happens
/// in `quick_capture.dart` because that depends on the requested capture flow.
factory Task.quickCapture({ factory Task.quickCapture({
required String id, required String id,
required String title, required String title,
@ -183,29 +259,81 @@ class Task {
); );
} }
/// Stable identifier used by persistence, UI selection, and scheduler changes.
final String id; final String id;
/// User-facing task title. The model expects this to already be trimmed.
final String title; final String title;
/// Owning project/profile id. `inbox` is used for uncategorized captures.
final String projectId; final String projectId;
/// Scheduling behavior category. See [TaskType] for rule-level meaning.
final TaskType type; final TaskType type;
/// Current lifecycle state. See [TaskStatus] for state-level meaning.
final TaskStatus status; final TaskStatus status;
/// Optional importance. Most creation paths default this to medium, but it is
/// nullable to leave room for imports or legacy data that have not set it yet.
final PriorityLevel? priority; final PriorityLevel? priority;
/// Motivational payoff used by backlog sorting and future planning hints.
final RewardLevel reward; final RewardLevel reward;
/// Activation/effort estimate used by backlog sorting and future planning hints.
final DifficultyLevel difficulty; final DifficultyLevel difficulty;
/// Estimated task length. Required for most scheduling operations, optional for
/// backlog capture because not every captured thought has an estimate yet.
final int? durationMinutes; final int? durationMinutes;
/// Inclusive scheduled start time. Null means the task is not currently placed.
final DateTime? scheduledStart; final DateTime? scheduledStart;
/// Exclusive scheduled end time. Null means the task is not currently placed.
final DateTime? scheduledEnd; final DateTime? scheduledEnd;
/// Parent task id when this task is a child/subtask. Null means top-level task.
final String? parentTaskId; final String? parentTaskId;
/// Backlog-specific flags, such as wishlist/someday behavior.
final Set<BacklogTag> backlogTags; final Set<BacklogTag> backlogTags;
/// Creation timestamp used for age/staleness sorting.
final DateTime createdAt; final DateTime createdAt;
/// Last domain-level update timestamp. Scheduling actions set this when moving
/// or changing tasks so persistence and reports can detect recent activity.
final DateTime updatedAt; final DateTime updatedAt;
/// Quiet counters for reporting and later heuristics.
final TaskStatistics stats; final TaskStatistics stats;
/// Convenience predicate for the task type most scheduler movement operates on.
bool get isFlexible => type == TaskType.flexible; bool get isFlexible => type == TaskType.flexible;
/// Critical and inflexible tasks are both visible to the user and treated as
/// blocked time by flexible scheduling.
bool get isRequiredVisible => bool get isRequiredVisible =>
type == TaskType.critical || type == TaskType.inflexible; type == TaskType.critical || type == TaskType.inflexible;
/// Locked tasks behave as timeline constraints, not normal interactive cards.
bool get isLocked => type == TaskType.locked; bool get isLocked => type == TaskType.locked;
/// Backlog status means the task is stored for later and has no active slot.
bool get isBacklog => status == TaskStatus.backlog; bool get isBacklog => status == TaskStatus.backlog;
/// Return a copy with selected fields changed.
///
/// The core uses this instead of mutation. That matters because scheduling
/// operations often need to produce an auditable before/after result, including
/// [SchedulingChange]-style records elsewhere.
///
/// [clearSchedule] is a deliberate escape hatch for nullable schedule fields.
/// Without it, passing null would be ambiguous: it could mean "do not change"
/// or "clear this value." When [clearSchedule] is true, both schedule fields
/// are removed even if [scheduledStart] or [scheduledEnd] are omitted.
Task copyWith({ Task copyWith({
String? id, String? id,
String? title, String? title,
@ -248,6 +376,14 @@ class Task {
} }
/// Starter project defaults used when creating or scheduling tasks. /// Starter project defaults used when creating or scheduling tasks.
///
/// A project profile represents reusable defaults for a group of tasks. UI code
/// can let the user pick a project, then call [createTask] so new tasks inherit
/// a color, default priority, reward, difficulty, reminder profile, and duration.
///
/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping
/// project defaults separate prevents every scheduling function from needing to
/// know project configuration details.
class ProjectProfile { class ProjectProfile {
const ProjectProfile({ const ProjectProfile({
required this.id, required this.id,
@ -260,16 +396,35 @@ class ProjectProfile {
this.defaultDurationMinutes, this.defaultDurationMinutes,
}); });
/// Stable project id stored on tasks.
final String id; final String id;
/// User-facing project name.
final String name; final String name;
/// Theme/color token for UI rendering. This is a key, not a raw color value.
final String colorKey; final String colorKey;
/// Default importance assigned when a task does not override priority.
final PriorityLevel defaultPriority; final PriorityLevel defaultPriority;
/// Default motivational payoff assigned when a task does not override reward.
final RewardLevel defaultReward; final RewardLevel defaultReward;
/// Default activation difficulty assigned when a task does not override effort.
final DifficultyLevel defaultDifficulty; final DifficultyLevel defaultDifficulty;
/// Default reminder behavior for future notification/UI layers.
final ReminderProfile defaultReminderProfile; final ReminderProfile defaultReminderProfile;
/// Optional duration estimate used for newly created tasks.
final int? defaultDurationMinutes; final int? defaultDurationMinutes;
/// Create a task using project defaults while allowing explicit overrides. /// Create a task using project defaults while allowing explicit overrides.
///
/// This keeps capture and project-default behavior in one place. Callers can
/// pass only the fields the user explicitly set; everything else falls back to
/// this profile. The created task receives this profile's [id] as [Task.projectId].
Task createTask({ Task createTask({
required String id, required String id,
required String title, required String title,
@ -307,6 +462,11 @@ class ProjectProfile {
} }
/// Starter time range value used by scheduling helpers. /// Starter time range value used by scheduling helpers.
///
/// [TimeInterval] is the scheduler's neutral representation of a time span. It
/// is used for scheduled task slots, locked blocks, required visible blocks, and
/// candidate placements. The interval convention is start-inclusive and
/// end-exclusive, which avoids treating two back-to-back blocks as overlapping.
class TimeInterval { class TimeInterval {
const TimeInterval({ const TimeInterval({
required this.start, required this.start,
@ -314,12 +474,24 @@ class TimeInterval {
this.label, this.label,
}); });
/// Inclusive beginning of the interval.
final DateTime start; final DateTime start;
/// Exclusive ending of the interval.
final DateTime end; final DateTime end;
/// Optional debug/UI label, often a task id or locked block name.
final String? label; final String? label;
/// Raw duration between [start] and [end]. Callers are responsible for only
/// constructing meaningful positive intervals when required by a rule.
Duration get duration => end.difference(start); Duration get duration => end.difference(start);
/// Whether this interval shares any actual time with [other].
///
/// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe
/// to place back-to-back because the first interval's end is the second
/// interval's start.
bool overlaps(TimeInterval other) { bool overlaps(TimeInterval other) {
return start.isBefore(other.end) && end.isAfter(other.start); return start.isBefore(other.end) && end.isAfter(other.start);
} }

View file

@ -1,14 +1,35 @@
// Low-friction capture flow.
//
// Quick capture is the "dump the thought before it disappears" path. The goal
// is to accept minimal data, preserve the user's input, and only ask for more
// structure when the user wants immediate scheduling.
import 'models.dart'; import 'models.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
/// Outcome of a quick-capture request. /// Outcome of a quick-capture request.
///
/// The UI can use this status to decide whether to show a passive success, draw
/// a scheduled card on the timeline, or display validation messages. It is not
/// an exception-based flow because quick capture should fail gently and keep the
/// user's typed task available.
enum QuickCaptureStatus { enum QuickCaptureStatus {
/// Capture succeeded and the task remains unscheduled in backlog.
addedToBacklog, addedToBacklog,
/// Capture succeeded and the task was placed on the timeline.
scheduled, scheduled,
/// Capture could not complete the requested flow; see result messages.
validationError, validationError,
} }
/// Input for low-friction task capture. /// Input for low-friction task capture.
///
/// This object represents what the UI knows at the moment of capture. It mirrors
/// the product goal: adding a thought should require as little structure as
/// possible, but the user can optionally provide enough detail to immediately
/// schedule it into the next open flexible slot.
class QuickCaptureRequest { class QuickCaptureRequest {
const QuickCaptureRequest({ const QuickCaptureRequest({
required this.id, required this.id,
@ -24,20 +45,47 @@ class QuickCaptureRequest {
this.backlogTags = const <BacklogTag>{}, this.backlogTags = const <BacklogTag>{},
}); });
/// Caller-generated id. Keeping id generation outside this service makes the
/// domain layer independent from persistence/database choices.
final String id; final String id;
/// Raw user-entered title. The [Task.quickCapture] factory trims it.
final String title; final String title;
/// Capture timestamp supplied by the caller for testability.
final DateTime createdAt; final DateTime createdAt;
/// Whether capture should attempt immediate timeline placement.
final bool addToNextAvailableSlot; final bool addToNextAvailableSlot;
/// Project id to assign; defaults to the inbox for uncategorized thoughts.
final String projectId; final String projectId;
/// Initial priority used by backlog/scheduler heuristics.
final PriorityLevel priority; final PriorityLevel priority;
/// Initial reward estimate.
final RewardLevel reward; final RewardLevel reward;
/// Initial difficulty estimate.
final DifficultyLevel difficulty; final DifficultyLevel difficulty;
/// Captured task type. Immediate scheduling currently requires flexible tasks.
final TaskType type; final TaskType type;
/// Optional duration estimate. Required only when scheduling immediately.
final int? durationMinutes; final int? durationMinutes;
/// Optional backlog flags such as wishlist/someday.
final Set<BacklogTag> backlogTags; final Set<BacklogTag> backlogTags;
} }
/// Result of a quick-capture request. /// Result of a quick-capture request.
///
/// The result always carries a [task], even on validation failure, so the UI can
/// preserve the user's input and show what needs to be fixed. When scheduling
/// was attempted, [schedulingResult] exposes the lower-level engine notices and
/// changes for debugging or timeline updates.
class QuickCaptureResult { class QuickCaptureResult {
const QuickCaptureResult({ const QuickCaptureResult({
required this.task, required this.task,
@ -46,20 +94,43 @@ class QuickCaptureResult {
this.messages = const <String>[], this.messages = const <String>[],
}); });
/// Captured task, scheduled or unscheduled depending on [status].
final Task task; final Task task;
/// High-level outcome of the capture attempt.
final QuickCaptureStatus status; final QuickCaptureStatus status;
/// Detailed scheduling output when immediate placement was attempted.
final SchedulingResult? schedulingResult; final SchedulingResult? schedulingResult;
/// Human-readable validation or scheduling messages.
final List<String> messages; final List<String> messages;
/// Convenience check for UI branches that only care whether capture succeeded.
bool get isValid => status != QuickCaptureStatus.validationError; bool get isValid => status != QuickCaptureStatus.validationError;
} }
/// Coordinates quick capture defaults and optional scheduling. /// Coordinates quick capture defaults and optional scheduling.
///
/// This service is intentionally thin: it builds a [Task], validates the extra
/// requirements for immediate scheduling, and delegates placement to
/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand
/// every scheduler precondition.
class QuickCaptureService { class QuickCaptureService {
const QuickCaptureService({this.engine = const SchedulingEngine()}); const QuickCaptureService({this.engine = const SchedulingEngine()});
/// Scheduling dependency. Defaults to the starter engine but can be swapped in
/// tests or future implementations.
final SchedulingEngine engine; final SchedulingEngine engine;
/// Capture a task and optionally place it into the next available slot.
///
/// Flow:
/// 1. Build the task using lightweight defaults.
/// 2. If immediate scheduling was not requested, return a backlog success.
/// 3. Validate the requirements for immediate scheduling.
/// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot].
/// 5. Convert the lower-level scheduling result into a capture result.
QuickCaptureResult capture( QuickCaptureResult capture(
QuickCaptureRequest request, { QuickCaptureRequest request, {
SchedulingInput? schedulingInput, SchedulingInput? schedulingInput,
@ -138,6 +209,10 @@ class QuickCaptureService {
} }
} }
/// Find a task in a returned scheduling result.
///
/// This duplicates a small helper rather than exposing scheduler internals. The
/// capture service only needs to retrieve the newly created task after placement.
Task? _taskById(List<Task> tasks, String id) { Task? _taskById(List<Task> tasks, String id) {
for (final task in tasks) { for (final task in tasks) {
if (task.id == id) { if (task.id == id) {

View file

@ -1,6 +1,24 @@
// Scheduling engine for the ADHD scheduling starter project.
//
// This file is the core timeline manipulation layer. It takes task data plus a
// planning window and returns a new task list, notices, changes, and analysis
// findings. The implementation is deliberately side-effect free so a first-time
// reader can trace each operation from input validation, to placement planning,
// to application of the plan.
//
// Human reading map:
// 1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes.
// 2. Public engine methods: the operations UI/actions can call.
// 3. Private planning helpers: calculate intervals without changing tasks.
// 4. Private apply helpers: convert plans into updated tasks and notices.
import 'models.dart'; import 'models.dart';
/// Category for scheduler notices. /// Category for scheduler notices.
///
/// Notices are human-readable summaries attached to a [SchedulingResult]. They
/// are not exceptions. The scheduler returns them alongside the task list so UI
/// can explain what happened without losing the successfully computed output.
enum SchedulingNoticeType { enum SchedulingNoticeType {
/// General informational notice. /// General informational notice.
info, info,
@ -19,17 +37,31 @@ enum SchedulingNoticeType {
} }
/// Window of time available to a scheduling operation. /// Window of time available to a scheduling operation.
///
/// Most engine methods operate on one planning window: "today", "tomorrow",
/// or any other bounded range supplied by the caller. The window constrains where
/// flexible tasks can be placed. Anything outside this range is treated as out of
/// scope for the operation.
class SchedulingWindow { class SchedulingWindow {
const SchedulingWindow({ const SchedulingWindow({
required this.start, required this.start,
required this.end, required this.end,
}); });
/// Inclusive beginning of the scheduling range.
final DateTime start; final DateTime start;
/// Exclusive ending of the scheduling range.
final DateTime end; final DateTime end;
/// The window as a [TimeInterval], useful for overlap checks.
TimeInterval get interval => TimeInterval(start: start, end: end); TimeInterval get interval => TimeInterval(start: start, end: end);
/// Whether [interval] is completely inside this window.
///
/// A task that starts before the window or ends after the window is considered
/// outside the operation. The engine may treat such tasks as fixed blocks
/// instead of moving them.
bool contains(TimeInterval interval) { bool contains(TimeInterval interval) {
final startsInWindow = final startsInWindow =
interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start); interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start);
@ -41,6 +73,11 @@ class SchedulingWindow {
} }
/// In-memory input for scheduling operations. /// In-memory input for scheduling operations.
///
/// This is the complete snapshot the pure scheduling engine needs. It contains
/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does
/// not know where the data came from: UI state, a database, tests, or generated
/// examples can all build this same object.
class SchedulingInput { class SchedulingInput {
const SchedulingInput({ const SchedulingInput({
required this.tasks, required this.tasks,
@ -49,29 +86,49 @@ class SchedulingInput {
this.requiredVisibleIntervals = const <TimeInterval>[], this.requiredVisibleIntervals = const <TimeInterval>[],
}); });
/// All tasks available to this operation. The scheduler returns a replacement
/// list rather than mutating this one.
final List<Task> tasks; final List<Task> tasks;
/// Date/time range that the operation is allowed to plan inside.
final SchedulingWindow window; final SchedulingWindow window;
/// External locked time intervals, usually produced by `locked_time.dart`.
final List<TimeInterval> lockedIntervals; final List<TimeInterval> lockedIntervals;
/// Extra fixed visible intervals supplied by the caller. This lets UI/backend
/// code reserve required time even when that time is not represented as a
/// [Task] in the current list.
final List<TimeInterval> requiredVisibleIntervals; final List<TimeInterval> requiredVisibleIntervals;
/// Tasks that the flexible movement algorithms are allowed to consider.
List<Task> get flexibleTasks { List<Task> get flexibleTasks {
return tasks.where((task) => task.isFlexible).toList(growable: false); return tasks.where((task) => task.isFlexible).toList(growable: false);
} }
/// Locked task records in [tasks], if the caller represents locked time as
/// tasks instead of only passing [lockedIntervals].
List<Task> get lockedTasks { List<Task> get lockedTasks {
return tasks.where((task) => task.isLocked).toList(growable: false); return tasks.where((task) => task.isLocked).toList(growable: false);
} }
/// Critical and inflexible task records that should block flexible placement.
List<Task> get requiredVisibleTasks { List<Task> get requiredVisibleTasks {
return tasks return tasks
.where((task) => task.isRequiredVisible) .where((task) => task.isRequiredVisible)
.toList(growable: false); .toList(growable: false);
} }
/// Scheduled intervals for flexible tasks only. Useful for analysis/debugging.
List<TimeInterval> get flexibleIntervals { List<TimeInterval> get flexibleIntervals {
return _scheduledIntervalsFor(flexibleTasks); return _scheduledIntervalsFor(flexibleTasks);
} }
/// All intervals that flexible scheduling must avoid.
///
/// This combines explicit locked intervals, locked task records, caller-supplied
/// required-visible intervals, and scheduled critical/inflexible tasks. The
/// result is sorted to make interval scanning deterministic.
List<TimeInterval> get blockedIntervals { List<TimeInterval> get blockedIntervals {
final intervals = <TimeInterval>[ final intervals = <TimeInterval>[
...lockedIntervals, ...lockedIntervals,
@ -85,6 +142,10 @@ class SchedulingInput {
} }
/// Exact placement change made by a scheduling operation. /// Exact placement change made by a scheduling operation.
///
/// Changes are machine-readable before/after records. UI can use notices for
/// display text, but persistence, undo, analytics, or tests should inspect these
/// fields to know exactly which task moved and where.
class SchedulingChange { class SchedulingChange {
const SchedulingChange({ const SchedulingChange({
required this.taskId, required this.taskId,
@ -94,14 +155,27 @@ class SchedulingChange {
required this.nextEnd, required this.nextEnd,
}); });
/// Task that moved or had its schedule cleared.
final String taskId; final String taskId;
/// Previous scheduled start, or null if the task was previously unplaced.
final DateTime? previousStart; final DateTime? previousStart;
/// Previous scheduled end, or null if the task was previously unplaced.
final DateTime? previousEnd; final DateTime? previousEnd;
/// New scheduled start, or null if the task was moved out of the timeline.
final DateTime? nextStart; final DateTime? nextStart;
/// New scheduled end, or null if the task was moved out of the timeline.
final DateTime? nextEnd; final DateTime? nextEnd;
} }
/// Overlap between a scheduled task and blocked time. /// Overlap between a scheduled task and blocked time.
///
/// Analysis uses this to report problems without moving anything. This is useful
/// when loading persisted data, debugging imports, or validating a day before the
/// UI presents it as clean.
class SchedulingOverlap { class SchedulingOverlap {
const SchedulingOverlap({ const SchedulingOverlap({
required this.taskId, required this.taskId,
@ -109,12 +183,21 @@ class SchedulingOverlap {
required this.blockedInterval, required this.blockedInterval,
}); });
/// Flexible task that overlaps blocked time.
final String taskId; final String taskId;
/// The task's scheduled interval.
final TimeInterval taskInterval; final TimeInterval taskInterval;
/// The blocked interval it overlaps.
final TimeInterval blockedInterval; final TimeInterval blockedInterval;
} }
/// Starter notice type returned by scheduling operations. /// Starter notice type returned by scheduling operations.
///
/// A notice is presentation-friendly context about an operation. It intentionally
/// carries both text and a structured [type] so the UI can decide whether to show
/// it as neutral info, movement, overlap, or failure.
class SchedulingNotice { class SchedulingNotice {
const SchedulingNotice( const SchedulingNotice(
this.message, { this.message, {
@ -122,12 +205,22 @@ class SchedulingNotice {
this.taskId, this.taskId,
}); });
/// Human-readable message safe to surface in UI or logs.
final String message; final String message;
/// Structured category for UI styling and tests.
final SchedulingNoticeType type; final SchedulingNoticeType type;
/// Optional task related to this notice. Null means the notice applies to the
/// whole operation.
final String? taskId; final String? taskId;
} }
/// Starter result wrapper for scheduling operations. /// Starter result wrapper for scheduling operations.
///
/// Every engine operation returns a [SchedulingResult], even when nothing moved.
/// This keeps the call pattern predictable: always inspect `tasks`, then surface
/// any `notices`, `changes`, or `overlaps` relevant to the UI.
class SchedulingResult { class SchedulingResult {
const SchedulingResult({ const SchedulingResult({
required this.tasks, required this.tasks,
@ -136,16 +229,37 @@ class SchedulingResult {
this.overlaps = const <SchedulingOverlap>[], this.overlaps = const <SchedulingOverlap>[],
}); });
/// Replacement task list after the operation.
final List<Task> tasks; final List<Task> tasks;
/// Human-readable operation messages.
final List<SchedulingNotice> notices; final List<SchedulingNotice> notices;
/// Machine-readable movements or schedule clears.
final List<SchedulingChange> changes; final List<SchedulingChange> changes;
/// Analysis-only overlap findings.
final List<SchedulingOverlap> overlaps; final List<SchedulingOverlap> overlaps;
} }
/// Starter scheduling engine. /// Starter scheduling engine.
/// ///
/// This is intentionally small. Codex should expand this according to the V1 /// The engine is a pure domain service: it receives immutable-ish input values
/// plan documents and add tests for every scheduling rule. /// and returns new values. It does not persist data, render UI, send reminders,
/// or read the clock except where an optional [updatedAt] timestamp is omitted.
///
/// Current V1 responsibilities:
/// - insert backlog tasks into the earliest available flexible slot;
/// - push flexible tasks later today;
/// - move flexible tasks to tomorrow's queue;
/// - roll unfinished flexible tasks into a new planning window;
/// - analyze overlaps against locked/required time;
/// - perform small state transitions such as missed/backlog handling.
///
/// Important rule vocabulary:
/// - `fixedBlocks` are intervals the engine will not move.
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
/// - `placement` is a map from task id to the interval chosen by the planner.
class SchedulingEngine { class SchedulingEngine {
const SchedulingEngine(); const SchedulingEngine();
@ -154,11 +268,17 @@ class SchedulingEngine {
/// Locked, inflexible, and critical time is treated as fixed. Planned /// Locked, inflexible, and critical time is treated as fixed. Planned
/// flexible tasks at or after the insertion point may shift later, preserving /// flexible tasks at or after the insertion point may shift later, preserving
/// their relative order. /// their relative order.
///
/// The selected task must already exist in [input.tasks], be flexible, be in
/// backlog status, and have a positive duration. If any precondition fails, the
/// original task list is returned with a no-fit/overflow notice.
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({ SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
// Step 1: resolve and validate the task. The engine does not create tasks;
// quick capture or persistence code is responsible for adding it to the list.
final task = _taskById(input.tasks, taskId); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
return _unchangedResult( return _unchangedResult(
@ -194,6 +314,8 @@ class SchedulingEngine {
); );
} }
// Step 2: compute placements without mutating any task. Planning returns null
// if the inserted task and shifted queue cannot fit inside the window.
final placement = _planBacklogInsertion( final placement = _planBacklogInsertion(
input: input, input: input,
task: task, task: task,
@ -223,11 +345,17 @@ class SchedulingEngine {
/// ///
/// The selected task moves after its current slot. Planned flexible tasks /// The selected task moves after its current slot. Planned flexible tasks
/// after it may shift later, preserving their relative order. /// after it may shift later, preserving their relative order.
///
/// This is the "not now, later today" action. Anything before the pushed
/// task's current end time becomes fixed for this operation, while later
/// planned flexible tasks may be shifted if necessary.
SchedulingResult pushFlexibleTaskToNextAvailableSlot({ SchedulingResult pushFlexibleTaskToNextAvailableSlot({
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
// Resolve the selected task by id so UI code only needs to pass a stable
// identifier, not object references.
final task = _taskById(input.tasks, taskId); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
return _unchangedResult( return _unchangedResult(
@ -304,6 +432,9 @@ class SchedulingEngine {
/// ///
/// The input window represents tomorrow's scheduling window. Existing planned /// The input window represents tomorrow's scheduling window. Existing planned
/// flexible tasks in that window may shift later, preserving their order. /// flexible tasks in that window may shift later, preserving their order.
///
/// The method name says "tomorrow" because that is the product action, but the
/// engine only trusts [input.window]. Tests can pass any future window.
SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
@ -376,10 +507,17 @@ class SchedulingEngine {
/// The input window represents tomorrow's scheduling window. Only planned and /// The input window represents tomorrow's scheduling window. Only planned and
/// active flexible tasks are rolled; required, locked, completed, and /// active flexible tasks are rolled; required, locked, completed, and
/// cancelled tasks remain unchanged. /// cancelled tasks remain unchanged.
///
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
/// flexible tasks outside the target window, preserves their relative order,
/// then places them at the start of the new window while shifting already
/// planned flexible tasks as needed.
SchedulingResult rollOverUnfinishedFlexibleTasks({ SchedulingResult rollOverUnfinishedFlexibleTasks({
required SchedulingInput input, required SchedulingInput input,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
// Build the explicit queue of tasks to roll before asking the planner to
// place anything. This keeps selection separate from placement.
final rolledItems = <_PlacementItem>[]; final rolledItems = <_PlacementItem>[];
final rolloverTasks = input.flexibleTasks final rolloverTasks = input.flexibleTasks
.where((task) => _shouldRollOver(task, input.window)) .where((task) => _shouldRollOver(task, input.window))
@ -441,6 +579,10 @@ class SchedulingEngine {
} }
/// Analyze the current in-memory schedule without moving tasks. /// Analyze the current in-memory schedule without moving tasks.
///
/// This is a validation/debugging helper. It scans scheduled flexible tasks and
/// reports any overlap with blocked intervals. It deliberately returns the
/// original task list unchanged.
SchedulingResult analyzeSchedule(SchedulingInput input) { SchedulingResult analyzeSchedule(SchedulingInput input) {
final overlaps = <SchedulingOverlap>[]; final overlaps = <SchedulingOverlap>[];
final notices = <SchedulingNotice>[]; final notices = <SchedulingNotice>[];
@ -483,7 +625,9 @@ class SchedulingEngine {
/// Move a task to backlog. /// Move a task to backlog.
/// ///
/// Backlog does not preserve original schedule/order placement. /// Backlog does not preserve original schedule/order placement. The task's
/// schedule is cleared and its moved-to-backlog counter is incremented so
/// reports can distinguish this from a task that was never scheduled.
Task moveToBacklog(Task task, {DateTime? updatedAt}) { Task moveToBacklog(Task task, {DateTime? updatedAt}) {
return task.copyWith( return task.copyWith(
status: TaskStatus.backlog, status: TaskStatus.backlog,
@ -494,6 +638,9 @@ class SchedulingEngine {
} }
/// Mark a flexible task pushed manually. /// Mark a flexible task pushed manually.
///
/// This updates statistics only. Use the push methods above when the task's
/// actual scheduled slot should change.
Task markManuallyPushed(Task task, {DateTime? updatedAt}) { Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
return task.copyWith( return task.copyWith(
updatedAt: updatedAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(),
@ -503,7 +650,9 @@ class SchedulingEngine {
/// Mark missed according to the current MVP rules. /// Mark missed according to the current MVP rules.
/// ///
/// Critical missed tasks go to backlog. Inflexible missed tasks stay in place. /// Critical missed tasks go to backlog so they remain actionable. Inflexible
/// missed tasks stay in place as missed because they represented a fixed event
/// or time block that cannot simply be rescheduled automatically.
Task markMissed(Task task, {DateTime? updatedAt}) { Task markMissed(Task task, {DateTime? updatedAt}) {
final nextStats = task.stats.incrementMissed(); final nextStats = task.stats.incrementMissed();
final now = updatedAt ?? DateTime.now(); final now = updatedAt ?? DateTime.now();
@ -527,8 +676,10 @@ class SchedulingEngine {
/// Finds the first interval that can fit the requested duration while avoiding /// Finds the first interval that can fit the requested duration while avoiding
/// blocked intervals. /// blocked intervals.
/// ///
/// This helper is deliberately simple. Full flexible bump behavior belongs in /// This public helper is deliberately simple and does not shift existing
/// later plan chunks. /// flexible tasks. It is useful for UI previews or tests that only need to know
/// the first open gap. Full bump/queue behavior lives in the private planning
/// helpers below.
TimeInterval? findFirstOpenInterval({ TimeInterval? findFirstOpenInterval({
required DateTime windowStart, required DateTime windowStart,
required DateTime windowEnd, required DateTime windowEnd,
@ -560,6 +711,10 @@ class SchedulingEngine {
} }
} }
/// Convert a scheduled task into an interval, or null if it is unplaced.
///
/// This helper does not validate positive duration; callers that require a valid
/// duration check that separately.
TimeInterval? _scheduledIntervalFor(Task task) { TimeInterval? _scheduledIntervalFor(Task task) {
final start = task.scheduledStart; final start = task.scheduledStart;
final end = task.scheduledEnd; final end = task.scheduledEnd;
@ -571,6 +726,7 @@ TimeInterval? _scheduledIntervalFor(Task task) {
return TimeInterval(start: start, end: end, label: task.id); return TimeInterval(start: start, end: end, label: task.id);
} }
/// Convert all placed tasks in [tasks] into intervals.
List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) { List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
final intervals = <TimeInterval>[]; final intervals = <TimeInterval>[];
@ -584,6 +740,10 @@ List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
return List<TimeInterval>.unmodifiable(intervals); return List<TimeInterval>.unmodifiable(intervals);
} }
/// Return the original task list with one explanatory notice.
///
/// Most validation failures use this so callers can keep rendering the existing
/// schedule while showing why the requested action did not apply.
SchedulingResult _unchangedResult( SchedulingResult _unchangedResult(
SchedulingInput input, SchedulingInput input,
SchedulingNotice notice, SchedulingNotice notice,
@ -594,6 +754,7 @@ SchedulingResult _unchangedResult(
); );
} }
/// Find a task by stable id.
Task? _taskById(List<Task> tasks, String taskId) { Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) { for (final task in tasks) {
if (task.id == taskId) { if (task.id == taskId) {
@ -604,6 +765,7 @@ Task? _taskById(List<Task> tasks, String taskId) {
return null; return null;
} }
/// Convert a nullable minute estimate into a positive [Duration].
Duration? _durationFromMinutes(int? minutes) { Duration? _durationFromMinutes(int? minutes) {
if (minutes == null || minutes <= 0) { if (minutes == null || minutes <= 0) {
return null; return null;
@ -612,11 +774,17 @@ Duration? _durationFromMinutes(int? minutes) {
return Duration(minutes: minutes); return Duration(minutes: minutes);
} }
/// Plan insertion of a backlog task plus any flexible tasks that must shift.
///
/// This function only calculates intervals. It does not update task objects. The
/// returned plan is later applied by [_applyPlacement], which creates notices,
/// changes, and updated task copies.
_BacklogInsertionPlan? _planBacklogInsertion({ _BacklogInsertionPlan? _planBacklogInsertion({
required SchedulingInput input, required SchedulingInput input,
required Task task, required Task task,
required Duration taskDuration, required Duration taskDuration,
}) { }) {
// Start with intervals that the algorithm is not allowed to move.
final fixedBlocks = <TimeInterval>[ final fixedBlocks = <TimeInterval>[
...input.blockedIntervals, ...input.blockedIntervals,
]; ];
@ -628,6 +796,9 @@ _BacklogInsertionPlan? _planBacklogInsertion({
), ),
]; ];
// Existing flexible tasks are inspected in timeline order. Planned tasks inside
// the movable portion become part of the placement queue; everything else is
// treated as fixed.
final scheduledFlexibleTasks = input.flexibleTasks final scheduledFlexibleTasks = input.flexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id) .where((flexibleTask) => flexibleTask.id != task.id)
.toList(growable: false) .toList(growable: false)
@ -669,6 +840,9 @@ _BacklogInsertionPlan? _planBacklogInsertion({
fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
// The cursor tracks the earliest point after the previously placed queue item.
// Each queued task is placed no earlier than both the cursor and its own
// original earliest start.
var cursor = input.window.start; var cursor = input.window.start;
final placements = <String, TimeInterval>{}; final placements = <String, TimeInterval>{};
@ -692,6 +866,11 @@ _BacklogInsertionPlan? _planBacklogInsertion({
return _BacklogInsertionPlan(placements: placements); return _BacklogInsertionPlan(placements: placements);
} }
/// Plan the "push later today" behavior for one flexible task.
///
/// Items before the pushed task's current end are fixed. The pushed task starts
/// the queue at its current end, followed by later planned flexible tasks that
/// may need to move to preserve order.
_BacklogInsertionPlan? _planFlexiblePush({ _BacklogInsertionPlan? _planFlexiblePush({
required SchedulingInput input, required SchedulingInput input,
required Task task, required Task task,
@ -772,6 +951,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
return _BacklogInsertionPlan(placements: placements); return _BacklogInsertionPlan(placements: placements);
} }
/// Plan putting a single task at the start of the supplied future window.
_BacklogInsertionPlan? _planTomorrowQueueInsertion({ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
required SchedulingInput input, required SchedulingInput input,
required Task task, required Task task,
@ -792,6 +972,11 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
); );
} }
/// Plan a queue of flexible tasks at the beginning of [input.window].
///
/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies
/// tasks already represented in the incoming [queue] so they are not also pulled
/// from existing scheduled flexible tasks.
_BacklogInsertionPlan? _planQueueAtWindowStart({ _BacklogInsertionPlan? _planQueueAtWindowStart({
required SchedulingInput input, required SchedulingInput input,
required List<_PlacementItem> queue, required List<_PlacementItem> queue,
@ -865,6 +1050,11 @@ _BacklogInsertionPlan? _planQueueAtWindowStart({
return _BacklogInsertionPlan(placements: placements); return _BacklogInsertionPlan(placements: placements);
} }
/// Apply a backlog insertion plan to the task list.
///
/// The inserted backlog task becomes planned and increments
/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room
/// increment `autoPushedCount`.
SchedulingResult _applyPlacement({ SchedulingResult _applyPlacement({
required SchedulingInput input, required SchedulingInput input,
required Task insertedTask, required Task insertedTask,
@ -930,6 +1120,11 @@ SchedulingResult _applyPlacement({
); );
} }
/// Apply a push/tomorrow placement plan to the task list.
///
/// The explicitly pushed task increments `manuallyPushedCount`; other moved
/// flexible tasks increment `autoPushedCount` because the scheduler moved them as
/// a side effect.
SchedulingResult _applyPushPlacement({ SchedulingResult _applyPushPlacement({
required SchedulingInput input, required SchedulingInput input,
required Task pushedTask, required Task pushedTask,
@ -992,6 +1187,10 @@ SchedulingResult _applyPushPlacement({
); );
} }
/// Apply a bulk rollover placement plan.
///
/// Rolled tasks are set back to planned status in the target window. Existing
/// tasks moved to make room receive normal movement notices.
SchedulingResult _applyRolloverPlacement({ SchedulingResult _applyRolloverPlacement({
required SchedulingInput input, required SchedulingInput input,
required Set<String> rolledTaskIds, required Set<String> rolledTaskIds,
@ -1066,6 +1265,10 @@ SchedulingResult _applyRolloverPlacement({
); );
} }
/// Whether [task] belongs in the rollover queue.
///
/// Planned/active flexible tasks already inside the target window are not rolled
/// again; they are handled as existing tasks that may shift to make room.
bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) { bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) {
final interval = _scheduledIntervalFor(task); final interval = _scheduledIntervalFor(task);
final isTomorrowTask = final isTomorrowTask =
@ -1076,6 +1279,7 @@ bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) {
(task.status == TaskStatus.planned || task.status == TaskStatus.active); (task.status == TaskStatus.planned || task.status == TaskStatus.active);
} }
/// Return whichever timestamp is later.
DateTime _laterOf(DateTime first, DateTime second) { DateTime _laterOf(DateTime first, DateTime second) {
if (first.isAfter(second)) { if (first.isAfter(second)) {
return first; return first;
@ -1084,6 +1288,7 @@ DateTime _laterOf(DateTime first, DateTime second) {
return second; return second;
} }
/// Null-safe exact timestamp comparison.
bool _sameDateTime(DateTime? first, DateTime? second) { bool _sameDateTime(DateTime? first, DateTime? second) {
if (first == null || second == null) { if (first == null || second == null) {
return first == null && second == null; return first == null && second == null;
@ -1092,6 +1297,12 @@ bool _sameDateTime(DateTime? first, DateTime? second) {
return first.isAtSameMomentAs(second); return first.isAtSameMomentAs(second);
} }
/// Find the first candidate interval at or after [earliestStart].
///
/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps
/// a blocked interval, the cursor jumps to that blocked interval's end and tries
/// again. This makes the algorithm easy to follow and adequate for the starter
/// in-memory engine.
TimeInterval? _firstOpenIntervalFrom({ TimeInterval? _firstOpenIntervalFrom({
required DateTime earliestStart, required DateTime earliestStart,
required DateTime windowEnd, required DateTime windowEnd,
@ -1133,6 +1344,11 @@ TimeInterval? _firstOpenIntervalFrom({
} }
} }
/// One item in a placement queue.
///
/// [earliestStart] preserves a task's natural ordering constraint. For existing
/// scheduled tasks, this is usually their current start; for a pushed task, it is
/// the earliest time the push operation allows.
class _PlacementItem { class _PlacementItem {
const _PlacementItem({ const _PlacementItem({
required this.task, required this.task,
@ -1140,13 +1356,24 @@ class _PlacementItem {
required this.earliestStart, required this.earliestStart,
}); });
/// Task represented by this queue entry.
final Task task; final Task task;
/// Duration the planner must reserve.
final Duration duration; final Duration duration;
/// Earliest allowed start time for this item.
final DateTime earliestStart; final DateTime earliestStart;
} }
/// Planned task intervals keyed by task id.
///
/// The name is historical from the first insertion feature; it now also supports
/// push and rollover placement plans. It remains private so it can be renamed or
/// expanded later without affecting callers.
class _BacklogInsertionPlan { class _BacklogInsertionPlan {
const _BacklogInsertionPlan({required this.placements}); const _BacklogInsertionPlan({required this.placements});
/// Chosen interval for each task that should be scheduled or moved.
final Map<String, TimeInterval> placements; final Map<String, TimeInterval> placements;
} }

View file

@ -1,22 +1,53 @@
// Flexible task card actions.
//
// The main scheduling engine moves tasks through time. This file models the
// small user actions that appear on a flexible task card, such as done, push,
// backlog, and break-up. Keeping these in a service makes UI button handlers
// thin and keeps task-type safety checks in one place.
import 'models.dart'; import 'models.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
/// Quick actions available from a flexible task card. /// Quick actions available from a flexible task card.
///
/// These are the low-friction card controls the UI can expose directly on a
/// planned flexible task. The service below translates each button into either a
/// direct task update, a scheduling operation, or a follow-up flow.
enum FlexibleTaskQuickAction { enum FlexibleTaskQuickAction {
/// Mark the task completed.
done, done,
/// Ask the user where the task should be pushed.
push, push,
/// Move the task out of today's plan and into backlog.
backlog, backlog,
/// Start a flow that splits the task into child tasks.
breakUp, breakUp,
} }
/// Explicit push destinations shown after choosing the push quick action. /// Explicit push destinations shown after choosing the push quick action.
///
/// Push starts as a simple quick action, but the actual destination requires one
/// more choice. Keeping destinations as a separate enum prevents the initial card
/// action list from becoming too crowded.
enum PushDestination { enum PushDestination {
/// Move the task later within the current planning window.
nextAvailableSlot, nextAvailableSlot,
/// Move the task to the beginning of the supplied tomorrow/future window.
tomorrowTopOfQueue, tomorrowTopOfQueue,
/// Remove the task from the active timeline and store it for later.
backlog, backlog,
} }
/// Domain result for a flexible task quick action. /// Domain result for a flexible task quick action.
///
/// This result deliberately supports three outcomes: the task changed, the user
/// must choose a push destination, or the UI should start the child-task flow.
/// That keeps card code from guessing how to interpret each action.
class FlexibleTaskActionResult { class FlexibleTaskActionResult {
const FlexibleTaskActionResult({ const FlexibleTaskActionResult({
required this.action, required this.action,
@ -25,37 +56,65 @@ class FlexibleTaskActionResult {
this.startsChildTaskFlow = false, this.startsChildTaskFlow = false,
}); });
/// Action the user selected.
final FlexibleTaskQuickAction action; final FlexibleTaskQuickAction action;
/// Current or updated task, depending on the action.
final Task task; final Task task;
/// Destination choices to show after `push`; empty for direct actions.
final List<PushDestination> pushDestinations; final List<PushDestination> pushDestinations;
/// Whether the UI should open a child-task creation flow.
final bool startsChildTaskFlow; final bool startsChildTaskFlow;
/// True when the action directly produced an updated [task].
bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty;
} }
/// Result from applying a selected push destination. /// Result from applying a selected push destination.
///
/// The selected destination is included next to the [SchedulingResult] so UI and
/// tests can distinguish "moved later today" from "moved to tomorrow" even if
/// the low-level scheduling change shape is similar.
class PushDestinationResult { class PushDestinationResult {
const PushDestinationResult({ const PushDestinationResult({
required this.destination, required this.destination,
required this.schedulingResult, required this.schedulingResult,
}); });
/// Destination that was applied.
final PushDestination destination; final PushDestination destination;
/// Full scheduler output: updated tasks, notices, changes, and overlaps.
final SchedulingResult schedulingResult; final SchedulingResult schedulingResult;
/// Convenience flag for UI copy or persistence behavior that cares about the
/// tomorrow queue specifically.
bool get placesAtTomorrowTopOfQueue { bool get placesAtTomorrowTopOfQueue {
return destination == PushDestination.tomorrowTopOfQueue; return destination == PushDestination.tomorrowTopOfQueue;
} }
} }
/// Applies low-friction quick actions for flexible task cards. /// Applies low-friction quick actions for flexible task cards.
///
/// This service is the adapter between small UI button presses and domain logic.
/// It intentionally only accepts flexible tasks; required/locked/surprise items
/// should have their own action rules so the UI cannot accidentally apply a
/// flexible-only behavior to a fixed commitment.
class FlexibleTaskActionService { class FlexibleTaskActionService {
const FlexibleTaskActionService({ const FlexibleTaskActionService({
this.schedulingEngine = const SchedulingEngine(), this.schedulingEngine = const SchedulingEngine(),
}); });
/// Scheduling dependency used for actions that need timeline changes.
final SchedulingEngine schedulingEngine; final SchedulingEngine schedulingEngine;
/// Apply the first-stage quick action.
///
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
/// list of destinations the UI should present. `breakUp` signals that the UI
/// should start a child-task flow rather than changing the task immediately.
FlexibleTaskActionResult apply({ FlexibleTaskActionResult apply({
required Task task, required Task task,
required FlexibleTaskQuickAction action, required FlexibleTaskQuickAction action,
@ -99,6 +158,10 @@ class FlexibleTaskActionService {
} }
} }
/// Apply the second-stage destination selected after the `push` action.
///
/// This needs the full [SchedulingInput] because pushing can shift other
/// flexible tasks and must avoid locked/required intervals.
PushDestinationResult applyPushDestination({ PushDestinationResult applyPushDestination({
required PushDestination destination, required PushDestination destination,
required SchedulingInput input, required SchedulingInput input,
@ -131,6 +194,10 @@ class FlexibleTaskActionService {
); );
} }
/// Move one planned flexible task to backlog inside a scheduling result.
///
/// This mirrors the shape of other push destination results so callers can
/// handle every destination through the same `SchedulingResult` interface.
SchedulingResult _moveTaskToBacklog({ SchedulingResult _moveTaskToBacklog({
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
@ -198,6 +265,7 @@ class FlexibleTaskActionService {
} }
} }
/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) { Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) { for (final task in tasks) {
if (task.id == taskId) { if (task.id == taskId) {

View file

@ -1,8 +1,20 @@
// Quiet per-task history counters.
//
// The app design calls for recovery/reporting features that can notice repeated
// pushes, backlog moves, burnout skips, and locked-hour leakage. This file keeps
// that history as immutable counters attached to each task.
/// Internal counters used for future filtering, reporting, and scheduling hints. /// Internal counters used for future filtering, reporting, and scheduling hints.
/// ///
/// These are intentionally quiet metadata. They should not become noisy UI by /// These counters answer "what has happened to this task over time?" without
/// default. This is a public starter placeholder while persistence and reporting /// changing the primary task state. They are intentionally quiet metadata: daily
/// needs are still being shaped by the V1 plan. /// task cards should not show all of this by default, but reports and filters can
/// use it to identify patterns such as tasks repeatedly pushed, skipped during
/// burnout, or completed inside locked hours.
///
/// Like [Task], this is immutable. Increment helpers return a new
/// [TaskStatistics] value so calling code can update a task through `copyWith`
/// while retaining predictable before/after behavior.
class TaskStatistics { class TaskStatistics {
const TaskStatistics({ const TaskStatistics({
this.skippedDuringBurnoutCount = 0, this.skippedDuringBurnoutCount = 0,
@ -17,17 +29,40 @@ class TaskStatistics {
this.completedDuringLockedHoursMinutes = 0, this.completedDuringLockedHoursMinutes = 0,
}); });
/// Number of times this task was skipped during Shield/recovery behavior.
final int skippedDuringBurnoutCount; final int skippedDuringBurnoutCount;
/// Number of times the user explicitly pushed this task.
final int manuallyPushedCount; final int manuallyPushedCount;
/// Number of times the scheduler moved this task to make room for something.
final int autoPushedCount; final int autoPushedCount;
/// Number of times this task was moved from schedule/today into backlog.
final int movedToBacklogCount; final int movedToBacklogCount;
/// Number of times this task came back from backlog into a planned slot.
final int restoredFromBacklogCount; final int restoredFromBacklogCount;
/// Number of times this task missed its intended timing.
final int missedCount; final int missedCount;
/// Number of times this task was intentionally cancelled.
final int cancelledCount; final int cancelledCount;
/// Number of times this task was completed after its scheduled window.
final int completedLateCount; final int completedLateCount;
/// Number of completion events that overlapped locked hours.
final int completedDuringLockedHoursCount; final int completedDuringLockedHoursCount;
/// Total minutes completed while overlapping locked hours.
final int completedDuringLockedHoursMinutes; final int completedDuringLockedHoursMinutes;
/// Return a copy with selected counters changed.
///
/// Counters default to their current values when omitted, which keeps small
/// increment helpers concise and avoids direct mutation.
TaskStatistics copyWith({ TaskStatistics copyWith({
int? skippedDuringBurnoutCount, int? skippedDuringBurnoutCount,
int? manuallyPushedCount, int? manuallyPushedCount,
@ -58,38 +93,50 @@ class TaskStatistics {
); );
} }
/// Record that the task was removed from active planning and stored for later.
TaskStatistics incrementMovedToBacklog() { TaskStatistics incrementMovedToBacklog() {
return copyWith(movedToBacklogCount: movedToBacklogCount + 1); return copyWith(movedToBacklogCount: movedToBacklogCount + 1);
} }
/// Record a recovery/Shield skip. This is distinct from manual cancellation.
TaskStatistics incrementSkippedDuringBurnout() { TaskStatistics incrementSkippedDuringBurnout() {
return copyWith(skippedDuringBurnoutCount: skippedDuringBurnoutCount + 1); return copyWith(skippedDuringBurnoutCount: skippedDuringBurnoutCount + 1);
} }
/// Record an explicit user push action.
TaskStatistics incrementManualPush() { TaskStatistics incrementManualPush() {
return copyWith(manuallyPushedCount: manuallyPushedCount + 1); return copyWith(manuallyPushedCount: manuallyPushedCount + 1);
} }
/// Record scheduler-driven movement caused by another placement.
TaskStatistics incrementAutoPush() { TaskStatistics incrementAutoPush() {
return copyWith(autoPushedCount: autoPushedCount + 1); return copyWith(autoPushedCount: autoPushedCount + 1);
} }
/// Record that a backlog item was scheduled again.
TaskStatistics incrementRestoredFromBacklog() { TaskStatistics incrementRestoredFromBacklog() {
return copyWith(restoredFromBacklogCount: restoredFromBacklogCount + 1); return copyWith(restoredFromBacklogCount: restoredFromBacklogCount + 1);
} }
/// Record a missed intended time or required task handling event.
TaskStatistics incrementMissed() { TaskStatistics incrementMissed() {
return copyWith(missedCount: missedCount + 1); return copyWith(missedCount: missedCount + 1);
} }
/// Record that the task was deliberately cancelled.
TaskStatistics incrementCancelled() { TaskStatistics incrementCancelled() {
return copyWith(cancelledCount: cancelledCount + 1); return copyWith(cancelledCount: cancelledCount + 1);
} }
/// Record that the task was completed after its planned end.
TaskStatistics incrementCompletedLate() { TaskStatistics incrementCompletedLate() {
return copyWith(completedLateCount: completedLateCount + 1); return copyWith(completedLateCount: completedLateCount + 1);
} }
/// Record completion that overlapped locked time by [minutes].
///
/// Both count and minutes are tracked because reports may want either "how
/// often did this happen?" or "how much time leaked into locked blocks?"
TaskStatistics incrementCompletedDuringLockedHours(int minutes) { TaskStatistics incrementCompletedDuringLockedHours(int minutes) {
return copyWith( return copyWith(
completedDuringLockedHoursCount: completedDuringLockedHoursCount + 1, completedDuringLockedHoursCount: completedDuringLockedHoursCount + 1,