diff --git a/Codex Documentation/Completed Plans/README.md b/Codex Documentation/Completed Plans/README.md index 961ae00..1682da3 100644 --- a/Codex Documentation/Completed Plans/README.md +++ b/Codex Documentation/Completed Plans/README.md @@ -16,6 +16,9 @@ Included documents cover: normal Flutter startup and close/reopen task lifecycle proof. - Date Selection 01, the completed day navigation and date picker plan for the persistence-backed Flutter Today timeline. +- Task Pushing 01, the completed past-task push controls plan for planned + flexible tasks, including backend scheduling semantics and Backlog freshness + reset behavior. Excluded documents include superseded originals, MongoDB-targeted persistence or runtime plans, planned/blocked UI handoff plans, old archive indexes, and review diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/README.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/README.md similarity index 96% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/README.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/README.md index 556d5b4..b931ad8 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/README.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/README.md @@ -4,7 +4,7 @@ # Task Pushing 01 — Past Task Push Controls -**Status:** Planned. Execute after Date Selection 01. +**Status:** Complete. Executed after Date Selection 01. **Scope level:** XHIGH implementation plan. This plan adds the push affordance for scheduled tasks that are in the past and diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md similarity index 81% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md index 4e5e24e..9501512 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_01_SCOPE_AND_DOMAIN_SEMANTICS.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 01 — Scope and Domain Semantics -**Status:** Planned. +**Status:** Complete. **Goal:** Confirm the exact pushable task scope and lock destination semantics before adding visible controls. @@ -15,6 +15,21 @@ before adding visible controls. After this block, the implementation should know which cards can show `Push`, what `past` means, and what each destination must do. +## Implementation notes + +1. Task Pushing 01 keeps V1 support limited to planned flexible tasks. Required, + locked, surprise, and free-slot cards must not show the full Push button. +2. `past incomplete` is evaluated against owner-local current time. Historical + selected dates can make an incomplete scheduled task pushable, but future + selected dates do not make tasks pushable before their scheduled end. +3. `Push to next` targets the next valid no-overlap slot after real current + owner-local time. Later-today placement is preferred when a fit exists. +4. `Push to tomorrow` targets the owner-local day after real current time, not + the day after the selected review date. +5. `Push to backlog` preserves `createdAt` and resets freshness through the + existing `backlogEnteredAt` persistence field, promoted into domain/read + behavior by this plan. + --- ## Chunk 1.1 — Confirm prerequisites diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md similarity index 86% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md index ba954a6..fd23eb9 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_02_PAST_TASK_READ_MODEL.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 02 — Past Task Read Model -**Status:** Planned. +**Status:** Complete. **Goal:** Add presentation-model state that tells widgets when to show the full Push button. @@ -15,6 +15,17 @@ Push button. After this block, `TimelineCardModel` or its successor exposes a clear push-state field. Leaf widgets should not recompute past/incomplete scheduling conditions. +## Implementation notes + +1. `TimelineItem` carries the source task `updatedAt` timestamp for stale-write + protection. +2. `TodayScreenData` and `TimelineCardModel` receive an injected clock for + deterministic past/incomplete detection. +3. `TimelineCardModel.showPastTaskPushButton` identifies past incomplete planned + flexible cards and suppresses hover quick actions while true. +4. Mapper tests cover earlier today, previous day, future day, completed, + unsupported task types, and missing schedule placement. + --- ## Chunk 2.1 — Add push-state presentation fields diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md similarity index 86% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md index 77bd30c..dc00f48 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 03 — Push Button Card UI -**Status:** Planned. +**Status:** Complete. **Goal:** Render the full right-side Push button on past incomplete cards. --- @@ -15,6 +15,16 @@ After this block, past incomplete planned flexible cards visibly show a `Push` button beside the reward/difficulty icons. The menu may still be stubbed until Block 04. +## Implementation notes + +1. `TaskTimelineCard` renders a full Push button beside reward/difficulty + controls when `TimelineCardModel.showPastTaskPushButton` is true. +2. Text reserves additional right-side space while Push is visible, and trailing + controls remain above the text layer. +3. Hover quick actions are not built while the full Push button is visible. +4. Widget tests cover render state, completed/future exclusions, hover + suppression, and callback activation. + --- ## Chunk 3.1 — Place the Push button in trailing controls diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md similarity index 86% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md index 9a9e717..1147940 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 04 — Push Menu Destinations -**Status:** Planned. +**Status:** Complete. **Goal:** Open a destination menu from the Push button and route each choice to a callback. @@ -16,6 +16,17 @@ After this block, clicking `Push` opens a menu with the three requested choices. The choices may call stubbed/controller callbacks until Block 05 wires real commands. +## Implementation notes + +1. The Push button opens a compact popup menu with exactly `Push to next`, + `Push to tomorrow`, and `Push to backlog`. +2. Destination-specific callbacks are threaded from the home scaffold through + `TimelineView` to `TaskTimelineCard`. +3. The card prevents duplicate destination submission while a push callback is + still running. +4. Widget tests cover menu labels, destination callbacks, outside-click + dismissal, and one-shot running behavior. + --- ## Chunk 4.1 — Add destination menu UI diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md similarity index 99% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md index ec73f5b..2a509f4 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_05_COMMAND_WIRING_AND_PERSISTENCE.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 05 — Command Wiring and Persistence -**Status:** Planned. +**Status:** Complete. **Goal:** Connect push menu destinations to application commands and prove their results persist. diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md similarity index 99% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md index df5b412..7951178 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_06_DESTINATION_SCHEDULING_RULES.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 06 — Destination Scheduling Rules -**Status:** Planned. +**Status:** Complete. **Goal:** Verify or adjust backend push scheduling so destination behavior exactly matches the requested product rules. diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md similarity index 99% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md index 80cdfba..86c8285 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_07_BACKLOG_FRESHNESS_RESET.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 07 — Backlog Freshness Reset -**Status:** Planned. +**Status:** Complete. **Goal:** Make Push to backlog reset the backlog freshness timer without losing original task creation history. diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md similarity index 70% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md index 54a23ab..98cc9cb 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_08_VALIDATION_HANDOFF.md @@ -4,7 +4,7 @@ # Task Pushing 01 Block 08 — Validation and Handoff -**Status:** Planned. +**Status:** Complete. **Goal:** Prove task pushing works end to end and close the plan cleanly. --- @@ -113,3 +113,35 @@ flutter test - Push results persist through SQLite. - Push to Backlog resets freshness without rewriting creation history. - Validation status is documented. + +## Handoff notes + +Implemented changes: + +1. Added past-task push presentation state, button/menu UI, and command + callbacks. +2. Routed all three destinations through `SchedulerCommandController` and + `V1ApplicationCommandUseCases`. +3. Added scheduler/application support for `Push to next` after command time, + including overdue tasks from previous dates. +4. Added `Task.backlogEnteredAt` and `backlogEnteredAtProvenance`; Backlog age + filtering and staleness markers now use that freshness anchor with + `createdAt` fallback. +5. Added on-disk SQLite close/reopen coverage for push-next, push-tomorrow, + push-backlog, and completion after push. + +Validation completed: + +```sh +cd packages/scheduler_core +dart test test/document_mapping_test.dart test/scheduling_engine_test.dart test/application_commands_test.dart test/persistence_edge_cases_test.dart test/edge_case_regression_test.dart test/task_lifecycle_test.dart +cd ../../apps/focus_flow_flutter +flutter test test/models/past_task_push_presentation_test.dart test/widget_test.dart test/persistent_composition_test.dart +flutter test test/persistent_composition_test.dart +``` + +Unverified gates: + +1. Full `scripts/test.sh`, full package `dart analyze`, and full Flutter + analyze/test were not rerun for this closeout. Earlier hook/analyzer runs in + this branch hit the known Dart analysis server `Too many open files` failure. diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md similarity index 98% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md index 8bb33d8..e75af0e 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_EXECUTION_ORDER.md @@ -4,7 +4,7 @@ # Task Pushing 01 Execution Order -**Status:** Planned. +**Status:** Complete. Run these files in order. All chunks are intended for `XHIGH` execution unless a chunk explicitly says otherwise. diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md similarity index 82% rename from Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md rename to Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md index 7c743d0..419fe2b 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md +++ b/Codex Documentation/Completed Plans/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_SUMMARY.md @@ -4,7 +4,7 @@ # Task Pushing 01 Summary — Past Task Push Controls -**Status:** Planned. +**Status:** Complete. **Scope level:** XHIGH implementation plan. **Primary outcome:** Past, incomplete scheduled tasks expose a persistent push control that can move the task to the next available slot, tomorrow's first open @@ -91,6 +91,30 @@ Task Pushing 01 is complete when all of the following are true: 11. UI widgets do not contain scheduling, Drift, SQL, or repository logic. 12. Validation gates pass, or unavailable commands are documented. +## Completion notes + +Implemented on branch `task-pushing-01-past-task-controls`. + +Key decisions: + +1. Push support remains limited to planned flexible tasks in V1. +2. `Push to next` uses the real current owner-local date/time, not the selected + review date, and never schedules before that instant. +3. `Push to tomorrow` targets the next owner-local civil day relative to command + time. +4. `Push to backlog` records `backlogEnteredAt` and provenance while preserving + original `createdAt`. + +Validation completed: + +```sh +cd packages/scheduler_core +dart test test/document_mapping_test.dart test/scheduling_engine_test.dart test/application_commands_test.dart test/persistence_edge_cases_test.dart test/edge_case_regression_test.dart test/task_lifecycle_test.dart +cd ../../apps/focus_flow_flutter +flutter test test/models/past_task_push_presentation_test.dart test/widget_test.dart test/persistent_composition_test.dart +flutter test test/persistent_composition_test.dart +``` + --- ## Non-goals diff --git a/Codex Documentation/Current Software Plan/README.md b/Codex Documentation/Current Software Plan/README.md index 21bdf89..a60844f 100644 --- a/Codex Documentation/Current Software Plan/README.md +++ b/Codex Documentation/Current Software Plan/README.md @@ -6,7 +6,7 @@ Ordered implementation queue: -1. `Task Pushing 01 - Past Task Push Controls/` — Next incomplete plan. +No active implementation plans remain in this folder. Execute only the first incomplete plan. diff --git a/Human Documentation/Things to consider todo/Deleted Task Statistics.md b/Human Documentation/Things to consider todo/Deleted Task Statistics.md new file mode 100644 index 0000000..7db0afa --- /dev/null +++ b/Human Documentation/Things to consider todo/Deleted Task Statistics.md @@ -0,0 +1,14 @@ + + + +# Deleted Task Statistics + +Consider how the system should remember lightweight statistics for tasks that +have been deleted, while still allowing deleted tasks to be fully removed from +task persistence. + +The likely direction is a small, separate deletion-statistics record rather than +keeping deleted task rows. One possible shape is a table that stores only the +deleted task name and the timestamp when it was deleted. This needs more design +work because it has wider implications for stats, privacy, export, backup, and +future cleanup behavior. diff --git a/LOGGING_RULES.md b/LOGGING_RULES.md new file mode 100644 index 0000000..37ce57e --- /dev/null +++ b/LOGGING_RULES.md @@ -0,0 +1,355 @@ +# FocusFlow Logging Rules + +These instructions are for adding logging calls to the FocusFlow codebase. +The goal is to add useful diagnostics without moving logging policy into +individual services, controllers, repositories, or widgets. + +## Hard Requirements + +1. Use the shared logger only. + Do not create service-specific logger classes, log enums, log entries, helper + sinks, caller-capture code, stack-frame parsing, or message-formatting code in + feature files. + +2. Call logging directly from feature code. + Correct examples: + + ```dart + logger.debug(() => 'Applying flexible quick action. taskId=${task.id}'); + logger.warn(() => 'Duplicate operation ignored. operationId=$operationId'); + logger.error(() => 'SQLite bootstrap failed. code=${failure.code.name}'); + ``` + + Incorrect examples: + + ```dart + if (logger.wouldWrite(FocusFlowLogLevel.debug)) { + logger.debug('Applying action. taskId=${task.id}'); + } + ``` + + ```dart + final message = 'Expensive state dump. tasks=${tasks.map(...).join(',')}'; + logger.finest(message); + ``` + +3. Lazy messages are required for anything with interpolation, collection + formatting, object dumps, date formatting, joins, maps, or stack-related + values. Pass a closure: `logger.debug(() => '...')`. The logger will not + evaluate the closure unless that level is enabled. + +4. Do not manually capture caller file, method, line, or stack traces for normal + log calls. The logging layer automatically captures caller information when + the configured level requires it: + - `finest` configuration captures caller information for every written log. + - `fine`, `finer`, or `finest` configuration captures caller information for + `warn` and `error` logs. + - `debug`, `info`, `warn`, or `error` configuration does not capture caller + information for normal lower-detail logs. + +5. If a caught exception already provides an error or stack trace, pass it to + the logger. Do not create `StackTrace.current` just to satisfy logging. + + ```dart + } on Object catch (error, stackTrace) { + logger.error( + () => 'Startup failed.', + error: error, + stackTrace: stackTrace, + ); + } + ``` + +6. Do not change runtime behavior to add logging. Logging must not affect return + values, operation IDs, scheduling results, persistence results, widget state, + retry behavior, or exception behavior. + +7. Preserve existing SPDX headers, library docs, Dartdoc, imports, and part-file + structure. New Dart files need SPDX metadata and Dartdoc, but this pass should + not need new Dart files. + +8. Use ASCII only unless the edited file already requires non-ASCII. + +## Import Rules + +Use the shared scheduler logger from: + +```dart +import 'package:scheduler_core/scheduler_core.dart' show logger; +``` + +If a file already has a local `logger` name or a conflict, use an alias: + +```dart +import 'package:scheduler_core/scheduler_core.dart' as scheduler_core; + +scheduler_core.logger.info(() => '...'); +``` + +Inside `packages/scheduler_core/lib/src/...`, prefer existing local library +patterns. Some scheduler core files are `part of` files. A `part of` file cannot +add imports. For those files, add the import to the parent library file instead. +Example: `flexible_task_action_service.dart` is a part of +`task_actions.dart`, so `task_actions.dart` imports the logging layer and the +part file can call `logger.debug(...)`. + +Do not import the Flutter app file logger into scheduler core or package code. +The app file logger is only the configured sink. Feature code should use the +shared `logger`. + +## Message Shape + +Prefer concise, structured text: + +```dart +logger.debug(() => 'Scheduling backlog item. taskId=$taskId ' + 'durationMinutes=$durationMinutes windowStart=${window.start.toIso8601String()}'); +``` + +Use: +- A short action phrase first. +- Stable key names in `key=value` form. +- IDs, enum names, dates, counts, status, outcome codes, and operation IDs. +- `toIso8601String()` for dates when logging dates. + +Avoid: +- Full user-authored task titles, descriptions, notes, or free-form content at + `info`, `debug`, `fine`, `warn`, or `error`. +- Large object dumps outside `finest`. +- Vague messages like `Something went wrong`. +- Messages that blame the user. +- Duplicate logs for the same event at multiple levels. + +## Level Rules + +### `finest` + +Use for extremely granular diagnostics only. + +Good uses: +- Full input or output dumps after level gating. +- Raw decoded payloads or document maps when debugging mapping/migration. +- Complete task lists, scheduling inputs, scheduling results, or repository + state snapshots. +- Per-candidate loop details only when needed to reconstruct a scheduling + decision. + +Do not use for: +- Normal app flow. +- High-level operation starts or finishes. +- Any message that would be useful in normal debugging at `debug`. + +Notes: +- `finest` configuration automatically prepends caller information for every + written log. +- Always use a lazy closure. + +### `finer` + +Use for intra-method minor actions and small state changes. + +Good uses: +- Branches selected inside a larger operation. +- Resolved operation IDs, generated IDs, or normalized inputs. +- A candidate accepted/rejected inside a scheduling method. +- A repository deciding which query path to use. +- A controller clearing or preserving local selection as a secondary state + change. + +Do not use for: +- Method entry/exit in every method. +- Normal high-level actions that belong at `debug`. +- Large data dumps that belong at `finest`. + +### `fine` + +Use for secondary detail and "possible issue" logging. + +Good uses: +- Extra detail that explains a `warn` or `error` path. +- Detailed result summaries, notice lists, change lists, or stack traces when + already caught. +- A handled unexpected or non-typical value that could indicate a real issue, + but the code safely handled it. + +Possible issue rule: +- A "possible issue" is an unexpected or non-typical value that was handled but + may indicate bad upstream state or a missed assumption. +- Only use this when there is a real reason to suspect an issue. +- Start the message with `Possible issue:` when using this concept. + +Good possible issue example: + +```dart +logger.fine(() => 'Possible issue: completion transition did not apply. ' + 'taskId=${task.id} outcome=${transition.outcomeCode.name}'); +``` + +Do not use `fine` for: +- Normal control flow. +- Expected empty states. +- User choices such as canceling, closing a modal, or choosing no date. +- Anything that should clearly be a handled issue at `warn`. + +### `debug` + +Use for high-level debug flow and useful state values. + +Good uses: +- A command/action starts with important input IDs and state. +- A scheduling operation finishes with outcome, change count, notice count. +- A task is pushed and the earliest candidate time or destination used. +- A controller submits an action and refreshes data. +- A repository save/load operation begins or finishes with IDs and counts. + +Do not use for: +- Healthy app lifecycle milestones that belong at `info`. +- Handled failures that belong at `warn`. +- Extremely detailed dumps that belong at `finest`. +- Per-frame or every-build Flutter widget logs. + +### `info` + +Use for very high-level healthy lifecycle checkpoints. + +Good uses: +- App startup started/completed. +- Persistent runtime opened/closed. +- SQLite connected or persistence backend opened. +- Backup/export started/completed. +- A major recovery routine completed with a compact summary. + +Do not use for: +- Per-task operations. +- UI taps. +- Scheduler internal decisions. +- Warnings, handled issues, or noisy repeated operations. + +### `warn` + +Use when something did go wrong, but the app handled it and kept going. + +Good uses: +- Duplicate operation ignored. +- Task not found and a typed no-op/not-found result is returned. +- Invalid task state handled with a typed result. +- Config file unreadable and defaults are used. +- Recovery performed a fallback and continued. +- Repository compare-and-set conflict handled without corrupting state. + +Do not use for: +- Expected user choices. +- Empty query results. +- Normal no-op behavior that is part of the happy path. +- "Possible issues" that are only unusual and safely handled; those are `fine`. + +### `error` + +Use when state may be corrupted, startup cannot continue, persistence may be in a +bad state, data may require review, or an invariant is being violated. + +Good uses: +- Throwing because a service received an impossible or invalid task type. +- Startup/bootstrap failure. +- Repository commit failure. +- Migration or mapping failure that prevents safe loading. +- Backup encryption/decryption failure. +- Unhandled command failure requiring review. + +Do not use for: +- Handled typed no-op results. +- Validation failures that are expected user input paths. +- Missing optional configuration. +- Anything recovered cleanly without review; use `warn`. + +## Where Logging Belongs + +Add logs to imperative boundaries and domain operations: +- `main.dart` and app composition/open/close paths. +- Controllers that submit commands, refresh data, or handle failures. +- Application use cases. +- Scheduling services and engines. +- Repository and persistence adapters. +- Backup/export/import operations. +- Recovery, migration, mapping, and command orchestration. + +Avoid logs in: +- Pure data classes, enums, constants, token files, and simple value objects. +- `copyWith`, equality, validation constructors, and simple formatting helpers + unless they throw or handle a real issue. +- Flutter `build` methods, painters, layout helpers, and frequently called + visual functions unless there is a rare handled error. Build methods can run + often; logging there can make logs unusable. + +For loops: +- Prefer one `debug` summary before/after the loop. +- Use `finer` for important branch decisions inside the loop only when useful. +- Use `finest` for per-item dumps only when the details are essential. + +For caught exceptions: +- `warn` if recovered and state is safe. +- `error` if state may be bad, operation failed, or review is needed. +- Pass `error:` and `stackTrace:` if already available. + +## Performance Rules + +The logger already gates work by level. Call sites should rely on that. + +Good: + +```dart +logger.finest(() => 'Scheduling input dump. input=$input tasks=${input.tasks}'); +``` + +Bad: + +```dart +final dump = 'Scheduling input dump. input=$input tasks=${input.tasks}'; +logger.finest(dump); +``` + +Do not add `logger.wouldWrite(...)` checks around normal calls. If message +creation is expensive, put that expensive work inside the closure. Only consider +`wouldWrite` for rare cases where a large temporary structure must be built +before a logger call and cannot reasonably be built inside the closure. + +## Validation Rules + +After adding logs: + +1. Run formatter on edited Dart files. +2. Run package analysis and tests for touched packages. +3. At minimum for scheduler core changes: + + ```bash + cd packages/scheduler_core + dart analyze + dart test + ``` + +4. At minimum for Flutter app changes: + + ```bash + cd apps/focus_flow_flutter + dart analyze + flutter test + ``` + +5. Do not introduce new analyzer warnings. Existing unrelated info-level lints + may remain, but do not add new ones. +6. Logging changes must not change functional test expectations except tests + that directly assert logging behavior. + +## Zip Replacement Instructions + +This zip is intended to be edited externally and then extracted over the repo +root. It contains this rules file at the top level and source files under their +repo-relative paths, such as `apps/...` and `packages/...`. + +When returning edited files: +- Keep the same paths. +- Do not rename files. +- Do not remove files from the archive unless they are intentionally deleted in + the repository. +- Do not add generated files, build output, `.dart_tool`, or dependency caches. +- Preserve the shared logging layer design. diff --git a/analysis_options.yaml b/analysis_options.yaml index 1654f9e..c7fb9a4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,13 @@ include: package:lints/recommended.yaml analyzer: + exclude: + - .dart_tool/** + - archive/** + - builds/** + - coverage/** + - doc/api/** + - logging_handoff_with_logging/** language: strict-casts: true strict-inference: true diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index 89ab14d..427c026 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -33,6 +33,58 @@ It is not inserted during normal startup. Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`. Scheduler core remains the source of truth for task state and Today ordering. +## Runtime Config + +The app optionally reads a JSON config file from: + +```text +~/ADHD_Scheduler/config.json +``` + +Override the config path with: + +```sh +flutter run -d linux --dart-define=FOCUS_FLOW_CONFIG_PATH=/tmp/focus_flow_config.json +``` + +Supported optional keys: + +```json +{ + "Timezone": "UTC", + "LogLevel": "fine", + "LogfileLocation": "~/ADHD_Scheduler/debug/" +} +``` + +`Timezone` accepts a three-letter time-zone code without regard to character +case, such as `UTC`, `GMT`, `PST`, or `PDT`. Common daylight-saving pairs such +as `PST`/`PDT`, `MST`/`MDT`, `CST`/`CDT`, and `EST`/`EDT` are resolved once when +the config loads, so either seasonal abbreviation uses the correct current +offset for that zone during the app session. The setting is an app/UI reference +only: it controls wall-clock timeline display, selected-day startup, and +time-relative actions such as pushing a task to the next slot or tomorrow. Task +instants and all persistence data remain stored as UTC/GMT values. + +`LogLevel` accepts `finest`, `finer`, `fine`, `debug`, `info`, `warn`, or +`error`. +`LogfileLocation` is treated as a directory, and the app writes +`focus_flow.log` inside it. If either key is absent or invalid, file logging is +not enabled and the app ignores that setting. + +Verbose log levels can include automatic caller information without requiring +each call site to pass it manually. `finest` includes caller frames for every +written message. `fine`, `finer`, and `finest` include caller frames for +warnings and errors. This caller lookup is skipped when the configured level +does not require it. + +For expensive debug/detail messages, pass a lazy closure so work is skipped when +the configured level would not write the log: + +```dart +logger.debug(() => 'state=${expensiveStateDump()}'); +``` + ## Package Boundary Allowed app imports include public scheduler APIs such as: diff --git a/apps/focus_flow_flutter/config.example.json b/apps/focus_flow_flutter/config.example.json new file mode 100644 index 0000000..311078f --- /dev/null +++ b/apps/focus_flow_flutter/config.example.json @@ -0,0 +1,5 @@ +{ + "Timezone": "PST", + "LogLevel": "fine", + "LogfileLocation": "~/ADHD_Scheduler/debug/" +} diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index efef249..60a5a51 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { return TodayScreenController( selectedDates: selectedDates, dateLabelFor: dateLabelFor, + now: () => readAt, read: (date) { return todayQuery.execute( GetTodayStateRequest( diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 6e48d31..15c23d8 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -23,6 +23,7 @@ class _FocusFlowHomeState extends State { @override void initState() { super.initState(); + logger.debug(() => 'FocusFlow home initializing controllers.'); selectedDateController = widget.composition.createSelectedDateController(); controller = widget.composition.createTodayScreenController( selectedDates: selectedDateController, @@ -31,6 +32,7 @@ class _FocusFlowHomeState extends State { refreshReads: controller.load, selectedDate: () => selectedDateController.date, ); + logger.finer(() => 'FocusFlow home triggering initial Today load.'); controller.load(); } @@ -38,6 +40,7 @@ class _FocusFlowHomeState extends State { /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. @override void dispose() { + logger.debug(() => 'FocusFlow home disposing controllers.'); commandController.dispose(); controller.dispose(); selectedDateController.dispose(); @@ -49,8 +52,16 @@ class _FocusFlowHomeState extends State { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future _toggleCardCompletion(TimelineCardModel card) async { if (!card.canToggleCompletion) { + logger.finer( + () => 'Timeline completion toggle ignored. cardId=${card.id}', + ); return; } + logger.debug( + () => + 'Timeline completion toggle requested. ' + 'cardId=${card.id} taskType=${card.taskType.name} isCompleted=${card.isCompleted}', + ); if (card.isCompleted) { await commandController.uncompleteTask(taskId: card.id); } else { @@ -63,16 +74,58 @@ class _FocusFlowHomeState extends State { /// Adds a generic filler task from the timeline context menu. Future _addGenericFlexibleTask() async { + logger.debug(() => 'Timeline add generic flexible task requested.'); await commandController.addGenericFlexibleTask(); } + /// Pushes [card] to the next available slot after the current clock time. + Future _pushCardToNext(TimelineCardModel card) async { + logger.debug(() => 'Timeline push-to-next requested. cardId=${card.id}'); + await commandController.pushTaskToNextAvailableSlot( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Pushes [card] to tomorrow's first available slot. + Future _pushCardToTomorrow(TimelineCardModel card) async { + logger.debug( + () => 'Timeline push-to-tomorrow requested. cardId=${card.id}', + ); + await commandController.pushTaskToTomorrow( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Pushes [card] back to Backlog. + Future _pushCardToBacklog(TimelineCardModel card) async { + logger.debug(() => 'Timeline push-to-backlog requested. cardId=${card.id}'); + await commandController.pushTaskToBacklog( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Permanently removes [card] from persistence. + Future _removeCard(TimelineCardModel card) async { + logger.debug(() => 'Timeline remove requested. cardId=${card.id}'); + await commandController.removeTask( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + controller.clearSelection(); + } + /// Moves the visible planning date backward by one day. void _selectPreviousDate() { + logger.finer(() => 'Previous date action requested.'); selectedDateController.selectPreviousDay(); } /// Moves the visible planning date forward by one day. void _selectNextDate() { + logger.finer(() => 'Next date action requested.'); selectedDateController.selectNextDay(); } @@ -86,9 +139,14 @@ class _FocusFlowHomeState extends State { builder: _buildDatePickerTheme, ); if (picked == null) { + logger.finer(() => 'Date picker dismissed without selection.'); return; } - selectedDateController.selectDate(_civilDateForDateTime(picked)); + final selected = _civilDateForDateTime(picked); + logger.debug( + () => 'Date picker selected date. date=${selected.toIsoString()}', + ); + selectedDateController.selectDate(selected); } /// Builds the widget subtree for this component. @@ -114,6 +172,10 @@ class _FocusFlowHomeState extends State { selectedCard: controller.selectedCard, onSelectCard: controller.selectCard, onCompleteCard: _toggleCardCompletion, + onPushToNext: _pushCardToNext, + onPushToTomorrow: _pushCardToTomorrow, + onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, onPreviousDate: _selectPreviousDate, @@ -127,6 +189,10 @@ class _FocusFlowHomeState extends State { selectedCard: controller.selectedCard, onSelectCard: controller.selectCard, onCompleteCard: _toggleCardCompletion, + onPushToNext: _pushCardToNext, + onPushToTomorrow: _pushCardToTomorrow, + onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, onPreviousDate: _selectPreviousDate, diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index 0c24d1a..a435cfd 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -13,6 +13,10 @@ class _TodayScreenScaffold extends StatefulWidget { required this.selectedCard, required this.onSelectCard, required this.onCompleteCard, + required this.onPushToNext, + required this.onPushToTomorrow, + required this.onPushToBacklog, + required this.onRemoveCard, required this.onAddTask, required this.onClearSelection, required this.onPreviousDate, @@ -36,6 +40,22 @@ class _TodayScreenScaffold extends StatefulWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Future Function(TimelineCardModel card) onCompleteCard; + /// Stores the `onPushToNext` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function(TimelineCardModel card) onPushToNext; + + /// Stores the `onPushToTomorrow` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function(TimelineCardModel card) onPushToTomorrow; + + /// Stores the `onPushToBacklog` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function(TimelineCardModel card) onPushToBacklog; + + /// Stores the `onRemoveCard` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function(TimelineCardModel card) onRemoveCard; + /// Stores the `onAddTask` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Future Function() onAddTask; diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 5441351..86d5fb3 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -18,6 +18,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. var _timelineScrollRequest = 0; + /// Private state stored as `_timelineScrollToNowRequest` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _timelineScrollToNowRequest = 0; + /// Runs the `_showUpcomingRequiredTask` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _showUpcomingRequiredTask() { @@ -31,6 +35,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { }); } + /// Runs the `_scrollTimelineToCurrentTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _scrollTimelineToCurrentTime() { + setState(() { + _timelineScrollTargetCardId = null; + _timelineScrollToNowRequest += 1; + }); + } + /// Performs the `didUpdateWidget` behavior for this scheduler component. /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override @@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { } _timelineScrollTargetCardId = null; _timelineScrollRequest = 0; + _timelineScrollToNowRequest = 0; } /// Builds the widget subtree for this component. @@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPreviousDate: widget.onPreviousDate, onNextDate: widget.onNextDate, onDatePressed: widget.onChooseDate, + onDateLabelPressed: _scrollTimelineToCurrentTime, ), if (widget.data.requiredBanner == null) const SizedBox(height: 16) @@ -81,9 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { range: widget.data.timelineRange, scrollTargetCardId: _timelineScrollTargetCardId, scrollRequest: _timelineScrollRequest, + scrollToNowRequest: _timelineScrollToNowRequest, onCardSelected: widget.onSelectCard, onAddTask: widget.onAddTask, onCardCompleted: widget.onCompleteCard, + onPushToNext: widget.onPushToNext, + onPushToTomorrow: widget.onPushToTomorrow, + onPushToBacklog: widget.onPushToBacklog, + now: () => + DateTime.now().toUtc().add(widget.data.timeZoneOffset), ), ), ], @@ -109,6 +130,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onStatusPressed: widget.selectedCard!.canToggleCompletion ? () => widget.onCompleteCard(widget.selectedCard!) : null, + onPushToNext: widget.onPushToNext, + onPushToTomorrow: widget.onPushToTomorrow, + onPushToBacklog: widget.onPushToBacklog, + onRemove: widget.onRemoveCard, ), ), ], diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index a4a5343..1133e1e 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -6,12 +6,17 @@ library; import 'dart:io'; -import 'package:scheduler_core/scheduler_core.dart'; +import 'package:scheduler_core/scheduler_core.dart' hide logger; +import 'package:scheduler_core/scheduler_core.dart' + as scheduler_core + show logger; import 'package:scheduler_persistence_sqlite/sqlite.dart'; import '../controllers/scheduler_command_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; +import 'runtime/focus_flow_file_logger.dart'; +import 'runtime/focus_flow_runtime_config.dart'; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'scheduler_app_composition.dart'; @@ -25,6 +30,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { required this.commandUseCases, required this.date, required this.readAt, + required this.logger, + required this.uiTimeZone, + required this.clock, + required this.timeZoneResolver, }); /// Opens the configured SQLite runtime and bootstraps required owner state. @@ -32,36 +41,65 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { String? sqlitePath, Clock? clock, CivilDate? selectedDate, + FocusFlowFileLogger? logger, + FocusFlowUiTimeZone? uiTimeZone, }) async { + final resolvedLogger = logger ?? FocusFlowFileLogger.disabled(); + final resolvedTimeZone = uiTimeZone ?? FocusFlowUiTimeZone.utc; + final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment(); + scheduler_core.logger.info( + () => + 'Opening SQLite runtime. sqlitePath=$resolvedSqlitePath ' + 'uiTimeZone=${resolvedTimeZone.code}', + ); final runtime = await SqliteApplicationRuntime.open( - path: sqlitePath ?? sqlitePathFromEnvironment(), + path: resolvedSqlitePath, ); final bootstrap = await runtime.bootstrap(); if (bootstrap.isFailure) { await runtime.close(); + scheduler_core.logger.error( + () => + 'SQLite bootstrap failed. failureCode=${bootstrap.failure!.code.name}', + error: bootstrap.failure!.code.name, + ); throw StateError( 'SQLite bootstrap failed: ${bootstrap.failure!.code.name}', ); } + scheduler_core.logger.debug(() => 'SQLite bootstrap completed.'); final resolvedClock = clock ?? const SystemClock(); - final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now()); - final readAt = resolvedClock.now().toUtc(); + final openedAt = resolvedClock.now(); + final date = selectedDate ?? resolvedTimeZone.toLocalCivilDate(openedAt); + final readAt = openedAt.toUtc(); + final resolver = FixedOffsetTimeZoneResolver( + offset: resolvedTimeZone.utcOffset, + ); + scheduler_core.logger.info( + () => + 'Persistent scheduler composition opened. date=${date.toIsoString()} ' + 'readAt=${readAt.toIso8601String()} uiTimeZone=${resolvedTimeZone.code}', + ); return PersistentSchedulerComposition._( runtime: runtime, todayQuery: GetTodayStateQuery( applicationStore: runtime.applicationStore, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + timeZoneResolver: resolver, ), managementUseCases: V1ApplicationManagementUseCases( applicationStore: runtime.applicationStore, ), commandUseCases: V1ApplicationCommandUseCases( applicationStore: runtime.applicationStore, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + timeZoneResolver: resolver, ), date: date, readAt: readAt, + logger: resolvedLogger, + uiTimeZone: resolvedTimeZone, + clock: resolvedClock, + timeZoneResolver: resolver, ); } @@ -98,6 +136,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Stable read clock instant for operation contexts. final DateTime readAt; + /// Optional app runtime logger. + final FocusFlowFileLogger logger; + + /// UI-local fixed-offset time-zone reference for wall-clock behavior. + final FocusFlowUiTimeZone uiTimeZone; + + /// Runtime clock used to derive current UTC instants. + final Clock clock; + + /// Resolver configured from [uiTimeZone]. + final FixedOffsetTimeZoneResolver timeZoneResolver; + /// Pending close operation, if disposal has started. Future? _disposeFuture; @@ -122,6 +172,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Creates the controller that owns the visible planning date. @override SelectedDateController createSelectedDateController() { + scheduler_core.logger.finer( + () => + 'Creating selected date controller. date=${initialDate.toIsoString()}', + ); return SelectedDateController(initialDate: initialDate); } @@ -130,9 +184,14 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { TodayScreenController createTodayScreenController({ required SelectedDateController selectedDates, }) { + scheduler_core.logger.finer( + () => 'Creating today screen controller. date=${date.toIsoString()}', + ); return TodayScreenController( selectedDates: selectedDates, dateLabelFor: dateLabelFor, + now: _utcNow, + timeZoneOffset: uiTimeZone.utcOffset, read: (date) { return todayQuery.execute( GetTodayStateRequest( @@ -151,6 +210,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { required ReadRefresh refreshReads, required SelectedDateReader selectedDate, }) { + scheduler_core.logger.finer( + () => + 'Creating scheduler command controller. operationIdPrefix=ui-command-${readAt.microsecondsSinceEpoch}', + ); return SchedulerCommandController( commands: commandUseCases, contextFor: context, @@ -158,6 +221,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}', + currentDateFor: uiTimeZone.toLocalCivilDate, + now: _utcNow, ); } @@ -167,9 +232,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { operationId: operationId, ownerTimeZone: OwnerTimeZoneContext( ownerId: SqliteApplicationRuntime.defaultV1OwnerId, - timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId, + timeZoneId: uiTimeZone.code, ), - clock: FixedClock(now ?? readAt), + clock: FixedClock((now ?? _utcNow()).toUtc()), idGenerator: SequentialIdGenerator(prefix: operationId), ); } @@ -177,6 +242,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Closes the persistent runtime. @override Future dispose() { - return _disposeFuture ??= runtime.close(); + return _disposeFuture ??= _dispose(); + } + + /// Closes runtime resources and records shutdown diagnostics when enabled. + Future _dispose() async { + scheduler_core.logger.debug(() => 'Closing SQLite runtime.'); + await runtime.close(); + scheduler_core.logger.info(() => 'SQLite runtime closed.'); + } + + /// Returns the current runtime instant normalized to UTC. + DateTime _utcNow() { + return clock.now().toUtc(); } } diff --git a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart new file mode 100644 index 0000000..62eaec1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Provides optional FocusFlow runtime file logging. +library; + +import 'dart:io'; + +import 'package:scheduler_core/scheduler_core.dart' as scheduler_core; + +import 'focus_flow_runtime_config.dart'; + +/// Minimal append-only file logger for FocusFlow runtime diagnostics. +final class FocusFlowFileLogger { + /// Creates a logger with explicit runtime state. + FocusFlowFileLogger._({ + required this.minimumLevel, + required this.logFilePath, + required this._logger, + }); + + /// Additional caller-frame marker for this app wrapper. + static const List _callerFrameIgnores = [ + 'focus_flow_file_logger.dart', + ]; + + /// Creates a disabled logger that drops every message. + factory FocusFlowFileLogger.disabled({DateTime Function()? now}) { + scheduler_core.logger.disable(now: now); + return FocusFlowFileLogger._( + minimumLevel: null, + logFilePath: null, + logger: scheduler_core.FocusFlowLogger.disabled(now: now), + ); + } + + /// Opens a logger from [config] when both logging settings are present. + static Future open( + FocusFlowRuntimeConfig config, { + DateTime Function()? now, + String fileName = 'focus_flow.log', + }) async { + final level = config.logLevel; + final location = config.logFileLocation; + if (level == null || location == null) { + return FocusFlowFileLogger.disabled(now: now); + } + + try { + final directory = Directory(location); + await directory.create(recursive: true); + final logFilePath = _joinPath(directory.path, fileName); + final resolvedNow = now ?? _utcNow; + final sink = _fileSink(logFilePath); + scheduler_core.logger.configure( + minimumLevel: level, + sink: sink, + now: resolvedNow, + callerFrameIgnores: _callerFrameIgnores, + ); + scheduler_core.logger.info( + () => + 'FocusFlow file logging configured. level=${level.name} path=$logFilePath', + ); + return FocusFlowFileLogger._( + minimumLevel: level, + logFilePath: logFilePath, + logger: scheduler_core.FocusFlowLogger( + minimumLevel: level, + sink: sink, + now: resolvedNow, + callerFrameIgnores: _callerFrameIgnores, + ), + ); + } on Object catch (error, stackTrace) { + scheduler_core.logger.error( + () => 'FocusFlow file logging setup failed; disabling file logging.', + error: error, + stackTrace: stackTrace, + ); + return FocusFlowFileLogger.disabled(now: now); + } + } + + /// Minimum severity that will be written, or null when disabled. + final FocusFlowLogLevel? minimumLevel; + + /// Concrete file path receiving appended log lines. + final String? logFilePath; + + /// Shared logger implementation that owns gating and caller capture. + final scheduler_core.FocusFlowLogger _logger; + + /// Whether this logger currently writes to a file. + bool get isEnabled => logFilePath != null && _logger.isEnabled; + + /// Writes a finest-level [message] when enabled for finest logging. + Future finest(Object? message) { + return write(scheduler_core.FocusFlowLogLevel.finest, message); + } + + /// Writes a finer-level [message] when enabled for finer logging. + Future finer(Object? message) { + return write(scheduler_core.FocusFlowLogLevel.finer, message); + } + + /// Writes a fine-level [message] when enabled for fine logging. + Future fine(Object? message) { + return write(scheduler_core.FocusFlowLogLevel.fine, message); + } + + /// Writes a debug-level [message] when enabled for debug or lower. + Future debug(Object? message) { + return write(scheduler_core.FocusFlowLogLevel.debug, message); + } + + /// Writes an info-level [message] when enabled for info or lower. + Future info(Object? message) { + return write(scheduler_core.FocusFlowLogLevel.info, message); + } + + /// Writes a warning-level [message] when enabled for warning or lower. + Future warn(Object? message, {Object? error, StackTrace? stackTrace}) { + return write( + scheduler_core.FocusFlowLogLevel.warn, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes an error-level [message] when file logging is enabled. + Future error(Object? message, {Object? error, StackTrace? stackTrace}) { + return write( + scheduler_core.FocusFlowLogLevel.error, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at [level] when it passes the configured threshold. + Future write( + scheduler_core.FocusFlowLogLevel level, + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + _logger.write(level, message, error: error, stackTrace: stackTrace); + return Future.value(); + } +} + +/// Returns the current UTC timestamp. +DateTime _utcNow() => DateTime.now().toUtc(); + +/// Creates a file sink that appends formatted log entries. +scheduler_core.FocusFlowLogSink _fileSink(String path) { + return (entry) { + File(path).writeAsStringSync( + '${entry.toSingleLine()}\n', + mode: FileMode.append, + flush: true, + ); + }; +} + +/// Joins [base] and [child] with the platform path separator. +String _joinPath(String base, String child) { + if (base.endsWith('/') || base.endsWith(r'\')) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart new file mode 100644 index 0000000..b2ed19a --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart @@ -0,0 +1,515 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Loads optional FocusFlow runtime configuration from a local JSON file. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:scheduler_core/scheduler_core.dart' + show CivilDate, FocusFlowLogLevel; +import 'package:scheduler_core/scheduler_core.dart' show logger; + +export 'package:scheduler_core/scheduler_core.dart' show FocusFlowLogLevel; + +/// File-backed runtime configuration for optional developer diagnostics. +final class FocusFlowRuntimeConfig { + /// Creates a runtime configuration with optional UI and logging controls. + const FocusFlowRuntimeConfig({ + this.logLevel, + this.logFileLocation, + this.timeZone, + }); + + /// Loads runtime configuration from [path] or the configured default path. + /// + /// [now] is evaluated once and used to resolve DST-sensitive time-zone codes + /// into one fixed offset for this app session. + static Future load({ + String? path, + DateTime Function()? now, + }) async { + final configPath = path ?? configPathFromEnvironment(); + final file = File(_expandHome(configPath)); + if (!await file.exists()) { + logger.finer(() => 'Runtime config file not found. path=${file.path}'); + return const FocusFlowRuntimeConfig(); + } + + try { + logger.debug(() => 'Loading runtime config. path=${file.path}'); + final decoded = jsonDecode(await file.readAsString()); + if (decoded is Map) { + final config = fromJson( + decoded, + referenceInstant: (now ?? DateTime.now).call().toUtc(), + ); + logger.debug( + () => + 'Runtime config loaded. hasLogLevel=${config.logLevel != null} ' + 'hasLogFileLocation=${config.logFileLocation != null} ' + 'hasTimeZone=${config.timeZone != null}', + ); + return config; + } + logger.warn( + () => + 'Runtime config ignored because decoded JSON was not an object. ' + 'path=${file.path} decodedType=${decoded.runtimeType}', + ); + } on Object catch (error, stackTrace) { + logger.warn( + () => 'Runtime config unreadable; using defaults. path=${file.path}', + error: error, + stackTrace: stackTrace, + ); + return const FocusFlowRuntimeConfig(); + } + + return const FocusFlowRuntimeConfig(); + } + + /// Resolves the config file path from a Dart define or the local default. + static String configPathFromEnvironment() { + const configured = String.fromEnvironment('FOCUS_FLOW_CONFIG_PATH'); + final trimmed = configured.trim(); + if (trimmed.isNotEmpty) { + logger.finer( + () => 'Runtime config path resolved from environment. path=$trimmed', + ); + return trimmed; + } + final path = defaultConfigPath(); + logger.finer(() => 'Runtime config path resolved from default. path=$path'); + return path; + } + + /// Returns the default config file path. + static String defaultConfigPath() { + return _joinPath(_schedulerHomePath(), 'config.json'); + } + + /// Builds a runtime configuration from decoded JSON fields. + /// + /// [referenceInstant] resolves DST-sensitive time zones once while reading the + /// config so normal app conversions can use the resulting fixed offset. + static FocusFlowRuntimeConfig fromJson( + Map json, { + DateTime? referenceInstant, + }) { + final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel'])); + final logLocation = _optionalString(json['LogfileLocation']); + final timeZone = FocusFlowUiTimeZone.tryParse( + _optionalString(json['Timezone'] ?? json['TimeZone'] ?? json['timezone']), + referenceInstant: referenceInstant, + ); + return FocusFlowRuntimeConfig( + logLevel: level, + logFileLocation: logLocation == null ? null : _expandHome(logLocation), + timeZone: timeZone, + ); + } + + /// Optional minimum level for file logging. + final FocusFlowLogLevel? logLevel; + + /// Optional directory where the app should append its debug log file. + final String? logFileLocation; + + /// Optional UI-local time-zone reference for wall-clock display and actions. + final FocusFlowUiTimeZone? timeZone; + + /// Whether this configuration has enough settings to enable file logging. + bool get enablesFileLogging => logLevel != null && logFileLocation != null; +} + +/// UI-local fixed-offset time-zone reference loaded from runtime config. +final class FocusFlowUiTimeZone { + /// Creates a UI time-zone reference from a normalized code and UTC offset. + const FocusFlowUiTimeZone._({required this.code, required this.utcOffset}); + + /// Default UI time-zone when no valid config value is provided. + static const utc = FocusFlowUiTimeZone._( + code: 'UTC', + utcOffset: Duration.zero, + ); + + /// Parses a three-letter time-zone code without regard to character case. + /// + /// [referenceInstant] resolves supported daylight-saving aliases, such as + /// `PST`/`PDT`, into the currently active code and offset up front. + static FocusFlowUiTimeZone? tryParse( + String? value, { + DateTime? referenceInstant, + }) { + if (value == null) { + return null; + } + final code = value.trim().toUpperCase(); + if (!_threeLetterTimeZoneCode.hasMatch(code)) { + return null; + } + final resolvedCode = _resolveDaylightSavingCode( + code, + referenceInstant ?? DateTime.now().toUtc(), + ); + final offsetMinutes = _timeZoneOffsetMinutesByCode[resolvedCode]; + if (offsetMinutes == null) { + return null; + } + return FocusFlowUiTimeZone._( + code: resolvedCode, + utcOffset: Duration(minutes: offsetMinutes), + ); + } + + /// Normalized three-letter time-zone code. + final String code; + + /// Fixed offset from UTC used for UI-local wall-clock interpretation. + final Duration utcOffset; + + /// Converts [instant] into a UI-local display `DateTime`. + DateTime toLocalDateTime(DateTime instant) { + return instant.toUtc().add(utcOffset); + } + + /// Converts [instant] into the UI-local civil date. + CivilDate toLocalCivilDate(DateTime instant) { + return CivilDate.fromDateTime(toLocalDateTime(instant)); + } +} + +/// Accepted time-zone code shape for manual UI config values. +final _threeLetterTimeZoneCode = RegExp(r'^[A-Z]{3}$'); + +/// Number of days in one calendar week. +const _daysPerWeek = 7; + +/// Rule family for North American zones with standard/daylight abbreviations. +const _northAmericaDstFamily = 'northAmerica'; + +/// Rule family for British Summer Time. +const _britishDstFamily = 'british'; + +/// DST-linked code groups that should resolve to the active seasonal code. +const _daylightSavingTimeZoneRules = + < + String, + ({ + String standardCode, + String daylightCode, + int standardOffsetMinutes, + int daylightOffsetMinutes, + String family, + }) + >{ + 'AST': ( + standardCode: 'AST', + daylightCode: 'ADT', + standardOffsetMinutes: -4 * 60, + daylightOffsetMinutes: -3 * 60, + family: _northAmericaDstFamily, + ), + 'ADT': ( + standardCode: 'AST', + daylightCode: 'ADT', + standardOffsetMinutes: -4 * 60, + daylightOffsetMinutes: -3 * 60, + family: _northAmericaDstFamily, + ), + 'CST': ( + standardCode: 'CST', + daylightCode: 'CDT', + standardOffsetMinutes: -6 * 60, + daylightOffsetMinutes: -5 * 60, + family: _northAmericaDstFamily, + ), + 'CDT': ( + standardCode: 'CST', + daylightCode: 'CDT', + standardOffsetMinutes: -6 * 60, + daylightOffsetMinutes: -5 * 60, + family: _northAmericaDstFamily, + ), + 'EST': ( + standardCode: 'EST', + daylightCode: 'EDT', + standardOffsetMinutes: -5 * 60, + daylightOffsetMinutes: -4 * 60, + family: _northAmericaDstFamily, + ), + 'EDT': ( + standardCode: 'EST', + daylightCode: 'EDT', + standardOffsetMinutes: -5 * 60, + daylightOffsetMinutes: -4 * 60, + family: _northAmericaDstFamily, + ), + 'MST': ( + standardCode: 'MST', + daylightCode: 'MDT', + standardOffsetMinutes: -7 * 60, + daylightOffsetMinutes: -6 * 60, + family: _northAmericaDstFamily, + ), + 'MDT': ( + standardCode: 'MST', + daylightCode: 'MDT', + standardOffsetMinutes: -7 * 60, + daylightOffsetMinutes: -6 * 60, + family: _northAmericaDstFamily, + ), + 'NST': ( + standardCode: 'NST', + daylightCode: 'NDT', + standardOffsetMinutes: -(3 * 60 + 30), + daylightOffsetMinutes: -(2 * 60 + 30), + family: _northAmericaDstFamily, + ), + 'NDT': ( + standardCode: 'NST', + daylightCode: 'NDT', + standardOffsetMinutes: -(3 * 60 + 30), + daylightOffsetMinutes: -(2 * 60 + 30), + family: _northAmericaDstFamily, + ), + 'PST': ( + standardCode: 'PST', + daylightCode: 'PDT', + standardOffsetMinutes: -8 * 60, + daylightOffsetMinutes: -7 * 60, + family: _northAmericaDstFamily, + ), + 'PDT': ( + standardCode: 'PST', + daylightCode: 'PDT', + standardOffsetMinutes: -8 * 60, + daylightOffsetMinutes: -7 * 60, + family: _northAmericaDstFamily, + ), + 'BST': ( + standardCode: 'GMT', + daylightCode: 'BST', + standardOffsetMinutes: 0, + daylightOffsetMinutes: 60, + family: _britishDstFamily, + ), + }; + +/// Fixed UTC offsets, in minutes, for recognized three-letter zone codes. +const _timeZoneOffsetMinutesByCode = { + 'ADT': -3 * 60, + 'AFT': 4 * 60 + 30, + 'AST': -4 * 60, + 'BDT': 6 * 60, + 'BST': 1 * 60, + 'CAT': 2 * 60, + 'CET': 1 * 60, + 'CDT': -5 * 60, + 'CST': -6 * 60, + 'EAT': 3 * 60, + 'EDT': -4 * 60, + 'EET': 2 * 60, + 'EST': -5 * 60, + 'GMT': 0, + 'GST': 4 * 60, + 'HDT': -9 * 60, + 'HKT': 8 * 60, + 'HST': -10 * 60, + 'ICT': 7 * 60, + 'IST': 5 * 60 + 30, + 'JST': 9 * 60, + 'KST': 9 * 60, + 'MDT': -6 * 60, + 'MMT': 6 * 60 + 30, + 'MSK': 3 * 60, + 'MST': -7 * 60, + 'NDT': -(2 * 60 + 30), + 'NPT': 5 * 60 + 45, + 'NST': -(3 * 60 + 30), + 'PDT': -7 * 60, + 'PHT': 8 * 60, + 'PKT': 5 * 60, + 'PST': -8 * 60, + 'SGT': 8 * 60, + 'TRT': 3 * 60, + 'UCT': 0, + 'UTC': 0, + 'WAT': 1 * 60, + 'WET': 0, + 'WIB': 7 * 60, + 'WIT': 9 * 60, +}; + +/// Resolves [code] to the active seasonal code for DST-linked zones. +String _resolveDaylightSavingCode(String code, DateTime referenceInstant) { + final rule = _daylightSavingTimeZoneRules[code]; + if (rule == null) { + return code; + } + final instant = referenceInstant.toUtc(); + final usesDaylight = _usesDaylightSavingTime(rule, instant); + return usesDaylight ? rule.daylightCode : rule.standardCode; +} + +/// Reports whether [rule] is in daylight-saving time at [instant]. +bool _usesDaylightSavingTime( + ({ + String standardCode, + String daylightCode, + int standardOffsetMinutes, + int daylightOffsetMinutes, + String family, + }) + rule, + DateTime instant, +) { + return switch (rule.family) { + _northAmericaDstFamily => _isNorthAmericaDaylightSavingTime( + instant: instant, + standardOffsetMinutes: rule.standardOffsetMinutes, + daylightOffsetMinutes: rule.daylightOffsetMinutes, + ), + _britishDstFamily => _isBritishSummerTime(instant), + _ => false, + }; +} + +/// Reports whether [instant] falls inside North American daylight saving time. +bool _isNorthAmericaDaylightSavingTime({ + required DateTime instant, + required int standardOffsetMinutes, + required int daylightOffsetMinutes, +}) { + final utc = instant.toUtc(); + final year = utc.year; + final startDay = _nthWeekdayOfMonth( + year: year, + month: DateTime.march, + weekday: DateTime.sunday, + occurrence: 2, + ); + final endDay = _nthWeekdayOfMonth( + year: year, + month: DateTime.november, + weekday: DateTime.sunday, + occurrence: 1, + ); + final startsAtUtc = DateTime.utc( + year, + DateTime.march, + startDay, + 2, + ).subtract(Duration(minutes: standardOffsetMinutes)); + final endsAtUtc = DateTime.utc( + year, + DateTime.november, + endDay, + 2, + ).subtract(Duration(minutes: daylightOffsetMinutes)); + return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc); +} + +/// Reports whether [instant] falls inside British Summer Time. +bool _isBritishSummerTime(DateTime instant) { + final utc = instant.toUtc(); + final year = utc.year; + final startDay = _lastWeekdayOfMonth( + year: year, + month: DateTime.march, + weekday: DateTime.sunday, + ); + final endDay = _lastWeekdayOfMonth( + year: year, + month: DateTime.october, + weekday: DateTime.sunday, + ); + final startsAtUtc = DateTime.utc(year, DateTime.march, startDay, 1); + final endsAtUtc = DateTime.utc(year, DateTime.october, endDay, 1); + return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc); +} + +/// Returns the day number for the [occurrence] of [weekday] in a month. +int _nthWeekdayOfMonth({ + required int year, + required int month, + required int weekday, + required int occurrence, +}) { + final firstDay = DateTime.utc(year, month); + final daysUntilWeekday = (weekday - firstDay.weekday) % _daysPerWeek; + return firstDay + .add(Duration(days: daysUntilWeekday + (_daysPerWeek * (occurrence - 1)))) + .day; +} + +/// Returns the day number for the last [weekday] in a month. +int _lastWeekdayOfMonth({ + required int year, + required int month, + required int weekday, +}) { + final lastDay = DateTime.utc(year, month + 1, 0); + final daysSinceWeekday = (lastDay.weekday - weekday) % _daysPerWeek; + return lastDay.subtract(Duration(days: daysSinceWeekday)).day; +} + +/// Reads [value] as a non-empty string when present. +String? _optionalString(Object? value) { + if (value is! String) { + return null; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +/// Returns the local scheduler home directory path. +String _schedulerHomePath() { + final configured = Platform.environment['SCHEDULER_HOME']?.trim(); + if (configured != null && configured.isNotEmpty) { + return _expandHome(configured); + } + final home = _homePath(); + if (home == null) { + return 'ADHD_Scheduler'; + } + return _joinPath(home, 'ADHD_Scheduler'); +} + +/// Resolves the current user's home directory when the platform exposes it. +String? _homePath() { + final home = Platform.environment['HOME']?.trim(); + if (home != null && home.isNotEmpty) { + return home; + } + final userProfile = Platform.environment['USERPROFILE']?.trim(); + if (userProfile != null && userProfile.isNotEmpty) { + return userProfile; + } + return null; +} + +/// Expands a leading `~` in [path] using the platform home directory. +String _expandHome(String path) { + if (path == '~') { + return _homePath() ?? path; + } + if (path.startsWith('~/') || path.startsWith(r'~\')) { + final home = _homePath(); + if (home == null) { + return path; + } + return _joinPath(home, path.substring(2)); + } + return path; +} + +/// Joins [base] and [child] with the platform path separator. +String _joinPath(String base, String child) { + if (base.endsWith('/') || base.endsWith(r'\')) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart index 8da3be4..d7b0eb4 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -12,3 +12,6 @@ typedef ReadRefresh = Future Function(); /// Reads the owner-local date that commands should target at execution time. typedef SchedulerSelectedDateReader = CivilDate Function(); + +/// Resolves the owner-local civil date for an absolute command instant. +typedef SchedulerCurrentDateResolver = CivilDate Function(DateTime instant); diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 7579371..5cd86fd 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -12,9 +12,10 @@ class SchedulerCommandController extends ChangeNotifier { required this.selectedDate, required this.refreshReads, required this.quickCaptureProjectId, - String operationIdPrefix = 'ui-command', + this.operationIdPrefix = 'ui-command', + SchedulerCurrentDateResolver? currentDateFor, DateTime Function()? now, - }) : _operationIdPrefix = operationIdPrefix, + }) : currentDateFor = currentDateFor ?? _utcCivilDateFor, now = now ?? _utcNow; /// Scheduler write use cases invoked by this controller. @@ -32,13 +33,15 @@ class SchedulerCommandController extends ChangeNotifier { /// Project id used by quick-capture commands. final String quickCaptureProjectId; - /// Private state stored as `_operationIdPrefix` for this implementation. - /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. - final String _operationIdPrefix; + /// Prefix used to generate unique operation ids for scheduler commands. + final String operationIdPrefix; /// Clock used by direct UI commands such as marking a task complete. final DateTime Function() now; + /// Resolves command instants to the owner-local current civil date. + final SchedulerCurrentDateResolver currentDateFor; + /// Filler title used by the temporary timeline add-task action. static const _genericFlexibleTaskTitle = 'New flexible task'; @@ -58,6 +61,11 @@ class SchedulerCommandController extends ChangeNotifier { /// Captures [title] as a backlog task. Future quickCaptureToBacklog(String title) async { + logger.debug( + () => + 'UI command requested: quick capture to backlog. ' + 'projectId=$quickCaptureProjectId hasTitle=${title.trim().isNotEmpty}', + ); await _run( label: 'Capturing task', successMessage: 'Task captured', @@ -75,6 +83,11 @@ class SchedulerCommandController extends ChangeNotifier { /// Adds a generic flexible task to the next available Today slot. Future addGenericFlexibleTask() async { + logger.debug( + () => + 'UI command requested: add generic flexible task. ' + 'durationMinutes=$_genericFlexibleTaskDurationMinutes projectId=$quickCaptureProjectId', + ); await _run( label: 'Adding task', successMessage: 'Task added', @@ -98,6 +111,12 @@ class SchedulerCommandController extends ChangeNotifier { required int durationMinutes, required DateTime expectedUpdatedAt, }) async { + logger.debug( + () => + 'UI command requested: schedule backlog item. ' + 'taskId=$taskId durationMinutes=$durationMinutes ' + 'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); await _run( label: 'Scheduling task', successMessage: 'Task scheduled', @@ -134,6 +153,13 @@ class SchedulerCommandController extends ChangeNotifier { DateTime? expectedUpdatedAt, }) async { final completedAt = now(); + logger.debug( + () => + 'UI command requested: complete task. ' + 'taskId=$taskId taskType=${taskType.name} ' + 'hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${completedAt.toIso8601String()}', + ); await _run( label: 'Completing task', successMessage: 'Task completed', @@ -177,6 +203,12 @@ class SchedulerCommandController extends ChangeNotifier { DateTime? expectedUpdatedAt, }) async { final occurredAt = now(); + logger.debug( + () => + 'UI command requested: uncomplete task. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${occurredAt.toIso8601String()}', + ); await _run( label: 'Reopening task', successMessage: 'Task marked as not done', @@ -192,6 +224,112 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Pushes a past flexible task to the next available slot after now. + Future pushTaskToNextAvailableSlot({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: push task to next available slot. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${commandAt.toIso8601String()}', + ); + await _run( + label: 'Pushing task', + successMessage: 'Task pushed', + refreshAfterSuccess: true, + action: (operationId) { + return commands.pushFlexibleToNextAvailableSlot( + context: contextFor(operationId, now: commandAt), + localDate: currentDateFor(commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Pushes a past flexible task to the first available slot tomorrow. + Future pushTaskToTomorrow({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: push task to tomorrow. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${commandAt.toIso8601String()}', + ); + await _run( + label: 'Pushing task', + successMessage: 'Task moved to tomorrow', + refreshAfterSuccess: true, + action: (operationId) { + return commands.pushFlexibleToTomorrowTopOfQueue( + context: contextFor(operationId, now: commandAt), + targetDate: currentDateFor(commandAt).addDays(1), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Pushes a past flexible task back to Backlog. + Future pushTaskToBacklog({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: push task to backlog. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${commandAt.toIso8601String()}', + ); + await _run( + label: 'Moving task to backlog', + successMessage: 'Task moved to backlog', + refreshAfterSuccess: true, + action: (operationId) { + return commands.moveFlexibleToBacklog( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Permanently removes a task from persistence. + Future removeTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: remove task. ' + 'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} ' + 'occurredAt=${commandAt.toIso8601String()}', + ); + await _run( + label: 'Removing task', + successMessage: 'Task removed', + refreshAfterSuccess: true, + action: (operationId) { + return commands.removeTask( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + /// Runs the `_run` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future _run({ @@ -204,19 +342,44 @@ class SchedulerCommandController extends ChangeNotifier { action, }) async { final operationId = _nextOperationId(); + logger.debug( + () => 'Scheduler command started. operationId=$operationId label=$label', + ); _setState(SchedulerCommandRunning(label)); try { final result = await action(operationId); final failure = result.failure; if (failure != null) { + logger.warn( + () => + 'Scheduler command returned failure. ' + 'operationId=$operationId code=${failure.code.name} ' + 'detailCode=${failure.detailCode} entityId=${failure.entityId}', + ); _setState(SchedulerCommandFailure(failure: failure)); return; } + logger.debug( + () => + 'Scheduler command succeeded. operationId=$operationId ' + 'refreshAfterSuccess=$refreshAfterSuccess', + ); if (refreshAfterSuccess) { + logger.finer( + () => + 'Refreshing reads after scheduler command. ' + 'operationId=$operationId', + ); await refreshReads(); } _setState(SchedulerCommandSuccess(successMessage)); - } on Object catch (error) { + } on Object catch (error, stackTrace) { + logger.error( + () => + 'Scheduler command threw unexpectedly. operationId=$operationId label=$label', + error: error, + stackTrace: stackTrace, + ); _setState(SchedulerCommandFailure(error: error)); } } @@ -225,7 +388,7 @@ class SchedulerCommandController extends ChangeNotifier { /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _nextOperationId() { _sequence += 1; - return '$_operationIdPrefix-$_sequence'; + return '$operationIdPrefix-$_sequence'; } /// Runs the `_setState` helper used inside this library. @@ -238,4 +401,11 @@ class SchedulerCommandController extends ChangeNotifier { /// Runs the `_utcNow` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. static DateTime _utcNow() => DateTime.now().toUtc(); + + /// Runs the `_utcCivilDateFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + static CivilDate _utcCivilDateFor(DateTime instant) { + final utc = instant.toUtc(); + return CivilDate(utc.year, utc.month, utc.day); + } } diff --git a/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart index b446e31..4a8d3cb 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart @@ -98,21 +98,35 @@ class ApplicationReadController extends UiReadController { /// Loads the current value and updates [state]. @override Future load() async { + logger.debug(() => 'Scheduler read started. valueType=$T'); _setState(SchedulerReadLoading()); try { final result = await read(); final failure = result.failure; if (failure != null) { + logger.warn( + () => + 'Scheduler read returned failure. valueType=$T ' + 'code=${failure.code.name} detailCode=${failure.detailCode} ' + 'entityId=${failure.entityId}', + ); _setState(SchedulerReadFailure(failure: failure)); return; } final value = result.requireValue; - _setState( - isEmpty(value) - ? SchedulerReadEmpty(value) - : SchedulerReadData(value), + final empty = isEmpty(value); + logger.debug( + () => 'Scheduler read finished. valueType=$T isEmpty=$empty', + ); + _setState( + empty ? SchedulerReadEmpty(value) : SchedulerReadData(value), + ); + } on Object catch (error, stackTrace) { + logger.error( + () => 'Scheduler read threw unexpectedly. valueType=$T', + error: error, + stackTrace: stackTrace, ); - } on Object catch (error) { _setState(SchedulerReadFailure(error: error)); } } diff --git a/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart b/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart index 72ad816..86f6923 100644 --- a/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart @@ -25,8 +25,13 @@ class SelectedDateController extends ChangeNotifier { /// Selects [date] as the visible planning day. bool selectDate(CivilDate date) { if (date == _date) { + logger.finer(() => 'Selected date unchanged. date=${date.toIsoString()}'); return false; } + logger.debug( + () => + 'Selected date changed. previous=${_date.toIsoString()} next=${date.toIsoString()}', + ); _date = date; notifyListeners(); return true; diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 25927a3..0ae5fb9 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -71,7 +71,10 @@ class TodayScreenController extends ChangeNotifier { required this.read, required this.selectedDates, required this.dateLabelFor, - }) : _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) { + this.timeZoneOffset = Duration.zero, + DateTime Function()? now, + }) : now = now ?? _utcNow, + _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) { selectedDates.addListener(_handleSelectedDateChanged); } @@ -84,6 +87,12 @@ class TodayScreenController extends ChangeNotifier { /// Formats dates before a backend read has returned screen data. final TodayDateLabelFormatter dateLabelFor; + /// Clock used while mapping time-sensitive presentation state. + final DateTime Function() now; + + /// UI-local offset used to format timeline instants as wall-clock values. + final Duration timeZoneOffset; + /// Private state stored as `_state` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. TodayScreenState _state; @@ -113,15 +122,30 @@ class TodayScreenController extends ChangeNotifier { Future load() async { final date = selectedDates.date; final sequence = _nextLoadSequence(); + logger.debug( + () => + 'Today screen load started. date=${date.toIsoString()} sequence=$sequence', + ); _state = TodayScreenLoading(dateLabelFor(date)); notifyListeners(); try { final result = await read(date); if (_isStale(sequence)) { + logger.finer( + () => + 'Ignoring stale Today screen load result. ' + 'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence', + ); return; } final failure = result.failure; if (failure != null) { + logger.warn( + () => + 'Today screen load returned failure. ' + 'date=${date.toIsoString()} code=${failure.code.name} ' + 'detailCode=${failure.detailCode} entityId=${failure.entityId}', + ); _state = TodayScreenFailure( message: failure.detailCode ?? failure.code.name, data: _emptyDataFor(date), @@ -130,16 +154,36 @@ class TodayScreenController extends ChangeNotifier { return; } - final data = TodayScreenData.fromTodayState(result.requireValue); + final data = TodayScreenData.fromTodayState( + result.requireValue, + now: now(), + timeZoneOffset: timeZoneOffset, + ); _syncSelectedCard(data); _state = data.cards.isEmpty ? TodayScreenEmpty(data) : TodayScreenReady(data); + logger.debug( + () => + 'Today screen load finished. date=${date.toIsoString()} ' + 'sequence=$sequence cardCount=${data.cards.length}', + ); notifyListeners(); - } on Object catch (error) { + } on Object catch (error, stackTrace) { if (_isStale(sequence)) { + logger.finer( + () => + 'Ignoring stale Today screen load exception. ' + 'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence', + ); return; } + logger.error( + () => + 'Today screen load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence', + error: error, + stackTrace: stackTrace, + ); _state = TodayScreenFailure( message: error.toString(), data: _emptyDataFor(date), @@ -151,8 +195,12 @@ class TodayScreenController extends ChangeNotifier { /// Selects [card] when the card allows selection. void selectCard(TimelineCardModel card) { if (!card.isSelectable) { + logger.finer( + () => 'Today screen card selection ignored. cardId=${card.id}', + ); return; } + logger.finer(() => 'Today screen card selected. cardId=${card.id}'); _selectedCard = card; notifyListeners(); } @@ -162,6 +210,9 @@ class TodayScreenController extends ChangeNotifier { if (_selectedCard == null) { return; } + logger.finer( + () => 'Today screen card selection cleared. cardId=${_selectedCard!.id}', + ); _selectedCard = null; notifyListeners(); } @@ -177,6 +228,10 @@ class TodayScreenController extends ChangeNotifier { /// Runs the `_handleSelectedDateChanged` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _handleSelectedDateChanged() { + logger.debug( + () => + 'Today screen selected date changed. date=${selectedDates.date.toIsoString()}', + ); _selectedCard = null; unawaited(load()); } @@ -197,7 +252,10 @@ class TodayScreenController extends ChangeNotifier { /// Runs the `_emptyDataFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TodayScreenData _emptyDataFor(CivilDate date) { - return TodayScreenData.empty(dateLabel: dateLabelFor(date)); + return TodayScreenData.empty( + dateLabel: dateLabelFor(date), + timeZoneOffset: timeZoneOffset, + ); } /// Runs the `_syncSelectedCard` helper used inside this library. @@ -213,6 +271,14 @@ class TodayScreenController extends ChangeNotifier { return; } } + logger.finer( + () => + 'Today screen selected card no longer present. cardId=${selected.id}', + ); _selectedCard = null; } + + /// Runs the `_utcNow` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + static DateTime _utcNow() => DateTime.now().toUtc(); } diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index 31f44a3..085243b 100644 --- a/apps/focus_flow_flutter/lib/main.dart +++ b/apps/focus_flow_flutter/lib/main.dart @@ -5,9 +5,14 @@ library; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart' + as scheduler_core + show logger; import 'app/focus_flow_app.dart'; import 'app/persistent_scheduler_composition.dart'; +import 'app/runtime/focus_flow_file_logger.dart'; +import 'app/runtime/focus_flow_runtime_config.dart'; export 'app/demo_scheduler_composition.dart'; export 'app/focus_flow_app.dart'; @@ -16,10 +21,26 @@ export 'app/persistent_scheduler_composition.dart'; /// Starts the FocusFlow app with the persistent SQLite scheduler composition. Future main() async { WidgetsFlutterBinding.ensureInitialized(); + var logger = FocusFlowFileLogger.disabled(); try { - final composition = await PersistentSchedulerComposition.open(); + final config = await FocusFlowRuntimeConfig.load(); + logger = await FocusFlowFileLogger.open(config); + scheduler_core.logger.info(() => 'FocusFlow startup started.'); + scheduler_core.logger.debug( + () => 'Runtime config loaded. fileLogging=${logger.isEnabled}', + ); + final composition = await PersistentSchedulerComposition.open( + logger: logger, + uiTimeZone: config.timeZone, + ); + scheduler_core.logger.info(() => 'FocusFlow startup completed.'); runApp(FocusFlowApp(composition: composition)); - } on Object catch (error) { + } on Object catch (error, stackTrace) { + scheduler_core.logger.error( + () => 'FocusFlow startup failed.', + error: error, + stackTrace: stackTrace, + ); runApp(_StartupFailureApp(message: error.toString())); } } diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart index 9225cec..1d923fc 100644 --- a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -25,7 +25,11 @@ String _dateLabel(CivilDate date) { /// Top-level helper that performs the `_shortDateTimeLabel` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -String _shortDateTimeLabel(DateTime value) { +String _shortDateTimeLabel( + DateTime value, { + Duration timeZoneOffset = Duration.zero, +}) { + final localValue = _displayDateTime(value, timeZoneOffset); const months = [ 'Jan', 'Feb', @@ -40,36 +44,52 @@ String _shortDateTimeLabel(DateTime value) { 'Nov', 'Dec', ]; - return '${months[value.month - 1]} ${value.day}, ${_formatTime(value)}'; + return '${months[localValue.month - 1]} ${localValue.day}, ' + '${_formatTime(value, timeZoneOffset: timeZoneOffset)}'; } /// Top-level helper that performs the `_civilDateForDateTime` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -CivilDate? _civilDateForDateTime(DateTime? value) { +CivilDate? _civilDateForDateTime( + DateTime? value, { + Duration timeZoneOffset = Duration.zero, +}) { if (value == null) { return null; } - return CivilDate(value.year, value.month, value.day); + final localValue = _displayDateTime(value, timeZoneOffset); + return CivilDate(localValue.year, localValue.month, localValue.day); } /// Top-level helper that performs the `_minutesSinceMidnight` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -int _minutesSinceMidnight(DateTime? value) { +int _minutesSinceMidnight( + DateTime? value, { + Duration timeZoneOffset = Duration.zero, +}) { if (value == null) { return 0; } - return value.hour * 60 + value.minute; + final localValue = _displayDateTime(value, timeZoneOffset); + return localValue.hour * 60 + localValue.minute; } /// Top-level helper that performs the `_formatTime` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -String _formatTime(DateTime? value) { +String _formatTime(DateTime? value, {Duration timeZoneOffset = Duration.zero}) { if (value == null) { return ''; } - final period = value.hour >= 12 ? 'PM' : 'AM'; - final rawHour = value.hour % 12; + final localValue = _displayDateTime(value, timeZoneOffset); + final period = localValue.hour >= 12 ? 'PM' : 'AM'; + final rawHour = localValue.hour % 12; final hour = rawHour == 0 ? 12 : rawHour; - final minute = value.minute.toString().padLeft(2, '0'); + final minute = localValue.minute.toString().padLeft(2, '0'); return '$hour:$minute $period'; } + +/// Top-level helper that performs the `_displayDateTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DateTime _displayDateTime(DateTime value, Duration timeZoneOffset) { + return value.toUtc().add(timeZoneOffset); +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart index 8d5de8d..578d1bf 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -21,26 +21,35 @@ class TimelineCardModel { required this.isCompleted, required this.isSelectable, required this.showsQuickActions, + this.showPastTaskPushButton = false, this.completionDateContextRequired = false, this.completedTimeText, + this.expectedUpdatedAt, this.durationMinutes, }); /// Maps a scheduler timeline item into card presentation data. - factory TimelineCardModel.fromItem(TodayTimelineItem item) { + factory TimelineCardModel.fromItem( + TodayTimelineItem item, { + required DateTime now, + Duration timeZoneOffset = Duration.zero, + }) { final start = item.start; final end = item.end; final visualKind = _visualKindFor(item); final isCompleted = item.taskStatus == TaskStatus.completed; + final showPastTaskPushButton = _showPastTaskPushButton(item, now: now); final title = item.item.displayTitle; final duration = item.item.durationMinutes; final timeText = start == null || end == null ? duration == null ? '' : '$duration min' - : '${_formatTime(start)} - ${_formatTime(end)}'; + : '${_formatTime(start, timeZoneOffset: timeZoneOffset)} - ' + '${_formatTime(end, timeZoneOffset: timeZoneOffset)}'; final completionDateContextRequired = - isCompleted && _completionDateContextRequired(item); + isCompleted && + _completionDateContextRequired(item, timeZoneOffset: timeZoneOffset); return TimelineCardModel( id: item.id, title: title, @@ -50,10 +59,14 @@ class TimelineCardModel { visualKind, timeText, completionDateContextRequired: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ), timeText: timeText, - startMinutes: _minutesSinceMidnight(start), - endMinutes: _minutesSinceMidnight(end), + startMinutes: _minutesSinceMidnight( + start, + timeZoneOffset: timeZoneOffset, + ), + endMinutes: _minutesSinceMidnight(end, timeZoneOffset: timeZoneOffset), taskType: item.taskType, visualKind: visualKind, rewardIconToken: item.item.rewardIconToken, @@ -64,11 +77,17 @@ class TimelineCardModel { ? _completionDisplayText( item, includeDate: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ) : null, + expectedUpdatedAt: item.item.updatedAt, isCompleted: isCompleted, isSelectable: true, - showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, + showPastTaskPushButton: showPastTaskPushButton, + showsQuickActions: + !showPastTaskPushButton && + !isCompleted && + visualKind != TaskVisualKind.freeSlot, ); } @@ -90,6 +109,9 @@ class TimelineCardModel { /// Display-ready completion time or date-time, if the task has been completed. final String? completedTimeText; + /// Expected update timestamp for stale-write protection. + final DateTime? expectedUpdatedAt; + /// Whether completion text includes date context because completion crossed dates. final bool completionDateContextRequired; @@ -123,6 +145,12 @@ class TimelineCardModel { /// Whether the card should show compact quick-action controls. final bool showsQuickActions; + /// Whether the card should show the full overdue-task push control. + final bool showPastTaskPushButton; + + /// Accessible label for the full overdue-task push control. + String get pastTaskPushSemanticLabel => 'Push overdue task'; + /// Whether the left status control can toggle this task's completion. bool get canToggleCompletion { return switch (taskType) { diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart index 6d764d6..683b1db 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -44,6 +44,7 @@ String _subtitleFor( TaskVisualKind visualKind, String timeText, { required bool completionDateContextRequired, + required Duration timeZoneOffset, }) { if (visualKind == TaskVisualKind.freeSlot) { return 'Intentional rest'; @@ -53,6 +54,7 @@ String _subtitleFor( final completedText = _completionDisplayText( item, includeDate: completionDateContextRequired, + timeZoneOffset: timeZoneOffset, ); if (completedText == null || completedText.isEmpty) { return 'Completed'; @@ -70,15 +72,19 @@ String _subtitleFor( String? _completionDisplayText( TodayTimelineItem item, { required bool includeDate, + required Duration timeZoneOffset, }) { final completionInstant = _completionDisplayInstant(item); if (completionInstant == null) { return null; } if (includeDate) { - return _shortDateTimeLabel(completionInstant); + return _shortDateTimeLabel( + completionInstant, + timeZoneOffset: timeZoneOffset, + ); } - return _formatTime(completionInstant); + return _formatTime(completionInstant, timeZoneOffset: timeZoneOffset); } /// Top-level helper that performs the `_completionDisplayInstant` operation for this file. @@ -89,15 +95,46 @@ DateTime? _completionDisplayInstant(TodayTimelineItem item) { /// Top-level helper that performs the `_completionDateContextRequired` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. -bool _completionDateContextRequired(TodayTimelineItem item) { - final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start); +bool _completionDateContextRequired( + TodayTimelineItem item, { + required Duration timeZoneOffset, +}) { + final scheduledDate = _civilDateForDateTime( + item.item.start ?? item.start, + timeZoneOffset: timeZoneOffset, + ); if (scheduledDate == null) { return false; } - final completedAtDate = _civilDateForDateTime(item.item.completedAt); - final actualStartDate = _civilDateForDateTime(item.item.actualStart); - final actualEndDate = _civilDateForDateTime(item.item.actualEnd); + final completedAtDate = _civilDateForDateTime( + item.item.completedAt, + timeZoneOffset: timeZoneOffset, + ); + final actualStartDate = _civilDateForDateTime( + item.item.actualStart, + timeZoneOffset: timeZoneOffset, + ); + final actualEndDate = _civilDateForDateTime( + item.item.actualEnd, + timeZoneOffset: timeZoneOffset, + ); return completedAtDate != null && completedAtDate != scheduledDate || actualStartDate != null && actualStartDate != scheduledDate || actualEndDate != null && actualEndDate != scheduledDate; } + +/// Top-level helper that performs the `_showPastTaskPushButton` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +bool _showPastTaskPushButton(TodayTimelineItem item, {required DateTime now}) { + if (item.source != TodayTimelineItemSource.task) { + return false; + } + if (item.taskType != TaskType.flexible) { + return false; + } + if (item.taskStatus != TaskStatus.planned) { + return false; + } + final end = item.end; + return end != null && end.isBefore(now); +} diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart index 4455c8e..d14afad 100644 --- a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -10,22 +10,36 @@ class TodayScreenData { required this.dateLabel, required this.timelineRange, required this.cards, + this.timeZoneOffset = Duration.zero, this.requiredBanner, }); /// Creates an empty Today screen model for [dateLabel]. - factory TodayScreenData.empty({required String dateLabel}) { + factory TodayScreenData.empty({ + required String dateLabel, + Duration timeZoneOffset = Duration.zero, + }) { return TodayScreenData( dateLabel: dateLabel, timelineRange: defaultRange, cards: const [], + timeZoneOffset: timeZoneOffset, ); } /// Maps scheduler core [TodayState] into UI presentation data. - factory TodayScreenData.fromTodayState(TodayState state) { + factory TodayScreenData.fromTodayState( + TodayState state, { + required DateTime now, + Duration timeZoneOffset = Duration.zero, + }) { final cards = [ - for (final item in state.timelineItems) TimelineCardModel.fromItem(item), + for (final item in state.timelineItems) + TimelineCardModel.fromItem( + item, + now: now, + timeZoneOffset: timeZoneOffset, + ), ]; final nextRequired = state.nextRequiredItem; return TodayScreenData( @@ -36,9 +50,13 @@ class TodayScreenData { : RequiredBannerModel( timelineCardId: nextRequired.id, title: nextRequired.item.displayTitle, - timeText: _formatTime(nextRequired.start), + timeText: _formatTime( + nextRequired.start, + timeZoneOffset: timeZoneOffset, + ), ), cards: List.unmodifiable(cards), + timeZoneOffset: timeZoneOffset, ); } @@ -57,6 +75,9 @@ class TodayScreenData { /// Visible timeline range. final TimelineRangeModel timelineRange; + /// UI-local offset used for wall-clock timeline labels and positions. + final Duration timeZoneOffset; + /// Timeline cards to render. final List cards; } diff --git a/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart b/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart new file mode 100644 index 0000000..cd55ade --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Shared status-circle affordance for timeline and modal task controls. +library; + +import 'package:flutter/material.dart'; + +/// Circular completion control with shared hover feedback. +class StatusCircleButton extends StatefulWidget { + /// Creates a status circle button. + const StatusCircleButton({ + required this.color, + required this.checkColor, + required this.isCompleted, + required this.size, + required this.borderWidth, + required this.iconSize, + required this.semanticLabel, + this.onPressed, + this.circleKey, + this.hoverBlurRadius = 10, + super.key, + }); + + /// Key assigned to the animated circle surface. + final Key? circleKey; + + /// Accent color used for the border, fill, and hover glow. + final Color color; + + /// Color used for the completed check icon. + final Color checkColor; + + /// Whether the circle is in its completed state. + final bool isCompleted; + + /// Square size of the rendered circle. + final double size; + + /// Border width for the circle outline. + final double borderWidth; + + /// Size of the completed check icon. + final double iconSize; + + /// Semantic label used when the control is interactive. + final String semanticLabel; + + /// Callback invoked when the circle is tapped. + final VoidCallback? onPressed; + + /// Blur radius used for the hover shadow. + final double hoverBlurRadius; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _StatusCircleButtonState(); +} + +/// Mutable state for [StatusCircleButton]. +class _StatusCircleButtonState extends State { + /// Whether the pointer is hovering this status circle. + bool _isHovered = false; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final ring = AnimatedContainer( + key: widget.circleKey, + width: widget.size, + height: widget.size, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isCompleted + ? widget.color + : _isHovered + ? widget.color.withValues(alpha: 0.12) + : Colors.transparent, + border: Border.all(color: widget.color, width: widget.borderWidth), + boxShadow: _isHovered && widget.onPressed != null + ? [ + BoxShadow( + color: widget.color.withValues(alpha: 0.38), + blurRadius: widget.hoverBlurRadius, + spreadRadius: 1, + ), + ] + : const [], + ), + child: widget.isCompleted + ? Icon(Icons.check, color: widget.checkColor, size: widget.iconSize) + : null, + ); + if (widget.onPressed == null) { + return ring; + } + return Semantics( + button: true, + label: widget.semanticLabel, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() { + _isHovered = true; + }); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: ring, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart index b2562a3..61df4b5 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -3,12 +3,24 @@ part of '../task_selection_modal.dart'; +/// Menu destinations exposed by the modal Push button. +enum _ModalPushDestination { + /// Pushes the task to the next available slot. + next, + + /// Pushes the task to tomorrow's first available slot. + tomorrow, + + /// Pushes the task to Backlog. + backlog, +} + /// Private implementation type for `_ActionButton` in this library. /// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. -class _ActionButton extends StatelessWidget { +class _ActionButton extends StatefulWidget { /// Creates a `_ActionButton` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. - const _ActionButton({required this.icon, required this.label}); + const _ActionButton({required this.icon, required this.label, this.onTap}); /// Stores the `icon` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. @@ -18,27 +30,307 @@ class _ActionButton extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final String label; + /// Callback invoked when the action is tapped. + final VoidCallback? onTap; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_ActionButton> createState() => _ActionButtonState(); +} + +/// Private implementation type for `_ActionButtonState` in this library. +/// It owns hover feedback for compact modal action buttons. +class _ActionButtonState extends State<_ActionButton> { + /// Whether the pointer is hovering this action button. + bool _isHovered = false; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + return _ModalActionSurface( + icon: widget.icon, + label: widget.label, + isHovered: _isHovered, + onHoverChanged: (value) { + setState(() { + _isHovered = value; + }); + }, + onTap: widget.onTap ?? () {}, + ); + } +} + +/// Private implementation type for `_PushActionButton` in this library. +/// It exposes push destinations from the clicked-task modal. +class _PushActionButton extends StatefulWidget { + /// Creates a `_PushActionButton` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _PushActionButton({ + required this.card, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, + }); + + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + + /// Callback invoked when Push to next is selected. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function(TimelineCardModel card)? onPushToBacklog; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_PushActionButton> createState() => _PushActionButtonState(); +} + +/// Private implementation type for `_PushActionButtonState` in this library. +/// It owns hover and running state for modal push destinations. +class _PushActionButtonState extends State<_PushActionButton> { + /// Whether the pointer is hovering this action button. + bool _isHovered = false; + + /// Whether a push destination command is in flight. + bool _isRunning = false; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final options = _modalPushDestinationOptions(); + final enabled = + !_isRunning && options.any((option) => option.callback != null); + return PopupMenuButton<_ModalPushDestination>( + key: ValueKey('${widget.card.id}-modal-push-button'), + tooltip: '', + enabled: enabled, + color: const Color(0xFF122018), + elevation: 8, + position: PopupMenuPosition.under, + onSelected: (destination) { + for (final option in options) { + if (option.destination != destination) { + continue; + } + final callback = option.callback; + if (callback != null) { + unawaited(_runPush(callback)); + } + return; + } + }, + itemBuilder: (context) => [ + for (final option in options) + PopupMenuItem<_ModalPushDestination>( + value: option.destination, + enabled: option.callback != null, + child: Text(option.label), ), - ), + ], + child: _ModalActionSurface( + icon: Icons.arrow_forward, + label: 'Push', + isHovered: _isHovered, + isEnabled: enabled, + onHoverChanged: (value) { + setState(() { + _isHovered = value; + }); + }, ), - icon: Icon(icon), - label: Text( - label, - style: const TextStyle( - fontSize: FocusFlowTokens.modalButtonSize, - fontWeight: FontWeight.w700, + ); + } + + /// Builds the push destination option list for this button. + List<_ModalPushDestinationOption> _modalPushDestinationOptions() { + return [ + _ModalPushDestinationOption( + destination: _ModalPushDestination.next, + label: 'Push to next', + callback: widget.onPushToNext == null + ? null + : () => widget.onPushToNext!(widget.card), + ), + _ModalPushDestinationOption( + destination: _ModalPushDestination.tomorrow, + label: 'Push to tomorrow', + callback: widget.onPushToTomorrow == null + ? null + : () => widget.onPushToTomorrow!(widget.card), + ), + _ModalPushDestinationOption( + destination: _ModalPushDestination.backlog, + label: 'Push to backlog', + callback: widget.onPushToBacklog == null + ? null + : () => widget.onPushToBacklog!(widget.card), + ), + ]; + } + + /// Runs [callback] while guarding against duplicate push commands. + Future _runPush(Future Function() callback) async { + if (_isRunning) { + return; + } + setState(() { + _isRunning = true; + }); + try { + await callback(); + } finally { + if (mounted) { + setState(() { + _isRunning = false; + }); + } + } + } +} + +/// Private implementation type for `_ModalPushDestinationOption` in this library. +/// It stores one modal push menu destination. +class _ModalPushDestinationOption { + /// Creates a `_ModalPushDestinationOption` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _ModalPushDestinationOption({ + required this.destination, + required this.label, + required this.callback, + }); + + /// Stores the `destination` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _ModalPushDestination destination; + + /// Stores the `label` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String label; + + /// Callback invoked when this option is selected. + final Future Function()? callback; +} + +/// Private implementation type for `_ModalActionSurface` in this library. +/// It draws compact modal action button chrome shared by push and placeholder actions. +class _ModalActionSurface extends StatelessWidget { + /// Creates a `_ModalActionSurface` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _ModalActionSurface({ + required this.icon, + required this.label, + required this.isHovered, + required this.onHoverChanged, + this.isEnabled = true, + this.onTap, + }); + + /// Stores the `icon` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final IconData icon; + + /// Stores the `label` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String label; + + /// Stores the `isHovered` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool isHovered; + + /// Stores the `isEnabled` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool isEnabled; + + /// Callback invoked when the hover state changes. + final ValueChanged onHoverChanged; + + /// Callback invoked when the action surface is tapped. + final VoidCallback? onTap; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final foreground = isEnabled + ? FocusFlowTokens.textPrimary + : FocusFlowTokens.textMuted; + final hoverColor = FocusFlowTokens.textPrimary.withValues(alpha: 0.08); + return Semantics( + button: true, + enabled: isEnabled, + label: label, + child: MouseRegion( + cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, + onEnter: (_) => onHoverChanged(true), + onExit: (_) => onHoverChanged(false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: isEnabled ? onTap : null, + child: AnimatedContainer( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 14), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: isHovered && isEnabled + ? hoverColor + : FocusFlowTokens.glassPanelStrong.withValues(alpha: 0.26), + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + border: Border.all( + color: isHovered && isEnabled + ? FocusFlowTokens.textPrimary.withValues(alpha: 0.22) + : FocusFlowTokens.subtleBorder.withValues(alpha: 0.72), + ), + boxShadow: isHovered && isEnabled + ? [ + BoxShadow( + color: FocusFlowTokens.textPrimary.withValues( + alpha: 0.12, + ), + blurRadius: 12, + spreadRadius: 1, + ), + ] + : const [], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + width: 28, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(icon, color: foreground, size: 18), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), ), ), ); diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart index 84ac17d..73328f4 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -3,50 +3,71 @@ part of '../task_selection_modal.dart'; -/// Private implementation type for `_InfoTile` in this library. -/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. -class _InfoTile extends StatelessWidget { - /// Creates a `_InfoTile` instance with the values required by its invariants. +/// Private implementation type for `_HeaderMetricIcons` in this library. +/// It keeps task effort and reward metadata near the modal close action. +class _HeaderMetricIcons extends StatelessWidget { + /// Creates a `_HeaderMetricIcons` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. - const _InfoTile({required this.child, required this.label}); + const _HeaderMetricIcons({required this.card}); - /// Stores the `child` value for this object. + /// Stores the `card` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final Widget child; - - /// Stores the `label` value for this object. - /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final String label; + final TimelineCardModel card; /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - return Container( - height: 58, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: [ - child, - const SizedBox(width: 18), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _HeaderMetricIcon( + tooltip: card.effortLabel == 'Effort not set' + ? 'Not Set' + : card.effortLabel, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: FocusFlowTokens.restPurple, + size: const Size(28, 16), ), - ], - ), + ), + const SizedBox(height: 8), + _HeaderMetricIcon( + tooltip: card.rewardLabel == 'Reward not set' + ? 'Not Set' + : card.rewardLabel, + child: const RewardIcon( + color: FocusFlowTokens.accentMagenta, + size: 18, + ), + ), + ], + ); + } +} + +/// Private implementation type for `_HeaderMetricIcon` in this library. +/// It keeps metric tooltips scoped to icon-only modal affordances. +class _HeaderMetricIcon extends StatelessWidget { + /// Creates a `_HeaderMetricIcon` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _HeaderMetricIcon({required this.tooltip, required this.child}); + + /// Stores the `tooltip` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String tooltip; + + /// Stores the `child` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Widget child; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: SizedBox.square(dimension: 28, child: Center(child: child)), ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart index aed36b8..05383de 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -4,7 +4,7 @@ part of '../task_selection_modal.dart'; /// Private implementation type for `_ModalStatusButton` in this library. -/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +/// It adapts modal sizing and labels to the shared status-circle button. class _ModalStatusButton extends StatelessWidget { /// Creates a `_ModalStatusButton` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. @@ -30,42 +30,19 @@ class _ModalStatusButton extends StatelessWidget { /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - final ring = Container( - key: const ValueKey('task-modal-status'), - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: Border.all(color: color, width: 4), - ), - child: card.isCompleted - ? const Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: 26, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), + return StatusCircleButton( + circleKey: const ValueKey('task-modal-status'), + color: color, + checkColor: FocusFlowTokens.appBackground, + isCompleted: card.isCompleted, + size: 44, + borderWidth: 4, + iconSize: 26, + hoverBlurRadius: 12, + semanticLabel: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + onPressed: onPressed, ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index e2e7f5c..18c29fc 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -4,10 +4,13 @@ /// Renders the Task Selection Modal widget for the FocusFlow Flutter UI. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_tokens.dart'; +import 'status_circle_button.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; @@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget { required this.card, required this.onClose, this.onStatusPressed, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, + this.onRemove, super.key, }); @@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget { /// Callback invoked when the status circle should toggle completion. final VoidCallback? onStatusPressed; + /// Callback invoked when Push to next is selected. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function(TimelineCardModel card)? onPushToBacklog; + + /// Callback invoked when Remove is selected. + final Future Function(TimelineCardModel card)? onRemove; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget { ], ), ), + const SizedBox(width: 14), + _HeaderMetricIcons(card: card), + const SizedBox(width: 14), IconButton( key: const ValueKey('close-task-modal'), onPressed: onClose, @@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget { ), ], ), - const SizedBox(height: 26), - Row( - children: [ - Expanded( - child: _InfoTile( - label: card.rewardLabel, - child: RewardIcon(color: FocusFlowTokens.accentMagenta), - ), - ), - const SizedBox(width: 28), - Expanded( - child: _InfoTile( - label: card.effortLabel, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: FocusFlowTokens.restPurple, - ), - ), - ), - ], - ), - const SizedBox(height: 30), + const SizedBox(height: 24), GridView.count( crossAxisCount: 2, - crossAxisSpacing: 26, - mainAxisSpacing: 16, - childAspectRatio: 4.1, + crossAxisSpacing: 18, + mainAxisSpacing: 12, + childAspectRatio: 5.3, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - children: const [ - _ActionButton(icon: Icons.check, label: 'Done'), - _ActionButton(icon: Icons.arrow_forward, label: 'Push'), + children: [ + _PushActionButton( + card: card, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ), _ActionButton( icon: Icons.inventory_2_outlined, - label: 'Move to Backlog', + label: 'Backlog', + ), + const _ActionButton(icon: Icons.apps, label: 'Break up'), + _ActionButton( + icon: Icons.delete_outline, + label: 'Remove', + onTap: onRemove == null + ? null + : () { + unawaited(onRemove!(card)); + }, ), - _ActionButton(icon: Icons.apps, label: 'Break up'), ], ), const SizedBox(height: 14), diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart index 1e3a927..9615d88 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -114,7 +114,19 @@ class _CardMetrics { /// Returns the derived `actionsWidth` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. - double get actionsWidth => actionButtonSize * 4 + actionGap; + double get actionsWidth => actionButtonSize * 3 + actionGap; + + /// Returns the derived `pushButtonHeight` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get pushButtonHeight => (28 * scale).clamp(18.0, 24.0).toDouble(); + + /// Returns the derived `pushButtonWidth` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get pushButtonWidth => (62 * scale).clamp(42.0, 58.0).toDouble(); + + /// Returns the derived `pushButtonGap` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get pushButtonGap => math.max(5, 8 * scale); /// Returns the derived `indicatorTop` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. @@ -158,10 +170,23 @@ class _CardMetrics { double get expandedTrailingReserve => indicatorWidth + math.max(8, 12 * scale); + /// Returns the derived `expandedPushTrailingReserve` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get expandedPushTrailingReserve => + indicatorWidth + + pushButtonGap + + pushButtonWidth + + math.max(8, 12 * scale); + /// Returns the derived `compactTrailingReserve` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); + /// Returns the derived `compactPushTrailingReserve` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get compactPushTrailingReserve => + indicatorWidth + pushButtonGap + pushButtonWidth + math.max(6, 8 * scale); + /// Returns the derived `difficultySize` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. Size get difficultySize => Size(26, 14); diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart index e9a7f58..eed8bf3 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget { style: metaStyle, ), ), - SizedBox(width: metrics.compactTrailingReserve), + SizedBox( + width: card.showPastTaskPushButton + ? metrics.compactPushTrailingReserve + : metrics.compactTrailingReserve, + ), ], ); if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart index 4dede80..7b2f224 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget { ], Expanded( child: Padding( - padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), + padding: EdgeInsets.only( + right: card.showPastTaskPushButton + ? metrics.expandedPushTrailingReserve + : metrics.expandedTrailingReserve, + ), child: _CardText( card: card, color: colors.accent, diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart index 0ad674e..4be5113 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -10,6 +10,7 @@ class _NoOpIcon extends StatelessWidget { /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. const _NoOpIcon({ required this.icon, + required this.tooltip, required this.color, required this.metrics, }); @@ -18,6 +19,9 @@ class _NoOpIcon extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final IconData icon; + /// Tooltip message for this inactive icon affordance. + final String tooltip; + /// Stores the `color` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Color color; @@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Tooltip( - message: 'No-op action', + message: tooltip, child: GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, @@ -43,3 +47,80 @@ class _NoOpIcon extends StatelessWidget { ); } } + +/// Private implementation type for `_QuickPushIcon` in this library. +/// It keeps the timeline hover push action visually compact while reusing the same destination menu as the full overdue Push button. +class _QuickPushIcon extends StatelessWidget { + /// Creates a `_QuickPushIcon` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _QuickPushIcon({ + required this.card, + required this.color, + required this.metrics, + required this.isRunning, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, + }); + + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + + /// Stores the `color` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Color color; + + /// Stores the `metrics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardMetrics metrics; + + /// Stores the `isRunning` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool isRunning; + + /// Callback invoked when Push to next is selected. + final Future Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function()? onPushToBacklog; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final options = _pushDestinationOptions( + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ); + final enabled = !isRunning && _hasEnabledPushDestination(options); + return PopupMenuButton<_PastTaskPushDestination>( + key: ValueKey('${card.id}-quick-push-button'), + tooltip: 'Push task', + enabled: enabled, + color: const Color(0xFF122018), + elevation: 8, + position: PopupMenuPosition.under, + padding: EdgeInsets.zero, + onSelected: (destination) => _selectPushDestination(destination, options), + itemBuilder: (context) => _pushDestinationMenuItems(options), + child: Semantics( + button: true, + enabled: enabled, + label: 'Push ${card.title}', + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon( + Icons.arrow_forward, + color: color, + size: metrics.actionIconSize, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart index 1f09d0f..059f05b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget { /// Creates a `_QuickActions` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. const _QuickActions({ + required this.card, required this.color, required this.backgroundColor, required this.metrics, required this.visible, + required this.pushRunning, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, }); + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + /// Stores the `color` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Color color; @@ -31,6 +40,19 @@ class _QuickActions extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final bool visible; + /// Stores the `pushRunning` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool pushRunning; + + /// Callback invoked when Push to next is selected. + final Future Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function()? onPushToBacklog; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget { ignoring: !visible, child: ClipRect( child: AnimatedContainer( + key: ValueKey('${card.id}-quick-actions-background'), width: visible ? metrics.actionsWidth : 0, height: metrics.actionButtonSize, decoration: BoxDecoration(color: backgroundColor), @@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget { maxWidth: metrics.actionsWidth, alignment: Alignment.centerRight, child: AnimatedOpacity( + key: ValueKey('${card.id}-quick-actions-opacity'), opacity: visible ? 1 : 0, duration: const Duration(milliseconds: 90), curve: Curves.easeOut, child: Row( mainAxisSize: MainAxisSize.min, children: [ - _NoOpIcon( - icon: Icons.check, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.arrow_forward, + _QuickPushIcon( + card: card, color: color, metrics: metrics, + isRunning: pushRunning, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, ), _NoOpIcon( icon: Icons.calendar_today, + tooltip: 'Schedule', color: color, metrics: metrics, ), _NoOpIcon( icon: Icons.wb_sunny_outlined, + tooltip: 'Protect time', color: color, metrics: metrics, ), diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart index 97f0497..4db1677 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -4,7 +4,7 @@ part of '../task_timeline_card.dart'; /// Private implementation type for `_StatusRing` in this library. -/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +/// It adapts timeline card sizing and labels to the shared status-circle button. class _StatusRing extends StatelessWidget { /// Creates a `_StatusRing` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. @@ -35,43 +35,18 @@ class _StatusRing extends StatelessWidget { /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - final border = Border.all(color: color, width: metrics.statusBorderWidth); - final ring = Container( - key: ValueKey('${card.id}-status'), - width: metrics.statusRingSize, - height: metrics.statusRingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: border, - ), - child: card.isCompleted - ? Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: metrics.statusIconSize, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), + return StatusCircleButton( + circleKey: ValueKey('${card.id}-status'), + color: color, + checkColor: FocusFlowTokens.appBackground, + isCompleted: card.isCompleted, + size: metrics.statusRingSize, + borderWidth: metrics.statusBorderWidth, + iconSize: metrics.statusIconSize, + semanticLabel: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + onPressed: onPressed, ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart index dac36c3..29eb8ed 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -10,6 +10,10 @@ class _TaskTimelineCardState extends State { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. bool _isHovered = false; + /// Private state stored as `_isPushRunning` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + bool _isPushRunning = false; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State { colors: colors, metrics: metrics, actionsVisible: card.showsQuickActions && _isHovered, + pushRunning: _isPushRunning, + onPushToNext: widget.onPushToNext == null + ? null + : () => _runPush(widget.onPushToNext!), + onPushToTomorrow: widget.onPushToTomorrow == null + ? null + : () => _runPush(widget.onPushToTomorrow!), + onPushToBacklog: widget.onPushToBacklog == null + ? null + : () => _runPush(widget.onPushToBacklog!), ), ), ], @@ -96,4 +110,38 @@ class _TaskTimelineCardState extends State { }, ); } + + /// Runs the `_runPush` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + Future _runPush( + Future Function(TimelineCardModel card) callback, + ) async { + if (_isPushRunning) { + logger.warn( + () => 'Duplicate card push action ignored. cardId=${widget.card.id}', + ); + return; + } + logger.debug(() => 'Card push action started. cardId=${widget.card.id}'); + setState(() { + _isPushRunning = true; + }); + try { + await callback(widget.card); + logger.debug(() => 'Card push action finished. cardId=${widget.card.id}'); + } on Object catch (error, stackTrace) { + logger.error( + () => 'Card push action failed. cardId=${widget.card.id}', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } finally { + if (mounted) { + setState(() { + _isPushRunning = false; + }); + } + } + } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart index 79c59d7..ba78fd4 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -13,6 +13,10 @@ class _TrailingControls extends StatelessWidget { required this.colors, required this.metrics, required this.actionsVisible, + required this.pushRunning, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, }); /// Stores the `card` value for this object. @@ -31,6 +35,19 @@ class _TrailingControls extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final bool actionsVisible; + /// Stores the `pushRunning` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool pushRunning; + + /// Callback invoked when Push to next is selected. + final Future Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function()? onPushToBacklog; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -40,11 +57,29 @@ class _TrailingControls extends StatelessWidget { children: [ if (card.showsQuickActions) _QuickActions( + card: card, color: colors.accent, backgroundColor: colors.background.withValues(alpha: 1), metrics: metrics, visible: actionsVisible, + pushRunning: pushRunning, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, ), + if (card.showPastTaskPushButton) ...[ + _PastTaskPushButton( + card: card, + color: colors.accent, + backgroundColor: colors.background, + metrics: metrics, + isRunning: pushRunning, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ), + SizedBox(width: metrics.pushButtonGap), + ], RewardIcon(color: colors.reward, size: metrics.rewardIconSize), SizedBox(width: metrics.indicatorGap), DifficultyBars( @@ -56,3 +91,189 @@ class _TrailingControls extends StatelessWidget { ); } } + +/// Menu destinations exposed by the overdue-task Push button. +enum _PastTaskPushDestination { + /// Pushes the task to the next available slot. + next, + + /// Pushes the task to tomorrow's first available slot. + tomorrow, + + /// Pushes the task to Backlog. + backlog, +} + +/// Menu item model for push destination options. +class _PushDestinationOption { + /// Creates one push destination menu option. + const _PushDestinationOption({ + required this.destination, + required this.label, + required this.callback, + }); + + /// Destination selected by this option. + final _PastTaskPushDestination destination; + + /// Visible label for this option. + final String label; + + /// Callback invoked when this option is selected. + final Future Function()? callback; +} + +/// Runs [callback] for [destination] when that destination has a callback. +void _selectPushDestination( + _PastTaskPushDestination destination, + List<_PushDestinationOption> options, +) { + for (final option in options) { + if (option.destination != destination) { + continue; + } + final callback = option.callback; + if (callback != null) { + unawaited(callback()); + } + return; + } +} + +/// Builds push destination menu options. +List<_PushDestinationOption> _pushDestinationOptions({ + required Future Function()? onPushToNext, + required Future Function()? onPushToTomorrow, + required Future Function()? onPushToBacklog, +}) { + return [ + _PushDestinationOption( + destination: _PastTaskPushDestination.next, + label: 'Push to next', + callback: onPushToNext, + ), + _PushDestinationOption( + destination: _PastTaskPushDestination.tomorrow, + label: 'Push to tomorrow', + callback: onPushToTomorrow, + ), + _PushDestinationOption( + destination: _PastTaskPushDestination.backlog, + label: 'Push to backlog', + callback: onPushToBacklog, + ), + ]; +} + +/// Whether any push destination option has an active callback. +bool _hasEnabledPushDestination(List<_PushDestinationOption> options) { + return options.any((option) => option.callback != null); +} + +/// Builds popup menu entries for push destination options. +List> _pushDestinationMenuItems( + List<_PushDestinationOption> options, +) { + return [ + for (final option in options) + PopupMenuItem<_PastTaskPushDestination>( + value: option.destination, + enabled: option.callback != null, + child: Text(option.label), + ), + ]; +} + +/// Private implementation type for `_PastTaskPushButton` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +class _PastTaskPushButton extends StatelessWidget { + /// Creates a `_PastTaskPushButton` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _PastTaskPushButton({ + required this.card, + required this.color, + required this.backgroundColor, + required this.metrics, + required this.isRunning, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, + }); + + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + + /// Stores the `color` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Color color; + + /// Stores the `backgroundColor` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Color backgroundColor; + + /// Stores the `metrics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardMetrics metrics; + + /// Stores the `isRunning` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final bool isRunning; + + /// Callback invoked when Push to next is selected. + final Future Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function()? onPushToBacklog; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final options = _pushDestinationOptions( + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ); + final enabled = !isRunning && _hasEnabledPushDestination(options); + return PopupMenuButton<_PastTaskPushDestination>( + key: ValueKey('${card.id}-push-button'), + tooltip: card.pastTaskPushSemanticLabel, + enabled: enabled, + color: const Color(0xFF122018), + elevation: 8, + position: PopupMenuPosition.under, + onSelected: (destination) => _selectPushDestination(destination, options), + itemBuilder: (context) => _pushDestinationMenuItems(options), + child: Semantics( + button: true, + enabled: enabled, + label: card.pastTaskPushSemanticLabel, + child: Container( + width: metrics.pushButtonWidth, + height: metrics.pushButtonHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.96), + border: Border.all(color: color, width: 1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Push', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: (11 * metrics.scale).clamp(8.0, 11.0).toDouble(), + fontWeight: FontWeight.w800, + height: 1, + ), + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index e75a043..937d1d2 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -4,12 +4,15 @@ /// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline. library; +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart' show logger; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; +import '../status_circle_button.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; @@ -31,6 +34,9 @@ class TaskTimelineCard extends StatefulWidget { required this.card, required this.onTap, this.onStatusPressed, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, super.key, }); @@ -43,6 +49,15 @@ class TaskTimelineCard extends StatefulWidget { /// Callback invoked when the left status control is clicked. final VoidCallback? onStatusPressed; + /// Callback invoked when the card requests Push to next. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when the card requests Push to tomorrow. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when the card requests Push to backlog. + final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index f8cb022..d9d3fb3 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -26,9 +26,13 @@ class TimelineView extends StatefulWidget { required this.onCardSelected, this.onAddTask, this.onCardCompleted, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, this.now, this.scrollTargetCardId, this.scrollRequest = 0, + this.scrollToNowRequest = 0, super.key, }); @@ -47,6 +51,15 @@ class TimelineView extends StatefulWidget { /// Callback invoked when a task card status control requests completion. final ValueChanged? onCardCompleted; + /// Callback invoked when a task card requests Push to next. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when a task card requests Push to tomorrow. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when a task card requests Push to backlog. + final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Clock used to center the initial scroll position. final DateTime Function()? now; @@ -56,6 +69,9 @@ class TimelineView extends StatefulWidget { /// Monotonic request number that allows repeated jumps to the same card. final int scrollRequest; + /// Monotonic request number that allows repeated jumps to current time. + final int scrollToNowRequest; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart index 83e87a5..b850032 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -28,6 +28,10 @@ class _TimelineViewState extends State { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. var _handledScrollRequest = 0; + /// Private state stored as `_handledScrollToNowRequest` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _handledScrollToNowRequest = 0; + /// Private state stored as `_geometry` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. late TimelineGeometry _geometry; @@ -86,6 +90,7 @@ class _TimelineViewState extends State { return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(_geometry, constraints.maxHeight); + _scheduleCurrentTimeScroll(_geometry, constraints.maxHeight); _scheduleTargetScroll(_geometry); return ScrollConfiguration( behavior: const _TimelineScrollBehavior(), @@ -140,6 +145,9 @@ class _TimelineViewState extends State { : () => widget.onCardCompleted!( placement.card, ), + onPushToNext: widget.onPushToNext, + onPushToTomorrow: widget.onPushToTomorrow, + onPushToBacklog: widget.onPushToBacklog, ), ), ), @@ -234,21 +242,31 @@ class _TimelineViewState extends State { return; } final now = widget.now?.call() ?? DateTime.now(); - final currentMinute = now.hour * 60 + now.minute; - final clampedMinute = currentMinute - .clamp(geometry.startMinutes, geometry.endMinutes) - .toInt(); - final centeredOffset = - geometry.yForMinutesSinceMidnight(clampedMinute) + - _topInset - - viewportHeight / 2; - final maxOffset = _scrollController.position.maxScrollExtent; - final offset = centeredOffset.clamp(0, maxOffset).toDouble(); - _scrollController.jumpTo(offset); + _jumpToCurrentTime(geometry, viewportHeight, now); _didSetInitialScroll = true; }); } + /// Runs the `_scheduleCurrentTimeScroll` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _scheduleCurrentTimeScroll( + TimelineGeometry geometry, + double viewportHeight, + ) { + if (widget.scrollToNowRequest == _handledScrollToNowRequest || + !viewportHeight.isFinite) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + final now = widget.now?.call() ?? DateTime.now(); + _jumpToCurrentTime(geometry, viewportHeight, now); + _handledScrollToNowRequest = widget.scrollToNowRequest; + }); + } + /// Runs the `_scheduleTargetScroll` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _scheduleTargetScroll(TimelineGeometry geometry) { @@ -281,4 +299,40 @@ class _TimelineViewState extends State { _handledScrollRequest = widget.scrollRequest; }); } + + /// Runs the `_jumpToCurrentTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _jumpToCurrentTime( + TimelineGeometry geometry, + double viewportHeight, + DateTime now, + ) { + final currentMinute = now.hour * 60 + now.minute; + final clampedMinute = currentMinute + .clamp(geometry.startMinutes, geometry.endMinutes) + .toInt(); + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = _scrollOffsetForMinute( + geometry: geometry, + minute: clampedMinute, + viewportHeight: viewportHeight, + maxOffset: maxOffset, + ); + _scrollController.jumpTo(offset); + } + + /// Runs the `_scrollOffsetForMinute` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + double _scrollOffsetForMinute({ + required TimelineGeometry geometry, + required int minute, + required double viewportHeight, + required double maxOffset, + }) { + final targetOffset = + geometry.yForMinutesSinceMidnight(minute) + + _topInset - + viewportHeight * 0.25; + return targetOffset.clamp(0, maxOffset).toDouble(); + } } diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index bca659c..e5f3acd 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -21,6 +21,7 @@ class TopBar extends StatelessWidget { this.onPreviousDate, this.onNextDate, this.onDatePressed, + this.onDateLabelPressed, super.key, }); @@ -36,6 +37,9 @@ class TopBar extends StatelessWidget { /// Callback invoked when the choose-date control is activated. final VoidCallback? onDatePressed; + /// Callback invoked when the displayed date label is activated. + final VoidCallback? onDateLabelPressed; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -78,13 +82,13 @@ class TopBar extends StatelessWidget { width: metrics.dateWidth, height: metrics.controlHeight, child: Tooltip( - message: 'Choose date', + message: 'Scroll to current time', child: Semantics( button: true, - label: 'Choose date, $dateLabel', + label: 'Scroll to current time, $dateLabel', child: TextButton( key: const ValueKey('top-bar-date-button'), - onPressed: onDatePressed, + onPressed: onDateLabelPressed, style: TextButton.styleFrom( foregroundColor: FocusFlowTokens.textPrimary, padding: EdgeInsets.symmetric( diff --git a/apps/focus_flow_flutter/test/app/date_navigation_test.dart b/apps/focus_flow_flutter/test/app/date_navigation_test.dart index 9038355..1d8845c 100644 --- a/apps/focus_flow_flutter/test/app/date_navigation_test.dart +++ b/apps/focus_flow_flutter/test/app/date_navigation_test.dart @@ -35,7 +35,7 @@ void main() { testWidgets('date picker jumps directly to the chosen date', (tester) async { await _pumpPlanApp(tester); - await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.tap(find.byTooltip('Choose date')); await tester.pumpAndSettle(); expect(find.byType(DatePickerDialog), findsOneWidget); @@ -49,6 +49,18 @@ void main() { expect(find.text('Clean coffee maker'), findsNothing); }); + testWidgets('date label scrolls timeline instead of opening picker', ( + tester, + ) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('May 20, 2025'), findsOneWidget); + }); + testWidgets('open modal closes when changing dates', (tester) async { await _pumpPlanApp(tester); diff --git a/apps/focus_flow_flutter/test/app/runtime_config_test.dart b/apps/focus_flow_flutter/test/app/runtime_config_test.dart new file mode 100644 index 0000000..5d54ed3 --- /dev/null +++ b/apps/focus_flow_flutter/test/app/runtime_config_test.dart @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests FocusFlow runtime config and file logging behavior. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_file_logger.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart'; +import 'package:scheduler_core/scheduler_core.dart' as scheduler_core; + +/// Runs runtime config tests. +void main() { + tearDown(scheduler_core.logger.disable); + + group('FocusFlowRuntimeConfig', () { + test('missing config file leaves optional settings unset', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-config-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + + final config = await FocusFlowRuntimeConfig.load( + path: '${directory.path}${Platform.pathSeparator}missing.json', + ); + + expect(config.logLevel, isNull); + expect(config.logFileLocation, isNull); + expect(config.enablesFileLogging, isFalse); + }); + + test('loads supported settings from a config file', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-config-file-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final configFile = File( + '${directory.path}${Platform.pathSeparator}config.json', + ); + await configFile.writeAsString( + jsonEncode({ + 'LogLevel': 'finest', + 'LogfileLocation': directory.path, + 'Timezone': 'pDt', + }), + ); + + final config = await FocusFlowRuntimeConfig.load( + path: configFile.path, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + expect(config.logLevel, FocusFlowLogLevel.finest); + expect(config.logFileLocation, directory.path); + expect(config.timeZone?.code, 'PDT'); + expect(config.timeZone?.utcOffset, const Duration(hours: -7)); + expect(config.enablesFileLogging, isTrue); + }); + + test('resolves daylight-saving aliases from the reference instant', () { + final pstInSummer = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 7, 5, 2), + ); + final pdtInWinter = FocusFlowUiTimeZone.tryParse( + 'pdt', + referenceInstant: DateTime.utc(2026, 1, 5, 12), + ); + final cstInSummer = FocusFlowUiTimeZone.tryParse( + 'CST', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + + expect(pstInSummer?.code, 'PDT'); + expect(pstInSummer?.utcOffset, const Duration(hours: -7)); + expect(pdtInWinter?.code, 'PST'); + expect(pdtInWinter?.utcOffset, const Duration(hours: -8)); + expect(cstInSummer?.code, 'CDT'); + expect(cstInSummer?.utcOffset, const Duration(hours: -5)); + }); + + test('keeps non-DST timezone codes fixed without regard to case', () { + final gmt = FocusFlowUiTimeZone.tryParse( + 'GmT', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + final jst = FocusFlowUiTimeZone.tryParse( + 'jst', + referenceInstant: DateTime.utc(2026, 7, 5, 12), + ); + + expect(gmt?.code, 'GMT'); + expect(gmt?.utcOffset, Duration.zero); + expect(jst?.code, 'JST'); + expect(jst?.utcOffset, const Duration(hours: 9)); + }); + + test('resolves daylight-saving boundaries at config parse time', () { + final beforePacificSpring = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 3, 8, 9, 59), + ); + final afterPacificSpring = FocusFlowUiTimeZone.tryParse( + 'PST', + referenceInstant: DateTime.utc(2026, 3, 8, 10), + ); + final beforePacificFall = FocusFlowUiTimeZone.tryParse( + 'PDT', + referenceInstant: DateTime.utc(2026, 11, 1, 8, 59), + ); + final afterPacificFall = FocusFlowUiTimeZone.tryParse( + 'PDT', + referenceInstant: DateTime.utc(2026, 11, 1, 9), + ); + + expect(beforePacificSpring?.code, 'PST'); + expect(afterPacificSpring?.code, 'PDT'); + expect(beforePacificFall?.code, 'PDT'); + expect(afterPacificFall?.code, 'PST'); + }); + + test('ignores timezone values that are not valid three-letter codes', () { + expect(FocusFlowUiTimeZone.tryParse('America/Los_Angeles'), isNull); + expect(FocusFlowUiTimeZone.tryParse('PT'), isNull); + expect(FocusFlowUiTimeZone.tryParse('XYZ'), isNull); + expect(FocusFlowUiTimeZone.tryParse(' '), isNull); + }); + + test('parses every supported log level', () { + expect(FocusFlowLogLevel.tryParse('finest'), FocusFlowLogLevel.finest); + expect(FocusFlowLogLevel.tryParse('finer'), FocusFlowLogLevel.finer); + expect(FocusFlowLogLevel.tryParse('fine'), FocusFlowLogLevel.fine); + expect(FocusFlowLogLevel.tryParse('debug'), FocusFlowLogLevel.debug); + expect(FocusFlowLogLevel.tryParse('info'), FocusFlowLogLevel.info); + expect(FocusFlowLogLevel.tryParse('warn'), FocusFlowLogLevel.warn); + expect(FocusFlowLogLevel.tryParse('error'), FocusFlowLogLevel.error); + }); + + test('loads supported logging settings from JSON', () { + final config = FocusFlowRuntimeConfig.fromJson({ + 'LogLevel': 'debug', + 'LogfileLocation': '/tmp/focus-flow-logs', + 'Timezone': 'GMT', + }); + + expect(config.logLevel, FocusFlowLogLevel.debug); + expect(config.logFileLocation, '/tmp/focus-flow-logs'); + expect(config.timeZone?.code, 'GMT'); + expect(config.enablesFileLogging, isTrue); + }); + + test('ignores missing and unsupported optional settings', () { + final config = FocusFlowRuntimeConfig.fromJson({ + 'LogLevel': 'verbose', + 'LogfileLocation': ' ', + 'Timezone': 'Pacific', + }); + + expect(config.logLevel, isNull); + expect(config.logFileLocation, isNull); + expect(config.timeZone, isNull); + expect(config.enablesFileLogging, isFalse); + }); + }); + + group('FocusFlowFileLogger', () { + test('does nothing unless both logging settings are set', () async { + final logger = await FocusFlowFileLogger.open( + const FocusFlowRuntimeConfig(logLevel: FocusFlowLogLevel.debug), + ); + + expect(logger.isEnabled, isFalse); + await logger.info('ignored'); + }); + + test('writes messages at or above the configured level', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-log-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.warn, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + await logger.info('not written'); + await logger.warn('written warning'); + await logger.error('written error', error: 'boom'); + + final logFile = File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ); + final contents = await logFile.readAsString(); + expect(contents, isNot(contains('not written'))); + expect(contents, contains('2026-07-03T12:00:00.000Z WARN')); + expect(contents, contains('written warning')); + expect(contents, contains('ERROR written error | error=boom')); + }); + + test('skips lazy message work below the configured level', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-lazy-skip-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.info, + logFileLocation: directory.path, + ), + ); + var evaluated = false; + + await logger.debug(() { + evaluated = true; + return 'expensive debug state'; + }); + + expect(evaluated, isFalse); + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, isNot(contains('expensive debug state'))); + }); + + test('evaluates lazy messages once the level is enabled', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-lazy-write-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.debug, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + var evaluations = 0; + + await logger.debug(() { + evaluations += 1; + return 'computed debug state'; + }); + + expect(evaluations, 1); + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, contains('DEBUG computed debug state')); + }); + + test( + 'finest captures caller information for every written level', + () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-finest-caller-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.finest, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + await logger.info('high level message'); + + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, contains('INFO caller=')); + expect(contents, contains('runtime_config_test.dart')); + expect(contents, contains('high level message')); + }, + ); + + test('fine captures caller information for warnings and errors', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-fine-caller-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.fine, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + await logger.warn('handled warning'); + + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, contains('WARN caller=')); + expect(contents, contains('runtime_config_test.dart')); + expect(contents, contains('handled warning')); + }); + + test('info does not capture caller information for warnings', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-info-caller-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.info, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + await logger.warn('minimal warning'); + + final contents = await File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ).readAsString(); + expect(contents, contains('WARN minimal warning')); + expect(contents, isNot(contains('caller='))); + expect(contents, isNot(contains('runtime_config_test.dart'))); + }); + }); +} diff --git a/apps/focus_flow_flutter/test/forbidden_imports_test.dart b/apps/focus_flow_flutter/test/forbidden_imports_test.dart index 3815853..f49f2e1 100644 --- a/apps/focus_flow_flutter/test/forbidden_imports_test.dart +++ b/apps/focus_flow_flutter/test/forbidden_imports_test.dart @@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) { ), if (relativePath != 'test/forbidden_imports_test.dart' && relativePath != 'test/persistent_composition_test.dart' && - !_isPersistentComposition(relativePath)) + relativePath != 'test/app/runtime_config_test.dart' && + !_isApprovedRuntimeBoundary(relativePath)) const _ForbiddenImport("dart:io", 'dart:io'), ]; } @@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) { return relativePath == 'lib/app/persistent_scheduler_composition.dart'; } +/// Whether [relativePath] is approved to touch local runtime files directly. +bool _isApprovedRuntimeBoundary(String relativePath) { + return _isPersistentComposition(relativePath) || + relativePath.startsWith('lib/app/runtime/'); +} + /// Private implementation type for `_ForbiddenImport` in this library. /// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. class _ForbiddenImport { diff --git a/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart b/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart index 0d9e235..de6ccc2 100644 --- a/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart +++ b/apps/focus_flow_flutter/test/models/completion_date_presentation_test.dart @@ -18,6 +18,7 @@ void main() { scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), completedAt: DateTime.utc(2026, 1, 3, 9, 25), ), + now: DateTime.utc(2026, 1, 3, 10), ); expect(card.completionDateContextRequired, isFalse); @@ -32,6 +33,7 @@ void main() { scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), completedAt: DateTime.utc(2026, 1, 4, 8, 5), ), + now: DateTime.utc(2026, 1, 4, 9), ); expect(card.completionDateContextRequired, isTrue); @@ -48,6 +50,7 @@ void main() { actualStart: DateTime.utc(2026, 1, 4, 0, 10), actualEnd: DateTime.utc(2026, 1, 4, 0, 20), ), + now: DateTime.utc(2026, 1, 4, 1), ); expect(card.completionDateContextRequired, isTrue); diff --git a/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart new file mode 100644 index 0000000..0cac13b --- /dev/null +++ b/apps/focus_flow_flutter/test/models/past_task_push_presentation_test.dart @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests past-task push presentation mapping. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/models/today_screen_models.dart'; + +/// Runs past-task push presentation tests. +void main() { + final now = DateTime.utc(2026, 1, 3, 12); + + test('planned flexible task earlier today shows Push state', () { + final updatedAt = DateTime.utc(2026, 1, 3, 8, 45); + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + updatedAt: updatedAt, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isTrue); + expect(card.showsQuickActions, isFalse); + expect(card.expectedUpdatedAt, updatedAt); + expect(card.pastTaskPushSemanticLabel, 'Push overdue task'); + }); + + test( + 'configured timezone offset changes wall-clock labels and positions', + () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 16), + end: DateTime.utc(2026, 1, 3, 16, 30), + ), + now: DateTime.utc(2026, 1, 3, 17), + timeZoneOffset: const Duration(hours: -8), + ); + + expect(card.timeText, '8:00 AM - 8:30 AM'); + expect(card.startMinutes, 8 * 60); + expect(card.endMinutes, 8 * 60 + 30); + expect(card.showPastTaskPushButton, isTrue); + }, + ); + + test('planned flexible task later today does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 14), + end: DateTime.utc(2026, 1, 3, 14, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isTrue); + }); + + test('planned flexible task on a previous date shows Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 2, 14), + end: DateTime.utc(2026, 1, 2, 14, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isTrue); + expect(card.showsQuickActions, isFalse); + }); + + test('planned flexible task on a future date does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 4, 9), + end: DateTime.utc(2026, 1, 4, 9, 30), + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isTrue); + }); + + test('completed flexible task in the past does not show Push state', () { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + status: TaskStatus.completed, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse); + expect(card.showsQuickActions, isFalse); + }); + + test('unsupported task types do not show Push state', () { + for (final type in [ + TaskType.critical, + TaskType.inflexible, + TaskType.locked, + TaskType.surprise, + TaskType.freeSlot, + ]) { + final card = TimelineCardModel.fromItem( + _item( + start: DateTime.utc(2026, 1, 3, 9), + end: DateTime.utc(2026, 1, 3, 9, 30), + type: type, + ), + now: now, + ); + + expect(card.showPastTaskPushButton, isFalse, reason: type.name); + } + }); + + test('task without schedule placement does not show Push state', () { + final card = TimelineCardModel.fromItem(_item(), now: now); + + expect(card.showPastTaskPushButton, isFalse); + }); +} + +/// Builds a timeline item for push-state presentation tests. +TodayTimelineItem _item({ + DateTime? start, + DateTime? end, + DateTime? updatedAt, + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.planned, +}) { + return TodayTimelineItem( + item: TimelineItem( + id: 'task-${type.name}-${status.name}', + displayTitle: 'Task', + taskType: type, + projectColorToken: 'project-home', + backgroundToken: TimelineBackgroundToken.flexible, + rewardIconToken: TimelineRewardIconToken.medium, + difficultyIconToken: TimelineDifficultyIconToken.medium, + showsExplicitTime: true, + quickActions: const [], + category: TimelineItemCategory.taskCard, + start: start, + end: end, + updatedAt: updatedAt, + durationMinutes: start == null || end == null + ? null + : end.difference(start).inMinutes, + ), + source: TodayTimelineItemSource.task, + taskStatus: status, + taskId: 'task-${type.name}-${status.name}', + ); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index c536293..1d157a6 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart'; import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; @@ -82,6 +83,43 @@ void main() { }, ); + testWidgets('ui timezone config controls local startup date only', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + final composition = await _runAsync( + tester, + () => PersistentSchedulerComposition.open( + sqlitePath: path, + clock: FixedClock(DateTime.utc(2026, 1, 3, 2)), + uiTimeZone: FocusFlowUiTimeZone.tryParse( + 'pst', + referenceInstant: DateTime.utc(2026, 1, 3, 2), + ), + ), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + + expect(composition.initialDate, CivilDate(2026, 1, 2)); + expect( + composition.context('timezone-context').ownerTimeZone.timeZoneId, + 'PST', + ); + + final storedSettings = await _runAsync( + tester, + () => composition.runtime.applicationStore.read( + action: (repositories) async { + return ApplicationResult.success( + await repositories.ownerSettings.findByOwnerId('owner-1'), + ); + }, + ), + ); + + expect(storedSettings.requireValue?.timeZoneId, 'UTC'); + }); + testWidgets( 'selected future-day schedule completion and uncomplete persist across reopen', (tester) async { @@ -128,7 +166,7 @@ void main() { selectedDate: selectedDate, clock: FixedClock(_instant(8, 10)), ); - var scheduled = await _readTodayItem( + final scheduled = await _readTodayItem( tester, composition, tomorrow, @@ -169,6 +207,7 @@ void main() { expect(completed.item.completedAt, isNotNull); final completedData = TodayScreenData.fromTodayState( await _readToday(tester, composition, tomorrow), + now: DateTime.utc(2026, 1, 4, 8, 20), ); final completedCard = completedData.cards.singleWhere( (card) => card.title == title, @@ -208,6 +247,239 @@ void main() { expect((await _readBacklog(tester, composition)).items, isEmpty); }, ); + + testWidgets('past task push destinations persist across reopen', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + const pushNextTitle = 'Push next persistence'; + const pushTomorrowTitle = 'Push tomorrow persistence'; + const pushBacklogTitle = 'Push backlog persistence'; + var commandNow = _instant(8); + + var composition = await _openComposition( + tester, + path: path, + selectedDate: _date, + clock: FixedClock(commandNow), + ); + final commandController = _commandController( + composition, + selectedDate: _date, + operationIdPrefix: 'past-push', + now: () => commandNow, + ); + + await _captureAndSchedule( + tester, + composition, + commandController, + pushNextTitle, + ); + await _captureAndSchedule( + tester, + composition, + commandController, + pushTomorrowTitle, + ); + await _captureAndSchedule( + tester, + composition, + commandController, + pushBacklogTitle, + ); + + commandNow = _instant(12); + final pastCards = + TodayScreenData.fromTodayState( + await _readToday(tester, composition, _date), + now: commandNow, + ).cards.where( + (card) => { + pushNextTitle, + pushTomorrowTitle, + pushBacklogTitle, + }.contains(card.title), + ); + expect(pastCards, hasLength(3)); + expect( + pastCards.map((card) => card.showPastTaskPushButton), + everyElement(isTrue), + ); + + final pushNextItem = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToNextAvailableSlot( + taskId: pushNextItem.id, + expectedUpdatedAt: pushNextItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushTomorrowItem = await _readTodayItem( + tester, + composition, + _date, + pushTomorrowTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToTomorrow( + taskId: pushTomorrowItem.id, + expectedUpdatedAt: pushTomorrowItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushBacklogItem = await _readTodayItem( + tester, + composition, + _date, + pushBacklogTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToBacklog( + taskId: pushBacklogItem.id, + expectedUpdatedAt: pushBacklogItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushedNext = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + expect(pushedNext.start, _instant(12)); + expect(pushedNext.end, _instant(12, 30)); + + commandNow = _instant(12, 45); + await tester.runAsync( + () => commandController.completeTask( + taskId: pushedNext.id, + taskType: pushedNext.taskType, + expectedUpdatedAt: pushedNext.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + commandController.dispose(); + await _closeComposition(tester, composition); + + composition = await _openComposition( + tester, + path: path, + selectedDate: _date, + clock: FixedClock(_instant(13)), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + + final reopenedNext = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + expect(reopenedNext.taskStatus, TaskStatus.completed); + expect(reopenedNext.start, _instant(12)); + expect(reopenedNext.item.completedAt, _instant(12, 45)); + final reopenedNextCard = TodayScreenData.fromTodayState( + await _readToday(tester, composition, _date), + now: _instant(13), + ).cards.singleWhere((card) => card.title == pushNextTitle); + expect(reopenedNextCard.showPastTaskPushButton, isFalse); + + final reopenedTomorrow = await _readTodayItem( + tester, + composition, + _date.addDays(1), + pushTomorrowTitle, + ); + expect(reopenedTomorrow.taskStatus, TaskStatus.planned); + expect(reopenedTomorrow.start, DateTime.utc(2026, 1, 3)); + expect(reopenedTomorrow.end, DateTime.utc(2026, 1, 3, 0, 30)); + + final reopenedBacklog = await _readBacklog(tester, composition); + final backlogTask = reopenedBacklog.items.single.task; + expect(backlogTask.title, pushBacklogTitle); + expect(backlogTask.scheduledStart, isNull); + expect(backlogTask.backlogEnteredAt, _instant(12)); + expect( + backlogTask.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + expect( + (await _readToday( + tester, + composition, + _date, + )).timelineItems.map((item) => item.item.displayTitle), + isNot(contains(pushBacklogTitle)), + ); + }); + + testWidgets('push to tomorrow uses ui-local current date at UTC rollover', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + final currentLocalDate = CivilDate(2026, 7, 4); + final tomorrow = currentLocalDate.addDays(1); + final dayAfterTomorrow = currentLocalDate.addDays(2); + final commandNow = DateTime.utc(2026, 7, 5, 2); + const title = 'Push tomorrow from evening PST'; + + final composition = await _openComposition( + tester, + path: path, + selectedDate: currentLocalDate, + clock: FixedClock(commandNow), + uiTimeZone: FocusFlowUiTimeZone.tryParse( + 'pst', + referenceInstant: commandNow, + ), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + final commandController = _commandController( + composition, + selectedDate: currentLocalDate, + operationIdPrefix: 'local-tomorrow-rollover', + now: () => commandNow, + ); + addTearDown(commandController.dispose); + + await _captureAndSchedule(tester, composition, commandController, title); + final scheduled = await _readTodayItem( + tester, + composition, + currentLocalDate, + title, + ); + + await tester.runAsync( + () => commandController.pushTaskToTomorrow( + taskId: scheduled.id, + expectedUpdatedAt: scheduled.item.updatedAt, + ), + ); + + expect(commandController.state, isA()); + final pushed = await _readTodayItem(tester, composition, tomorrow, title); + expect(pushed.taskStatus, TaskStatus.planned); + expect(pushed.start, DateTime.utc(2026, 7, 5, 7)); + expect(pushed.end, DateTime.utc(2026, 7, 5, 7, 30)); + expect( + (await _readToday( + tester, + composition, + dayAfterTomorrow, + )).timelineItems.map((item) => item.item.displayTitle), + isNot(contains(title)), + ); + }); } /// Fixed date used by persistent Flutter tests. @@ -233,6 +505,7 @@ Future _openComposition( required String path, required CivilDate selectedDate, required Clock clock, + FocusFlowUiTimeZone? uiTimeZone, }) { return _runAsync( tester, @@ -240,6 +513,7 @@ Future _openComposition( sqlitePath: path, selectedDate: selectedDate, clock: clock, + uiTimeZone: uiTimeZone, ), ); } @@ -283,10 +557,35 @@ SchedulerCommandController _commandController( refreshReads: () async {}, quickCaptureProjectId: composition.defaultProjectId, operationIdPrefix: operationIdPrefix, + currentDateFor: composition.uiTimeZone.toLocalCivilDate, now: now, ); } +/// Captures [title] into Backlog and schedules it on the selected test date. +Future _captureAndSchedule( + WidgetTester tester, + PersistentSchedulerComposition composition, + SchedulerCommandController commandController, + String title, +) async { + await tester.runAsync(() => commandController.quickCaptureToBacklog(title)); + expect(commandController.state, isA()); + + final captured = (await _readBacklog( + tester, + composition, + )).items.singleWhere((item) => item.task.title == title).task; + await tester.runAsync( + () => commandController.scheduleBacklogItem( + taskId: captured.id, + durationMinutes: 30, + expectedUpdatedAt: captured.updatedAt, + ), + ); + expect(commandController.state, isA()); +} + /// Reads one Today timeline item by [title]. Future _readTodayItem( WidgetTester tester, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 6eb790b..3f53e5d 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -4,6 +4,8 @@ /// Tests Widget behavior in the FocusFlow Flutter app. library; +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -14,6 +16,7 @@ import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; import 'package:focus_flow_flutter/widgets/required_banner.dart'; +import 'package:focus_flow_flutter/widgets/task_selection_modal.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; @@ -65,7 +68,10 @@ void main() { 'visual token adapter maps seeded items to expected visual kinds', () async { final state = await _readSeededToday(); - final data = TodayScreenData.fromTodayState(state); + final data = TodayScreenData.fromTodayState( + state, + now: DateTime.utc(2025, 5, 20, 15, 20), + ); expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible); expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible); @@ -90,7 +96,7 @@ void main() { expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); }); - testWidgets('task modal opens closes and keeps action buttons inert', ( + testWidgets('task modal opens closes and keeps compact actions inert', ( tester, ) async { await _pumpPlanApp(tester); @@ -104,14 +110,18 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.textContaining('Required'), findsWidgets); - expect(find.text('Reward level 2'), findsOneWidget); - expect(find.text('Medium effort'), findsOneWidget); - expect(find.text('Done'), findsOneWidget); - expect(find.text('Push'), findsOneWidget); - expect(find.text('Move to Backlog'), findsOneWidget); + expect(find.byTooltip('Reward level 2'), findsOneWidget); + expect(find.byTooltip('Medium effort'), findsOneWidget); + expect(find.byTooltip('Mark complete'), findsNothing); + expect(find.text('Reward level 2'), findsNothing); + expect(find.text('Medium effort'), findsNothing); + expect(find.text('Done'), findsNothing); + expect(find.text('Push'), findsWidgets); + expect(find.text('Move to Backlog'), findsNothing); + expect(find.text('Backlog'), findsWidgets); expect(find.text('Break up'), findsOneWidget); - await tester.tap(find.text('Done')); + await tester.tap(find.text('Break up')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); @@ -132,6 +142,91 @@ void main() { expect(find.text('Pay bill'), findsOneWidget); }); + testWidgets('task modal Push exposes destination labels and callbacks', ( + tester, + ) async { + var nextCount = 0; + var tomorrowCount = 0; + var backlogCount = 0; + + Future openMenu() async { + await tester.tap( + find.byKey(const ValueKey('modal-push-modal-push-button')), + ); + await tester.pumpAndSettle(); + } + + await _pumpConstrainedWidget( + tester, + size: const Size(640, 380), + child: TaskSelectionModal( + card: _timelineCard( + id: 'modal-push', + title: 'Modal push task', + rewardIconToken: TimelineRewardIconToken.notSet, + difficultyIconToken: TimelineDifficultyIconToken.notSet, + ), + onClose: () {}, + onPushToNext: (_) async { + nextCount += 1; + }, + onPushToTomorrow: (_) async { + tomorrowCount += 1; + }, + onPushToBacklog: (_) async { + backlogCount += 1; + }, + ), + ); + + expect(find.byTooltip('Not Set'), findsNWidgets(2)); + expect(find.byTooltip('Push task'), findsNothing); + expect(find.text('Remove'), findsOneWidget); + + await openMenu(); + expect(find.text('Push to next'), findsOneWidget); + expect(find.text('Push to tomorrow'), findsOneWidget); + expect(find.text('Push to backlog'), findsOneWidget); + + await tester.tap(find.text('Push to next')); + await tester.pumpAndSettle(); + expect(nextCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to tomorrow')); + await tester.pumpAndSettle(); + expect(tomorrowCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to backlog')); + await tester.pumpAndSettle(); + expect(backlogCount, 1); + }); + + testWidgets('task modal Remove deletes task and closes modal', ( + tester, + ) async { + final composition = _composition(); + await _pumpPlanApp(tester, composition: composition); + + await _scrollIntoView(tester, const ValueKey('water-plants')); + await tester.tap(find.byKey(const ValueKey('water-plants'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Water plants'), findsWidgets); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.text('Water plants'), findsNothing); + expect( + composition.store.currentTasks.any((task) => task.id == 'water-plants'), + isFalse, + ); + }); + test('Today screen timeline range covers the full day', () { expect(TodayScreenData.defaultRange.startMinutes, 0); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); @@ -193,7 +288,7 @@ void main() { }); testWidgets( - 'timeline defaults near the current time while keeping full day', + 'timeline defaults with current time one quarter down the viewport', (tester) async { await _pumpConstrainedWidget( tester, @@ -213,10 +308,48 @@ void main() { final scrollable = tester.state(find.byType(Scrollable)); final position = scrollable.position; expect(position.maxScrollExtent, greaterThan(3000)); - expect(position.pixels, closeTo(1576, 1)); + expect(position.pixels, closeTo(1676, 1)); }, ); + testWidgets('timeline scroll-to-now request reuses initial scroll math', ( + tester, + ) async { + var request = 0; + var currentTime = DateTime(2026, 6, 30, 12); + late StateSetter updateTimeline; + + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: StatefulBuilder( + builder: (context, setState) { + updateTimeline = setState; + return TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => currentTime, + scrollToNowRequest: request, + onCardSelected: (_) {}, + ); + }, + ), + ); + + final scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.position.pixels, closeTo(1676, 1)); + + scrollable.position.jumpTo(0); + await tester.pump(); + updateTimeline(() { + currentTime = DateTime(2026, 6, 30, 18); + request += 1; + }); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, closeTo(2558, 1)); + }); + testWidgets('timeline hour labels stay on one line', (tester) async { await _pumpConstrainedWidget( tester, @@ -357,7 +490,10 @@ void main() { tester, ) async { final state = await _readSeededToday(); - final data = TodayScreenData.fromTodayState(state); + final data = TodayScreenData.fromTodayState( + state, + now: DateTime.utc(2025, 5, 20, 15, 20), + ); await _pumpConstrainedWidget( tester, @@ -422,6 +558,7 @@ void main() { var previousCount = 0; var nextCount = 0; var datePressedCount = 0; + var dateLabelPressedCount = 0; await _pumpConstrainedWidget( tester, @@ -437,18 +574,24 @@ void main() { onDatePressed: () { datePressedCount += 1; }, + onDateLabelPressed: () { + dateLabelPressedCount += 1; + }, ), ); await tester.tap(find.byTooltip('Previous day')); await tester.tap(find.byTooltip('Next day')); + await tester.tap(find.byTooltip('Choose date')); await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); await tester.pumpAndSettle(); expect(previousCount, 1); expect(nextCount, 1); expect(datePressedCount, 1); - expect(find.byTooltip('Choose date'), findsWidgets); + expect(dateLabelPressedCount, 1); + expect(find.byTooltip('Choose date'), findsOneWidget); + expect(find.byTooltip('Scroll to current time'), findsOneWidget); }); testWidgets('required banner scales inside a compact header width', ( @@ -507,7 +650,11 @@ void main() { expect(tester.takeException(), isNull); expect( - tester.widget(find.byType(AnimatedOpacity)).opacity, + tester + .widget( + find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), + ) + .opacity, 0, ); @@ -518,11 +665,15 @@ void main() { await tester.pumpAndSettle(); expect( - tester.widget(find.byType(AnimatedOpacity)).opacity, + tester + .widget( + find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), + ) + .opacity, 1, ); final actionBackground = tester.widget( - find.byType(AnimatedContainer), + find.byKey(const ValueKey('narrow-actions-quick-actions-background')), ); final decoration = actionBackground.decoration; expect(decoration, isA()); @@ -540,6 +691,243 @@ void main() { await gesture.removePointer(); }); + testWidgets('past incomplete flexible card renders Push', (tester) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'past-flexible', + title: 'Past flexible task', + showPastTaskPushButton: true, + ), + size: const Size(360, 88), + onPushToNext: (_) async {}, + ); + + expect(find.text('Push'), findsOneWidget); + expect( + find.byKey(const ValueKey('past-flexible-push-button')), + findsOneWidget, + ); + }); + + testWidgets('completed and future cards do not render Push', (tester) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'completed-flexible', + title: 'Completed flexible task', + isCompleted: true, + showsQuickActions: false, + ), + size: const Size(360, 88), + ); + + expect(find.text('Push'), findsNothing); + + await _pumpTimelineCard( + tester, + card: _timelineCard(id: 'future-flexible', title: 'Future flexible task'), + size: const Size(360, 88), + ); + + expect(find.text('Push'), findsNothing); + }); + + testWidgets('past incomplete flexible card suppresses hover quick actions', ( + tester, + ) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'past-no-hover', + title: 'Past task', + showPastTaskPushButton: true, + ), + size: const Size(360, 88), + onPushToNext: (_) async {}, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('past-no-hover'))), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AnimatedOpacity), findsNothing); + expect(find.byIcon(Icons.arrow_forward), findsNothing); + + await gesture.removePointer(); + }); + + testWidgets('Push menu exposes destination labels and callbacks', ( + tester, + ) async { + var nextCount = 0; + var tomorrowCount = 0; + var backlogCount = 0; + + Future openMenu() async { + await tester.tap(find.byKey(const ValueKey('push-menu-push-button'))); + await tester.pumpAndSettle(); + } + + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'push-menu', + title: 'Past task', + showPastTaskPushButton: true, + ), + size: const Size(420, 88), + onPushToNext: (_) async { + nextCount += 1; + }, + onPushToTomorrow: (_) async { + tomorrowCount += 1; + }, + onPushToBacklog: (_) async { + backlogCount += 1; + }, + ); + + await openMenu(); + + expect(find.text('Push to next'), findsOneWidget); + expect(find.text('Push to tomorrow'), findsOneWidget); + expect(find.text('Push to backlog'), findsOneWidget); + expect(find.textContaining('Push to '), findsNWidgets(3)); + + await tester.tap(find.text('Push to next')); + await tester.pumpAndSettle(); + expect(nextCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to tomorrow')); + await tester.pumpAndSettle(); + expect(tomorrowCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to backlog')); + await tester.pumpAndSettle(); + expect(backlogCount, 1); + }); + + testWidgets('hover arrow exposes the same Push destination menu', ( + tester, + ) async { + var nextCount = 0; + var tomorrowCount = 0; + var backlogCount = 0; + + Future openMenu() async { + await tester.tap( + find.byKey(const ValueKey('quick-push-menu-quick-push-button')), + ); + await tester.pumpAndSettle(); + } + + await _pumpTimelineCard( + tester, + card: _timelineCard(id: 'quick-push-menu', title: 'Current task'), + size: const Size(420, 88), + onPushToNext: (_) async { + nextCount += 1; + }, + onPushToTomorrow: (_) async { + tomorrowCount += 1; + }, + onPushToBacklog: (_) async { + backlogCount += 1; + }, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('quick-push-menu'))), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check), findsNothing); + await openMenu(); + expect(find.text('Push to next'), findsOneWidget); + expect(find.text('Push to tomorrow'), findsOneWidget); + expect(find.text('Push to backlog'), findsOneWidget); + + await tester.tap(find.text('Push to next')); + await tester.pumpAndSettle(); + expect(nextCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to tomorrow')); + await tester.pumpAndSettle(); + expect(tomorrowCount, 1); + + await openMenu(); + await tester.tap(find.text('Push to backlog')); + await tester.pumpAndSettle(); + expect(backlogCount, 1); + + await gesture.removePointer(); + }); + + testWidgets('Push menu dismisses on outside click without callback', ( + tester, + ) async { + var count = 0; + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'push-dismiss', + title: 'Past task', + showPastTaskPushButton: true, + ), + size: const Size(420, 88), + onPushToNext: (_) async { + count += 1; + }, + ); + + await tester.tap(find.byKey(const ValueKey('push-dismiss-push-button'))); + await tester.pumpAndSettle(); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.text('Push to next'), findsNothing); + expect(count, 0); + }); + + testWidgets('Push selection is one shot while command is running', ( + tester, + ) async { + final completer = Completer(); + var count = 0; + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'push-running', + title: 'Past task', + showPastTaskPushButton: true, + ), + size: const Size(420, 88), + onPushToNext: (_) async { + count += 1; + await completer.future; + }, + ); + + await tester.tap(find.byKey(const ValueKey('push-running-push-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Push to next')); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('push-running-push-button'))); + await tester.pump(); + + expect(count, 1); + + completer.complete(); + await tester.pumpAndSettle(); + }); + testWidgets('short task card keeps time inline with title', (tester) async { await _pumpTimelineCard( tester, @@ -593,6 +981,33 @@ void main() { await _pumpPlanApp(tester, composition: composition); await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); + var statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isEmpty); + + final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ), + ); + await tester.pumpAndSettle(); + statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isNotEmpty); + await hover.removePointer(); + await tester.pumpAndSettle(); + final beforeClick = DateTime.now().toUtc(); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); @@ -610,6 +1025,26 @@ void main() { expect(completedTask.completedAt!.isAfter(afterClick), isFalse); expect(find.textContaining('Completed at:'), findsWidgets); + final completedHover = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await completedHover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ), + ); + await tester.pumpAndSettle(); + statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isNotEmpty); + await completedHover.removePointer(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); @@ -638,6 +1073,32 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.textContaining('Completed -'), findsNothing); + var modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isEmpty); + + final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('task-modal-status')), + ), + ); + await tester.pumpAndSettle(); + modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isNotEmpty); + await hover.removePointer(); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); @@ -652,6 +1113,26 @@ void main() { expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.byIcon(Icons.check), findsWidgets); + final completedHover = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await completedHover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('task-modal-status')), + ), + ); + await tester.pumpAndSettle(); + modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isNotEmpty); + await completedHover.removePointer(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); @@ -739,8 +1220,13 @@ Future _pumpTimelineCard( WidgetTester tester, { required TimelineCardModel card, required Size size, + Future Function(TimelineCardModel card)? onPushToNext, + Future Function(TimelineCardModel card)? onPushToTomorrow, + Future Function(TimelineCardModel card)? onPushToBacklog, }) async { - await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40)); + await tester.binding.setSurfaceSize( + Size(size.width + 120, size.height + 220), + ); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( @@ -753,6 +1239,9 @@ Future _pumpTimelineCard( card: card, onTap: () {}, onStatusPressed: () {}, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, ), ), ), @@ -796,7 +1285,12 @@ TimelineCardModel _timelineCard({ TaskVisualKind visualKind = TaskVisualKind.flexible, TaskType? taskType, bool showsQuickActions = true, + bool showPastTaskPushButton = false, + bool isCompleted = false, String? completedTimeText, + TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium, + TimelineDifficultyIconToken difficultyIconToken = + TimelineDifficultyIconToken.medium, }) { final resolvedTaskType = taskType ?? @@ -817,12 +1311,13 @@ TimelineCardModel _timelineCard({ endMinutes: endMinutes, taskType: resolvedTaskType, visualKind: visualKind, - rewardIconToken: TimelineRewardIconToken.medium, - difficultyIconToken: TimelineDifficultyIconToken.medium, + rewardIconToken: rewardIconToken, + difficultyIconToken: difficultyIconToken, completedTimeText: completedTimeText, - isCompleted: false, + isCompleted: isCompleted, isSelectable: true, - showsQuickActions: showsQuickActions, + showsQuickActions: showPastTaskPushButton ? false : showsQuickActions, + showPastTaskPushButton: showPastTaskPushButton, durationMinutes: endMinutes - startMinutes, ); } diff --git a/packages/scheduler_backup/lib/src/encrypted_backup.dart b/packages/scheduler_backup/lib/src/encrypted_backup.dart index 1169931..5f55ebb 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup.dart @@ -10,6 +10,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; +import 'package:scheduler_core/scheduler_core.dart' show logger; part 'encrypted_backup/errors/backup_decryption_exception.dart'; part 'encrypted_backup/codec/backup_encryption_codec.dart'; part 'encrypted_backup/errors/backup_exception.dart'; diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart index 00b43ff..8ccba31 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart @@ -56,9 +56,19 @@ Future> _decryptBytes({ SecretBox(cipherText, nonce: nonce, mac: mac), secretKey: key, ); - } on BackupDecryptionException { + } on BackupDecryptionException catch (error, stackTrace) { + logger.error( + () => 'Encrypted backup decryption failed with known backup error.', + error: error, + stackTrace: stackTrace, + ); rethrow; - } on Object { + } on Object catch (error, stackTrace) { + logger.error( + () => 'Encrypted backup decryption failed.', + error: error, + stackTrace: stackTrace, + ); throw const BackupDecryptionException(); } } diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart index 9174859..4a32971 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart @@ -16,7 +16,10 @@ Future createEncryptedBackup({ DateTime? now, }) async { final source = sqliteFile ?? defaultSchedulerSqliteFile(); + logger.info(() => 'Encrypted backup started. sourcePath=${source.path}'); if (!await source.exists()) { + logger.warn(() => + 'Encrypted backup source file missing. sourcePath=${source.path}'); throw BackupException('SQLite file does not exist: ${source.path}'); } @@ -35,7 +38,11 @@ Future createEncryptedBackup({ passphrase: passphrase, plaintext: plaintext, ); - return destination.writeAsBytes(encoded, flush: true); + final output = await destination.writeAsBytes(encoded, flush: true); + logger.info(() => 'Encrypted backup completed. sourcePath=${source.path} ' + 'destinationPath=${output.path} sourceBytes=${plaintext.length} ' + 'encodedBytes=${encoded.length}'); + return output; } /// Restore an encrypted backup over the scheduler SQLite file. @@ -47,7 +54,11 @@ Future restoreEncryptedBackup( String passphrase, { File? targetFile, }) async { + logger.info( + () => 'Encrypted backup restore started. backupPath=${backup.path}'); if (!await backup.exists()) { + logger.warn(() => + 'Encrypted backup restore source missing. backupPath=${backup.path}'); throw BackupException('Backup file does not exist: ${backup.path}'); } @@ -64,4 +75,7 @@ Future restoreEncryptedBackup( await target.delete(); } await temporary.rename(target.path); + logger.info( + () => 'Encrypted backup restore completed. backupPath=${backup.path} ' + 'targetPath=${target.path} restoredBytes=${plaintext.length}'); } diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart index 2bd5e85..d6c8b12 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart @@ -28,6 +28,8 @@ Directory _homeDirectory() { Platform.environment['USERPROFILE'] ?? Platform.environment['HOMEDRIVE']; if (home == null || home.trim().isEmpty) { + logger.warn( + () => 'Backup home directory could not be resolved from environment.'); throw const BackupException('Home directory could not be resolved.'); } return Directory(home); diff --git a/packages/scheduler_backup/pubspec.yaml b/packages/scheduler_backup/pubspec.yaml index 1c8bf80..9f960c5 100644 --- a/packages/scheduler_backup/pubspec.yaml +++ b/packages/scheduler_backup/pubspec.yaml @@ -13,6 +13,7 @@ environment: dependencies: cryptography: ^2.7.0 encrypt: ^5.0.3 + scheduler_core: any dev_dependencies: lints: ^5.0.0 diff --git a/packages/scheduler_core/lib/scheduler_core.dart b/packages/scheduler_core/lib/scheduler_core.dart index 8736dd5..c8eab85 100644 --- a/packages/scheduler_core/lib/scheduler_core.dart +++ b/packages/scheduler_core/lib/scheduler_core.dart @@ -33,6 +33,7 @@ export 'src/persistence/document_mapping.dart'; export 'src/persistence/document_migration.dart'; export 'src/scheduling/free_slots.dart'; export 'src/domain/locked_time.dart'; +export 'src/logging/focus_flow_logger.dart'; export 'src/domain/occupancy_policy.dart'; export 'src/persistence/persistence_contract.dart'; export 'src/domain/project_statistics.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_commands.dart b/packages/scheduler_core/lib/src/application/application_commands.dart index 4c0d3b7..6d068d9 100644 --- a/packages/scheduler_core/lib/src/application/application_commands.dart +++ b/packages/scheduler_core/lib/src/application/application_commands.dart @@ -21,6 +21,8 @@ import '../scheduling/scheduling_engine.dart'; import '../scheduling/task_actions.dart'; import '../scheduling/task_lifecycle.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; +import '../persistence/repositories.dart'; part 'application_commands/codes/application_command_code.dart'; part 'application_commands/messages/application_child_task_draft.dart'; part 'application_commands/messages/application_command_read_hint.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart index 4cc4509..1068181 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -29,6 +29,10 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. moveFlexibleToBacklog, + /// Selects the `removeTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + removeTask, + /// Selects the `completeFlexibleTask` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. completeFlexibleTask, diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index 245753c..15bc738 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -80,6 +80,10 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} hasTitle=${title.trim().isNotEmpty} ' + 'projectId=$projectId taskType=${type.name} durationMinutes=$durationMinutes'); final result = quickCaptureService.capture( QuickCaptureRequest( id: context.idGenerator.nextId(), @@ -136,6 +140,11 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} ' + 'hasTitle=${title.trim().isNotEmpty} projectId=$projectId ' + 'durationMinutes=$durationMinutes'); final state = await _loadPlanningState(repositories, context, localDate); final taskId = context.idGenerator.nextId(); @@ -199,9 +208,16 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} ' + 'taskId=$taskId durationMinutes=$durationMinutes'); var state = await _loadPlanningState(repositories, context, localDate); var task = _taskById(state.tasks, taskId); if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); task = await repositories.tasks.findById(taskId); if (task != null) { state = state.withTask(task); @@ -316,6 +332,9 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); @@ -356,6 +375,54 @@ class V1ApplicationCommandUseCases { ); } + /// Permanently remove a task from persistence. + Future> removeTask({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.removeTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final record = await repositories.tasks.findRecordById(taskId); + if (record == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(record.value, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final deleteResult = await repositories.tasks.deleteIfRevision( + taskId: taskId, + ownerId: context.ownerTimeZone.ownerId, + expectedRevision: record.revision, + ); + final deleteFailure = deleteResult.failure; + if (deleteFailure != null) { + return ApplicationResult.failure( + _failureForRepositoryMutation(deleteFailure), + ); + } + return ApplicationResult.success( + ApplicationCommandResult( + commandCode: commandCode, + operationId: context.operationId, + changedTasks: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: true, + ), + ), + ); + }, + ); + } + /// Complete a flexible task. Future> completeFlexibleTask({ required ApplicationOperationContext context, @@ -370,9 +437,15 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId'); var state = await _loadPlanningState(repositories, context, localDate); var task = _taskById(state.tasks, taskId); if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); task = await repositories.tasks.findById(taskId); if (task != null) { state = state.withTask(task); @@ -432,9 +505,15 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId'); var state = await _loadPlanningState(repositories, context, localDate); var task = _taskById(state.tasks, taskId); if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); task = await repositories.tasks.findById(taskId); if (task != null) { state = state.withTask(task); @@ -492,9 +571,16 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId ' + 'action=${action.name}'); var state = await _loadPlanningState(repositories, context, localDate); var task = _taskById(state.tasks, taskId); if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); task = await repositories.tasks.findById(taskId); if (task != null) { state = state.withTask(task); @@ -560,6 +646,11 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} ' + 'hasTitle=${title.trim().isNotEmpty} hasStartedAt=${startedAt != null} ' + 'timeUsedMinutes=$timeUsedMinutes projectId=$projectId'); final state = await _loadPlanningState(repositories, context, localDate); final logResult = surpriseTaskLogService.log( @@ -613,6 +704,11 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} ' + 'start=${start.toIso8601String()} end=${end.toIso8601String()} ' + 'projectId=$projectId hasTitle=${title.trim().isNotEmpty}'); final freeSlot = freeSlotService.create( id: context.idGenerator.nextId(), title: title, @@ -654,6 +750,11 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId ' + 'start=${start.toIso8601String()} end=${end.toIso8601String()} ' + 'hasTitle=${title != null} projectId=$projectId'); final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); @@ -698,6 +799,9 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId'); final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); @@ -746,6 +850,10 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} parentTaskId=$parentTaskId ' + 'childDraftCount=${children.length}'); final tasks = await repositories.tasks.findAll(); final parent = _taskById(tasks, parentTaskId); if (parent == null) { @@ -882,9 +990,16 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} taskId=$taskId ' + 'destination=${destination.name}'); var state = await _loadPlanningState(repositories, context, localDate); var task = _taskById(state.tasks, taskId); if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); task = await repositories.tasks.findById(taskId); if (task != null) { state = state.withTask(task); @@ -903,6 +1018,9 @@ class V1ApplicationCommandUseCases { input: state.input, taskId: taskId, updatedAt: context.now, + earliestStart: destination == PushDestination.nextAvailableSlot + ? context.now + : null, operationId: context.operationId, existingActivities: existingActivities, ); @@ -947,6 +1065,9 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); final tasks = await repositories.tasks.findAll(); final task = _taskById(tasks, taskId); if (task == null) { @@ -986,20 +1107,24 @@ class V1ApplicationCommandUseCases { CivilDate localDate, ) async { final ownerId = context.ownerTimeZone.ownerId; + logger.debug(() => 'Loading planning state. ownerId=$ownerId ' + 'localDate=${localDate.toIsoString()} operationId=${context.operationId}'); final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? OwnerSettings( ownerId: ownerId, timeZoneId: context.ownerTimeZone.timeZoneId, ); + final timeZoneId = context.ownerTimeZone.timeZoneId; + final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId); final dayStart = _resolve( date: localDate, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, + wallTime: effectiveSettings.dayStart, + timeZoneId: timeZoneId, ); final dayEnd = _resolve( date: localDate, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, + wallTime: effectiveSettings.dayEnd, + timeZoneId: timeZoneId, ); final window = SchedulingWindow(start: dayStart, end: dayEnd); final tasks = (await repositories.tasks.findForLocalDay( @@ -1019,11 +1144,16 @@ class V1ApplicationCommandUseCases { )) .items, date: localDate, - timeZoneId: settings.timeZoneId, + timeZoneId: timeZoneId, timeZoneResolver: timeZoneResolver, resolutionOptions: resolutionOptions, ); + logger.debug(() => + 'Planning state loaded. ownerId=$ownerId localDate=${localDate.toIsoString()} ' + 'taskCount=${tasks.length} lockedIntervalCount=${lockedExpansion.schedulingIntervals.length} ' + 'windowStart=${window.start.toIso8601String()} windowEnd=${window.end.toIso8601String()}'); + return _PlanningState( tasks: tasks, window: window, @@ -1064,6 +1194,11 @@ class V1ApplicationCommandUseCases { List childTaskIds = const [], }) async { final changedTasks = _changedTasks(beforeTasks, afterTasks); + logger.debug(() => + 'Persisting application command result. command=${commandCode.name} ' + 'operationId=${context.operationId} changedTaskCount=${changedTasks.length} ' + 'activityCount=${activities.length} noticeCount=${schedulingResult?.notices.length ?? 0} ' + 'changeCount=${schedulingResult?.changes.length ?? 0}'); await repositories.tasks.saveAll(changedTasks); await repositories.taskActivities.saveAll(activities); final projectStats = await _saveProjectStatistics( @@ -1072,6 +1207,11 @@ class V1ApplicationCommandUseCases { tasks: afterTasks, ); + logger.debug(() => + 'Application command persisted. command=${commandCode.name} ' + 'operationId=${context.operationId} changedTaskCount=${changedTasks.length} ' + 'projectStatisticsCount=${projectStats.length}'); + return ApplicationResult.success( ApplicationCommandResult( commandCode: commandCode, @@ -1101,6 +1241,8 @@ class V1ApplicationCommandUseCases { .where((activity) => activity.code == TaskActivityCode.completed) .map((activity) => activity.projectId) .toSet(); + logger.finer(() => 'Saving project statistics after command. ' + 'projectCount=${projectIds.length} activityCount=${activities.length}'); final updated = []; for (final projectId in projectIds) { @@ -1116,6 +1258,8 @@ class V1ApplicationCommandUseCases { if (result.appliedActivityIds.isEmpty) { continue; } + logger.finer(() => 'Project statistics updated. projectId=$projectId ' + 'appliedActivityCount=${result.appliedActivityIds.length}'); await repositories.projectStatistics.save(result.statistics); updated.add(result.statistics); } @@ -1123,3 +1267,33 @@ class V1ApplicationCommandUseCases { return List.unmodifiable(updated); } } + +/// Top-level helper that performs the `_failureForRepositoryMutation` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ApplicationFailure _failureForRepositoryMutation(RepositoryFailure failure) { + return switch (failure.code) { + RepositoryFailureCode.notFound => ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.staleRevision => ApplicationFailure( + code: ApplicationFailureCode.staleRevision, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.invalidRevision => ApplicationFailure( + code: ApplicationFailureCode.validation, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + RepositoryFailureCode.ownerMismatch || + RepositoryFailureCode.duplicateId || + RepositoryFailureCode.duplicateOperation => + ApplicationFailure( + code: ApplicationFailureCode.conflict, + entityId: failure.entityId, + detailCode: failure.code.name, + ), + }; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer.dart b/packages/scheduler_core/lib/src/application/application_layer.dart index 5d67f36..fe730ba 100644 --- a/packages/scheduler_core/lib/src/application/application_layer.dart +++ b/packages/scheduler_core/lib/src/application/application_layer.dart @@ -18,6 +18,7 @@ import '../persistence/repositories.dart'; import '../scheduling/scheduling_engine.dart'; import '../scheduling/task_lifecycle.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'application_layer/context/owner_time_zone_context.dart'; part 'application_layer/context/application_operation_context.dart'; part 'application_layer/results/application_failure_code.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart index d3049dc..add1b88 100644 --- a/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart @@ -195,7 +195,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ApplicationUnitOfWorkRepositories repositories, ) action, }) async { + logger.debug(() => 'In-memory unit of work run started. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'ownerId=${context.ownerTimeZone.ownerId}'); if (_state.operationsById.containsKey(context.operationId)) { + logger.warn(() => + 'Duplicate operation ignored by in-memory unit of work. ' + 'operationId=${context.operationId} operationName=$operationName'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.duplicateOperation, @@ -210,6 +216,9 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { try { final result = await action(repositories); if (result.isFailure) { + logger.warn(() => 'In-memory unit of work action returned failure. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'code=${result.failure!.code.name} detailCode=${result.failure!.detailCode}'); return result; } @@ -222,14 +231,29 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { if (_failNextCommit) { _failNextCommit = false; + logger.error(() => 'In-memory unit of work commit failed by test hook. ' + 'operationId=${context.operationId} operationName=$operationName'); throw const ApplicationPersistenceException(); } _state = staged; + logger.debug(() => 'In-memory unit of work run committed. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'taskCount=${_state.tasksById.length} projectCount=${_state.projectsById.length} ' + 'lockedBlockCount=${_state.lockedBlocksById.length} ' + 'snapshotCount=${_state.schedulingSnapshotsById.length} ' + 'operationCount=${_state.operationsById.length}'); return result; } on ApplicationFailureException catch (error) { + logger.warn(() => 'In-memory unit of work caught application failure. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'code=${error.failure.code.name} detailCode=${error.failure.detailCode}'); return ApplicationResult.failure(error.failure); } on DomainValidationException catch (error) { + logger.warn( + () => 'In-memory unit of work caught domain validation failure. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'code=${error.code.name}'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.validation, @@ -237,19 +261,35 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ), ); } on ArgumentError catch (error) { + logger.warn( + () => 'In-memory unit of work caught argument validation failure. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'argumentName=${error.name}'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.validation, detailCode: error.name, ), ); - } on ApplicationPersistenceException { + } on ApplicationPersistenceException catch (error, stackTrace) { + logger.error( + () => 'In-memory unit of work persistence failure. ' + 'operationId=${context.operationId} operationName=$operationName', + error: error, + stackTrace: stackTrace, + ); return ApplicationResult.failure( const ApplicationFailure( code: ApplicationFailureCode.persistenceFailure, ), ); - } catch (_) { + } catch (error, stackTrace) { + logger.error( + () => 'In-memory unit of work unexpected failure. ' + 'operationId=${context.operationId} operationName=$operationName', + error: error, + stackTrace: stackTrace, + ); return ApplicationResult.failure( const ApplicationFailure(code: ApplicationFailureCode.unexpected), ); @@ -264,14 +304,34 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ApplicationUnitOfWorkRepositories repositories, ) action, }) async { + logger.debug(() => 'In-memory unit of work read started. ' + 'taskCount=${_state.tasksById.length} projectCount=${_state.projectsById.length} ' + 'lockedBlockCount=${_state.lockedBlocksById.length} ' + 'snapshotCount=${_state.schedulingSnapshotsById.length}'); final staged = _state.copy(); final repositories = _InMemoryApplicationRepositories(staged); try { - return await action(repositories); + final result = await action(repositories); + if (result.isFailure) { + logger.warn(() => 'In-memory unit of work read returned failure. ' + 'code=${result.failure!.code.name} detailCode=${result.failure!.detailCode}'); + } else { + logger.debug(() => 'In-memory unit of work read finished. ' + 'taskCount=${staged.tasksById.length} projectCount=${staged.projectsById.length} ' + 'lockedBlockCount=${staged.lockedBlocksById.length} ' + 'snapshotCount=${staged.schedulingSnapshotsById.length}'); + } + return result; } on ApplicationFailureException catch (error) { + logger.warn(() => + 'In-memory unit of work read caught application failure. ' + 'code=${error.failure.code.name} detailCode=${error.failure.detailCode}'); return ApplicationResult.failure(error.failure); } on DomainValidationException catch (error) { + logger.warn( + () => 'In-memory unit of work read caught domain validation failure. ' + 'code=${error.code.name}'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.validation, @@ -279,19 +339,32 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ), ); } on ArgumentError catch (error) { + logger.warn(() => + 'In-memory unit of work read caught argument validation failure. ' + 'argumentName=${error.name}'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.validation, detailCode: error.name, ), ); - } on ApplicationPersistenceException { + } on ApplicationPersistenceException catch (error, stackTrace) { + logger.error( + () => 'In-memory unit of work read persistence failure.', + error: error, + stackTrace: stackTrace, + ); return ApplicationResult.failure( const ApplicationFailure( code: ApplicationFailureCode.persistenceFailure, ), ); - } catch (_) { + } catch (error, stackTrace) { + logger.error( + () => 'In-memory unit of work read unexpected failure.', + error: error, + stackTrace: stackTrace, + ); return ApplicationResult.failure( const ApplicationFailure(code: ApplicationFailureCode.unexpected), ); diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart index 181abe5..f28b9ea 100644 --- a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart @@ -251,6 +251,38 @@ class _UnitOfWorkTaskRepository implements TaskRepository { return RepositoryMutationResult.success(revision: nextRevision); } + /// Runs the `deleteIfRevision` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + if (!_tasksById.containsKey(taskId) || _ownerFor(taskId) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: taskId), + ); + } + final actualRevision = _revisionFor(taskId); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById.remove(taskId); + _ownersById.remove(taskId); + _revisionsById.remove(taskId); + return RepositoryMutationResult.success(revision: nextRevision); + } + /// Runs the `_ownerFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart index 5016520..258d5e8 100644 --- a/packages/scheduler_core/lib/src/application/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -17,6 +17,7 @@ import '../domain/locked_time.dart'; import '../domain/models.dart'; import '../domain/project_statistics.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'application_management/codes/application_management_command_code.dart'; part 'application_management/codes/owner_settings_warning_code.dart'; part 'application_management/requests/get_backlog_request.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index 20cbe71..1b8ea36 100644 --- a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -26,6 +26,8 @@ class V1ApplicationManagementUseCases { return applicationStore.read( action: (repositories) async { final ownerId = request.context.ownerTimeZone.ownerId; + logger.debug(() => 'Loading backlog query. ownerId=$ownerId ' + 'sortKey=${request.sortKey.name} hasFilter=${request.filter != null}'); final settings = await _ownerSettingsOrDefault( repositories, request.context.ownerTimeZone, @@ -46,6 +48,9 @@ class V1ApplicationManagementUseCases { stalenessSettings: settings.backlogStalenessSettings, ); final sorted = view.sorted(request.sortKey); + logger.debug(() => 'Backlog query loaded. ownerId=$ownerId ' + 'candidateCount=${candidates.length} filteredCount=${filtered.length} ' + 'itemCount=${sorted.length}'); return ApplicationResult.success( BacklogQueryResult( @@ -70,6 +75,9 @@ class V1ApplicationManagementUseCases { ) { return applicationStore.read( action: (repositories) async { + logger.debug(() => + 'Loading project defaults. ownerId=${request.context.ownerTimeZone.ownerId} ' + 'includeArchived=${request.includeArchived}'); final projects = (await repositories.projects.findByOwner( ownerId: request.context.ownerTimeZone.ownerId, includeArchived: request.includeArchived, @@ -94,6 +102,10 @@ class V1ApplicationManagementUseCases { ); } + logger.debug(() => + 'Project defaults loaded. ownerId=${request.context.ownerTimeZone.ownerId} ' + 'projectCount=${projects.length} resolutionCount=${resolutions.length}'); + return ApplicationResult.success( ProjectDefaultsQueryResult( ownerId: request.context.ownerTimeZone.ownerId, @@ -115,8 +127,14 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.createProject.name, action: (repositories) async { final id = draft.id ?? context.idGenerator.nextId(); + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.createProject.name} ' + 'operationId=${context.operationId} projectId=$id hasName=${draft.name.trim().isNotEmpty} ' + 'defaultDurationMinutes=${draft.defaultDurationMinutes}'); final existing = await repositories.projects.findById(id); if (existing != null) { + logger.warn(() => 'Create project failed: project already exists. ' + 'operationId=${context.operationId} projectId=$id'); return _failure( ApplicationFailureCode.conflict, entityId: id, @@ -134,6 +152,8 @@ class V1ApplicationManagementUseCases { defaultDurationMinutes: draft.defaultDurationMinutes, ); await repositories.projects.save(project); + logger.debug(() => + 'Project created. operationId=${context.operationId} projectId=$id'); return ApplicationResult.success(project); }, ); @@ -150,6 +170,10 @@ class V1ApplicationManagementUseCases { context: context, operationName: ApplicationManagementCommandCode.updateProject.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.updateProject.name} ' + 'operationId=${context.operationId} projectId=$projectId ' + 'hasName=${update.name != null} hasColorKey=${update.colorKey != null}'); final project = await _requireProject(repositories, projectId); final updated = project.copyWith( name: update.name, @@ -162,6 +186,8 @@ class V1ApplicationManagementUseCases { clearDefaultDuration: update.clearDefaultDuration, ); await repositories.projects.save(updated); + logger.debug(() => + 'Project updated. operationId=${context.operationId} projectId=$projectId'); return ApplicationResult.success(updated); }, ); @@ -177,9 +203,14 @@ class V1ApplicationManagementUseCases { context: context, operationName: ApplicationManagementCommandCode.archiveProject.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.archiveProject.name} ' + 'operationId=${context.operationId} projectId=$projectId'); final project = await _requireProject(repositories, projectId); final archived = project.copyWith(archivedAt: context.now); await repositories.projects.save(archived); + logger.debug(() => + 'Project archived. operationId=${context.operationId} projectId=$projectId'); return ApplicationResult.success(archived); }, ); @@ -196,8 +227,14 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.createLockedBlock.name, action: (repositories) async { final id = draft.id ?? context.idGenerator.nextId(); + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.createLockedBlock.name} ' + 'operationId=${context.operationId} lockedBlockId=$id date=${draft.date?.toIsoString()} ' + 'hasRecurrence=${draft.recurrence != null} hiddenByDefault=${draft.hiddenByDefault}'); final existing = await repositories.lockedBlocks.findBlockById(id); if (existing != null) { + logger.warn(() => 'Create locked block failed: block already exists. ' + 'operationId=${context.operationId} lockedBlockId=$id'); return _failure( ApplicationFailureCode.conflict, entityId: id, @@ -217,6 +254,8 @@ class V1ApplicationManagementUseCases { updatedAt: context.now, ); await repositories.lockedBlocks.saveBlock(block); + logger.debug(() => + 'Locked block created. operationId=${context.operationId} lockedBlockId=$id'); return ApplicationResult.success(block); }, ); @@ -233,6 +272,11 @@ class V1ApplicationManagementUseCases { context: context, operationName: ApplicationManagementCommandCode.updateLockedBlock.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.updateLockedBlock.name} ' + 'operationId=${context.operationId} lockedBlockId=$lockedBlockId ' + 'hasName=${update.name != null} hasDate=${update.date != null} ' + 'hasRecurrence=${update.recurrence != null}'); final block = await _requireLockedBlock(repositories, lockedBlockId); final updated = block.copyWith( name: update.name, @@ -245,6 +289,8 @@ class V1ApplicationManagementUseCases { updatedAt: context.now, ); await repositories.lockedBlocks.saveBlock(updated); + logger.debug(() => + 'Locked block updated. operationId=${context.operationId} lockedBlockId=$lockedBlockId'); return ApplicationResult.success(updated); }, ); @@ -260,12 +306,17 @@ class V1ApplicationManagementUseCases { context: context, operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.archiveLockedBlock.name} ' + 'operationId=${context.operationId} lockedBlockId=$lockedBlockId'); final block = await _requireLockedBlock(repositories, lockedBlockId); final archived = block.copyWith( updatedAt: context.now, archivedAt: context.now, ); await repositories.lockedBlocks.saveBlock(archived); + logger.debug(() => + 'Locked block archived. operationId=${context.operationId} lockedBlockId=$lockedBlockId'); return ApplicationResult.success(archived); }, ); @@ -287,6 +338,10 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.addLockedBlockOverride.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.addLockedBlockOverride.name} ' + 'operationId=${context.operationId} date=${date.toIsoString()} hiddenByDefault=$hiddenByDefault ' + 'hasProjectId=${projectId != null}'); final override = LockedBlockOverride.add( id: context.idGenerator.nextId(), date: date, @@ -298,6 +353,8 @@ class V1ApplicationManagementUseCases { createdAt: context.now, ); await repositories.lockedBlocks.saveOverride(override); + logger.debug(() => + 'Locked block override added. operationId=${context.operationId} overrideId=${override.id}'); return ApplicationResult.success(override); }, ); @@ -315,6 +372,9 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.removeLockedBlockOccurrence.name} ' + 'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()}'); await _requireLockedBlock(repositories, lockedBlockId); final override = LockedBlockOverride.remove( id: context.idGenerator.nextId(), @@ -323,6 +383,9 @@ class V1ApplicationManagementUseCases { createdAt: context.now, ); await repositories.lockedBlocks.saveOverride(override); + logger.debug(() => + 'Locked block occurrence removed. operationId=${context.operationId} ' + 'lockedBlockId=$lockedBlockId overrideId=${override.id}'); return ApplicationResult.success(override); }, ); @@ -345,6 +408,10 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name} ' + 'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()} ' + 'hasName=${name != null} hiddenByDefault=$hiddenByDefault'); await _requireLockedBlock(repositories, lockedBlockId); final override = LockedBlockOverride.replace( id: context.idGenerator.nextId(), @@ -358,6 +425,9 @@ class V1ApplicationManagementUseCases { createdAt: context.now, ); await repositories.lockedBlocks.saveOverride(override); + logger.debug(() => + 'Locked block occurrence replaced. operationId=${context.operationId} ' + 'lockedBlockId=$lockedBlockId overrideId=${override.id}'); return ApplicationResult.success(override); }, ); @@ -373,6 +443,11 @@ class V1ApplicationManagementUseCases { context: context, operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, action: (repositories) async { + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.updateOwnerSettings.name} ' + 'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId} ' + 'hasTimeZone=${update.timeZoneId != null} hasDayStart=${update.dayStart != null} ' + 'hasDayEnd=${update.dayEnd != null} hasStaleness=${update.backlogStalenessSettings != null}'); final current = await _ownerSettingsOrDefault( repositories, context.ownerTimeZone, @@ -382,6 +457,9 @@ class V1ApplicationManagementUseCases { (stalenessSettings.greenMaxAge.isNegative || stalenessSettings.blueMaxAge.isNegative || stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { + logger.warn(() => + 'Owner settings update failed: invalid staleness thresholds. ' + 'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId}'); return _failure( ApplicationFailureCode.validation, detailCode: 'invalidBacklogStalenessThresholds', @@ -404,6 +482,9 @@ class V1ApplicationManagementUseCases { OwnerSettingsWarningCode.planningWindowChanged, ]; await repositories.ownerSettings.save(updated); + logger.debug(() => + 'Owner settings updated. operationId=${context.operationId} ' + 'ownerId=${context.ownerTimeZone.ownerId} warningCount=${warnings.length}'); return ApplicationResult.success( OwnerSettingsUpdateResult(settings: updated, warnings: warnings), ); @@ -422,8 +503,13 @@ class V1ApplicationManagementUseCases { operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, action: (repositories) async { final ownerId = context.ownerTimeZone.ownerId; + logger.debug(() => + 'Management command executing. command=${ApplicationManagementCommandCode.acknowledgeNotice.name} ' + 'operationId=${context.operationId} ownerId=$ownerId noticeId=$noticeId'); final parsed = _parseNoticeId(noticeId); if (parsed == null) { + logger.warn(() => 'Notice acknowledgement failed: invalid notice id. ' + 'operationId=${context.operationId} noticeId=$noticeId'); return _failure( ApplicationFailureCode.validation, entityId: noticeId, @@ -433,6 +519,9 @@ class V1ApplicationManagementUseCases { final snapshot = await repositories.schedulingSnapshots.findById(parsed.snapshotId); if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { + logger.warn(() => 'Notice acknowledgement failed: notice not found. ' + 'operationId=${context.operationId} noticeId=$noticeId ' + 'snapshotId=${parsed.snapshotId} noticeIndex=${parsed.noticeIndex}'); return _failure( ApplicationFailureCode.notFound, entityId: noticeId, @@ -444,6 +533,9 @@ class V1ApplicationManagementUseCases { noticeId: noticeId, ); if (existing != null) { + logger.finer(() => + 'Notice already acknowledged. operationId=${context.operationId} ' + 'noticeId=$noticeId ownerId=$ownerId'); return ApplicationResult.success( NoticeAcknowledgementResult( record: existing, @@ -458,6 +550,9 @@ class V1ApplicationManagementUseCases { acknowledgedAt: context.now, ); await repositories.noticeAcknowledgements.save(record); + logger.debug( + () => 'Notice acknowledged. operationId=${context.operationId} ' + 'noticeId=$noticeId ownerId=$ownerId'); return ApplicationResult.success( NoticeAcknowledgementResult( record: record, @@ -476,6 +571,7 @@ class V1ApplicationManagementUseCases { ) async { final project = await repositories.projects.findById(projectId); if (project == null) { + logger.warn(() => 'Required project not found. projectId=$projectId'); throw ApplicationFailureException( ApplicationFailure( code: ApplicationFailureCode.notFound, @@ -495,6 +591,8 @@ class V1ApplicationManagementUseCases { ) async { final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); if (block == null) { + logger.warn(() => + 'Required locked block not found. lockedBlockId=$lockedBlockId'); throw ApplicationFailureException( ApplicationFailure( code: ApplicationFailureCode.notFound, @@ -513,12 +611,17 @@ Future _ownerSettingsOrDefault( ApplicationUnitOfWorkRepositories repositories, OwnerTimeZoneContext ownerTimeZone, ) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); + final settings = + await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId); + if (settings != null) { + return settings; + } + logger.finer(() => 'Owner settings missing; using defaults. ' + 'ownerId=${ownerTimeZone.ownerId} timeZoneId=${ownerTimeZone.timeZoneId}'); + return OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); } /// Top-level helper that performs the `_failure` operation for this file. diff --git a/packages/scheduler_core/lib/src/application/application_recovery.dart b/packages/scheduler_core/lib/src/application/application_recovery.dart index 021891c..e2ca4ee 100644 --- a/packages/scheduler_core/lib/src/application/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application/application_recovery.dart @@ -17,6 +17,7 @@ import '../persistence/repositories.dart'; import '../scheduling/scheduling_engine.dart'; import '../scheduling/task_lifecycle.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'application_recovery/app_open_recovery_outcome.dart'; part 'application_recovery/run_app_open_recovery_request.dart'; part 'application_recovery/app_open_recovery_result.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart index b84239a..aacbe44 100644 --- a/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart @@ -42,9 +42,16 @@ class V1AppOpenRecoveryUseCases { ownerId: ownerId, sourceDate: sourceDate, ); + logger.info(() => 'App-open recovery started. ownerId=$ownerId ' + 'operationId=${context.operationId} sourceDate=${sourceDate.toIsoString()} ' + 'targetDate=${targetDate.toIsoString()} snapshotId=$snapshotId'); final existingSnapshot = await repositories.schedulingSnapshots.findById(snapshotId); if (existingSnapshot != null) { + logger.info(() => + 'App-open recovery already processed. ownerId=$ownerId ' + 'operationId=${context.operationId} snapshotId=$snapshotId ' + 'noticeCount=${existingSnapshot.notices.length} changeCount=${existingSnapshot.changes.length}'); return ApplicationResult.success( AppOpenRecoveryResult( outcome: AppOpenRecoveryOutcome.alreadyProcessed, @@ -63,8 +70,11 @@ class V1AppOpenRecoveryUseCases { repositories, context.ownerTimeZone, ); - final sourceWindow = _windowForDate(sourceDate, settings); - final targetWindow = _windowForDate(targetDate, settings); + final effectiveSettings = settings.copyWith( + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final sourceWindow = _windowForDate(sourceDate, effectiveSettings); + final targetWindow = _windowForDate(targetDate, effectiveSettings); final tasks = await _loadSourceAndTargetTasks( repositories, sourceWindow: sourceWindow, @@ -83,10 +93,14 @@ class V1AppOpenRecoveryUseCases { blocks: blocks, overrides: overrides, date: targetDate, - timeZoneId: settings.timeZoneId, + timeZoneId: effectiveSettings.timeZoneId, timeZoneResolver: timeZoneResolver, resolutionOptions: resolutionOptions, ); + logger.debug(() => 'App-open recovery loaded source and target state. ' + 'ownerId=$ownerId taskCount=${tasks.length} blockCount=${blocks.length} ' + 'overrideCount=${overrides.length} ' + 'lockedIntervalCount=${lockedExpansion.schedulingIntervals.length}'); final input = SchedulingInput( tasks: tasks, window: targetWindow, @@ -101,6 +115,10 @@ class V1AppOpenRecoveryUseCases { if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { + logger.warn(() => 'App-open recovery could not place rollover tasks. ' + 'ownerId=$ownerId operationId=${context.operationId} ' + 'snapshotId=$snapshotId outcome=${schedulingResult.outcomeCode.name} ' + 'noticeCount=${schedulingResult.notices.length}'); return ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.noSlot, @@ -135,9 +153,16 @@ class V1AppOpenRecoveryUseCases { operationName: 'appOpenRecovery', ); + logger.debug(() => 'Persisting app-open recovery. ownerId=$ownerId ' + 'operationId=${context.operationId} snapshotId=$snapshotId ' + 'changedTaskCount=${changedTasks.length} activityCount=${activities.length} ' + 'noticeCount=${snapshot.notices.length} changeCount=${schedulingResult.changes.length}'); await repositories.tasks.saveAll(changedTasks); await repositories.taskActivities.saveAll(activities); await repositories.schedulingSnapshots.save(snapshot); + logger.info(() => 'App-open recovery completed. ownerId=$ownerId ' + 'operationId=${context.operationId} outcome=${rolloverNotice == null ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks.name : AppOpenRecoveryOutcome.rolledOver.name} ' + 'changedTaskCount=${changedTasks.length} activityCount=${activities.length}'); return ApplicationResult.success( AppOpenRecoveryResult( @@ -198,12 +223,17 @@ Future _ownerSettingsOrDefault( ApplicationUnitOfWorkRepositories repositories, OwnerTimeZoneContext ownerTimeZone, ) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); + final settings = + await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId); + if (settings != null) { + return settings; + } + logger.finer(() => 'Owner settings missing during recovery; using defaults. ' + 'ownerId=${ownerTimeZone.ownerId} timeZoneId=${ownerTimeZone.timeZoneId}'); + return OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); } /// Top-level helper that performs the `_loadSourceAndTargetTasks` operation for this file. @@ -213,6 +243,11 @@ Future> _loadSourceAndTargetTasks( required SchedulingWindow sourceWindow, required SchedulingWindow targetWindow, }) async { + logger.debug(() => 'Loading recovery source and target tasks. ' + 'sourceStart=${sourceWindow.start.toIso8601String()} ' + 'sourceEnd=${sourceWindow.end.toIso8601String()} ' + 'targetStart=${targetWindow.start.toIso8601String()} ' + 'targetEnd=${targetWindow.end.toIso8601String()}'); final byId = {}; for (final task in await repositories.tasks.findScheduledInWindow( sourceWindow, @@ -224,6 +259,8 @@ Future> _loadSourceAndTargetTasks( )) { byId[task.id] = task; } + logger.debug(() => + 'Recovery source and target tasks loaded. taskCount=${byId.length}'); return List.unmodifiable(byId.values); } @@ -249,8 +286,12 @@ SchedulingNotice? _rolloverNotice({ .map((change) => change.taskId) .toSet(); if (rolledTaskIds.isEmpty) { + logger.finer( + () => 'No rollover notice needed; no tasks moved from source day.'); return null; } + logger.debug( + () => 'Rollover notice created. rolledTaskCount=${rolledTaskIds.length}'); return SchedulingNotice( '${rolledTaskIds.length} unfinished flexible tasks were moved to today.', diff --git a/packages/scheduler_core/lib/src/domain/models/entities/task.dart b/packages/scheduler_core/lib/src/domain/models/entities/task.dart index 46b9b8d..1801175 100644 --- a/packages/scheduler_core/lib/src/domain/models/entities/task.dart +++ b/packages/scheduler_core/lib/src/domain/models/entities/task.dart @@ -44,6 +44,8 @@ class Task { this.actualStart, this.actualEnd, this.completedAt, + this.backlogEnteredAt, + this.backlogEnteredAtProvenance, String? parentTaskId, Set backlogTags = const {}, this.reminderOverride, @@ -127,6 +129,7 @@ class Task { Set backlogTags = const {}, DateTime? updatedAt, }) { + final entersBacklog = status == TaskStatus.backlog; return Task( id: id, title: title.trim(), @@ -138,10 +141,19 @@ class Task { difficulty: difficulty, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, + backlogEnteredAt: entersBacklog ? createdAt : null, + backlogEnteredAtProvenance: + entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null, backlogTags: backlogTags, ); } + /// Provenance value for tasks first created directly in Backlog. + static const backlogEnteredAtProvenanceQuickCapture = 'quickCapture'; + + /// Provenance value for scheduled tasks moved or pushed back to Backlog. + static const backlogEnteredAtProvenanceMovedToBacklog = 'movedToBacklog'; + /// Stable identifier used by persistence, UI selection, and scheduler changes. final String id; @@ -186,6 +198,12 @@ class Task { /// Explicit completion timestamp, distinct from the generic update timestamp. final DateTime? completedAt; + /// Freshness anchor for Backlog aging. Null falls back to [createdAt]. + final DateTime? backlogEnteredAt; + + /// Optional explanation for how [backlogEnteredAt] was set. + final String? backlogEnteredAtProvenance; + /// Parent task id when this task is a child/subtask. Null means top-level task. final String? parentTaskId; @@ -219,6 +237,9 @@ class Task { /// Backlog status means the task is stored for later and has no active slot. bool get isBacklog => status == TaskStatus.backlog; + /// Timestamp used for Backlog freshness and age filters. + DateTime get backlogFreshnessAnchorAt => backlogEnteredAt ?? createdAt; + /// Return a copy with selected fields changed. /// /// The core uses this instead of mutation. That matters because scheduling @@ -247,6 +268,8 @@ class Task { DateTime? actualStart, DateTime? actualEnd, DateTime? completedAt, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, String? parentTaskId, Set? backlogTags, ReminderProfile? reminderOverride, @@ -258,6 +281,7 @@ class Task { bool clearSchedule = false, bool clearActualInterval = false, bool clearCompletion = false, + bool clearBacklogEntry = false, bool clearParentTask = false, bool clearReminderOverride = false, }) { @@ -279,6 +303,12 @@ class Task { clearActualInterval ? null : (actualStart ?? this.actualStart), actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), + backlogEnteredAt: clearBacklogEntry + ? null + : (backlogEnteredAt ?? this.backlogEnteredAt), + backlogEnteredAtProvenance: clearBacklogEntry + ? null + : (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance), parentTaskId: clearParentTask ? null : (parentTaskId ?? this.parentTaskId), backlogTags: backlogTags ?? this.backlogTags, diff --git a/packages/scheduler_core/lib/src/logging/focus_flow_logger.dart b/packages/scheduler_core/lib/src/logging/focus_flow_logger.dart new file mode 100644 index 0000000..efee2f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/logging/focus_flow_logger.dart @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Provides shared FocusFlow logging primitives for scheduler code. +library; + +/// Lazily creates a log message after level gating passes. +typedef FocusFlowLogMessage = Object? Function(); + +/// Receives one enabled log entry. +typedef FocusFlowLogSink = void Function(FocusFlowLogEntry entry); + +/// Shared process logger used by scheduler core code. +final FocusFlowLogger logger = FocusFlowLogger.disabled(); + +/// Supported FocusFlow logging levels, ordered from most to least verbose. +enum FocusFlowLogLevel { + /// Extremely granular diagnostics, data dumps, and caller details for every log. + finest, + + /// Intra-method minor actions and small state changes. + finer, + + /// Secondary details, possible issues, and rich warning/error context. + fine, + + /// High-level debug flow and useful state values. + debug, + + /// Very high-level operational checkpoints. + info, + + /// Handled issues where the app recovered and kept going. + warn, + + /// Invalid or unsafe states that need review. + error; + + /// Parses [value] into a supported logging level. + static FocusFlowLogLevel? tryParse(String? value) { + final normalized = value?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + if (normalized == 'trace') { + return finest; + } + for (final level in FocusFlowLogLevel.values) { + if (level.name == normalized) { + return level; + } + } + return null; + } + + /// Ordered severity used for threshold comparisons. + int get severity { + return switch (this) { + FocusFlowLogLevel.finest => 0, + FocusFlowLogLevel.finer => 1, + FocusFlowLogLevel.fine => 2, + FocusFlowLogLevel.debug => 3, + FocusFlowLogLevel.info => 4, + FocusFlowLogLevel.warn => 5, + FocusFlowLogLevel.error => 6, + }; + } + + /// Whether this level should write when [minimumLevel] is configured. + bool writesAt(FocusFlowLogLevel minimumLevel) { + return severity >= minimumLevel.severity; + } +} + +/// Structured log entry produced after level gating has passed. +final class FocusFlowLogEntry { + /// Creates a structured log entry. + const FocusFlowLogEntry({ + required this.timestamp, + required this.level, + required this.message, + this.caller, + this.error, + this.stackTrace, + }); + + /// UTC timestamp for the log event. + final DateTime timestamp; + + /// Severity level for the log event. + final FocusFlowLogLevel level; + + /// Resolved log message. + final String message; + + /// Optional caller frame captured by [FocusFlowLogger]. + final String? caller; + + /// Optional error object attached to the entry. + final Object? error; + + /// Optional stack trace attached to the entry. + final StackTrace? stackTrace; + + /// Formats this entry as one append-only log line. + String toSingleLine() { + final buffer = StringBuffer() + ..write(timestamp.toUtc().toIso8601String()) + ..write(' ') + ..write(level.name.toUpperCase()) + ..write(' '); + final currentCaller = caller; + if (currentCaller != null) { + buffer + ..write('caller=') + ..write(_sanitizeForSingleLine(currentCaller)) + ..write(' '); + } + buffer.write(_sanitizeForSingleLine(message)); + final currentError = error; + if (currentError != null) { + buffer + ..write(' | error=') + ..write(_sanitizeForSingleLine(currentError.toString())); + } + final currentStackTrace = stackTrace; + if (currentStackTrace != null) { + buffer + ..write(' | stackTrace=') + ..write(_sanitizeForSingleLine(currentStackTrace.toString())); + } + return buffer.toString(); + } +} + +/// Logger that handles level gating, lazy messages, and caller capture. +final class FocusFlowLogger { + /// Creates a logger with optional runtime configuration. + FocusFlowLogger({ + FocusFlowLogLevel? minimumLevel, + FocusFlowLogSink? sink, + DateTime Function()? now, + Iterable callerFrameIgnores = const [], + }) : _minimumLevel = minimumLevel, + _sink = sink, + _now = now ?? _utcNow, + _callerFrameIgnores = _normalizedCallerFrameIgnores( + callerFrameIgnores, + ); + + /// Creates a logger that drops every message. + factory FocusFlowLogger.disabled({DateTime Function()? now}) { + return FocusFlowLogger(now: now); + } + + /// Minimum level currently configured for this logger. + FocusFlowLogLevel? _minimumLevel; + + /// Destination receiving enabled entries. + FocusFlowLogSink? _sink; + + /// Clock used to timestamp log entries. + DateTime Function() _now; + + /// Stack-frame markers ignored when capturing the caller. + List _callerFrameIgnores; + + /// Minimum level that will be written, or null when disabled. + FocusFlowLogLevel? get minimumLevel => _minimumLevel; + + /// Whether this logger has enough configuration to write entries. + bool get isEnabled => _minimumLevel != null && _sink != null; + + /// Replaces this logger's runtime configuration. + void configure({ + required FocusFlowLogLevel minimumLevel, + required FocusFlowLogSink sink, + DateTime Function()? now, + Iterable callerFrameIgnores = const [], + }) { + _minimumLevel = minimumLevel; + _sink = sink; + _now = now ?? _utcNow; + _callerFrameIgnores = _normalizedCallerFrameIgnores(callerFrameIgnores); + } + + /// Disables this logger and drops subsequent messages. + void disable({DateTime Function()? now}) { + _minimumLevel = null; + _sink = null; + _now = now ?? _utcNow; + _callerFrameIgnores = _normalizedCallerFrameIgnores(const []); + } + + /// Whether [level] would currently be written. + bool wouldWrite(FocusFlowLogLevel level) { + final configured = _minimumLevel; + return configured != null && _sink != null && level.writesAt(configured); + } + + /// Writes [message] at `finest` when enabled. + void finest( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.finest, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `finer` when enabled. + void finer( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.finer, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `fine` when enabled. + void fine( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.fine, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `debug` when enabled. + void debug( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.debug, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `info` when enabled. + void info( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.info, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `warn` when enabled. + void warn( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.warn, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at `error` when enabled. + void error( + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + write( + FocusFlowLogLevel.error, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at [level] when it passes the configured threshold. + void write( + FocusFlowLogLevel level, + Object? message, { + Object? error, + StackTrace? stackTrace, + }) { + final configured = _minimumLevel; + final currentSink = _sink; + if (configured == null || + currentSink == null || + !level.writesAt(configured)) { + return; + } + + final entry = FocusFlowLogEntry( + timestamp: _now().toUtc(), + level: level, + message: _resolveMessage(message), + caller: _shouldCaptureCaller(level: level, minimumLevel: configured) + ? _callerFrame() + : null, + error: error, + stackTrace: stackTrace, + ); + + try { + currentSink(entry); + } on Object { + return; + } + } + + /// Captures the first stack frame outside known logging internals. + String _callerFrame() { + for (final line in StackTrace.current.toString().split('\n')) { + final trimmed = line.trim(); + final shouldIgnore = _callerFrameIgnores.any(trimmed.contains); + if (trimmed.isEmpty || shouldIgnore) { + continue; + } + return _sanitizeForSingleLine(trimmed); + } + return 'unknown'; + } +} + +/// Returns whether caller capture should run for [level]. +bool _shouldCaptureCaller({ + required FocusFlowLogLevel level, + required FocusFlowLogLevel minimumLevel, +}) { + if (minimumLevel == FocusFlowLogLevel.finest) { + return true; + } + final capturesWarningContext = + level == FocusFlowLogLevel.warn || level == FocusFlowLogLevel.error; + return capturesWarningContext && + minimumLevel.severity <= FocusFlowLogLevel.fine.severity; +} + +/// Resolves a plain or lazy [message] after level gating passes. +String _resolveMessage(Object? message) { + try { + final resolved = message is FocusFlowLogMessage ? message() : message; + return resolved?.toString().trim() ?? ''; + } on Object catch (error) { + return 'Log message evaluation failed: $error'; + } +} + +/// Returns the current UTC timestamp. +DateTime _utcNow() => DateTime.now().toUtc(); + +/// Builds the complete set of caller frame markers to skip. +List _normalizedCallerFrameIgnores( + Iterable callerFrameIgnores, +) { + final ignores = {'focus_flow_logger.dart'}; + for (final ignore in callerFrameIgnores) { + final trimmed = ignore.trim(); + if (trimmed.isNotEmpty) { + ignores.add(trimmed); + } + } + return List.unmodifiable(ignores); +} + +/// Keeps diagnostic text on one physical log line. +String _sanitizeForSingleLine(String value) { + return value.replaceAll('\n', r'\n'); +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart index 3f43df4..59c0f55 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart @@ -55,8 +55,10 @@ abstract final class TaskDocumentMapping { TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( task.stats, ), - TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), - TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, + TaskDocumentFields.backlogEnteredAt: + _dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt), + TaskDocumentFields.backlogEnteredAtProvenance: + backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance, }; } @@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping { document, TaskDocumentFields.completedAt, ), + backlogEnteredAt: _requiredNullableDateTime( + document, + TaskDocumentFields.backlogEnteredAt, + ), + backlogEnteredAtProvenance: _requiredNullableString( + document, + TaskDocumentFields.backlogEnteredAtProvenance, + ), parentTaskId: _requiredNullableString( document, TaskDocumentFields.parentTaskId, diff --git a/packages/scheduler_core/lib/src/persistence/document_migration.dart b/packages/scheduler_core/lib/src/persistence/document_migration.dart index b57cefe..f7fd9da 100644 --- a/packages/scheduler_core/lib/src/persistence/document_migration.dart +++ b/packages/scheduler_core/lib/src/persistence/document_migration.dart @@ -13,6 +13,7 @@ library; import 'document_mapping.dart'; import 'persistence_contract.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'document_migration/codes/document_migration_entity.dart'; part 'document_migration/codes/document_migration_status.dart'; part 'document_migration/codes/document_migration_failure_code.dart'; diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart index 9c71f0a..7930dca 100644 --- a/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart +++ b/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart @@ -80,8 +80,14 @@ class V1DocumentMigrationService { required void Function(Map document) validateCurrent, }) { final documentId = _documentId(document); + logger.debug(() => 'Document migration started. ' + 'entity=${entity.name} documentId=$documentId ' + 'ownerId=$ownerId migratedAt=${migratedAt.toIso8601String()}'); final schemaRead = _readSchemaVersion(document); if (schemaRead.failure != null) { + logger.warn(() => 'Document migration failed schema version read. ' + 'entity=${entity.name} documentId=$documentId ' + 'detailCode=${schemaRead.failure}'); return _failed( entity: entity, documentId: documentId, @@ -93,10 +99,15 @@ class V1DocumentMigrationService { } final fromVersion = schemaRead.version; + logger.finer(() => 'Document migration schema version resolved. ' + 'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion'); if (fromVersion == v1SchemaVersion) { try { final copy = _deepCopyDocument(document); validateCurrent(copy); + logger.debug(() => + 'Document migration skipped; document already current. ' + 'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion'); return DocumentMigrationResult( document: copy, report: DocumentMigrationReport( @@ -107,6 +118,11 @@ class V1DocumentMigrationService { ), ); } on DocumentMappingException catch (error) { + logger.warn( + () => 'Document migration failed current document validation. ' + 'entity=${entity.name} documentId=$documentId ' + 'fromSchemaVersion=$fromVersion mappingCode=${error.code.name} ' + 'fieldName=${error.fieldName} detailCode=${error.detailCode}'); return _failedFromMapping( entity: entity, documentId: documentId, @@ -118,6 +134,9 @@ class V1DocumentMigrationService { } if (fromVersion > v1SchemaVersion || fromVersion < 0) { + logger.warn(() => + 'Document migration rejected unsupported schema version. ' + 'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion'); return _failed( entity: entity, documentId: documentId, @@ -129,6 +148,9 @@ class V1DocumentMigrationService { } if (fromVersion != 0) { + logger.warn(() => + 'Document migration rejected unsupported schema version. ' + 'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion'); return _failed( entity: entity, documentId: documentId, @@ -144,6 +166,10 @@ class V1DocumentMigrationService { try { migrated = migrateV0(document, notes); } on _LegacyDocumentException catch (error) { + logger.warn(() => 'Document migration failed legacy document validation. ' + 'entity=${entity.name} documentId=$documentId ' + 'fromSchemaVersion=$fromVersion fieldName=${error.fieldName} ' + 'detailCode=${error.detailCode}'); return _failed( entity: entity, documentId: documentId, @@ -157,6 +183,11 @@ class V1DocumentMigrationService { try { validateCurrent(migrated); } on DocumentMappingException catch (error) { + logger + .warn(() => 'Document migration failed migrated document validation. ' + 'entity=${entity.name} documentId=$documentId ' + 'fromSchemaVersion=$fromVersion mappingCode=${error.code.name} ' + 'fieldName=${error.fieldName} detailCode=${error.detailCode}'); return _failedFromMapping( entity: entity, documentId: documentId, @@ -166,6 +197,9 @@ class V1DocumentMigrationService { ); } + logger.debug(() => 'Document migration finished. ' + 'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion ' + 'toSchemaVersion=$v1SchemaVersion noteCount=${notes.length}'); return DocumentMigrationResult( document: migrated, report: DocumentMigrationReport( diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart index 952608a..1a3435d 100644 --- a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart @@ -284,6 +284,58 @@ class InMemoryTaskRepository implements TaskRepository { return RepositoryMutationResult.success(revision: nextRevision); } + /// Runs the `deleteIfRevision` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: taskId, + expectedRevision: expectedRevision, + ), + ); + } + final current = _tasksById[taskId]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: taskId, + ), + ); + } + if (_ownerFor(taskId) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: taskId, + ), + ); + } + final actualRevision = _revisionFor(taskId); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById.remove(taskId); + _ownersById.remove(taskId); + _revisionsById.remove(taskId); + return RepositoryMutationResult.success(revision: nextRevision); + } + /// Runs the `_ownerFor` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart index d479d6a..7617a8f 100644 --- a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart @@ -79,4 +79,11 @@ abstract interface class TaskRepository { required String ownerId, required int expectedRevision, }); + + /// Delete [taskId] by expected repository revision. + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }); } diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart index f100a3a..5f8973c 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart @@ -25,14 +25,14 @@ class BacklogStalenessSettings { /// 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. + /// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because + /// ordinary edits should not make an old backlog item look fresh. Commands + /// that deliberately re-enter Backlog reset the anchor. BacklogStalenessMarker markerFor({ required Task task, required DateTime now, }) { - final age = now.difference(task.createdAt); + final age = now.difference(task.backlogFreshnessAnchorAt); if (age <= greenMaxAge) { return BacklogStalenessMarker.green; diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart index e19cecf..defde6f 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart @@ -26,11 +26,11 @@ class BacklogView { /// Clock value supplied by the caller so age/staleness behavior is testable. final DateTime now; - /// Age since [Task.createdAt] that qualifies for the `stale` filter. + /// Age since the task's Backlog freshness anchor that qualifies for the `stale` filter. /// - /// V1 does not yet store a separate "entered backlog at" timestamp. Until - /// persistence adds that field, both stale filtering and visual staleness use - /// task creation age so they do not disagree after a task edit. + /// Tasks persisted before the anchor existed fall back to [Task.createdAt]. + /// This keeps older data readable while letting Push to Backlog make a task + /// fresh without rewriting original creation history. final Duration staleAfter; /// Color-bucket threshold configuration for backlog aging indicators. @@ -101,7 +101,8 @@ class BacklogView { BacklogFilter.criticalMissed => task.type == TaskType.critical && task.stats.missedCount > 0, BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), - BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, + BacklogFilter.stale => + now.difference(task.backlogFreshnessAnchorAt) >= staleAfter, BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, }; } @@ -116,7 +117,8 @@ class BacklogView { _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), BacklogSortKey.rewardVsEffort => _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), - BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), + BacklogSortKey.age => + a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt), BacklogSortKey.project => a.projectId.compareTo(b.projectId), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), }; diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart index f0abef4..9cbb071 100644 --- a/packages/scheduler_core/lib/src/scheduling/child_tasks.dart +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart @@ -13,6 +13,7 @@ library; import '../domain/models.dart'; import 'task_lifecycle.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'child_tasks/models/child_task_entry.dart'; part 'child_tasks/requests/child_task_break_up_request.dart'; part 'child_tasks/results/child_task_break_up_result.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart index 23fa5ce..a89d871 100644 --- a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart @@ -17,8 +17,14 @@ class ChildTaskBreakUpService { required List tasks, required ChildTaskBreakUpRequest request, }) { + logger.debug( + () => 'Breaking up parent task. parentTaskId=${request.parentTaskId} ' + 'entryCount=${request.entries.length} taskCount=${tasks.length} ' + 'operationId=${request.operationId}'); final parent = _taskById(tasks, request.parentTaskId); if (parent == null) { + logger.warn(() => 'Child task break-up failed: parent not found. ' + 'parentTaskId=${request.parentTaskId} operationId=${request.operationId}'); throw ArgumentError.value( request.parentTaskId, 'parentTaskId', @@ -26,6 +32,8 @@ class ChildTaskBreakUpService { ); } if (request.entries.isEmpty) { + logger.warn(() => 'Child task break-up failed: no child entries. ' + 'parentTaskId=${request.parentTaskId} operationId=${request.operationId}'); throw ArgumentError.value( request.entries, 'entries', @@ -38,9 +46,14 @@ class ChildTaskBreakUpService { for (final entry in request.entries) { final childId = entry.id.trim(); if (childId.isEmpty) { + logger.warn(() => 'Child task break-up failed: blank child id. ' + 'parentTaskId=${parent.id} operationId=${request.operationId}'); throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); } if (childId == parent.id) { + logger + .warn(() => 'Child task break-up failed: child id matched parent. ' + 'parentTaskId=${parent.id} operationId=${request.operationId}'); throw ArgumentError.value( entry.id, 'id', @@ -48,6 +61,9 @@ class ChildTaskBreakUpService { ); } if (!requestedIds.add(childId)) { + logger.warn(() => 'Child task break-up failed: duplicate child id. ' + 'parentTaskId=${parent.id} childTaskId=$childId ' + 'operationId=${request.operationId}'); throw ArgumentError.value( entry.id, 'id', @@ -55,6 +71,10 @@ class ChildTaskBreakUpService { ); } if (existingIds.contains(childId)) { + logger + .warn(() => 'Child task break-up failed: child id already exists. ' + 'parentTaskId=${parent.id} childTaskId=$childId ' + 'operationId=${request.operationId}'); throw ArgumentError.value( entry.id, 'id', @@ -66,6 +86,9 @@ class ChildTaskBreakUpService { final childTasks = request.entries .map((entry) => entry.toTask(parent: parent)) .toList(growable: false); + logger.debug(() => 'Parent task break-up produced children. ' + 'parentTaskId=${parent.id} childCount=${childTasks.length} ' + 'operationId=${request.operationId}'); return ChildTaskBreakUpResult( tasks: List.unmodifiable([...tasks, ...childTasks]), diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart index 9b8509a..5e8dfbc 100644 --- a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart @@ -32,19 +32,28 @@ class ChildTaskCompletionService { Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); + logger.debug(() => 'Completing child task. childTaskId=$childTaskId ' + 'taskCount=${tasks.length} operationId=$operationId ' + 'updatedAt=${now.toIso8601String()}'); final child = _taskById(tasks, childTaskId); if (child == null) { + logger.warn(() => 'Child completion failed: task not found. ' + 'childTaskId=$childTaskId operationId=$operationId'); throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); } final parentId = child.parentTaskId; if (parentId == null) { + logger.warn(() => 'Child completion failed: task has no parent. ' + 'childTaskId=$childTaskId operationId=$operationId'); throw ArgumentError.value( childTaskId, 'childTaskId', 'Task is not a child.'); } final parent = _taskById(tasks, parentId); if (parent == null) { + logger.warn(() => 'Child completion failed: parent not found. ' + 'childTaskId=$childTaskId parentTaskId=$parentId operationId=$operationId'); throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); } @@ -69,6 +78,9 @@ class ChildTaskCompletionService { final outcomes = [childTransition.outcomeCode]; if (!shouldCompleteParent) { + logger.debug(() => 'Child task completed without parent auto-complete. ' + 'childTaskId=${child.id} parentTaskId=${parent.id} ' + 'childApplied=${childTransition.applied} operationId=$opId'); return ChildTaskCompletionResult( tasks: List.unmodifiable(withChildCompleted), changedTaskIds: childTransition.applied ? [child.id] : const [], @@ -100,6 +112,10 @@ class ChildTaskCompletionService { if (parentTransition.applied) parent.id, ]; + logger.debug(() => 'Child task completion auto-completed parent. ' + 'childTaskId=${child.id} parentTaskId=${parent.id} ' + 'changedCount=${changedTaskIds.length} operationId=$opId'); + return ChildTaskCompletionResult( tasks: List.unmodifiable(completedTasks), changedTaskIds: List.unmodifiable(changedTaskIds), @@ -120,12 +136,19 @@ class ChildTaskCompletionService { Iterable existingActivities = const [], Iterable appliedActivityIds = const [], }) { + logger.debug(() => 'Completing parent from child. childTaskId=$childTaskId ' + 'taskCount=${tasks.length} operationId=$operationId'); final child = _taskById(tasks, childTaskId); if (child == null) { + logger.warn(() => 'Parent-from-child completion failed: task not found. ' + 'childTaskId=$childTaskId operationId=$operationId'); throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); } final parentId = child.parentTaskId; if (parentId == null) { + logger.warn( + () => 'Parent-from-child completion failed: task has no parent. ' + 'childTaskId=$childTaskId operationId=$operationId'); throw ArgumentError.value( childTaskId, 'childTaskId', @@ -155,8 +178,13 @@ class ChildTaskCompletionService { Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); + logger.debug(() => 'Completing parent task. parentTaskId=$parentTaskId ' + 'taskCount=${tasks.length} initiatingChildTaskId=$initiatingChildTaskId ' + 'operationId=$operationId updatedAt=${now.toIso8601String()}'); final parent = _taskById(tasks, parentTaskId); if (parent == null) { + logger.warn(() => 'Parent completion failed: parent not found. ' + 'parentTaskId=$parentTaskId operationId=$operationId'); throw ArgumentError.value( parentTaskId, 'parentTaskId', 'Parent not found.'); } @@ -164,6 +192,10 @@ class ChildTaskCompletionService { final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); if (initiatingChildTaskId != null && !directChildren.any((child) => child.id == initiatingChildTaskId)) { + logger.warn( + () => 'Parent completion failed: initiating child is not direct. ' + 'parentTaskId=${parent.id} childTaskId=$initiatingChildTaskId ' + 'operationId=$operationId'); throw ArgumentError.value( initiatingChildTaskId, 'initiatingChildTaskId', @@ -234,6 +266,12 @@ class ChildTaskCompletionService { updatedTasks.add(task); } + logger.debug( + () => 'Parent task completion finished. parentTaskId=${parent.id} ' + 'directChildCount=${directChildren.length} ' + 'forceCompletedCount=${forceCompletedChildIds.length} ' + 'changedCount=${changedTaskIds.length} operationId=$opId'); + return ChildTaskCompletionResult( tasks: List.unmodifiable(updatedTasks), changedTaskIds: List.unmodifiable(changedTaskIds), diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots.dart b/packages/scheduler_core/lib/src/scheduling/free_slots.dart index 7278c70..79bfb4d 100644 --- a/packages/scheduler_core/lib/src/scheduling/free_slots.dart +++ b/packages/scheduler_core/lib/src/scheduling/free_slots.dart @@ -13,6 +13,7 @@ library; import '../domain/models.dart'; import '../domain/occupancy_policy.dart'; import 'scheduling_engine.dart'; +import '../logging/focus_flow_logger.dart'; part 'free_slots/required_commitment_schedule_status.dart'; part 'free_slots/protected_free_slot_conflict.dart'; part 'free_slots/required_commitment_schedule_result.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart index 6e4b32f..f9cf46c 100644 --- a/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart @@ -19,6 +19,9 @@ class FreeSlotService { String projectId = 'inbox', DateTime? updatedAt, }) { + logger.debug(() => 'Creating protected free slot. taskId=$id ' + 'start=${start.toIso8601String()} end=${end.toIso8601String()} ' + 'projectId=$projectId'); final interval = TimeInterval(start: start, end: end, label: id); return Task( @@ -45,7 +48,12 @@ class FreeSlotService { String? title, String? projectId, }) { + logger.debug(() => 'Updating protected free slot. taskId=${freeSlot.id} ' + 'start=${start.toIso8601String()} end=${end.toIso8601String()} ' + 'projectId=$projectId hasTitle=${title != null}'); if (freeSlot.type != TaskType.freeSlot) { + logger.error(() => 'Free slot update failed: invalid task type. ' + 'taskId=${freeSlot.id} taskType=${freeSlot.type.name}'); throw ArgumentError.value( freeSlot.type, 'freeSlot.type', @@ -78,8 +86,14 @@ class FreeSlotService { required DateTime end, DateTime? updatedAt, }) { + logger.debug(() => 'Scheduling required commitment. taskId=$taskId ' + 'start=${start.toIso8601String()} end=${end.toIso8601String()} ' + 'taskCount=${input.tasks.length}'); final task = _taskById(input.tasks, taskId); if (task == null) { + logger + .warn(() => 'Required commitment scheduling failed: task not found. ' + 'taskId=$taskId'); return _unchangedRequiredResult( input: input, status: RequiredCommitmentScheduleStatus.taskNotFound, @@ -94,6 +108,9 @@ class FreeSlotService { } if (!task.isRequiredVisible) { + logger.warn( + () => 'Required commitment scheduling failed: invalid task type. ' + 'taskId=${task.id} taskType=${task.type.name}'); return _unchangedRequiredResult( input: input, status: RequiredCommitmentScheduleStatus.invalidTaskType, @@ -171,6 +188,11 @@ class FreeSlotService { ), ]; + logger.debug(() => + 'Required commitment scheduling finished. taskId=${task.id} ' + 'status=${conflicts.isEmpty ? RequiredCommitmentScheduleStatus.scheduled.name : RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict.name} ' + 'conflictCount=${conflicts.length} changeCount=${changes.length}'); + return RequiredCommitmentScheduleResult( status: conflicts.isEmpty ? RequiredCommitmentScheduleStatus.scheduled diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart index 8c04100..4afb0d9 100644 --- a/packages/scheduler_core/lib/src/scheduling/quick_capture.dart +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart @@ -13,6 +13,7 @@ library; import '../domain/models.dart'; import 'scheduling_engine.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'quick_capture/quick_capture_status.dart'; part 'quick_capture/quick_capture_request.dart'; part 'quick_capture/quick_capture_result.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart index 6e205fc..c184a36 100644 --- a/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart @@ -41,6 +41,10 @@ class QuickCaptureService { SchedulingInput? schedulingInput, DateTime? updatedAt, }) { + logger.debug(() => 'Quick capture requested. taskId=${request.id} ' + 'projectId=${request.projectId} taskType=${request.type.name} ' + 'hasDuration=${request.durationMinutes != null} ' + 'addToNextAvailableSlot=${request.addToNextAvailableSlot}'); final capturedTaskWithoutDuration = Task.quickCapture( id: request.id, title: request.title, @@ -55,6 +59,9 @@ class QuickCaptureService { ); if (request.durationMinutes != null && request.durationMinutes! <= 0) { + logger.warn(() => + 'Quick capture validation failed: non-positive duration. ' + 'taskId=${request.id} durationMinutes=${request.durationMinutes}'); return QuickCaptureResult( task: capturedTaskWithoutDuration, status: QuickCaptureStatus.validationError, @@ -67,6 +74,8 @@ class QuickCaptureService { ); if (!request.addToNextAvailableSlot) { + logger.debug( + () => 'Quick capture added to backlog. taskId=${capturedTask.id}'); return QuickCaptureResult( task: capturedTask, status: QuickCaptureStatus.addedToBacklog, @@ -74,6 +83,9 @@ class QuickCaptureService { } if (request.durationMinutes == null || request.durationMinutes! <= 0) { + logger.warn( + () => 'Quick capture scheduling validation failed: missing duration. ' + 'taskId=${capturedTask.id}'); return QuickCaptureResult( task: capturedTask, status: QuickCaptureStatus.validationError, @@ -82,6 +94,9 @@ class QuickCaptureService { } if (request.type != TaskType.flexible) { + logger.warn(() => + 'Quick capture scheduling validation failed: non-flexible task. ' + 'taskId=${capturedTask.id} taskType=${request.type.name}'); return QuickCaptureResult( task: capturedTask, status: QuickCaptureStatus.validationError, @@ -90,6 +105,9 @@ class QuickCaptureService { } if (schedulingInput == null) { + logger.warn(() => + 'Quick capture scheduling validation failed: missing scheduling input. ' + 'taskId=${capturedTask.id}'); return QuickCaptureResult( task: capturedTask, status: QuickCaptureStatus.validationError, @@ -110,6 +128,9 @@ class QuickCaptureService { final scheduledTask = _taskById(result.tasks, capturedTask.id); if (scheduledTask == null || scheduledTask.isBacklog) { + logger.warn(() => 'Quick capture scheduling did not place task. ' + 'taskId=${capturedTask.id} outcome=${result.outcomeCode.name} ' + 'noticeCount=${result.notices.length}'); return QuickCaptureResult( task: capturedTask, status: QuickCaptureStatus.validationError, @@ -118,6 +139,10 @@ class QuickCaptureService { ); } + logger.debug(() => 'Quick capture scheduled. taskId=${scheduledTask.id} ' + 'start=${scheduledTask.scheduledStart?.toIso8601String()} ' + 'end=${scheduledTask.scheduledEnd?.toIso8601String()} ' + 'changeCount=${result.changes.length} noticeCount=${result.notices.length}'); return QuickCaptureResult( task: scheduledTask, status: QuickCaptureStatus.scheduled, @@ -142,10 +167,16 @@ class QuickCaptureService { }) { final generator = idGenerator; if (generator == null) { + logger.error( + () => 'Quick capture rejected because id generator is missing.'); throw StateError('Quick capture needs an id generator.'); } final now = createdAt ?? updatedAt ?? clock.now(); + logger.debug(() => 'Quick capture new task requested. ' + 'hasTitle=${title.trim().isNotEmpty} projectId=$projectId ' + 'type=${type.name} addToNextAvailableSlot=$addToNextAvailableSlot ' + 'occurredAt=${now.toIso8601String()}'); return capture( QuickCaptureRequest( diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart index 584b54c..b3d6273 100644 --- a/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart @@ -12,6 +12,7 @@ library; import '../domain/models.dart'; import '../domain/occupancy_policy.dart'; +import '../logging/focus_flow_logger.dart'; part 'reminder_policy/directives/reminder_directive_action.dart'; part 'reminder_policy/directives/reminder_directive_reason.dart'; part 'reminder_policy/profiles/effective_reminder_profile.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart index f60bf11..3970deb 100644 --- a/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart @@ -25,6 +25,8 @@ class ReminderPolicyService { }) { final override = task.reminderOverride; if (override != null) { + logger.finer(() => 'Reminder profile resolved from task override. ' + 'taskId=${task.id} profile=${override.name}'); return EffectiveReminderProfile( profile: override, source: EffectiveReminderProfileSource.taskOverride, @@ -32,12 +34,17 @@ class ReminderPolicyService { } if (project != null) { + logger.finer(() => 'Reminder profile resolved from project default. ' + 'taskId=${task.id} projectId=${project.id} ' + 'profile=${project.defaultReminderProfile.name}'); return EffectiveReminderProfile( profile: project.defaultReminderProfile, source: EffectiveReminderProfileSource.projectDefault, ); } + logger.finer(() => 'Reminder profile resolved from fallback. ' + 'taskId=${task.id} profile=${fallbackProfile.name}'); return EffectiveReminderProfile( profile: fallbackProfile, source: EffectiveReminderProfileSource.fallback, @@ -51,11 +58,19 @@ class ReminderPolicyService { ProjectProfile? project, Iterable contextTasks = const [], }) { + logger.debug(() => 'Reminder directive evaluation started. ' + 'taskId=${task.id} type=${task.type.name} status=${task.status.name} ' + 'now=${now.toIso8601String()}'); final effective = resolveEffectiveProfile(task: task, project: project); final activeFreeSlot = _activeProtectedFreeSlot( now: now, contextTasks: contextTasks, ); + if (activeFreeSlot != null) { + logger.finer(() => 'Reminder directive found active protected free slot. ' + 'taskId=${task.id} freeSlotTaskId=${activeFreeSlot.taskId} ' + 'freeSlotEnd=${activeFreeSlot.interval?.end.toIso8601String()}'); + } return _directiveForTask( task: task, @@ -81,6 +96,12 @@ class ReminderPolicyService { required ReminderDirectiveReason reason, DateTime? nextEligibleAt, }) { + logger.debug(() => 'Reminder directive evaluation finished. ' + 'taskId=${task.id} action=${action.name} reason=${reason.name} ' + 'profile=${effective.profile.name} source=${effective.source.name} ' + 'hasSchedule=${start != null && end != null} ' + 'nextEligibleAt=${nextEligibleAt?.toIso8601String()} ' + 'protectedFreeSlotTaskId=${activeFreeSlot?.taskId}'); return ReminderDirective( taskId: task.id, action: action, @@ -165,6 +186,8 @@ class ReminderPolicyService { if (task.type == TaskType.critical && effective.profile == ReminderProfile.strict) { + logger.finer(() => 'Reminder directive requires acknowledgement. ' + 'taskId=${task.id} profile=${effective.profile.name}'); return build( action: ReminderDirectiveAction.requireAcknowledgement, reason: ReminderDirectiveReason.requireRequiredAcknowledgement, @@ -191,6 +214,9 @@ class ReminderPolicyService { }) { if (task.type == TaskType.critical && effective.profile == ReminderProfile.strict) { + logger.finer(() => + 'Reminder directive requires acknowledgement during protected rest. ' + 'taskId=${task.id} profile=${effective.profile.name}'); return build( action: ReminderDirectiveAction.requireAcknowledgement, reason: ReminderDirectiveReason.requireRequiredAcknowledgement, @@ -199,6 +225,10 @@ class ReminderPolicyService { if (task.type == TaskType.inflexible && effective.profile == ReminderProfile.gentle) { + logger.finer(() => + 'Reminder directive deferred required task during protected rest. ' + 'taskId=${task.id} profile=${effective.profile.name} ' + 'nextEligibleAt=${freeSlotEnd?.toIso8601String()}'); return build( action: ReminderDirectiveAction.defer, reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart index 3309442..65db2f3 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart @@ -22,6 +22,7 @@ import '../domain/models.dart'; import '../domain/occupancy_policy.dart'; import 'task_lifecycle.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'scheduling_engine/codes/notices/scheduling_notice_type.dart'; part 'scheduling_engine/codes/operations/scheduling_operation_code.dart'; part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart index abb5d5b..a420756 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart @@ -45,10 +45,16 @@ class SchedulingEngine { required String taskId, DateTime? updatedAt, }) { + logger.debug(() => 'Scheduling backlog task into next available slot. ' + 'taskId=$taskId taskCount=${input.tasks.length} ' + 'windowStart=${input.window.start.toIso8601String()} ' + 'windowEnd=${input.window.end.toIso8601String()}'); // 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); if (task == null) { + logger.warn( + () => 'Backlog scheduling failed: task not found. taskId=$taskId'); return _unchangedResult( input, SchedulingNotice( @@ -64,6 +70,8 @@ class SchedulingEngine { } if (!task.isFlexible || !task.isBacklog) { + logger.warn(() => 'Backlog scheduling failed: invalid task state. ' + 'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}'); return _unchangedResult( input, SchedulingNotice( @@ -80,6 +88,8 @@ class SchedulingEngine { final taskDuration = _durationFromMinutes(task.durationMinutes); if (taskDuration == null) { + logger.warn(() => 'Backlog scheduling failed: missing positive duration. ' + 'taskId=${task.id} durationMinutes=${task.durationMinutes}'); return _unchangedResult( input, SchedulingNotice( @@ -103,6 +113,8 @@ class SchedulingEngine { ); if (placement == null) { + logger.warn(() => 'Backlog scheduling failed: no available slot. ' + 'taskId=${task.id} blockedIntervalCount=${input.blockedIntervals.length}'); return _unchangedResult( input, SchedulingNotice( @@ -137,12 +149,18 @@ class SchedulingEngine { required SchedulingInput input, required String taskId, DateTime? updatedAt, + DateTime? earliestStart, bool countAsManualPush = true, }) { + logger.debug(() => 'Pushing flexible task to next available slot. ' + 'taskId=$taskId taskCount=${input.tasks.length} ' + 'earliestStart=${earliestStart?.toIso8601String()} ' + 'countAsManualPush=$countAsManualPush'); // 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); if (task == null) { + logger.warn(() => 'Flexible push failed: task not found. taskId=$taskId'); return _unchangedResult( input, SchedulingNotice( @@ -158,6 +176,8 @@ class SchedulingEngine { } if (!task.isFlexible || task.status != TaskStatus.planned) { + logger.warn(() => 'Flexible push failed: invalid task state. ' + 'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}'); return _unchangedResult( input, SchedulingNotice( @@ -173,7 +193,11 @@ class SchedulingEngine { } final currentInterval = _scheduledIntervalFor(task); - if (currentInterval == null || !input.window.contains(currentInterval)) { + if (currentInterval == null || + (earliestStart == null && !input.window.contains(currentInterval))) { + logger.warn(() => + 'Flexible push failed: missing or out-of-window scheduled slot. ' + 'taskId=${task.id} hasCurrentInterval=${currentInterval != null}'); return _unchangedResult( input, SchedulingNotice( @@ -189,6 +213,9 @@ class SchedulingEngine { } if (currentInterval.duration.inMicroseconds <= 0) { + logger.warn(() => + 'Flexible push failed: non-positive scheduled duration. ' + 'taskId=${task.id} durationMicroseconds=${currentInterval.duration.inMicroseconds}'); return _unchangedResult( input, SchedulingNotice( @@ -207,9 +234,12 @@ class SchedulingEngine { input: input, task: task, currentInterval: currentInterval, + earliestStart: earliestStart, ); if (placement == null) { + logger.warn(() => + 'Flexible push failed: no available slot today. taskId=${task.id}'); return _unchangedResult( input, SchedulingNotice( @@ -250,8 +280,13 @@ class SchedulingEngine { required String taskId, DateTime? updatedAt, }) { + logger.debug(() => 'Pushing flexible task to tomorrow top of queue. ' + 'taskId=$taskId taskCount=${input.tasks.length} ' + 'windowStart=${input.window.start.toIso8601String()} ' + 'windowEnd=${input.window.end.toIso8601String()}'); final task = _taskById(input.tasks, taskId); if (task == null) { + logger.warn(() => 'Tomorrow push failed: task not found. taskId=$taskId'); return _unchangedResult( input, SchedulingNotice( @@ -267,6 +302,8 @@ class SchedulingEngine { } if (!task.isFlexible || task.status != TaskStatus.planned) { + logger.warn(() => 'Tomorrow push failed: invalid task state. ' + 'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}'); return _unchangedResult( input, SchedulingNotice( @@ -284,6 +321,8 @@ class SchedulingEngine { final taskDuration = _scheduledIntervalFor(task)?.duration ?? _durationFromMinutes(task.durationMinutes); if (taskDuration == null || taskDuration.inMicroseconds <= 0) { + logger.warn(() => 'Tomorrow push failed: missing positive duration. ' + 'taskId=${task.id} durationMinutes=${task.durationMinutes}'); return _unchangedResult( input, SchedulingNotice( @@ -305,6 +344,8 @@ class SchedulingEngine { ); if (placement == null) { + logger.warn( + () => 'Tomorrow push failed: no available slot. taskId=${task.id}'); return _unchangedResult( input, SchedulingNotice( @@ -349,6 +390,10 @@ class SchedulingEngine { SchedulingWindow? sourceWindow, DateTime? updatedAt, }) { + logger.debug(() => 'Rolling over unfinished flexible tasks. ' + 'taskCount=${input.tasks.length} targetWindowStart=${input.window.start.toIso8601String()} ' + 'targetWindowEnd=${input.window.end.toIso8601String()} ' + 'hasSourceWindow=${sourceWindow != null}'); // Build the explicit queue of tasks to roll before asking the planner to // place anything. This keeps selection separate from placement. final rolledItems = <_PlacementItem>[]; @@ -385,6 +430,8 @@ class SchedulingEngine { } if (rolledItems.isEmpty) { + logger + .debug(() => 'Rollover finished with no unfinished flexible tasks.'); return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, @@ -405,6 +452,9 @@ class SchedulingEngine { ); if (placement == null) { + logger.warn( + () => 'Rollover failed: unfinished flexible tasks could not fit. ' + 'rolledCount=${rolledItems.length}'); return _unchangedResult( input, SchedulingNotice( @@ -431,6 +481,8 @@ class SchedulingEngine { /// reports any overlap with blocked intervals. It deliberately returns the /// original task list unchanged. SchedulingResult analyzeSchedule(SchedulingInput input) { + logger.debug(() => 'Analyzing schedule. taskCount=${input.tasks.length} ' + 'conflictOccupancyCount=${input.conflictReportingOccupancies.length}'); final overlaps = []; final notices = []; final conflictOccupancies = input.conflictReportingOccupancies; @@ -474,6 +526,9 @@ class SchedulingEngine { } } + logger.debug( + () => 'Schedule analysis finished. overlapCount=${overlaps.length} ' + 'noticeCount=${notices.length}'); return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.analyzeSchedule, @@ -492,6 +547,8 @@ class SchedulingEngine { /// reports can distinguish this from a task that was never scheduled. Task moveToBacklog(Task task, {DateTime? updatedAt}) { final now = updatedAt ?? clock.now(); + logger.debug(() => 'Legacy move-to-backlog requested. taskId=${task.id} ' + 'taskStatus=${task.status.name} occurredAt=${now.toIso8601String()}'); final result = _transitionService.apply( task: task, transitionCode: TaskTransitionCode.moveToBacklog, @@ -500,6 +557,8 @@ class SchedulingEngine { 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', occurredAt: now, ); + logger.debug(() => 'Legacy move-to-backlog finished. taskId=${task.id} ' + 'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}'); return result.applied ? result.task : task; } @@ -509,6 +568,9 @@ class SchedulingEngine { /// actual scheduled slot should change. Task markManuallyPushed(Task task, {DateTime? updatedAt}) { final now = updatedAt ?? clock.now(); + logger.debug( + () => 'Legacy manual push statistic requested. taskId=${task.id} ' + 'occurredAt=${now.toIso8601String()}'); final result = _transitionService.apply( task: task, transitionCode: TaskTransitionCode.manualPush, @@ -517,6 +579,9 @@ class SchedulingEngine { 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', occurredAt: now, ); + logger + .debug(() => 'Legacy manual push statistic finished. taskId=${task.id} ' + 'outcome=${result.outcomeCode.name}'); return result.applied ? result.task : task; } @@ -527,6 +592,9 @@ class SchedulingEngine { /// or time block that cannot simply be rescheduled automatically. Task markMissed(Task task, {DateTime? updatedAt}) { final now = updatedAt ?? clock.now(); + logger.debug(() => 'Legacy mark-missed requested. taskId=${task.id} ' + 'taskType=${task.type.name} taskStatus=${task.status.name} ' + 'occurredAt=${now.toIso8601String()}'); final result = _transitionService.apply( task: task, transitionCode: TaskTransitionCode.miss, @@ -534,6 +602,8 @@ class SchedulingEngine { activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', occurredAt: now, ); + logger.debug(() => 'Legacy mark-missed finished. taskId=${task.id} ' + 'outcome=${result.outcomeCode.name} nextStatus=${result.task.status.name}'); return result.applied ? result.task : task; } @@ -550,6 +620,10 @@ class SchedulingEngine { required Duration duration, required List blocked, }) { + logger.debug(() => 'Finding first open interval. ' + 'windowStart=${windowStart.toIso8601String()} ' + 'windowEnd=${windowEnd.toIso8601String()} ' + 'durationMinutes=${duration.inMinutes} blockedCount=${blocked.length}'); final sortedBlocked = [...blocked] ..sort((a, b) => a.start.compareTo(b.start)); var cursor = windowStart; @@ -738,7 +812,12 @@ _BacklogInsertionPlan? _planFlexiblePush({ required SchedulingInput input, required Task task, required TimeInterval currentInterval, + DateTime? earliestStart, }) { + final pushPoint = _laterOf( + currentInterval.end, + earliestStart ?? input.window.start, + ); final fixedBlocks = [ ...input.blockedIntervals, ]; @@ -746,7 +825,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ _PlacementItem( task: task, duration: currentInterval.duration, - earliestStart: currentInterval.end, + earliestStart: pushPoint, ), ]; @@ -766,7 +845,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ continue; } - final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); + final startsBeforePushPoint = interval.start.isBefore(pushPoint); final startsAfterWindow = interval.start.isAfter(input.window.end) || interval.start.isAtSameMomentAs(input.window.end); @@ -786,7 +865,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - var cursor = currentInterval.end; + var cursor = pushPoint; final placements = {}; for (final item in queue) { @@ -944,6 +1023,7 @@ SchedulingResult _applyPlacement({ scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, + clearBacklogEntry: isInsertedTask, ); final updatedTask = _applySchedulingActivity( originalTask: task, @@ -990,6 +1070,9 @@ SchedulingResult _applyPlacement({ ); } + logger.debug(() => 'Backlog insertion placement applied. ' + 'insertedTaskId=${insertedTask.id} changeCount=${changes.length} ' + 'noticeCount=${notices.length}'); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), operationCode: @@ -1039,6 +1122,7 @@ SchedulingResult _applyPushPlacement({ scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, + clearBacklogEntry: true, ); final updatedTask = _applySchedulingActivity( originalTask: task, @@ -1082,6 +1166,9 @@ SchedulingResult _applyPushPlacement({ ); } + logger.debug(() => 'Flexible push placement applied. ' + 'operationCode=${operationCode.name} pushedTaskId=${pushedTask.id} ' + 'changeCount=${changes.length} noticeCount=${notices.length}'); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), operationCode: operationCode, @@ -1183,6 +1270,8 @@ SchedulingResult _applyRolloverPlacement({ ), ); + logger.debug(() => 'Rollover placement applied. rolledCount=$rolledCount ' + 'changeCount=${changes.length} noticeCount=${notices.length}'); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions.dart b/packages/scheduler_core/lib/src/scheduling/task_actions.dart index d06a096..75340b9 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_actions.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions.dart @@ -13,6 +13,7 @@ library; import '../domain/models.dart'; import '../domain/occupancy_policy.dart'; +import '../logging/focus_flow_logger.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import '../domain/time_contracts.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart index 12870b9..2887ee0 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart @@ -43,15 +43,35 @@ class FlexibleTaskActionService { Iterable existingActivities = const [], }) { if (!task.isFlexible) { + logger.error(() => 'Flexible action rejected for non-flexible task. ' + 'action=${action.name} taskId=${task.id} ' + 'taskType=${task.type.name} taskStatus=${task.status.name}'); throw ArgumentError.value( task.type, 'task.type', 'Task must be flexible.'); } + logger.debug(() => 'Applying flexible quick action. ' + 'action=${action.name} taskId=${task.id} ' + 'taskStatus=${task.status.name} hasExplicitUpdatedAt=${updatedAt != null} ' + 'hasOperationId=${operationId != null}'); + logger.finest(() => 'Flexible quick action input dump. ' + 'action=${action.name} task=$task ' + 'updatedAt=${updatedAt?.toIso8601String()} operationId=$operationId ' + 'actualStart=${actualStart?.toIso8601String()} ' + 'actualEnd=${actualEnd?.toIso8601String()} ' + 'lockedIntervals=$lockedIntervals ' + 'existingActivityCount=${existingActivities.length}'); + switch (action) { case FlexibleTaskQuickAction.done: final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + logger.finer(() => 'Completing flexible task. taskId=${task.id} ' + 'operationId=$opId occurredAt=${now.toIso8601String()} ' + 'hasActualStart=${actualStart != null} ' + 'hasActualEnd=${actualEnd != null} ' + 'lockedIntervalCount=${lockedIntervals.length}'); final transition = transitionService.apply( task: task, transitionCode: TaskTransitionCode.complete, @@ -67,6 +87,17 @@ class FlexibleTaskActionService { lockedIntervals: lockedIntervals, existingActivities: existingActivities, ); + logger.debug(() => 'Flexible task completion transition finished. ' + 'taskId=${task.id} outcome=${transition.outcomeCode.name} ' + 'nextStatus=${transition.task.status.name} ' + 'activityCount=${transition.activities.length}'); + if (transition.outcomeCode != TaskTransitionOutcomeCode.applied) { + logger + .fine(() => 'Possible issue: flexible completion did not apply. ' + 'taskId=${task.id} outcome=${transition.outcomeCode.name} ' + 'previousStatus=${task.status.name} ' + 'nextStatus=${transition.task.status.name}'); + } return FlexibleTaskActionResult( action: action, task: transition.task, @@ -74,6 +105,10 @@ class FlexibleTaskActionService { transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.push: + logger.debug(() => 'Flexible push destination menu requested. ' + 'taskId=${task.id} taskStatus=${task.status.name}'); + logger.finer(() => 'Flexible push destinations prepared. ' + 'taskId=${task.id} destinationCount=3'); return FlexibleTaskActionResult( action: action, task: task, @@ -87,6 +122,10 @@ class FlexibleTaskActionService { final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + logger.debug(() => 'Moving flexible task to backlog from quick action. ' + 'taskId=${task.id} operationId=$opId ' + 'occurredAt=${now.toIso8601String()} ' + 'previousStatus=${task.status.name}'); final transition = transitionService.apply( task: task, transitionCode: TaskTransitionCode.moveToBacklog, @@ -99,6 +138,17 @@ class FlexibleTaskActionService { occurredAt: now, existingActivities: existingActivities, ); + logger.debug(() => 'Flexible task backlog transition finished. ' + 'taskId=${task.id} outcome=${transition.outcomeCode.name} ' + 'nextStatus=${transition.task.status.name} ' + 'activityCount=${transition.activities.length}'); + if (transition.outcomeCode != TaskTransitionOutcomeCode.applied) { + logger.fine(() => + 'Possible issue: move-to-backlog quick action did not apply. ' + 'taskId=${task.id} outcome=${transition.outcomeCode.name} ' + 'previousStatus=${task.status.name} ' + 'nextStatus=${transition.task.status.name}'); + } return FlexibleTaskActionResult( action: action, task: transition.task, @@ -106,6 +156,8 @@ class FlexibleTaskActionService { transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.breakUp: + logger.debug(() => 'Flexible break-up flow requested. ' + 'taskId=${task.id} taskStatus=${task.status.name}'); return FlexibleTaskActionResult( action: action, task: task, @@ -123,17 +175,28 @@ class FlexibleTaskActionService { required SchedulingInput input, required String taskId, DateTime? updatedAt, + DateTime? earliestStart, String? operationId, Iterable existingActivities = const [], }) { final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId('push-${destination.name}', taskId, now); + logger.debug(() => 'Applying flexible push destination. ' + 'destination=${destination.name} taskId=$taskId operationId=$opId ' + 'occurredAt=${now.toIso8601String()} taskCount=${input.tasks.length} ' + 'windowStart=${input.window.start.toIso8601String()} ' + 'windowEnd=${input.window.end.toIso8601String()}'); + logger.finest(() => 'Flexible push destination input dump. ' + 'destination=${destination.name} taskId=$taskId input=$input ' + 'existingActivityCount=${existingActivities.length}'); if (_hasDuplicateActivity( existingActivities: existingActivities, operationId: opId, taskId: taskId, )) { + logger.warn(() => 'Duplicate flexible push operation ignored. ' + 'destination=${destination.name} taskId=$taskId operationId=$opId'); return PushDestinationResult( destination: destination, schedulingResult: SchedulingResult( @@ -147,12 +210,15 @@ class FlexibleTaskActionService { ); } + logger.finer(() => 'Dispatching flexible push destination to scheduler. ' + 'destination=${destination.name} taskId=$taskId'); final result = switch (destination) { PushDestination.nextAvailableSlot => schedulingEngine.pushFlexibleTaskToNextAvailableSlot( input: input, taskId: taskId, updatedAt: now, + earliestStart: earliestStart, ), PushDestination.tomorrowTopOfQueue => schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( @@ -166,19 +232,38 @@ class FlexibleTaskActionService { updatedAt: now, ), }; + logger.debug(() => 'Flexible push destination scheduling finished. ' + 'destination=${destination.name} taskId=$taskId ' + 'outcome=${result.outcomeCode.name} changeCount=${result.changes.length} ' + 'noticeCount=${result.notices.length}'); + if (result.outcomeCode != SchedulingOutcomeCode.success) { + logger.warn(() => 'Handled flexible push issue. ' + 'destination=${destination.name} taskId=$taskId ' + 'outcome=${result.outcomeCode.name} notices=${result.notices}'); + } + logger.fine(() => 'Flexible push result details. ' + 'destination=${destination.name} taskId=$taskId ' + 'changes=${result.changes} notices=${result.notices}'); + logger.finest(() => 'Flexible push destination result dump. ' + 'destination=${destination.name} taskId=$taskId result=$result'); + + final activities = _activitiesForPushDestination( + destination: destination, + input: input, + result: result, + taskId: taskId, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + ); + logger.debug(() => 'Flexible push destination activities prepared. ' + 'destination=${destination.name} taskId=$taskId ' + 'activityCount=${activities.length}'); return PushDestinationResult( destination: destination, schedulingResult: result, - activities: _activitiesForPushDestination( - destination: destination, - input: input, - result: result, - taskId: taskId, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - ), + activities: activities, ); } @@ -191,8 +276,15 @@ class FlexibleTaskActionService { required String taskId, DateTime? updatedAt, }) { + logger.debug(() => 'Moving flexible task to backlog from push destination. ' + 'taskId=$taskId taskCount=${input.tasks.length} ' + 'hasExplicitUpdatedAt=${updatedAt != null}'); + logger.finest(() => 'Move-to-backlog input dump. ' + 'taskId=$taskId tasks=${input.tasks}'); final task = _taskById(input.tasks, taskId); if (task == null) { + logger.warn(() => 'Handled move-to-backlog issue: task not found. ' + 'taskId=$taskId'); return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, @@ -209,6 +301,9 @@ class FlexibleTaskActionService { } if (!task.isFlexible || task.status != TaskStatus.planned) { + logger.warn(() => 'Handled move-to-backlog issue: invalid task state. ' + 'taskId=${task.id} taskType=${task.type.name} ' + 'taskStatus=${task.status.name}'); return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, @@ -228,6 +323,10 @@ class FlexibleTaskActionService { task, updatedAt: updatedAt, ); + logger.debug(() => 'Flexible task moved to backlog. ' + 'taskId=${task.id} previousStart=${task.scheduledStart?.toIso8601String()} ' + 'previousEnd=${task.scheduledEnd?.toIso8601String()} ' + 'updatedAt=${updatedAt?.toIso8601String()}'); return SchedulingResult( tasks: List.unmodifiable( diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart index 35874f2..94ef868 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart @@ -37,7 +37,12 @@ class RequiredTaskActionService { List lockedIntervals = const [], Iterable existingActivities = const [], }) { + logger.debug(() => 'Applying required task action. taskId=${task.id} ' + 'action=${action.name} taskType=${task.type.name} status=${task.status.name} ' + 'operationId=$operationId'); if (!task.isRequiredVisible) { + logger.error(() => 'Required task action failed: invalid task type. ' + 'taskId=${task.id} taskType=${task.type.name} action=${action.name}'); throw ArgumentError.value( task.type, 'task.type', @@ -61,6 +66,10 @@ class RequiredTaskActionService { RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, }; final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + logger.finer( + () => 'Required task action resolved transition. taskId=${task.id} ' + 'action=${action.name} transition=${transitionCode.name} ' + 'operationId=$opId'); final transition = transitionService.apply( task: task, transitionCode: transitionCode, @@ -73,6 +82,11 @@ class RequiredTaskActionService { existingActivities: existingActivities, ); + logger.debug(() => 'Required task action finished. taskId=${task.id} ' + 'action=${action.name} outcome=${transition.outcomeCode.name} ' + 'activityCount=${transition.activities.length} ' + 'removedFromActivePlan=${transition.task.status == TaskStatus.backlog || transition.task.status == TaskStatus.cancelled || transition.task.status == TaskStatus.noLongerRelevant}'); + return RequiredTaskActionResult( action: action, task: transition.task, diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart index 7af04ec..7ea05d0 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart @@ -39,9 +39,16 @@ class SurpriseTaskLogService { bool revealHiddenLockedDetails = false, }) { final now = updatedAt ?? clock.now(); + logger.debug(() => 'Logging surprise task. taskId=${request.id} ' + 'taskCount=${input.tasks.length} hasStartedAt=${request.startedAt != null} ' + 'hasDuration=${request.timeUsedMinutes != null} ' + 'revealHiddenLockedDetails=$revealHiddenLockedDetails'); final existingTask = _taskById(input.tasks, request.id); if (existingTask != null) { if (existingTask.type != TaskType.surprise) { + logger.error(() => + 'Surprise task log failed: id already used by another task type. ' + 'taskId=${request.id} taskType=${existingTask.type.name}'); throw ArgumentError.value( request.id, 'request.id', @@ -49,6 +56,8 @@ class SurpriseTaskLogService { ); } + logger.warn( + () => 'Duplicate surprise task log ignored. taskId=${request.id}'); return SurpriseTaskLogResult( surpriseTask: existingTask, schedulingResult: SchedulingResult( @@ -82,6 +91,8 @@ class SurpriseTaskLogService { var currentTasks = [surpriseTask, ...input.tasks]; if (surpriseInterval == null) { + logger.debug(() => 'Surprise task logged without occupying interval. ' + 'taskId=${surpriseTask.id} activityCount=1'); return SurpriseTaskLogResult( surpriseTask: surpriseTask, schedulingResult: SchedulingResult( @@ -113,6 +124,12 @@ class SurpriseTaskLogService { final movementNotices = []; var usedNormalPushRules = false; + logger.debug(() => + 'Surprise task repair scanning overlaps. taskId=${surpriseTask.id} ' + 'requiredOverlapCount=${requiredOverlaps.length} ' + 'lockedOverlapCount=${lockedOverlaps.length} ' + 'flexibleOverlapCount=${flexibleTaskIds.length}'); + for (final taskId in flexibleTaskIds) { final currentTask = _taskById(currentTasks, taskId); final currentInterval = @@ -120,9 +137,14 @@ class SurpriseTaskLogService { if (currentTask == null || currentInterval == null || !currentInterval.overlaps(surpriseInterval)) { + logger.finer(() => 'Surprise task repair skipped flexible candidate. ' + 'surpriseTaskId=${surpriseTask.id} taskId=$taskId ' + 'hasCurrentTask=${currentTask != null} hasInterval=${currentInterval != null}'); continue; } + logger.finer(() => 'Surprise task repair pushing flexible overlap. ' + 'surpriseTaskId=${surpriseTask.id} taskId=$taskId'); final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: currentTasks, @@ -157,6 +179,11 @@ class SurpriseTaskLogService { ) .toList(growable: false); + logger.debug(() => 'Surprise task log finished. taskId=${surpriseTask.id} ' + 'outcome=${requiredOverlaps.isEmpty ? SchedulingOutcomeCode.success.name : SchedulingOutcomeCode.conflict.name} ' + 'changeCount=${changes.length} noticeCount=${movementNotices.length + overlapNotices.length + 1} ' + 'requiredOverlapCount=${requiredOverlaps.length} lockedOverlapCount=${lockedOverlaps.length}'); + return SurpriseTaskLogResult( surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, schedulingResult: SchedulingResult( @@ -196,10 +223,15 @@ class SurpriseTaskLogService { }) { final generator = idGenerator; if (generator == null) { + logger.error(() => 'Surprise task logNew failed: missing id generator.'); throw StateError('Surprise task logging needs an id generator.'); } final now = createdAt ?? updatedAt ?? clock.now(); + logger.debug(() => + 'Logging new surprise task. hasTitle=${title.trim().isNotEmpty} ' + 'hasStartedAt=${startedAt != null} hasDuration=${timeUsedMinutes != null} ' + 'projectId=$projectId'); return log( request: SurpriseTaskLogRequest( @@ -227,6 +259,8 @@ class SurpriseTaskLogService { }) { final trimmedTitle = request.title.trim(); if (trimmedTitle.isEmpty) { + logger.warn(() => 'Surprise task creation failed: blank title. ' + 'taskId=${request.id}'); throw ArgumentError.value(request.title, 'title', 'Title is required.'); } diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart index f4ba3bd..c876320 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart @@ -13,6 +13,7 @@ library; import '../domain/models.dart'; import '../domain/task_statistics.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'task_lifecycle/codes/task_transition_code.dart'; part 'task_lifecycle/codes/task_activity_code.dart'; part 'task_lifecycle/codes/task_transition_outcome_code.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart index 3425815..01939c6 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart @@ -37,12 +37,19 @@ class TaskTransitionService { Map metadata = const {}, }) { final now = occurredAt ?? clock.now(); + logger.debug(() => 'Applying task transition. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'operationId=$operationId taskStatus=${task.status.name} ' + 'occurredAt=${now.toIso8601String()}'); final duplicate = _duplicateActivity( existingActivities: existingActivities, taskId: task.id, operationId: operationId, ); if (duplicate != null) { + logger.warn(() => 'Duplicate task transition ignored. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'operationId=$operationId duplicateActivityId=${duplicate.id}'); return TaskTransitionResult( transitionCode: transitionCode, outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, @@ -52,6 +59,10 @@ class TaskTransitionService { } if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { + logger.warn(() => 'Task transition rejected: invalid actual interval. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'actualStart=${actualStart?.toIso8601String()} ' + 'actualEnd=${actualEnd?.toIso8601String()}'); return TaskTransitionResult( transitionCode: transitionCode, outcomeCode: TaskTransitionOutcomeCode.invalidState, @@ -60,6 +71,9 @@ class TaskTransitionService { } if (_isNoOpTerminalRepeat(task, transitionCode)) { + logger.fine(() => 'Possible issue: terminal task transition repeated. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'taskStatus=${task.status.name}'); return TaskTransitionResult( transitionCode: transitionCode, outcomeCode: TaskTransitionOutcomeCode.noOp, @@ -68,6 +82,10 @@ class TaskTransitionService { } if (_isBlockedByTerminalState(task, transitionCode)) { + logger.warn( + () => 'Task transition rejected: terminal state blocks transition. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'taskStatus=${task.status.name}'); return TaskTransitionResult( transitionCode: transitionCode, outcomeCode: TaskTransitionOutcomeCode.invalidState, @@ -75,7 +93,7 @@ class TaskTransitionService { ); } - return switch (transitionCode) { + final result = switch (transitionCode) { TaskTransitionCode.complete => _complete( task: task, operationId: operationId, @@ -156,6 +174,16 @@ class TaskTransitionService { metadata: metadata, ), }; + logger.debug(() => 'Task transition finished. taskId=${task.id} ' + 'transition=${transitionCode.name} outcome=${result.outcomeCode.name} ' + 'nextStatus=${result.task.status.name} activityCount=${result.activities.length}'); + if (result.outcomeCode == TaskTransitionOutcomeCode.invalidState) { + logger.warn(() => 'Task transition returned invalid state. ' + 'taskId=${task.id} transition=${transitionCode.name} ' + 'taskType=${task.type.name} previousStatus=${task.status.name} ' + 'nextStatus=${result.task.status.name}'); + } + return result; } /// Runs the `_complete` helper used inside this library. @@ -181,6 +209,7 @@ class TaskTransitionService { completedAt: occurredAt, actualStart: actualStart, actualEnd: actualEnd, + clearBacklogEntry: true, ); final activities = [ _activity( @@ -245,6 +274,9 @@ class TaskTransitionService { status: TaskStatus.backlog, updatedAt: occurredAt, clearSchedule: true, + backlogEnteredAt: occurredAt, + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, ) : task.copyWith( status: TaskStatus.missed, @@ -313,6 +345,7 @@ class TaskTransitionService { status: TaskStatus.cancelled, updatedAt: occurredAt, clearSchedule: true, + clearBacklogEntry: true, ); final activities = [ _activity( @@ -359,6 +392,7 @@ class TaskTransitionService { status: TaskStatus.noLongerRelevant, updatedAt: occurredAt, clearSchedule: true, + clearBacklogEntry: true, ); final activities = [ @@ -446,6 +480,8 @@ class TaskTransitionService { status: TaskStatus.backlog, updatedAt: occurredAt, clearSchedule: true, + backlogEnteredAt: occurredAt, + backlogEnteredAtProvenance: Task.backlogEnteredAtProvenanceMovedToBacklog, ); final activities = [ _activity( @@ -489,6 +525,7 @@ class TaskTransitionService { final restored = task.copyWith( status: TaskStatus.planned, updatedAt: occurredAt, + clearBacklogEntry: true, ); final activities = [ _activity( @@ -742,6 +779,8 @@ TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { /// Top-level helper that performs the `_noOp` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { + logger.fine(() => 'Possible issue: task transition no-op. taskId=${task.id} ' + 'transition=${transitionCode.name} taskStatus=${task.status.name}'); return TaskTransitionResult( transitionCode: transitionCode, outcomeCode: TaskTransitionOutcomeCode.noOp, diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart index 82f33dd..5325aa4 100644 --- a/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart @@ -40,6 +40,7 @@ class TimelineItemMapper { actualStart: task.actualStart, actualEnd: task.actualEnd, completedAt: task.completedAt, + updatedAt: task.updatedAt, durationMinutes: _durationMinutesFor(task), quickActions: _quickActionsFor(task.type), category: task.isLocked diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart index 977c350..4b2abbc 100644 --- a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart @@ -23,6 +23,7 @@ class TimelineItem { this.actualStart, this.actualEnd, this.completedAt, + this.updatedAt, this.durationMinutes, }) : quickActions = List.unmodifiable(quickActions); @@ -65,6 +66,9 @@ class TimelineItem { /// Completion instant, if the source task has been completed. final DateTime? completedAt; + /// Source task update timestamp, when the item was mapped from a task. + final DateTime? updatedAt; + /// Duration display value for flexible task cards. final int? durationMinutes; diff --git a/packages/scheduler_core/lib/src/scheduling/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state.dart index 8b42495..e87e733 100644 --- a/packages/scheduler_core/lib/src/scheduling/today_state.dart +++ b/packages/scheduler_core/lib/src/scheduling/today_state.dart @@ -17,6 +17,7 @@ import '../persistence/repositories.dart'; import 'scheduling_engine.dart'; import 'timeline_state.dart'; import '../domain/time_contracts.dart'; +import '../logging/focus_flow_logger.dart'; part 'today_state/models/today_timeline_item_source.dart'; part 'today_state/requests/get_today_state_request.dart'; part 'today_state/models/today_timeline_item.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart index 256b296..e7e0036 100644 --- a/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart +++ b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart @@ -30,6 +30,10 @@ class GetTodayStateQuery { action: (repositories) async { final context = request.context; final ownerId = context.ownerTimeZone.ownerId; + logger.debug(() => 'Today state query started. ' + 'ownerId=$ownerId date=${request.date.toIsoString()} ' + 'revealHiddenLockedBlocks=${request.revealHiddenLockedBlocks} ' + 'includeNextFlexibleItem=${request.includeNextFlexibleItem}'); final storedSettings = await repositories.ownerSettings.findByOwnerId(ownerId); final settings = storedSettings ?? @@ -37,15 +41,21 @@ class GetTodayStateQuery { ownerId: ownerId, timeZoneId: context.ownerTimeZone.timeZoneId, ); - final timeZoneId = settings.timeZoneId; + if (storedSettings == null) { + logger.finer(() => + 'Today state query owner settings missing; using defaults. ' + 'ownerId=$ownerId timeZoneId=${context.ownerTimeZone.timeZoneId}'); + } + final timeZoneId = context.ownerTimeZone.timeZoneId; + final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId); final dayStart = _resolve( date: request.date, - wallTime: settings.dayStart, + wallTime: effectiveSettings.dayStart, timeZoneId: timeZoneId, ); final dayEnd = _resolve( date: request.date, - wallTime: settings.dayEnd, + wallTime: effectiveSettings.dayEnd, timeZoneId: timeZoneId, ); final window = SchedulingWindow(start: dayStart, end: dayEnd); @@ -76,6 +86,12 @@ class GetTodayStateQuery { (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) .map((record) => record.noticeId) .toSet(); + logger.debug(() => 'Today state query loaded persisted inputs. ' + 'ownerId=$ownerId date=${request.date.toIsoString()} ' + 'projectCount=${projects.length} taskCount=${tasks.length} ' + 'lockedBlockCount=${blocks.length} overrideCount=${overrides.length} ' + 'snapshotCount=${snapshots.length} ' + 'acknowledgedNoticeCount=${acknowledgedNoticeIds.length}'); final lockedExpansion = expandLockedBlocksForDay( blocks: blocks, @@ -138,6 +154,13 @@ class GetTodayStateQuery { snapshots, acknowledgedNoticeIds: acknowledgedNoticeIds, ); + logger.debug(() => 'Today state query finished. ' + 'ownerId=$ownerId date=${request.date.toIsoString()} ' + 'timelineItemCount=${selectedItems.length} ' + 'pendingNoticeCount=${pendingNotices.length} ' + 'hasCurrent=${selectedCurrent != null} ' + 'hasNextRequired=${selectedNextRequired != null} ' + 'hasNextFlexible=${selectedNextFlexible != null}'); return ApplicationResult.success( TodayState( @@ -147,13 +170,13 @@ class GetTodayStateQuery { readAt: context.now, dayStart: dayStart, dayEnd: dayEnd, - ownerSettings: settings, + ownerSettings: effectiveSettings, timelineItems: selectedItems, currentItem: selectedCurrent, nextRequiredItem: selectedNextRequired, nextFlexibleItem: selectedNextFlexible, compactState: TodayCompactState( - manualCompactModeEnabled: settings.compactModeEnabled, + manualCompactModeEnabled: effectiveSettings.compactModeEnabled, fullTimelineExpanded: request.fullTimelineExpanded, currentItem: selectedCurrent, nextRequiredItem: selectedNextRequired, diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index b95e931..60bae32 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -168,6 +168,55 @@ void main() { expect(store.currentOperations, isEmpty); }); + test('remove task deletes it from persistence', () async { + final planned = task( + id: 'remove-me', + title: 'Remove me', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).removeTask( + context: appContext('remove-task', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect(store.currentTasks, isEmpty); + expect(result.requireValue.changedTasks, isEmpty); + expect(result.requireValue.readHint.affectedTaskIds, [planned.id]); + expect(store.currentOperations.single.operationId, 'remove-task'); + }); + + test('remove task rejects stale expected update', () async { + final planned = task( + id: 'stale-remove', + title: 'Stale remove', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).removeTask( + context: appContext('stale-remove', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: + planned.updatedAt.subtract(const Duration(minutes: 1)), + ); + + expect(result.failure!.code, ApplicationFailureCode.staleRevision); + expect(taskById(store.currentTasks, planned.id), planned); + expect(store.currentOperations, isEmpty); + }); + test('commit persistence failure rolls back command-side task and activity', () async { final planned = task( @@ -252,7 +301,13 @@ void main() { DateTime.utc(2026, 6, 26, 9), ); expect(moveBacklog.isSuccess, isTrue); - expect(taskById(store.currentTasks, first.id).status, TaskStatus.backlog); + final movedBacklog = taskById(store.currentTasks, first.id); + expect(movedBacklog.status, TaskStatus.backlog); + expect(movedBacklog.backlogEnteredAt, DateTime.utc(2026, 6, 25, 8, 10)); + expect( + movedBacklog.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect( store.currentTaskActivities .map((activity) => activity.operationId) @@ -261,6 +316,48 @@ void main() { ); }); + test('push next moves an overdue task after the command time', () async { + final overdue = task( + id: 'overdue', + title: 'Overdue', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 24, 9), + end: DateTime.utc(2026, 6, 24, 9, 30), + ); + final afternoon = task( + id: 'afternoon', + title: 'Afternoon', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 13), + end: DateTime.utc(2026, 6, 25, 13, 30), + ); + final store = storeWithSettings(tasks: [overdue, afternoon]); + + final result = await commands(store).pushFlexibleToNextAvailableSlot( + context: appContext('push-overdue', DateTime.utc(2026, 6, 25, 12)), + localDate: day, + taskId: overdue.id, + ); + + expect(result.isSuccess, isTrue); + final pushed = taskById(store.currentTasks, overdue.id); + expect(pushed.scheduledStart, DateTime.utc(2026, 6, 25, 12)); + expect(pushed.scheduledEnd, DateTime.utc(2026, 6, 25, 12, 30)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(taskById(store.currentTasks, afternoon.id).scheduledStart, + DateTime.utc(2026, 6, 25, 13)); + expect( + store.currentTaskActivities + .where((activity) => activity.operationId == 'push-overdue') + .map((activity) => activity.code), + [TaskActivityCode.manuallyPushed], + ); + }); + test('completion commands persist activities and project statistics', () async { final flexible = task( diff --git a/packages/scheduler_core/test/document_mapping_test.dart b/packages/scheduler_core/test/document_mapping_test.dart index 5de95f5..7f74373 100644 --- a/packages/scheduler_core/test/document_mapping_test.dart +++ b/packages/scheduler_core/test/document_mapping_test.dart @@ -47,6 +47,8 @@ void main() { expect(restored.scheduledStart, task.scheduledStart); expect(restored.actualStart, task.actualStart); expect(restored.completedAt, task.completedAt); + expect(restored.backlogEnteredAt, createdAt); + expect(restored.backlogEnteredAtProvenance, 'recorded'); expect(restored.backlogTags, task.backlogTags); expect(restored.stats.manuallyPushedCount, 1); @@ -73,6 +75,35 @@ void main() { expect(nullableRestored.scheduledStart, isNull); }); + test('task documents serialize backlog entry fields from the task', () { + final task = Task.quickCapture( + id: 'backlog-entry', + title: 'Backlog entry', + createdAt: createdAt, + ).copyWith( + backlogEnteredAt: updatedAt, + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + + final document = task.toDocument(ownerId: 'owner-1'); + final restored = TaskDocumentMapping.fromDocument(document); + + expect( + document[TaskDocumentFields.backlogEnteredAt], + '2026-06-23T08:05:00.000Z', + ); + expect( + document[TaskDocumentFields.backlogEnteredAtProvenance], + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + expect(restored.backlogEnteredAt, updatedAt); + expect( + restored.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + }); + test('task documents preserve explicit schedule clearing', () { final task = scheduledTask(id: 'scheduled', createdAt: createdAt); diff --git a/packages/scheduler_core/test/focus_flow_logger_test.dart b/packages/scheduler_core/test/focus_flow_logger_test.dart new file mode 100644 index 0000000..72483bf --- /dev/null +++ b/packages/scheduler_core/test/focus_flow_logger_test.dart @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests shared FocusFlow logging behavior. +library; + +import 'package:scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +/// Runs shared logger tests. +void main() { + tearDown(logger.disable); + + group('FocusFlowLogLevel', () { + test('parses every supported level and trace alias', () { + expect(FocusFlowLogLevel.tryParse('finest'), FocusFlowLogLevel.finest); + expect(FocusFlowLogLevel.tryParse('finer'), FocusFlowLogLevel.finer); + expect(FocusFlowLogLevel.tryParse('fine'), FocusFlowLogLevel.fine); + expect(FocusFlowLogLevel.tryParse('debug'), FocusFlowLogLevel.debug); + expect(FocusFlowLogLevel.tryParse('info'), FocusFlowLogLevel.info); + expect(FocusFlowLogLevel.tryParse('warn'), FocusFlowLogLevel.warn); + expect(FocusFlowLogLevel.tryParse('error'), FocusFlowLogLevel.error); + expect(FocusFlowLogLevel.tryParse('trace'), FocusFlowLogLevel.finest); + expect(FocusFlowLogLevel.tryParse('verbose'), isNull); + }); + }); + + group('FocusFlowLogger', () { + test('skips lazy messages below the configured level', () { + final entries = []; + final testLogger = FocusFlowLogger( + minimumLevel: FocusFlowLogLevel.info, + sink: entries.add, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + var evaluated = false; + + testLogger.debug(() { + evaluated = true; + return 'expensive debug state'; + }); + + expect(evaluated, isFalse); + expect(entries, isEmpty); + }); + + test('writes enabled lazy messages with timestamps', () { + final entries = []; + final testLogger = FocusFlowLogger( + minimumLevel: FocusFlowLogLevel.debug, + sink: entries.add, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + testLogger.debug(() => 'computed debug state'); + + expect(entries.single.level, FocusFlowLogLevel.debug); + expect(entries.single.timestamp, DateTime.utc(2026, 7, 3, 12)); + expect(entries.single.message, 'computed debug state'); + }); + + test('finest captures caller information for every written level', () { + final entries = []; + final testLogger = FocusFlowLogger( + minimumLevel: FocusFlowLogLevel.finest, + sink: entries.add, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + testLogger.info('high level message'); + + expect(entries.single.caller, contains('focus_flow_logger_test.dart')); + }); + + test('fine captures caller information for warnings and errors', () { + final entries = []; + final testLogger = FocusFlowLogger( + minimumLevel: FocusFlowLogLevel.fine, + sink: entries.add, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + testLogger.warn('handled warning'); + + expect(entries.single.caller, contains('focus_flow_logger_test.dart')); + }); + + test('info does not capture caller information for warnings', () { + final entries = []; + final testLogger = FocusFlowLogger( + minimumLevel: FocusFlowLogLevel.info, + sink: entries.add, + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + testLogger.warn('minimal warning'); + + expect(entries.single.caller, isNull); + }); + + test('formats errors and stack traces on a single line', () { + final entry = FocusFlowLogEntry( + timestamp: DateTime.utc(2026, 7, 3, 12), + level: FocusFlowLogLevel.error, + message: 'bad state', + caller: '#1 method (file.dart:10:2)', + error: 'boom', + stackTrace: StackTrace.fromString('first\nsecond'), + ); + + expect( + entry.toSingleLine(), + '2026-07-03T12:00:00.000Z ERROR caller=#1 method (file.dart:10:2) ' + r'bad state | error=boom | stackTrace=first\nsecond', + ); + }); + }); +} diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 5097dc5..6d9e5d0 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -42,6 +42,12 @@ void main() { expect(task.scheduledEnd, isNull); expect(task.createdAt, now); expect(task.updatedAt, now); + expect(task.backlogEnteredAt, now); + expect( + task.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceQuickCapture, + ); + expect(task.backlogFreshnessAnchorAt, now); }); test('quick capture requires a title', () { @@ -132,7 +138,7 @@ void main() { ]); }); - test('backlog sorting uses backlog keys instead of original schedule', () { + test('backlog sorting uses backlog entry timestamps', () { final older = Task.quickCapture( id: 'older', title: 'Older backlog item', @@ -151,7 +157,7 @@ void main() { ); final movedOlder = const SchedulingEngine().moveToBacklog( older, - updatedAt: now, + updatedAt: now.subtract(const Duration(minutes: 5)), ); final movedNewer = const SchedulingEngine().moveToBacklog( newer, @@ -161,6 +167,11 @@ void main() { expect(movedOlder.scheduledStart, isNull); expect(movedNewer.scheduledStart, isNull); + expect( + movedOlder.backlogEnteredAt, + now.subtract(const Duration(minutes: 5)), + ); + expect(movedNewer.backlogEnteredAt, now); expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ 'older', 'newer', @@ -249,6 +260,44 @@ void main() { expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple); }); + test('backlog freshness uses backlog entry time instead of creation time', + () { + final oldButRecentlyMoved = Task( + id: 'recent-backlog', + title: 'Recently moved', + projectId: 'home', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: now.subtract(const Duration(days: 60)), + updatedAt: now, + backlogEnteredAt: now.subtract(const Duration(days: 1)), + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + final oldAndStillOld = Task.quickCapture( + id: 'old-backlog', + title: 'Old backlog', + createdAt: now.subtract(const Duration(days: 60)), + ); + final view = BacklogView( + tasks: [oldButRecentlyMoved, oldAndStillOld], + now: now, + ); + + expect( + view.filter(BacklogFilter.stale).map((task) => task.id), + ['old-backlog'], + ); + expect( + view.stalenessMarkerFor(oldButRecentlyMoved), + BacklogStalenessMarker.green, + ); + expect( + view.sorted(BacklogSortKey.age).map((task) => task.id), + ['old-backlog', 'recent-backlog'], + ); + }); + test('backlog staleness markers support configurable thresholds', () { final task = Task.quickCapture( id: 'custom', @@ -1423,6 +1472,32 @@ void main() { expect(result.notices.single.type, SchedulingNoticeType.moved); }); + test('push flexible task can start after explicit current time', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 18, 9), + scheduledEnd: DateTime(2026, 6, 18, 9, 30), + durationMinutes: 30, + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 17), + ), + ), + taskId: 'task-1', + updatedAt: DateTime(2026, 6, 19, 12), + earliestStart: DateTime(2026, 6, 19, 12), + ); + final pushed = result.tasks.single; + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 12)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 12, 30)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(result.notices.single.type, SchedulingNoticeType.moved); + }); + test('push flexible task bumps downstream flexible tasks in order', () { final first = flexibleTask().copyWith( id: 'flexible-1', @@ -1762,6 +1837,11 @@ void main() { expect(moved.status, TaskStatus.backlog); expect(moved.scheduledStart, isNull); expect(moved.scheduledEnd, isNull); + expect(moved.backlogEnteredAt, now); + expect( + moved.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect(moved.stats.movedToBacklogCount, 1); }); @@ -1771,6 +1851,11 @@ void main() { final missed = engine.markMissed(task, updatedAt: now); expect(missed.status, TaskStatus.backlog); + expect(missed.backlogEnteredAt, now); + expect( + missed.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect(missed.stats.missedCount, 1); expect(missed.stats.movedToBacklogCount, 1); }); diff --git a/packages/scheduler_core/test/today_state_test.dart b/packages/scheduler_core/test/today_state_test.dart index da71eff..9076cb0 100644 --- a/packages/scheduler_core/test/today_state_test.dart +++ b/packages/scheduler_core/test/today_state_test.dart @@ -49,6 +49,40 @@ void main() { expect(store.currentProjectStatistics, isEmpty); }); + test('uses context timezone without rewriting persisted owner settings', + () async { + final localDay = CivilDate(2026, 1, 3); + final store = InMemoryApplicationUnitOfWork( + initialOwnerSettings: [ + OwnerSettings(ownerId: 'owner-1', timeZoneId: 'UTC'), + ], + ); + final query = GetTodayStateQuery( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver( + offset: Duration(hours: -8), + ), + ); + + final state = (await query.execute( + GetTodayStateRequest( + context: appContext( + operationId: 'read-context-timezone', + now: DateTime.utc(2026, 1, 3, 12), + timeZoneId: 'PST', + ), + date: localDay, + ), + )) + .requireValue; + + expect(state.timeZoneId, 'PST'); + expect(state.ownerSettings.timeZoneId, 'PST'); + expect(state.dayStart, DateTime.utc(2026, 1, 3, 8)); + expect(state.dayEnd, DateTime.utc(2026, 1, 4, 7, 59)); + expect(store.currentOwnerSettings.single.timeZoneId, 'UTC'); + }); + test( 'builds one sorted model for tasks, Free Slots, locked overlays, and notices', () async { diff --git a/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart index de85645..63ef66f 100644 --- a/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart @@ -27,6 +27,8 @@ final class ExportController { required StringSink sink, int pageSize = 100, }) { + core.logger.info(() => 'Task export to sink requested. ' + 'ownerId=${context.ownerId.value} format=$format pageSize=$pageSize'); final writer = _writerRegistry.create(format, sink); return exportTasks( context: context, @@ -41,11 +43,18 @@ final class ExportController { required ExportWriter writer, int pageSize = 100, }) async { + core.logger.info(() => 'Task export started. ' + 'ownerId=${context.ownerId.value} timezone=${context.timezone} ' + 'version=${context.version} pageSize=$pageSize'); if (pageSize <= 0) { + core.logger.warn(() => 'Task export rejected invalid page size. ' + 'ownerId=${context.ownerId.value} pageSize=$pageSize'); throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.'); } await writer.begin(context); + var pageCount = 0; + var taskCount = 0; String? cursor; do { final result = await _taskRepository.findByOwner( @@ -53,14 +62,24 @@ final class ExportController { page: core.PageRequest(cursor: cursor, limit: pageSize), ); if (result.isLeft) { + core.logger.warn(() => 'Task export repository read failed. ' + 'ownerId=${context.ownerId.value} pageCount=$pageCount ' + 'taskCount=$taskCount failureType=${result.left.runtimeType}'); throw ExportRepositoryException(result.left); } final page = result.right; + pageCount += 1; + core.logger.finer(() => 'Task export page loaded. ' + 'ownerId=${context.ownerId.value} pageNumber=$pageCount ' + 'itemCount=${page.items.length} hasNext=${page.nextCursor != null}'); for (final task in page.items) { await writer.writeTask(task); + taskCount += 1; } cursor = page.nextCursor; } while (cursor != null); await writer.end(); + core.logger.info(() => 'Task export completed. ' + 'ownerId=${context.ownerId.value} pageCount=$pageCount taskCount=$taskCount'); } } diff --git a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart index b70406b..fc23a9d 100644 --- a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart @@ -34,7 +34,9 @@ final class ExportWriterRegistry { /// Register [factory] for [format]. void register(String format, ExportWriterFactory factory) { - _factories[_normalizeFormat(format)] = factory; + final normalized = _normalizeFormat(format); + core.logger.finer(() => 'Export writer registered. format=$normalized'); + _factories[normalized] = factory; } /// Create a writer for [format] using [sink]. @@ -42,8 +44,11 @@ final class ExportWriterRegistry { final normalized = _normalizeFormat(format); final factory = _factories[normalized]; if (factory == null) { + core.logger.warn(() => 'Export writer lookup failed: unknown format. ' + 'format=$normalized availableFormatCount=${_factories.length}'); throw ExportException('Unknown export format: $format'); } + core.logger.finer(() => 'Export writer created. format=$normalized'); return factory(sink); } } diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers.dart b/packages/scheduler_export_json/lib/src/json_csv_writers.dart index 7fa4548..fab4499 100644 --- a/packages/scheduler_export_json/lib/src/json_csv_writers.dart +++ b/packages/scheduler_export_json/lib/src/json_csv_writers.dart @@ -15,6 +15,8 @@ part 'json_csv_writers/csv_export_writer.dart'; /// Create the readable export writer registry used by export commands. ExportWriterRegistry readableExportWriterRegistry() { + core.logger + .finer(() => 'Readable export writer registry created. formatCount=2'); return ExportWriterRegistry( factories: { 'json': JsonExportWriter.new, diff --git a/packages/scheduler_notifications/lib/src/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter.dart index 8105622..44d5dcf 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -5,6 +5,7 @@ library; import 'dart:async'; +import 'package:scheduler_core/scheduler_core.dart' show logger; part 'notification_adapter/requests/notification_request.dart'; part 'notification_adapter/requests/notification_request_impl.dart'; part 'notification_adapter/feedback/notification_feedback_type.dart'; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart index 6eca676..0991879 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart @@ -35,6 +35,9 @@ final class FakeNotificationAdapter implements NotificationAdapter { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future schedule(NotificationRequest request) async { + logger.debug(() => 'Fake notification scheduled. ' + 'notificationId=${request.id} ' + 'scheduledDateTimeUtc=${request.scheduledDateTimeUtc.toIso8601String()}'); _scheduled[request.id] = request; } @@ -43,17 +46,24 @@ final class FakeNotificationAdapter implements NotificationAdapter { @override Future cancel(String id) async { final trimmed = _requiredTrimmed(id, 'id'); - _scheduled.remove(trimmed); + final removed = _scheduled.remove(trimmed) != null; _cancelledIds.add(trimmed); + logger.debug(() => 'Fake notification cancelled. ' + 'notificationId=$trimmed removed=$removed'); } /// Emit [event] to feedback listeners. void emitFeedback(NotificationFeedback event) { + logger.debug(() => 'Fake notification feedback emitted. ' + 'notificationId=${event.id} type=${event.type.name} ' + 'hasAction=${event.actionId != null}'); _feedbackController.add(event); } /// Release stream resources held by this fake. Future close() { + logger.debug(() => 'Fake notification adapter closing. ' + 'scheduledCount=${_scheduled.length} cancelledCount=${_cancelledIds.length}'); return _feedbackController.close(); } } diff --git a/packages/scheduler_notifications/pubspec.yaml b/packages/scheduler_notifications/pubspec.yaml index 7cbf28c..fdd1825 100644 --- a/packages/scheduler_notifications/pubspec.yaml +++ b/packages/scheduler_notifications/pubspec.yaml @@ -10,6 +10,9 @@ resolution: workspace environment: sdk: '>=3.6.0 <4.0.0' +dependencies: + scheduler_core: any + dev_dependencies: lints: ^5.0.0 test: ^1.25.0 diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart index 7720b84..ff57dab 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:scheduler_notifications/notifications.dart'; +import 'package:scheduler_core/scheduler_core.dart' show logger; part 'desktop_notification_adapter_io/platform/desktop_notification_platform.dart'; part 'desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart'; part 'desktop_notification_adapter_io/callbacks/desktop_process_runner.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart index 50b2c4f..099c191 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart @@ -19,6 +19,9 @@ final class DesktopNotificationAdapter implements NotificationAdapter { DesktopProcessRunner? processRunner, DesktopNotificationLogSink? logSink, }) { + logger.info(() => 'Creating desktop notification adapter. ' + 'requestedPlatform=${platform?.name} hasCustomRunner=${processRunner != null} ' + 'hasCustomLogSink=${logSink != null}'); return DesktopNotificationAdapter( backend: createDefaultDesktopNotificationBackend( platform: platform, @@ -37,6 +40,10 @@ final class DesktopNotificationAdapter implements NotificationAdapter { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future schedule(NotificationRequest request) { + logger.debug(() => 'Desktop notification schedule requested. ' + 'notificationId=${request.id} ' + 'scheduledDateTimeUtc=${request.scheduledDateTimeUtc.toIso8601String()} ' + 'backendSupported=${_backend.isSupported}'); return _backend.deliver(request); } @@ -44,6 +51,9 @@ final class DesktopNotificationAdapter implements NotificationAdapter { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) { - return _backend.cancel(id.trim()); + final trimmed = id.trim(); + logger.debug(() => 'Desktop notification cancel requested. ' + 'notificationId=$trimmed backendSupported=${_backend.isSupported}'); + return _backend.cancel(trimmed); } } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart index a07ff17..4db784c 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart @@ -15,6 +15,9 @@ DesktopNotificationBackend createDefaultDesktopNotificationBackend({ logSink: logSink ?? stdout.writeln, platform: resolvedPlatform, ); + logger.info(() => 'Resolved desktop notification backend. ' + 'platform=${resolvedPlatform.name} hasCustomRunner=${processRunner != null} ' + 'hasCustomLogSink=${logSink != null}'); return switch (resolvedPlatform) { DesktopNotificationPlatform.linux => LinuxNotifySendBackend( diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart index b8e3d14..8c2aac6 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart @@ -27,6 +27,8 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future deliver(NotificationRequest request) async { + logger.warn(() => 'Desktop notification fallback delivery used. ' + 'platform=${platform.name} notificationId=${request.id}'); _logSink( 'notification[$platform] ${request.id}: ${request.title} - ' '${request.body}', @@ -37,6 +39,9 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); + final trimmed = id.trim(); + logger.finer(() => 'Desktop notification fallback cancel used. ' + 'platform=${platform.name} notificationId=$trimmed'); + _logSink('notification[$platform] cancel: $trimmed'); } } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart index e336a69..99ce38f 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart @@ -29,6 +29,8 @@ final class LinuxNotifySendBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future deliver(NotificationRequest request) async { + logger.debug(() => 'Linux notification delivery requested. ' + 'notificationId=${request.id} backend=notify-send'); await _runOrFallback( request, () => _processRunner('notify-send', [ @@ -43,6 +45,8 @@ final class LinuxNotifySendBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) async { + logger.finer(() => 'Linux notification cancel delegated to fallback. ' + 'notificationId=${id.trim()}'); await _fallback.cancel(id); } @@ -55,9 +59,20 @@ final class LinuxNotifySendBackend implements DesktopNotificationBackend { try { final result = await run(); if (result.exitCode != 0) { + logger.warn(() => 'Linux notification command failed; using fallback. ' + 'notificationId=${request.id} exitCode=${result.exitCode}'); await _fallback.deliver(request); + } else { + logger.debug(() => 'Linux notification command completed. ' + 'notificationId=${request.id} exitCode=${result.exitCode}'); } - } on ProcessException { + } on ProcessException catch (error, stackTrace) { + logger.warn( + () => 'Linux notification command unavailable; using fallback. ' + 'notificationId=${request.id}', + error: error, + stackTrace: stackTrace, + ); await _fallback.deliver(request); } } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart index 70bbcf3..655c9ac 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart @@ -29,14 +29,27 @@ final class MacOsNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future deliver(NotificationRequest request) async { + logger.debug(() => 'macOS notification delivery requested. ' + 'notificationId=${request.id} backend=osascript'); final script = 'display notification ${_appleScriptString(request.body)} ' 'with title ${_appleScriptString(request.title)}'; try { final result = await _processRunner('osascript', ['-e', script]); if (result.exitCode != 0) { + logger.warn(() => 'macOS notification command failed; using fallback. ' + 'notificationId=${request.id} exitCode=${result.exitCode}'); await _fallback.deliver(request); + } else { + logger.debug(() => 'macOS notification command completed. ' + 'notificationId=${request.id} exitCode=${result.exitCode}'); } - } on ProcessException { + } on ProcessException catch (error, stackTrace) { + logger.warn( + () => 'macOS notification command unavailable; using fallback. ' + 'notificationId=${request.id}', + error: error, + stackTrace: stackTrace, + ); await _fallback.deliver(request); } } @@ -45,6 +58,8 @@ final class MacOsNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) async { + logger.finer(() => 'macOS notification cancel delegated to fallback. ' + 'notificationId=${id.trim()}'); await _fallback.cancel(id); } } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart index eabbd58..14414a5 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; import 'package:scheduler_notifications/notifications.dart'; +import 'package:scheduler_core/scheduler_core.dart' show logger; part 'desktop_notification_adapter_stub/platform/desktop_notification_platform.dart'; part 'desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart'; part 'desktop_notification_adapter_stub/backends/desktop_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart index 3b876dd..acc20d0 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart @@ -19,6 +19,9 @@ final class DesktopNotificationAdapter implements NotificationAdapter { Object? processRunner, DesktopNotificationLogSink? logSink, }) { + logger.info(() => 'Creating stub desktop notification adapter. ' + 'requestedPlatform=${platform?.name} hasCustomRunner=${processRunner != null} ' + 'hasCustomLogSink=${logSink != null}'); return DesktopNotificationAdapter( backend: createDefaultDesktopNotificationBackend( platform: platform, @@ -37,6 +40,10 @@ final class DesktopNotificationAdapter implements NotificationAdapter { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future schedule(NotificationRequest request) { + logger.debug(() => 'Stub desktop notification schedule requested. ' + 'notificationId=${request.id} ' + 'scheduledDateTimeUtc=${request.scheduledDateTimeUtc.toIso8601String()} ' + 'backendSupported=${_backend.isSupported}'); return _backend.deliver(request); } @@ -44,6 +51,9 @@ final class DesktopNotificationAdapter implements NotificationAdapter { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) { - return _backend.cancel(id.trim()); + final trimmed = id.trim(); + logger.debug(() => 'Stub desktop notification cancel requested. ' + 'notificationId=$trimmed backendSupported=${_backend.isSupported}'); + return _backend.cancel(trimmed); } } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart index 39626a7..f098ee0 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart @@ -9,8 +9,12 @@ DesktopNotificationBackend createDefaultDesktopNotificationBackend({ Object? processRunner, DesktopNotificationLogSink? logSink, }) { + final resolvedPlatform = platform ?? DesktopNotificationPlatform.unsupported; + logger.info(() => 'Resolved stub desktop notification backend. ' + 'platform=${resolvedPlatform.name} hasCustomRunner=${processRunner != null} ' + 'hasCustomLogSink=${logSink != null}'); return StdoutNotificationBackend( logSink: logSink ?? _defaultLogSink, - platform: platform ?? DesktopNotificationPlatform.unsupported, + platform: resolvedPlatform, ); } diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart index 797cc2d..f99dd0f 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart @@ -27,6 +27,8 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future deliver(NotificationRequest request) async { + logger.warn(() => 'Stub desktop notification fallback delivery used. ' + 'platform=${platform.name} notificationId=${request.id}'); _logSink( 'notification[$platform] ${request.id}: ${request.title} - ' '${request.body}', @@ -37,6 +39,9 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. @override Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); + final trimmed = id.trim(); + logger.finer(() => 'Stub desktop notification fallback cancel used. ' + 'platform=${platform.name} notificationId=$trimmed'); + _logSink('notification[$platform] cancel: $trimmed'); } } diff --git a/packages/scheduler_notifications_desktop/pubspec.yaml b/packages/scheduler_notifications_desktop/pubspec.yaml index aebf6bc..2fa470e 100644 --- a/packages/scheduler_notifications_desktop/pubspec.yaml +++ b/packages/scheduler_notifications_desktop/pubspec.yaml @@ -11,6 +11,7 @@ environment: sdk: '>=3.6.0 <4.0.0' dependencies: + scheduler_core: any scheduler_notifications: any dev_dependencies: diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart index ca2c902..8d2c5c9 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart @@ -94,6 +94,9 @@ Either> _checkWrite({ required core.Revision expectedRevision, }) { if (expectedRevision.value <= 0) { + core.logger.warn(() => + 'In-memory repository write rejected invalid revision. ' + 'entityId=$id ownerId=${ownerId.value} expectedRevision=${expectedRevision.value}'); return Left( RepositoryInvalidRevision( entityId: id, @@ -103,12 +106,21 @@ Either> _checkWrite({ } final record = records[id]; if (record == null) { + core.logger.warn(() => 'In-memory repository write target missing. ' + 'entityId=$id ownerId=${ownerId.value} expectedRevision=${expectedRevision.value}'); return Left(RepositoryNotFound(entityId: id)); } if (record.ownerId != ownerId) { + core.logger.warn(() => + 'In-memory repository write rejected owner mismatch. ' + 'entityId=$id ownerId=${ownerId.value} recordOwnerId=${record.ownerId.value}'); return Left(RepositoryOwnerMismatch(entityId: id)); } if (record.revision != expectedRevision) { + core.logger.warn(() => + 'In-memory repository write rejected stale revision. ' + 'entityId=$id ownerId=${ownerId.value} expectedRevision=${expectedRevision.value} ' + 'actualRevision=${record.revision.value}'); return Left( RepositoryStaleRevision( entityId: id, diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_runtime.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_runtime.dart index b4a3707..2987882 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_runtime.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_runtime.dart @@ -28,12 +28,17 @@ final class SqliteApplicationRuntime { required String path, String defaultOwnerId = defaultV1OwnerId, }) async { + core.logger.info(() => 'Opening SQLite application runtime. ' + 'path=$path defaultOwnerId=$defaultOwnerId'); final file = File(path); await file.parent.create(recursive: true); - return SqliteApplicationRuntime( + final runtime = SqliteApplicationRuntime( db: SchedulerDb(NativeDatabase(file)), defaultOwnerId: defaultOwnerId, ); + core.logger.info(() => 'SQLite application runtime opened. ' + 'path=$path defaultOwnerId=$defaultOwnerId'); + return runtime; } /// Shared SQLite database handle. @@ -48,6 +53,8 @@ final class SqliteApplicationRuntime { String timeZoneId = defaultV1TimeZoneId, String defaultProjectId = defaultV1ProjectId, }) { + core.logger.info(() => 'SQLite application bootstrap started. ' + 'ownerId=$ownerId timeZoneId=$timeZoneId defaultProjectId=$defaultProjectId'); return _guardApplicationErrors(() { return db.transaction(() async { final repositories = _SqliteApplicationRepositories( @@ -58,6 +65,8 @@ final class SqliteApplicationRuntime { ownerId, ); if (settings == null) { + core.logger.finer(() => 'SQLite bootstrap creating owner settings. ' + 'ownerId=$ownerId timeZoneId=$timeZoneId'); await repositories.ownerSettings.save( core.OwnerSettings( ownerId: ownerId, @@ -69,6 +78,8 @@ final class SqliteApplicationRuntime { final project = await repositories.projects.findById(defaultProjectId); if (project == null) { + core.logger.finer(() => 'SQLite bootstrap creating default project. ' + 'ownerId=$ownerId projectId=$defaultProjectId'); await repositories.projects.save( core.ProjectProfile( id: defaultProjectId, @@ -78,13 +89,18 @@ final class SqliteApplicationRuntime { ); } + core.logger.info(() => 'SQLite application bootstrap completed. ' + 'ownerId=$ownerId createdSettings=${settings == null} ' + 'createdProject=${project == null}'); return core.ApplicationResult.success(null); }); }); } /// Closes the underlying SQLite database. - Future close() { - return db.close(); + Future close() async { + core.logger.info(() => 'Closing SQLite application runtime.'); + await db.close(); + core.logger.info(() => 'SQLite application runtime closed.'); } } diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart index 9df51e7..e577ee9 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_task_repository.dart @@ -274,6 +274,45 @@ final class _SqliteApplicationTaskRepository implements core.TaskRepository { return core.RepositoryMutationResult.success(revision: nextRevision.value); } + /// Deletes [taskId] only when the repository revision matches [expectedRevision]. + @override + Future deleteIfRevision({ + required String taskId, + required String ownerId, + required int expectedRevision, + }) async { + final row = await _taskById(taskId); + final failure = _compareForWrite( + row: row, + ownerId: ownerId, + expectedRevision: expectedRevision, + entityId: taskId, + rowOwnerId: row?.ownerId, + rowRevision: row?.revision, + ); + if (failure != null) { + return failure; + } + final nextRevision = core.Revision(expectedRevision).next(); + final deleted = await (db.delete(db.tasks) + ..where( + (table) => + table.id.equals(taskId) & + table.ownerId.equals(ownerId) & + table.revision.equals(expectedRevision), + )) + .go(); + if (deleted != 1) { + return _mutationFailure( + core.RepositoryFailureCode.staleRevision, + entityId: taskId, + expectedRevision: expectedRevision, + actualRevision: row?.revision, + ); + } + return core.RepositoryMutationResult.success(revision: nextRevision.value); + } + /// Returns a raw task row by id. Future _taskById(String id) { return (db.select(db.tasks)..where((table) => table.id.equals(id))) diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_unit_of_work.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_unit_of_work.dart index 36bcb2e..cea9c5f 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_unit_of_work.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_application/sqlite_application_unit_of_work.dart @@ -26,6 +26,9 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { core.ApplicationUnitOfWorkRepositories repositories, ) action, }) { + core.logger.debug(() => 'SQLite unit of work run started. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'ownerId=${context.ownerTimeZone.ownerId}'); return _guardApplicationErrors(() { return db.transaction(() async { final repositories = _SqliteApplicationRepositories( @@ -36,6 +39,9 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { context.operationId, ); if (existing != null) { + core.logger.warn(() => + 'Duplicate operation ignored by SQLite unit of work. ' + 'operationId=${context.operationId} operationName=$operationName'); return core.ApplicationResult.failure( core.ApplicationFailure( code: core.ApplicationFailureCode.duplicateOperation, @@ -46,6 +52,10 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { final result = await action(repositories); if (result.isFailure) { + core.logger.warn(() => 'SQLite unit of work action returned failure. ' + 'operationId=${context.operationId} operationName=$operationName ' + 'code=${result.failure!.code.name} ' + 'detailCode=${result.failure!.detailCode}'); return result; } @@ -58,6 +68,9 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { ), ); if (!operationResult.isSuccess) { + core.logger.warn(() => + 'SQLite unit of work operation record insert conflicted. ' + 'operationId=${context.operationId} operationName=$operationName'); return core.ApplicationResult.failure( core.ApplicationFailure( code: core.ApplicationFailureCode.duplicateOperation, @@ -66,6 +79,8 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { ); } + core.logger.debug(() => 'SQLite unit of work run committed. ' + 'operationId=${context.operationId} operationName=$operationName'); return result; }); }); @@ -78,13 +93,23 @@ final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork { core.ApplicationUnitOfWorkRepositories repositories, ) action, }) { - return _guardApplicationErrors(() { - return action( + core.logger.debug(() => 'SQLite unit of work read started. ' + 'defaultOwnerId=$defaultOwnerId'); + return _guardApplicationErrors(() async { + final result = await action( _SqliteApplicationRepositories( db, defaultOwnerId: defaultOwnerId, ), ); + if (result.isFailure) { + core.logger.warn(() => 'SQLite unit of work read returned failure. ' + 'code=${result.failure!.code.name} detailCode=${result.failure!.detailCode}'); + } else { + core.logger.debug(() => 'SQLite unit of work read finished. ' + 'defaultOwnerId=$defaultOwnerId'); + } + return result; }); } } @@ -96,8 +121,13 @@ Future> _guardApplicationErrors( try { return await action(); } on core.ApplicationFailureException catch (error) { + core.logger.warn(() => 'SQLite application caught application failure. ' + 'code=${error.failure.code.name} detailCode=${error.failure.detailCode}'); return core.ApplicationResult.failure(error.failure); } on core.DomainValidationException catch (error) { + core.logger + .warn(() => 'SQLite application caught domain validation failure. ' + 'code=${error.code.name}'); return core.ApplicationResult.failure( core.ApplicationFailure( code: core.ApplicationFailureCode.validation, @@ -105,25 +135,43 @@ Future> _guardApplicationErrors( ), ); } on ArgumentError catch (error) { + core.logger + .warn(() => 'SQLite application caught argument validation failure. ' + 'argumentName=${error.name}'); return core.ApplicationResult.failure( core.ApplicationFailure( code: core.ApplicationFailureCode.validation, detailCode: error.name, ), ); - } on core.ApplicationPersistenceException { + } on core.ApplicationPersistenceException catch (error, stackTrace) { + core.logger.error( + () => 'SQLite application persistence failure.', + error: error, + stackTrace: stackTrace, + ); return core.ApplicationResult.failure( const core.ApplicationFailure( code: core.ApplicationFailureCode.persistenceFailure, ), ); - } on sqlite.SqliteException catch (_) { + } on sqlite.SqliteException catch (error, stackTrace) { + core.logger.error( + () => 'SQLite database operation failed. errorType=${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); return core.ApplicationResult.failure( const core.ApplicationFailure( code: core.ApplicationFailureCode.persistenceFailure, ), ); - } catch (_) { + } catch (error, stackTrace) { + core.logger.error( + () => 'SQLite application unexpected failure.', + error: error, + stackTrace: stackTrace, + ); return core.ApplicationResult.failure( const core.ApplicationFailure( code: core.ApplicationFailureCode.unexpected), diff --git a/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart b/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart index 8239206..9ad2b16 100644 --- a/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart +++ b/packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart @@ -113,7 +113,7 @@ void main() { await runtime.close(); runtime = await _openBootstrappedRuntime(path); - addTearDown(runtime.close); + commands = _commands(runtime); todayQuery = _todayQuery(runtime); today = await todayQuery.execute( GetTodayStateRequest( @@ -125,6 +125,30 @@ void main() { .singleWhere((item) => item.taskId == task.id); expect(scheduledTask.taskStatus, TaskStatus.planned); + final removed = await commands.removeTask( + context: _context('remove-task', _instant(8, 40)), + taskId: task.id, + expectedUpdatedAt: scheduledTask.item.updatedAt, + ); + expect(removed.isSuccess, isTrue); + await runtime.close(); + + runtime = await _openBootstrappedRuntime(path); + addTearDown(runtime.close); + todayQuery = _todayQuery(runtime); + today = await todayQuery.execute( + GetTodayStateRequest( + context: _context('read-today-after-remove', _instant(8, 45)), + date: _date, + ), + ); + expect( + today.requireValue.timelineItems.any((item) => item.taskId == task.id), + isFalse, + ); + final taskRows = await runtime.db.select(runtime.db.tasks).get(); + expect(taskRows, isEmpty); + final activityRows = await runtime.db.select(runtime.db.taskActivities).get(); final operationRows =