merge: task pushing controls
Some checks failed
CI / test (macos-latest) (push) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / package (macos-latest, macos) (push) Has been cancelled
CI / package (ubuntu-latest, linux) (push) Has been cancelled
CI / package (windows-latest, windows) (push) Has been cancelled

This commit is contained in:
Ashley Venn 2026-07-04 22:17:30 -07:00
commit a488d1a5f5
132 changed files with 6420 additions and 352 deletions

View file

@ -16,6 +16,9 @@ Included documents cover:
normal Flutter startup and close/reopen task lifecycle proof. normal Flutter startup and close/reopen task lifecycle proof.
- Date Selection 01, the completed day navigation and date picker plan for the - Date Selection 01, the completed day navigation and date picker plan for the
persistence-backed Flutter Today timeline. 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 Excluded documents include superseded originals, MongoDB-targeted persistence or
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review runtime plans, planned/blocked UI handoff plans, old archive indexes, and review

View file

@ -4,7 +4,7 @@
# Task Pushing 01 — Past Task Push Controls # 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. **Scope level:** XHIGH implementation plan.
This plan adds the push affordance for scheduled tasks that are in the past and This plan adds the push affordance for scheduled tasks that are in the past and

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 01 — Scope and Domain Semantics # Task Pushing 01 Block 01 — Scope and Domain Semantics
**Status:** Planned. **Status:** Complete.
**Goal:** Confirm the exact pushable task scope and lock destination semantics **Goal:** Confirm the exact pushable task scope and lock destination semantics
before adding visible controls. before adding visible controls.
@ -15,6 +15,21 @@ before adding visible controls.
After this block, the implementation should know which cards can show `Push`, After this block, the implementation should know which cards can show `Push`,
what `past` means, and what each destination must do. 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 ## Chunk 1.1 — Confirm prerequisites

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 02 — Past Task Read Model # 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 **Goal:** Add presentation-model state that tells widgets when to show the full
Push button. Push button.
@ -15,6 +15,17 @@ Push button.
After this block, `TimelineCardModel` or its successor exposes a clear push-state After this block, `TimelineCardModel` or its successor exposes a clear push-state
field. Leaf widgets should not recompute past/incomplete scheduling conditions. 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 ## Chunk 2.1 — Add push-state presentation fields

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 03 — Push Button Card UI # 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. **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 button beside the reward/difficulty icons. The menu may still be stubbed until
Block 04. 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 ## Chunk 3.1 — Place the Push button in trailing controls

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 04 — Push Menu Destinations # 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 **Goal:** Open a destination menu from the Push button and route each choice to a
callback. 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 The choices may call stubbed/controller callbacks until Block 05 wires real
commands. 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 ## Chunk 4.1 — Add destination menu UI

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 05 — Command Wiring and Persistence # Task Pushing 01 Block 05 — Command Wiring and Persistence
**Status:** Planned. **Status:** Complete.
**Goal:** Connect push menu destinations to application commands and prove their **Goal:** Connect push menu destinations to application commands and prove their
results persist. results persist.

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 06 — Destination Scheduling Rules # Task Pushing 01 Block 06 — Destination Scheduling Rules
**Status:** Planned. **Status:** Complete.
**Goal:** Verify or adjust backend push scheduling so destination behavior exactly **Goal:** Verify or adjust backend push scheduling so destination behavior exactly
matches the requested product rules. matches the requested product rules.

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 07 — Backlog Freshness Reset # Task Pushing 01 Block 07 — Backlog Freshness Reset
**Status:** Planned. **Status:** Complete.
**Goal:** Make Push to backlog reset the backlog freshness timer without losing **Goal:** Make Push to backlog reset the backlog freshness timer without losing
original task creation history. original task creation history.

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 08 — Validation and Handoff # 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. **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 results persist through SQLite.
- Push to Backlog resets freshness without rewriting creation history. - Push to Backlog resets freshness without rewriting creation history.
- Validation status is documented. - 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.

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Execution Order # Task Pushing 01 Execution Order
**Status:** Planned. **Status:** Complete.
Run these files in order. All chunks are intended for `XHIGH` execution unless a Run these files in order. All chunks are intended for `XHIGH` execution unless a
chunk explicitly says otherwise. chunk explicitly says otherwise.

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Summary — Past Task Push Controls # Task Pushing 01 Summary — Past Task Push Controls
**Status:** Planned. **Status:** Complete.
**Scope level:** XHIGH implementation plan. **Scope level:** XHIGH implementation plan.
**Primary outcome:** Past, incomplete scheduled tasks expose a persistent push **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 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. 11. UI widgets do not contain scheduling, Drift, SQL, or repository logic.
12. Validation gates pass, or unavailable commands are documented. 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 ## Non-goals

View file

@ -6,7 +6,7 @@
Ordered implementation queue: 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. Execute only the first incomplete plan.

View file

@ -0,0 +1,14 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

355
LOGGING_RULES.md Normal file
View file

@ -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.

View file

@ -4,6 +4,13 @@
include: package:lints/recommended.yaml include: package:lints/recommended.yaml
analyzer: analyzer:
exclude:
- .dart_tool/**
- archive/**
- builds/**
- coverage/**
- doc/api/**
- logging_handoff_with_logging/**
language: language:
strict-casts: true strict-casts: true
strict-inference: true strict-inference: true

View file

@ -33,6 +33,58 @@ It is not inserted during normal startup.
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`. Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
Scheduler core remains the source of truth for task state and Today ordering. 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 ## Package Boundary
Allowed app imports include public scheduler APIs such as: Allowed app imports include public scheduler APIs such as:

View file

@ -0,0 +1,5 @@
{
"Timezone": "PST",
"LogLevel": "fine",
"LogfileLocation": "~/ADHD_Scheduler/debug/"
}

View file

@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
return TodayScreenController( return TodayScreenController(
selectedDates: selectedDates, selectedDates: selectedDates,
dateLabelFor: dateLabelFor, dateLabelFor: dateLabelFor,
now: () => readAt,
read: (date) { read: (date) {
return todayQuery.execute( return todayQuery.execute(
GetTodayStateRequest( GetTodayStateRequest(

View file

@ -23,6 +23,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
logger.debug(() => 'FocusFlow home initializing controllers.');
selectedDateController = widget.composition.createSelectedDateController(); selectedDateController = widget.composition.createSelectedDateController();
controller = widget.composition.createTodayScreenController( controller = widget.composition.createTodayScreenController(
selectedDates: selectedDateController, selectedDates: selectedDateController,
@ -31,6 +32,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
refreshReads: controller.load, refreshReads: controller.load,
selectedDate: () => selectedDateController.date, selectedDate: () => selectedDateController.date,
); );
logger.finer(() => 'FocusFlow home triggering initial Today load.');
controller.load(); controller.load();
} }
@ -38,6 +40,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override @override
void dispose() { void dispose() {
logger.debug(() => 'FocusFlow home disposing controllers.');
commandController.dispose(); commandController.dispose();
controller.dispose(); controller.dispose();
selectedDateController.dispose(); selectedDateController.dispose();
@ -49,8 +52,16 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _toggleCardCompletion(TimelineCardModel card) async { Future<void> _toggleCardCompletion(TimelineCardModel card) async {
if (!card.canToggleCompletion) { if (!card.canToggleCompletion) {
logger.finer(
() => 'Timeline completion toggle ignored. cardId=${card.id}',
);
return; return;
} }
logger.debug(
() =>
'Timeline completion toggle requested. '
'cardId=${card.id} taskType=${card.taskType.name} isCompleted=${card.isCompleted}',
);
if (card.isCompleted) { if (card.isCompleted) {
await commandController.uncompleteTask(taskId: card.id); await commandController.uncompleteTask(taskId: card.id);
} else { } else {
@ -63,16 +74,58 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// Adds a generic filler task from the timeline context menu. /// Adds a generic filler task from the timeline context menu.
Future<void> _addGenericFlexibleTask() async { Future<void> _addGenericFlexibleTask() async {
logger.debug(() => 'Timeline add generic flexible task requested.');
await commandController.addGenericFlexibleTask(); await commandController.addGenericFlexibleTask();
} }
/// Pushes [card] to the next available slot after the current clock time.
Future<void> _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<void> _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<void> _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<void> _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. /// Moves the visible planning date backward by one day.
void _selectPreviousDate() { void _selectPreviousDate() {
logger.finer(() => 'Previous date action requested.');
selectedDateController.selectPreviousDay(); selectedDateController.selectPreviousDay();
} }
/// Moves the visible planning date forward by one day. /// Moves the visible planning date forward by one day.
void _selectNextDate() { void _selectNextDate() {
logger.finer(() => 'Next date action requested.');
selectedDateController.selectNextDay(); selectedDateController.selectNextDay();
} }
@ -86,9 +139,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
builder: _buildDatePickerTheme, builder: _buildDatePickerTheme,
); );
if (picked == null) { if (picked == null) {
logger.finer(() => 'Date picker dismissed without selection.');
return; 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. /// Builds the widget subtree for this component.
@ -114,6 +172,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion, onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,
@ -127,6 +189,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion, onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,

View file

@ -13,6 +13,10 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.selectedCard, required this.selectedCard,
required this.onSelectCard, required this.onSelectCard,
required this.onCompleteCard, required this.onCompleteCard,
required this.onPushToNext,
required this.onPushToTomorrow,
required this.onPushToBacklog,
required this.onRemoveCard,
required this.onAddTask, required this.onAddTask,
required this.onClearSelection, required this.onClearSelection,
required this.onPreviousDate, 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(TimelineCardModel card) onCompleteCard; final Future<void> 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<void> 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<void> 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<void> 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<void> Function(TimelineCardModel card) onRemoveCard;
/// Stores the `onAddTask` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function() onAddTask; final Future<void> Function() onAddTask;

View file

@ -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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _timelineScrollRequest = 0; 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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _showUpcomingRequiredTask() { 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. /// 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. /// 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 @override
@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
} }
_timelineScrollTargetCardId = null; _timelineScrollTargetCardId = null;
_timelineScrollRequest = 0; _timelineScrollRequest = 0;
_timelineScrollToNowRequest = 0;
} }
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPreviousDate: widget.onPreviousDate, onPreviousDate: widget.onPreviousDate,
onNextDate: widget.onNextDate, onNextDate: widget.onNextDate,
onDatePressed: widget.onChooseDate, onDatePressed: widget.onChooseDate,
onDateLabelPressed: _scrollTimelineToCurrentTime,
), ),
if (widget.data.requiredBanner == null) if (widget.data.requiredBanner == null)
const SizedBox(height: 16) const SizedBox(height: 16)
@ -81,9 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
range: widget.data.timelineRange, range: widget.data.timelineRange,
scrollTargetCardId: _timelineScrollTargetCardId, scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest, scrollRequest: _timelineScrollRequest,
scrollToNowRequest: _timelineScrollToNowRequest,
onCardSelected: widget.onSelectCard, onCardSelected: widget.onSelectCard,
onAddTask: widget.onAddTask, onAddTask: widget.onAddTask,
onCardCompleted: widget.onCompleteCard, 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 onStatusPressed: widget.selectedCard!.canToggleCompletion
? () => widget.onCompleteCard(widget.selectedCard!) ? () => widget.onCompleteCard(widget.selectedCard!)
: null, : null,
onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
onRemove: widget.onRemoveCard,
), ),
), ),
], ],

View file

@ -6,12 +6,17 @@ library;
import 'dart:io'; 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 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_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 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
import 'scheduler_app_composition.dart'; import 'scheduler_app_composition.dart';
@ -25,6 +30,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
required this.commandUseCases, required this.commandUseCases,
required this.date, required this.date,
required this.readAt, 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. /// Opens the configured SQLite runtime and bootstraps required owner state.
@ -32,36 +41,65 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
String? sqlitePath, String? sqlitePath,
Clock? clock, Clock? clock,
CivilDate? selectedDate, CivilDate? selectedDate,
FocusFlowFileLogger? logger,
FocusFlowUiTimeZone? uiTimeZone,
}) async { }) 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( final runtime = await SqliteApplicationRuntime.open(
path: sqlitePath ?? sqlitePathFromEnvironment(), path: resolvedSqlitePath,
); );
final bootstrap = await runtime.bootstrap(); final bootstrap = await runtime.bootstrap();
if (bootstrap.isFailure) { if (bootstrap.isFailure) {
await runtime.close(); await runtime.close();
scheduler_core.logger.error(
() =>
'SQLite bootstrap failed. failureCode=${bootstrap.failure!.code.name}',
error: bootstrap.failure!.code.name,
);
throw StateError( throw StateError(
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}', 'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
); );
} }
scheduler_core.logger.debug(() => 'SQLite bootstrap completed.');
final resolvedClock = clock ?? const SystemClock(); final resolvedClock = clock ?? const SystemClock();
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now()); final openedAt = resolvedClock.now();
final readAt = resolvedClock.now().toUtc(); 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._( return PersistentSchedulerComposition._(
runtime: runtime, runtime: runtime,
todayQuery: GetTodayStateQuery( todayQuery: GetTodayStateQuery(
applicationStore: runtime.applicationStore, applicationStore: runtime.applicationStore,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), timeZoneResolver: resolver,
), ),
managementUseCases: V1ApplicationManagementUseCases( managementUseCases: V1ApplicationManagementUseCases(
applicationStore: runtime.applicationStore, applicationStore: runtime.applicationStore,
), ),
commandUseCases: V1ApplicationCommandUseCases( commandUseCases: V1ApplicationCommandUseCases(
applicationStore: runtime.applicationStore, applicationStore: runtime.applicationStore,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), timeZoneResolver: resolver,
), ),
date: date, date: date,
readAt: readAt, 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. /// Stable read clock instant for operation contexts.
final DateTime readAt; 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. /// Pending close operation, if disposal has started.
Future<void>? _disposeFuture; Future<void>? _disposeFuture;
@ -122,6 +172,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
/// Creates the controller that owns the visible planning date. /// Creates the controller that owns the visible planning date.
@override @override
SelectedDateController createSelectedDateController() { SelectedDateController createSelectedDateController() {
scheduler_core.logger.finer(
() =>
'Creating selected date controller. date=${initialDate.toIsoString()}',
);
return SelectedDateController(initialDate: initialDate); return SelectedDateController(initialDate: initialDate);
} }
@ -130,9 +184,14 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
TodayScreenController createTodayScreenController({ TodayScreenController createTodayScreenController({
required SelectedDateController selectedDates, required SelectedDateController selectedDates,
}) { }) {
scheduler_core.logger.finer(
() => 'Creating today screen controller. date=${date.toIsoString()}',
);
return TodayScreenController( return TodayScreenController(
selectedDates: selectedDates, selectedDates: selectedDates,
dateLabelFor: dateLabelFor, dateLabelFor: dateLabelFor,
now: _utcNow,
timeZoneOffset: uiTimeZone.utcOffset,
read: (date) { read: (date) {
return todayQuery.execute( return todayQuery.execute(
GetTodayStateRequest( GetTodayStateRequest(
@ -151,6 +210,10 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
required ReadRefresh refreshReads, required ReadRefresh refreshReads,
required SelectedDateReader selectedDate, required SelectedDateReader selectedDate,
}) { }) {
scheduler_core.logger.finer(
() =>
'Creating scheduler command controller. operationIdPrefix=ui-command-${readAt.microsecondsSinceEpoch}',
);
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
contextFor: context, contextFor: context,
@ -158,6 +221,8 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
refreshReads: refreshReads, refreshReads: refreshReads,
quickCaptureProjectId: defaultProjectId, quickCaptureProjectId: defaultProjectId,
operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}', operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}',
currentDateFor: uiTimeZone.toLocalCivilDate,
now: _utcNow,
); );
} }
@ -167,9 +232,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
operationId: operationId, operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext( ownerTimeZone: OwnerTimeZoneContext(
ownerId: SqliteApplicationRuntime.defaultV1OwnerId, ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId, timeZoneId: uiTimeZone.code,
), ),
clock: FixedClock(now ?? readAt), clock: FixedClock((now ?? _utcNow()).toUtc()),
idGenerator: SequentialIdGenerator(prefix: operationId), idGenerator: SequentialIdGenerator(prefix: operationId),
); );
} }
@ -177,6 +242,18 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
/// Closes the persistent runtime. /// Closes the persistent runtime.
@override @override
Future<void> dispose() { Future<void> dispose() {
return _disposeFuture ??= runtime.close(); return _disposeFuture ??= _dispose();
}
/// Closes runtime resources and records shutdown diagnostics when enabled.
Future<void> _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();
} }
} }

View file

@ -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<String> _callerFrameIgnores = <String>[
'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<FocusFlowFileLogger> 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<void> finest(Object? message) {
return write(scheduler_core.FocusFlowLogLevel.finest, message);
}
/// Writes a finer-level [message] when enabled for finer logging.
Future<void> finer(Object? message) {
return write(scheduler_core.FocusFlowLogLevel.finer, message);
}
/// Writes a fine-level [message] when enabled for fine logging.
Future<void> fine(Object? message) {
return write(scheduler_core.FocusFlowLogLevel.fine, message);
}
/// Writes a debug-level [message] when enabled for debug or lower.
Future<void> debug(Object? message) {
return write(scheduler_core.FocusFlowLogLevel.debug, message);
}
/// Writes an info-level [message] when enabled for info or lower.
Future<void> info(Object? message) {
return write(scheduler_core.FocusFlowLogLevel.info, message);
}
/// Writes a warning-level [message] when enabled for warning or lower.
Future<void> 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<void> 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<void> write(
scheduler_core.FocusFlowLogLevel level,
Object? message, {
Object? error,
StackTrace? stackTrace,
}) {
_logger.write(level, message, error: error, stackTrace: stackTrace);
return Future<void>.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';
}

View file

@ -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<FocusFlowRuntimeConfig> 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<String, Object?>) {
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<String, Object?> 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 = <String, int>{
'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';
}

View file

@ -12,3 +12,6 @@ typedef ReadRefresh = Future<void> Function();
/// Reads the owner-local date that commands should target at execution time. /// Reads the owner-local date that commands should target at execution time.
typedef SchedulerSelectedDateReader = CivilDate Function(); typedef SchedulerSelectedDateReader = CivilDate Function();
/// Resolves the owner-local civil date for an absolute command instant.
typedef SchedulerCurrentDateResolver = CivilDate Function(DateTime instant);

View file

@ -12,9 +12,10 @@ class SchedulerCommandController extends ChangeNotifier {
required this.selectedDate, required this.selectedDate,
required this.refreshReads, required this.refreshReads,
required this.quickCaptureProjectId, required this.quickCaptureProjectId,
String operationIdPrefix = 'ui-command', this.operationIdPrefix = 'ui-command',
SchedulerCurrentDateResolver? currentDateFor,
DateTime Function()? now, DateTime Function()? now,
}) : _operationIdPrefix = operationIdPrefix, }) : currentDateFor = currentDateFor ?? _utcCivilDateFor,
now = now ?? _utcNow; now = now ?? _utcNow;
/// Scheduler write use cases invoked by this controller. /// Scheduler write use cases invoked by this controller.
@ -32,13 +33,15 @@ class SchedulerCommandController extends ChangeNotifier {
/// Project id used by quick-capture commands. /// Project id used by quick-capture commands.
final String quickCaptureProjectId; final String quickCaptureProjectId;
/// Private state stored as `_operationIdPrefix` for this implementation. /// Prefix used to generate unique operation ids for scheduler commands.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. final String operationIdPrefix;
final String _operationIdPrefix;
/// Clock used by direct UI commands such as marking a task complete. /// Clock used by direct UI commands such as marking a task complete.
final DateTime Function() now; 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. /// Filler title used by the temporary timeline add-task action.
static const _genericFlexibleTaskTitle = 'New flexible task'; static const _genericFlexibleTaskTitle = 'New flexible task';
@ -58,6 +61,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Captures [title] as a backlog task. /// Captures [title] as a backlog task.
Future<void> quickCaptureToBacklog(String title) async { Future<void> quickCaptureToBacklog(String title) async {
logger.debug(
() =>
'UI command requested: quick capture to backlog. '
'projectId=$quickCaptureProjectId hasTitle=${title.trim().isNotEmpty}',
);
await _run( await _run(
label: 'Capturing task', label: 'Capturing task',
successMessage: 'Task captured', successMessage: 'Task captured',
@ -75,6 +83,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Adds a generic flexible task to the next available Today slot. /// Adds a generic flexible task to the next available Today slot.
Future<void> addGenericFlexibleTask() async { Future<void> addGenericFlexibleTask() async {
logger.debug(
() =>
'UI command requested: add generic flexible task. '
'durationMinutes=$_genericFlexibleTaskDurationMinutes projectId=$quickCaptureProjectId',
);
await _run( await _run(
label: 'Adding task', label: 'Adding task',
successMessage: 'Task added', successMessage: 'Task added',
@ -98,6 +111,12 @@ class SchedulerCommandController extends ChangeNotifier {
required int durationMinutes, required int durationMinutes,
required DateTime expectedUpdatedAt, required DateTime expectedUpdatedAt,
}) async { }) async {
logger.debug(
() =>
'UI command requested: schedule backlog item. '
'taskId=$taskId durationMinutes=$durationMinutes '
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
);
await _run( await _run(
label: 'Scheduling task', label: 'Scheduling task',
successMessage: 'Task scheduled', successMessage: 'Task scheduled',
@ -134,6 +153,13 @@ class SchedulerCommandController extends ChangeNotifier {
DateTime? expectedUpdatedAt, DateTime? expectedUpdatedAt,
}) async { }) async {
final completedAt = now(); final completedAt = now();
logger.debug(
() =>
'UI command requested: complete task. '
'taskId=$taskId taskType=${taskType.name} '
'hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
'occurredAt=${completedAt.toIso8601String()}',
);
await _run( await _run(
label: 'Completing task', label: 'Completing task',
successMessage: 'Task completed', successMessage: 'Task completed',
@ -177,6 +203,12 @@ class SchedulerCommandController extends ChangeNotifier {
DateTime? expectedUpdatedAt, DateTime? expectedUpdatedAt,
}) async { }) async {
final occurredAt = now(); final occurredAt = now();
logger.debug(
() =>
'UI command requested: uncomplete task. '
'taskId=$taskId hasExpectedUpdatedAt=${expectedUpdatedAt != null} '
'occurredAt=${occurredAt.toIso8601String()}',
);
await _run( await _run(
label: 'Reopening task', label: 'Reopening task',
successMessage: 'Task marked as not done', 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<void> 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<void> 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<void> 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<void> 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. /// 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. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _run({ Future<void> _run({
@ -204,19 +342,44 @@ class SchedulerCommandController extends ChangeNotifier {
action, action,
}) async { }) async {
final operationId = _nextOperationId(); final operationId = _nextOperationId();
logger.debug(
() => 'Scheduler command started. operationId=$operationId label=$label',
);
_setState(SchedulerCommandRunning(label)); _setState(SchedulerCommandRunning(label));
try { try {
final result = await action(operationId); final result = await action(operationId);
final failure = result.failure; final failure = result.failure;
if (failure != null) { 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)); _setState(SchedulerCommandFailure(failure: failure));
return; return;
} }
logger.debug(
() =>
'Scheduler command succeeded. operationId=$operationId '
'refreshAfterSuccess=$refreshAfterSuccess',
);
if (refreshAfterSuccess) { if (refreshAfterSuccess) {
logger.finer(
() =>
'Refreshing reads after scheduler command. '
'operationId=$operationId',
);
await refreshReads(); await refreshReads();
} }
_setState(SchedulerCommandSuccess(successMessage)); _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)); _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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
String _nextOperationId() { String _nextOperationId() {
_sequence += 1; _sequence += 1;
return '$_operationIdPrefix-$_sequence'; return '$operationIdPrefix-$_sequence';
} }
/// Runs the `_setState` helper used inside this library. /// Runs the `_setState` helper used inside this library.
@ -238,4 +401,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Runs the `_utcNow` helper used inside this library. /// 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. /// 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(); 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);
}
} }

View file

@ -98,21 +98,35 @@ class ApplicationReadController<T> extends UiReadController<T> {
/// Loads the current value and updates [state]. /// Loads the current value and updates [state].
@override @override
Future<void> load() async { Future<void> load() async {
logger.debug(() => 'Scheduler read started. valueType=$T');
_setState(SchedulerReadLoading<T>()); _setState(SchedulerReadLoading<T>());
try { try {
final result = await read(); final result = await read();
final failure = result.failure; final failure = result.failure;
if (failure != null) { if (failure != null) {
logger.warn(
() =>
'Scheduler read returned failure. valueType=$T '
'code=${failure.code.name} detailCode=${failure.detailCode} '
'entityId=${failure.entityId}',
);
_setState(SchedulerReadFailure<T>(failure: failure)); _setState(SchedulerReadFailure<T>(failure: failure));
return; return;
} }
final value = result.requireValue; final value = result.requireValue;
_setState( final empty = isEmpty(value);
isEmpty(value) logger.debug(
? SchedulerReadEmpty<T>(value) () => 'Scheduler read finished. valueType=$T isEmpty=$empty',
: SchedulerReadData<T>(value), );
_setState(
empty ? SchedulerReadEmpty<T>(value) : SchedulerReadData<T>(value),
);
} on Object catch (error, stackTrace) {
logger.error(
() => 'Scheduler read threw unexpectedly. valueType=$T',
error: error,
stackTrace: stackTrace,
); );
} on Object catch (error) {
_setState(SchedulerReadFailure<T>(error: error)); _setState(SchedulerReadFailure<T>(error: error));
} }
} }

View file

@ -25,8 +25,13 @@ class SelectedDateController extends ChangeNotifier {
/// Selects [date] as the visible planning day. /// Selects [date] as the visible planning day.
bool selectDate(CivilDate date) { bool selectDate(CivilDate date) {
if (date == _date) { if (date == _date) {
logger.finer(() => 'Selected date unchanged. date=${date.toIsoString()}');
return false; return false;
} }
logger.debug(
() =>
'Selected date changed. previous=${_date.toIsoString()} next=${date.toIsoString()}',
);
_date = date; _date = date;
notifyListeners(); notifyListeners();
return true; return true;

View file

@ -71,7 +71,10 @@ class TodayScreenController extends ChangeNotifier {
required this.read, required this.read,
required this.selectedDates, required this.selectedDates,
required this.dateLabelFor, 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); selectedDates.addListener(_handleSelectedDateChanged);
} }
@ -84,6 +87,12 @@ class TodayScreenController extends ChangeNotifier {
/// Formats dates before a backend read has returned screen data. /// Formats dates before a backend read has returned screen data.
final TodayDateLabelFormatter dateLabelFor; 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. /// 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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TodayScreenState _state; TodayScreenState _state;
@ -113,15 +122,30 @@ class TodayScreenController extends ChangeNotifier {
Future<void> load() async { Future<void> load() async {
final date = selectedDates.date; final date = selectedDates.date;
final sequence = _nextLoadSequence(); final sequence = _nextLoadSequence();
logger.debug(
() =>
'Today screen load started. date=${date.toIsoString()} sequence=$sequence',
);
_state = TodayScreenLoading(dateLabelFor(date)); _state = TodayScreenLoading(dateLabelFor(date));
notifyListeners(); notifyListeners();
try { try {
final result = await read(date); final result = await read(date);
if (_isStale(sequence)) { if (_isStale(sequence)) {
logger.finer(
() =>
'Ignoring stale Today screen load result. '
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
);
return; return;
} }
final failure = result.failure; final failure = result.failure;
if (failure != null) { 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( _state = TodayScreenFailure(
message: failure.detailCode ?? failure.code.name, message: failure.detailCode ?? failure.code.name,
data: _emptyDataFor(date), data: _emptyDataFor(date),
@ -130,16 +154,36 @@ class TodayScreenController extends ChangeNotifier {
return; return;
} }
final data = TodayScreenData.fromTodayState(result.requireValue); final data = TodayScreenData.fromTodayState(
result.requireValue,
now: now(),
timeZoneOffset: timeZoneOffset,
);
_syncSelectedCard(data); _syncSelectedCard(data);
_state = data.cards.isEmpty _state = data.cards.isEmpty
? TodayScreenEmpty(data) ? TodayScreenEmpty(data)
: TodayScreenReady(data); : TodayScreenReady(data);
logger.debug(
() =>
'Today screen load finished. date=${date.toIsoString()} '
'sequence=$sequence cardCount=${data.cards.length}',
);
notifyListeners(); notifyListeners();
} on Object catch (error) { } on Object catch (error, stackTrace) {
if (_isStale(sequence)) { if (_isStale(sequence)) {
logger.finer(
() =>
'Ignoring stale Today screen load exception. '
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
);
return; return;
} }
logger.error(
() =>
'Today screen load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
error: error,
stackTrace: stackTrace,
);
_state = TodayScreenFailure( _state = TodayScreenFailure(
message: error.toString(), message: error.toString(),
data: _emptyDataFor(date), data: _emptyDataFor(date),
@ -151,8 +195,12 @@ class TodayScreenController extends ChangeNotifier {
/// Selects [card] when the card allows selection. /// Selects [card] when the card allows selection.
void selectCard(TimelineCardModel card) { void selectCard(TimelineCardModel card) {
if (!card.isSelectable) { if (!card.isSelectable) {
logger.finer(
() => 'Today screen card selection ignored. cardId=${card.id}',
);
return; return;
} }
logger.finer(() => 'Today screen card selected. cardId=${card.id}');
_selectedCard = card; _selectedCard = card;
notifyListeners(); notifyListeners();
} }
@ -162,6 +210,9 @@ class TodayScreenController extends ChangeNotifier {
if (_selectedCard == null) { if (_selectedCard == null) {
return; return;
} }
logger.finer(
() => 'Today screen card selection cleared. cardId=${_selectedCard!.id}',
);
_selectedCard = null; _selectedCard = null;
notifyListeners(); notifyListeners();
} }
@ -177,6 +228,10 @@ class TodayScreenController extends ChangeNotifier {
/// Runs the `_handleSelectedDateChanged` helper used inside this library. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _handleSelectedDateChanged() { void _handleSelectedDateChanged() {
logger.debug(
() =>
'Today screen selected date changed. date=${selectedDates.date.toIsoString()}',
);
_selectedCard = null; _selectedCard = null;
unawaited(load()); unawaited(load());
} }
@ -197,7 +252,10 @@ class TodayScreenController extends ChangeNotifier {
/// Runs the `_emptyDataFor` helper used inside this library. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
TodayScreenData _emptyDataFor(CivilDate date) { 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. /// Runs the `_syncSelectedCard` helper used inside this library.
@ -213,6 +271,14 @@ class TodayScreenController extends ChangeNotifier {
return; return;
} }
} }
logger.finer(
() =>
'Today screen selected card no longer present. cardId=${selected.id}',
);
_selectedCard = null; _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();
} }

View file

@ -5,9 +5,14 @@
library; library;
import 'package:flutter/material.dart'; 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/focus_flow_app.dart';
import 'app/persistent_scheduler_composition.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/demo_scheduler_composition.dart';
export 'app/focus_flow_app.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. /// Starts the FocusFlow app with the persistent SQLite scheduler composition.
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
var logger = FocusFlowFileLogger.disabled();
try { 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)); 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())); runApp(_StartupFailureApp(message: error.toString()));
} }
} }

View file

@ -25,7 +25,11 @@ String _dateLabel(CivilDate date) {
/// Top-level helper that performs the `_shortDateTimeLabel` operation for this file. /// 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. /// 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 = [ const months = [
'Jan', 'Jan',
'Feb', 'Feb',
@ -40,36 +44,52 @@ String _shortDateTimeLabel(DateTime value) {
'Nov', 'Nov',
'Dec', '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. /// 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. /// 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) { if (value == null) {
return 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. /// 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. /// 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) { if (value == null) {
return 0; 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. /// 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. /// 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) { if (value == null) {
return ''; return '';
} }
final period = value.hour >= 12 ? 'PM' : 'AM'; final localValue = _displayDateTime(value, timeZoneOffset);
final rawHour = value.hour % 12; final period = localValue.hour >= 12 ? 'PM' : 'AM';
final rawHour = localValue.hour % 12;
final hour = rawHour == 0 ? 12 : rawHour; 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'; 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);
}

View file

@ -21,26 +21,35 @@ class TimelineCardModel {
required this.isCompleted, required this.isCompleted,
required this.isSelectable, required this.isSelectable,
required this.showsQuickActions, required this.showsQuickActions,
this.showPastTaskPushButton = false,
this.completionDateContextRequired = false, this.completionDateContextRequired = false,
this.completedTimeText, this.completedTimeText,
this.expectedUpdatedAt,
this.durationMinutes, this.durationMinutes,
}); });
/// Maps a scheduler timeline item into card presentation data. /// 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 start = item.start;
final end = item.end; final end = item.end;
final visualKind = _visualKindFor(item); final visualKind = _visualKindFor(item);
final isCompleted = item.taskStatus == TaskStatus.completed; final isCompleted = item.taskStatus == TaskStatus.completed;
final showPastTaskPushButton = _showPastTaskPushButton(item, now: now);
final title = item.item.displayTitle; final title = item.item.displayTitle;
final duration = item.item.durationMinutes; final duration = item.item.durationMinutes;
final timeText = start == null || end == null final timeText = start == null || end == null
? duration == null ? duration == null
? '' ? ''
: '$duration min' : '$duration min'
: '${_formatTime(start)} - ${_formatTime(end)}'; : '${_formatTime(start, timeZoneOffset: timeZoneOffset)} - '
'${_formatTime(end, timeZoneOffset: timeZoneOffset)}';
final completionDateContextRequired = final completionDateContextRequired =
isCompleted && _completionDateContextRequired(item); isCompleted &&
_completionDateContextRequired(item, timeZoneOffset: timeZoneOffset);
return TimelineCardModel( return TimelineCardModel(
id: item.id, id: item.id,
title: title, title: title,
@ -50,10 +59,14 @@ class TimelineCardModel {
visualKind, visualKind,
timeText, timeText,
completionDateContextRequired: completionDateContextRequired, completionDateContextRequired: completionDateContextRequired,
timeZoneOffset: timeZoneOffset,
), ),
timeText: timeText, timeText: timeText,
startMinutes: _minutesSinceMidnight(start), startMinutes: _minutesSinceMidnight(
endMinutes: _minutesSinceMidnight(end), start,
timeZoneOffset: timeZoneOffset,
),
endMinutes: _minutesSinceMidnight(end, timeZoneOffset: timeZoneOffset),
taskType: item.taskType, taskType: item.taskType,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: item.item.rewardIconToken, rewardIconToken: item.item.rewardIconToken,
@ -64,11 +77,17 @@ class TimelineCardModel {
? _completionDisplayText( ? _completionDisplayText(
item, item,
includeDate: completionDateContextRequired, includeDate: completionDateContextRequired,
timeZoneOffset: timeZoneOffset,
) )
: null, : null,
expectedUpdatedAt: item.item.updatedAt,
isCompleted: isCompleted, isCompleted: isCompleted,
isSelectable: true, 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. /// Display-ready completion time or date-time, if the task has been completed.
final String? completedTimeText; final String? completedTimeText;
/// Expected update timestamp for stale-write protection.
final DateTime? expectedUpdatedAt;
/// Whether completion text includes date context because completion crossed dates. /// Whether completion text includes date context because completion crossed dates.
final bool completionDateContextRequired; final bool completionDateContextRequired;
@ -123,6 +145,12 @@ class TimelineCardModel {
/// Whether the card should show compact quick-action controls. /// Whether the card should show compact quick-action controls.
final bool showsQuickActions; 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. /// Whether the left status control can toggle this task's completion.
bool get canToggleCompletion { bool get canToggleCompletion {
return switch (taskType) { return switch (taskType) {

View file

@ -44,6 +44,7 @@ String _subtitleFor(
TaskVisualKind visualKind, TaskVisualKind visualKind,
String timeText, { String timeText, {
required bool completionDateContextRequired, required bool completionDateContextRequired,
required Duration timeZoneOffset,
}) { }) {
if (visualKind == TaskVisualKind.freeSlot) { if (visualKind == TaskVisualKind.freeSlot) {
return 'Intentional rest'; return 'Intentional rest';
@ -53,6 +54,7 @@ String _subtitleFor(
final completedText = _completionDisplayText( final completedText = _completionDisplayText(
item, item,
includeDate: completionDateContextRequired, includeDate: completionDateContextRequired,
timeZoneOffset: timeZoneOffset,
); );
if (completedText == null || completedText.isEmpty) { if (completedText == null || completedText.isEmpty) {
return 'Completed'; return 'Completed';
@ -70,15 +72,19 @@ String _subtitleFor(
String? _completionDisplayText( String? _completionDisplayText(
TodayTimelineItem item, { TodayTimelineItem item, {
required bool includeDate, required bool includeDate,
required Duration timeZoneOffset,
}) { }) {
final completionInstant = _completionDisplayInstant(item); final completionInstant = _completionDisplayInstant(item);
if (completionInstant == null) { if (completionInstant == null) {
return null; return null;
} }
if (includeDate) { 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. /// 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. /// 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. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
bool _completionDateContextRequired(TodayTimelineItem item) { bool _completionDateContextRequired(
final scheduledDate = _civilDateForDateTime(item.item.start ?? item.start); TodayTimelineItem item, {
required Duration timeZoneOffset,
}) {
final scheduledDate = _civilDateForDateTime(
item.item.start ?? item.start,
timeZoneOffset: timeZoneOffset,
);
if (scheduledDate == null) { if (scheduledDate == null) {
return false; return false;
} }
final completedAtDate = _civilDateForDateTime(item.item.completedAt); final completedAtDate = _civilDateForDateTime(
final actualStartDate = _civilDateForDateTime(item.item.actualStart); item.item.completedAt,
final actualEndDate = _civilDateForDateTime(item.item.actualEnd); timeZoneOffset: timeZoneOffset,
);
final actualStartDate = _civilDateForDateTime(
item.item.actualStart,
timeZoneOffset: timeZoneOffset,
);
final actualEndDate = _civilDateForDateTime(
item.item.actualEnd,
timeZoneOffset: timeZoneOffset,
);
return completedAtDate != null && completedAtDate != scheduledDate || return completedAtDate != null && completedAtDate != scheduledDate ||
actualStartDate != null && actualStartDate != scheduledDate || actualStartDate != null && actualStartDate != scheduledDate ||
actualEndDate != null && actualEndDate != 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);
}

View file

@ -10,22 +10,36 @@ class TodayScreenData {
required this.dateLabel, required this.dateLabel,
required this.timelineRange, required this.timelineRange,
required this.cards, required this.cards,
this.timeZoneOffset = Duration.zero,
this.requiredBanner, this.requiredBanner,
}); });
/// Creates an empty Today screen model for [dateLabel]. /// 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( return TodayScreenData(
dateLabel: dateLabel, dateLabel: dateLabel,
timelineRange: defaultRange, timelineRange: defaultRange,
cards: const [], cards: const [],
timeZoneOffset: timeZoneOffset,
); );
} }
/// Maps scheduler core [TodayState] into UI presentation data. /// 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 = [ 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; final nextRequired = state.nextRequiredItem;
return TodayScreenData( return TodayScreenData(
@ -36,9 +50,13 @@ class TodayScreenData {
: RequiredBannerModel( : RequiredBannerModel(
timelineCardId: nextRequired.id, timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle, title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start), timeText: _formatTime(
nextRequired.start,
timeZoneOffset: timeZoneOffset,
),
), ),
cards: List<TimelineCardModel>.unmodifiable(cards), cards: List<TimelineCardModel>.unmodifiable(cards),
timeZoneOffset: timeZoneOffset,
); );
} }
@ -57,6 +75,9 @@ class TodayScreenData {
/// Visible timeline range. /// Visible timeline range.
final TimelineRangeModel timelineRange; final TimelineRangeModel timelineRange;
/// UI-local offset used for wall-clock timeline labels and positions.
final Duration timeZoneOffset;
/// Timeline cards to render. /// Timeline cards to render.
final List<TimelineCardModel> cards; final List<TimelineCardModel> cards;
} }

View file

@ -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<StatusCircleButton> createState() => _StatusCircleButtonState();
}
/// Mutable state for [StatusCircleButton].
class _StatusCircleButtonState extends State<StatusCircleButton> {
/// 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,
),
),
);
}
}

View file

@ -3,12 +3,24 @@
part of '../task_selection_modal.dart'; 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. /// 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. /// 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. /// 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. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
@ -18,29 +30,309 @@ class _ActionButton extends StatelessWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton.icon( return _ModalActionSurface(
onPressed: () {}, icon: widget.icon,
style: OutlinedButton.styleFrom( label: widget.label,
foregroundColor: FocusFlowTokens.textPrimary, isHovered: _isHovered,
side: const BorderSide(color: FocusFlowTokens.subtleBorder), onHoverChanged: (value) {
shape: RoundedRectangleBorder( 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<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> 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;
});
},
),
);
}
/// 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<void> _runPush(Future<void> 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<void> 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<bool> 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( borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius, 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),
), ),
), ),
icon: Icon(icon), const SizedBox(width: 8),
label: Text( Flexible(
child: Text(
label, label,
style: const TextStyle( maxLines: 1,
fontSize: FocusFlowTokens.modalButtonSize, overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
),
],
),
),
),
),
); );
} }
} }

View file

@ -3,50 +3,71 @@
part of '../task_selection_modal.dart'; part of '../task_selection_modal.dart';
/// Private implementation type for `_InfoTile` in this library. /// Private implementation type for `_HeaderMetricIcons` 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 keeps task effort and reward metadata near the modal close action.
class _InfoTile extends StatelessWidget { class _HeaderMetricIcons extends StatelessWidget {
/// Creates a `_InfoTile` instance with the values required by its invariants. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Widget child; final TimelineCardModel card;
/// 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;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Column(
height: 58, mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Row(
children: [ children: [
child, _HeaderMetricIcon(
const SizedBox(width: 18), tooltip: card.effortLabel == 'Effort not set'
Flexible( ? 'Not Set'
child: Text( : card.effortLabel,
label, child: DifficultyBars(
maxLines: 1, difficulty: card.difficultyIconToken,
overflow: TextOverflow.ellipsis, color: FocusFlowTokens.restPurple,
style: const TextStyle( size: const Size(28, 16),
color: FocusFlowTokens.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
), ),
), ),
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)),
); );
} }
} }

View file

@ -4,7 +4,7 @@
part of '../task_selection_modal.dart'; part of '../task_selection_modal.dart';
/// Private implementation type for `_ModalStatusButton` in this library. /// 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 { class _ModalStatusButton extends StatelessWidget {
/// Creates a `_ModalStatusButton` instance with the values required by its invariants. /// 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. /// 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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ring = Container( return StatusCircleButton(
key: const ValueKey('task-modal-status'), circleKey: const ValueKey('task-modal-status'),
width: 44, color: color,
height: 44, checkColor: FocusFlowTokens.appBackground,
decoration: BoxDecoration( isCompleted: card.isCompleted,
shape: BoxShape.circle, size: 44,
color: card.isCompleted ? color : Colors.transparent, borderWidth: 4,
border: Border.all(color: color, width: 4), iconSize: 26,
), hoverBlurRadius: 12,
child: card.isCompleted semanticLabel: 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} not done'
: 'Mark ${card.title} complete', : 'Mark ${card.title} complete',
child: MouseRegion( onPressed: onPressed,
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
); );
} }
} }

View file

@ -4,10 +4,13 @@
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI. /// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
library; library;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
import 'status_circle_button.dart';
import 'timeline/difficulty_bars.dart'; import 'timeline/difficulty_bars.dart';
import 'timeline/reward_icon.dart'; import 'timeline/reward_icon.dart';
@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget {
required this.card, required this.card,
required this.onClose, required this.onClose,
this.onStatusPressed, this.onStatusPressed,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
this.onRemove,
super.key, super.key,
}); });
@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when the status circle should toggle completion. /// Callback invoked when the status circle should toggle completion.
final VoidCallback? onStatusPressed; final VoidCallback? onStatusPressed;
/// Callback invoked when Push to next is selected.
final Future<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked when Remove is selected.
final Future<void> Function(TimelineCardModel card)? onRemove;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 14),
_HeaderMetricIcons(card: card),
const SizedBox(width: 14),
IconButton( IconButton(
key: const ValueKey('close-task-modal'), key: const ValueKey('close-task-modal'),
onPressed: onClose, onPressed: onClose,
@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 26), const SizedBox(height: 24),
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),
GridView.count( GridView.count(
crossAxisCount: 2, crossAxisCount: 2,
crossAxisSpacing: 26, crossAxisSpacing: 18,
mainAxisSpacing: 16, mainAxisSpacing: 12,
childAspectRatio: 4.1, childAspectRatio: 5.3,
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: const [ children: [
_ActionButton(icon: Icons.check, label: 'Done'), _PushActionButton(
_ActionButton(icon: Icons.arrow_forward, label: 'Push'), card: card,
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
),
_ActionButton( _ActionButton(
icon: Icons.inventory_2_outlined, 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), const SizedBox(height: 14),

View file

@ -114,7 +114,19 @@ class _CardMetrics {
/// Returns the derived `actionsWidth` value for this object. /// 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. /// 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. /// 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. /// 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 => double get expandedTrailingReserve =>
indicatorWidth + math.max(8, 12 * scale); 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. /// 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. /// 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); 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. /// 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. /// 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); Size get difficultySize => Size(26, 14);

View file

@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget {
style: metaStyle, style: metaStyle,
), ),
), ),
SizedBox(width: metrics.compactTrailingReserve), SizedBox(
width: card.showPastTaskPushButton
? metrics.compactPushTrailingReserve
: metrics.compactTrailingReserve,
),
], ],
); );
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {

View file

@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget {
], ],
Expanded( Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), padding: EdgeInsets.only(
right: card.showPastTaskPushButton
? metrics.expandedPushTrailingReserve
: metrics.expandedTrailingReserve,
),
child: _CardText( child: _CardText(
card: card, card: card,
color: colors.accent, color: colors.accent,

View file

@ -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. /// 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({ const _NoOpIcon({
required this.icon, required this.icon,
required this.tooltip,
required this.color, required this.color,
required this.metrics, 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon; final IconData icon;
/// Tooltip message for this inactive icon affordance.
final String tooltip;
/// Stores the `color` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color; final Color color;
@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( return Tooltip(
message: 'No-op action', message: tooltip,
child: GestureDetector( child: GestureDetector(
onTap: () {}, onTap: () {},
behavior: HitTestBehavior.opaque, 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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> 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,
),
),
),
);
}
}

View file

@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget {
/// Creates a `_QuickActions` instance with the values required by its invariants. /// 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. /// 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({ const _QuickActions({
required this.card,
required this.color, required this.color,
required this.backgroundColor, required this.backgroundColor,
required this.metrics, required this.metrics,
required this.visible, 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color; 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool visible; 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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget {
ignoring: !visible, ignoring: !visible,
child: ClipRect( child: ClipRect(
child: AnimatedContainer( child: AnimatedContainer(
key: ValueKey('${card.id}-quick-actions-background'),
width: visible ? metrics.actionsWidth : 0, width: visible ? metrics.actionsWidth : 0,
height: metrics.actionButtonSize, height: metrics.actionButtonSize,
decoration: BoxDecoration(color: backgroundColor), decoration: BoxDecoration(color: backgroundColor),
@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget {
maxWidth: metrics.actionsWidth, maxWidth: metrics.actionsWidth,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: AnimatedOpacity( child: AnimatedOpacity(
key: ValueKey('${card.id}-quick-actions-opacity'),
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 90), duration: const Duration(milliseconds: 90),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_NoOpIcon( _QuickPushIcon(
icon: Icons.check, card: card,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color, color: color,
metrics: metrics, metrics: metrics,
isRunning: pushRunning,
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
), ),
_NoOpIcon( _NoOpIcon(
icon: Icons.calendar_today, icon: Icons.calendar_today,
tooltip: 'Schedule',
color: color, color: color,
metrics: metrics, metrics: metrics,
), ),
_NoOpIcon( _NoOpIcon(
icon: Icons.wb_sunny_outlined, icon: Icons.wb_sunny_outlined,
tooltip: 'Protect time',
color: color, color: color,
metrics: metrics, metrics: metrics,
), ),

View file

@ -4,7 +4,7 @@
part of '../task_timeline_card.dart'; part of '../task_timeline_card.dart';
/// Private implementation type for `_StatusRing` in this library. /// 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 { class _StatusRing extends StatelessWidget {
/// Creates a `_StatusRing` instance with the values required by its invariants. /// 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. /// 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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = Border.all(color: color, width: metrics.statusBorderWidth); return StatusCircleButton(
final ring = Container( circleKey: ValueKey('${card.id}-status'),
key: ValueKey('${card.id}-status'), color: color,
width: metrics.statusRingSize, checkColor: FocusFlowTokens.appBackground,
height: metrics.statusRingSize, isCompleted: card.isCompleted,
decoration: BoxDecoration( size: metrics.statusRingSize,
shape: BoxShape.circle, borderWidth: metrics.statusBorderWidth,
color: card.isCompleted ? color : Colors.transparent, iconSize: metrics.statusIconSize,
border: border, semanticLabel: card.isCompleted
),
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} not done'
: 'Mark ${card.title} complete', : 'Mark ${card.title} complete',
child: MouseRegion( onPressed: onPressed,
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
); );
} }
} }

View file

@ -10,6 +10,10 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
bool _isHovered = false; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
colors: colors, colors: colors,
metrics: metrics, metrics: metrics,
actionsVisible: card.showsQuickActions && _isHovered, 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<TaskTimelineCard> {
}, },
); );
} }
/// 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<void> _runPush(
Future<void> 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;
});
}
}
}
} }

View file

@ -13,6 +13,10 @@ class _TrailingControls extends StatelessWidget {
required this.colors, required this.colors,
required this.metrics, required this.metrics,
required this.actionsVisible, required this.actionsVisible,
required this.pushRunning,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
}); });
/// Stores the `card` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool actionsVisible; 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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -40,11 +57,29 @@ class _TrailingControls extends StatelessWidget {
children: [ children: [
if (card.showsQuickActions) if (card.showsQuickActions)
_QuickActions( _QuickActions(
card: card,
color: colors.accent, color: colors.accent,
backgroundColor: colors.background.withValues(alpha: 1), backgroundColor: colors.background.withValues(alpha: 1),
metrics: metrics, metrics: metrics,
visible: actionsVisible, 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), RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
SizedBox(width: metrics.indicatorGap), SizedBox(width: metrics.indicatorGap),
DifficultyBars( 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<void> 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<void> Function()? onPushToNext,
required Future<void> Function()? onPushToTomorrow,
required Future<void> 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<PopupMenuEntry<_PastTaskPushDestination>> _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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> 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,
),
),
),
),
);
}
}

View file

@ -4,12 +4,15 @@
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline. /// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
library; library;
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart' show logger;
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import '../status_circle_button.dart';
import 'difficulty_bars.dart'; import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';
@ -31,6 +34,9 @@ class TaskTimelineCard extends StatefulWidget {
required this.card, required this.card,
required this.onTap, required this.onTap,
this.onStatusPressed, this.onStatusPressed,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
super.key, super.key,
}); });
@ -43,6 +49,15 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the left status control is clicked. /// Callback invoked when the left status control is clicked.
final VoidCallback? onStatusPressed; final VoidCallback? onStatusPressed;
/// Callback invoked when the card requests Push to next.
final Future<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when the card requests Push to tomorrow.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when the card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Creates the mutable state object used by this stateful widget. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -26,9 +26,13 @@ class TimelineView extends StatefulWidget {
required this.onCardSelected, required this.onCardSelected,
this.onAddTask, this.onAddTask,
this.onCardCompleted, this.onCardCompleted,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
this.scrollToNowRequest = 0,
super.key, super.key,
}); });
@ -47,6 +51,15 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a task card status control requests completion. /// Callback invoked when a task card status control requests completion.
final ValueChanged<TimelineCardModel>? onCardCompleted; final ValueChanged<TimelineCardModel>? onCardCompleted;
/// Callback invoked when a task card requests Push to next.
final Future<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when a task card requests Push to tomorrow.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when a task card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Clock used to center the initial scroll position. /// Clock used to center the initial scroll position.
final DateTime Function()? now; final DateTime Function()? now;
@ -56,6 +69,9 @@ class TimelineView extends StatefulWidget {
/// Monotonic request number that allows repeated jumps to the same card. /// Monotonic request number that allows repeated jumps to the same card.
final int scrollRequest; 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. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -28,6 +28,10 @@ class _TimelineViewState extends State<TimelineView> {
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _handledScrollRequest = 0; 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. /// 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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late TimelineGeometry _geometry; late TimelineGeometry _geometry;
@ -86,6 +90,7 @@ class _TimelineViewState extends State<TimelineView> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_scheduleInitialScroll(_geometry, constraints.maxHeight); _scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleCurrentTimeScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry); _scheduleTargetScroll(_geometry);
return ScrollConfiguration( return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(), behavior: const _TimelineScrollBehavior(),
@ -140,6 +145,9 @@ class _TimelineViewState extends State<TimelineView> {
: () => widget.onCardCompleted!( : () => widget.onCardCompleted!(
placement.card, placement.card,
), ),
onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
), ),
), ),
), ),
@ -234,21 +242,31 @@ class _TimelineViewState extends State<TimelineView> {
return; return;
} }
final now = widget.now?.call() ?? DateTime.now(); final now = widget.now?.call() ?? DateTime.now();
final currentMinute = now.hour * 60 + now.minute; _jumpToCurrentTime(geometry, viewportHeight, now);
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);
_didSetInitialScroll = true; _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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _scheduleTargetScroll(TimelineGeometry geometry) { void _scheduleTargetScroll(TimelineGeometry geometry) {
@ -281,4 +299,40 @@ class _TimelineViewState extends State<TimelineView> {
_handledScrollRequest = widget.scrollRequest; _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();
}
} }

View file

@ -21,6 +21,7 @@ class TopBar extends StatelessWidget {
this.onPreviousDate, this.onPreviousDate,
this.onNextDate, this.onNextDate,
this.onDatePressed, this.onDatePressed,
this.onDateLabelPressed,
super.key, super.key,
}); });
@ -36,6 +37,9 @@ class TopBar extends StatelessWidget {
/// Callback invoked when the choose-date control is activated. /// Callback invoked when the choose-date control is activated.
final VoidCallback? onDatePressed; final VoidCallback? onDatePressed;
/// Callback invoked when the displayed date label is activated.
final VoidCallback? onDateLabelPressed;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -78,13 +82,13 @@ class TopBar extends StatelessWidget {
width: metrics.dateWidth, width: metrics.dateWidth,
height: metrics.controlHeight, height: metrics.controlHeight,
child: Tooltip( child: Tooltip(
message: 'Choose date', message: 'Scroll to current time',
child: Semantics( child: Semantics(
button: true, button: true,
label: 'Choose date, $dateLabel', label: 'Scroll to current time, $dateLabel',
child: TextButton( child: TextButton(
key: const ValueKey('top-bar-date-button'), key: const ValueKey('top-bar-date-button'),
onPressed: onDatePressed, onPressed: onDateLabelPressed,
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary, foregroundColor: FocusFlowTokens.textPrimary,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(

View file

@ -35,7 +35,7 @@ void main() {
testWidgets('date picker jumps directly to the chosen date', (tester) async { testWidgets('date picker jumps directly to the chosen date', (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); await tester.tap(find.byTooltip('Choose date'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(DatePickerDialog), findsOneWidget); expect(find.byType(DatePickerDialog), findsOneWidget);
@ -49,6 +49,18 @@ void main() {
expect(find.text('Clean coffee maker'), findsNothing); 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 { testWidgets('open modal closes when changing dates', (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);

View file

@ -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')));
});
});
}

View file

@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
), ),
if (relativePath != 'test/forbidden_imports_test.dart' && if (relativePath != 'test/forbidden_imports_test.dart' &&
relativePath != 'test/persistent_composition_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'), const _ForbiddenImport("dart:io", 'dart:io'),
]; ];
} }
@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) {
return relativePath == 'lib/app/persistent_scheduler_composition.dart'; 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. /// 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. /// 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 { class _ForbiddenImport {

View file

@ -18,6 +18,7 @@ void main() {
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
completedAt: DateTime.utc(2026, 1, 3, 9, 25), completedAt: DateTime.utc(2026, 1, 3, 9, 25),
), ),
now: DateTime.utc(2026, 1, 3, 10),
); );
expect(card.completionDateContextRequired, isFalse); expect(card.completionDateContextRequired, isFalse);
@ -32,6 +33,7 @@ void main() {
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30), scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
completedAt: DateTime.utc(2026, 1, 4, 8, 5), completedAt: DateTime.utc(2026, 1, 4, 8, 5),
), ),
now: DateTime.utc(2026, 1, 4, 9),
); );
expect(card.completionDateContextRequired, isTrue); expect(card.completionDateContextRequired, isTrue);
@ -48,6 +50,7 @@ void main() {
actualStart: DateTime.utc(2026, 1, 4, 0, 10), actualStart: DateTime.utc(2026, 1, 4, 0, 10),
actualEnd: DateTime.utc(2026, 1, 4, 0, 20), actualEnd: DateTime.utc(2026, 1, 4, 0, 20),
), ),
now: DateTime.utc(2026, 1, 4, 1),
); );
expect(card.completionDateContextRequired, isTrue); expect(card.completionDateContextRequired, isTrue);

View file

@ -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}',
);
}

View file

@ -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/focus_flow_app.dart';
import 'package:focus_flow_flutter/app/persistent_scheduler_composition.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/controllers/scheduler_command_controller.dart';
import 'package:focus_flow_flutter/models/today_screen_models.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<OwnerSettings?>(
action: (repositories) async {
return ApplicationResult.success(
await repositories.ownerSettings.findByOwnerId('owner-1'),
);
},
),
);
expect(storedSettings.requireValue?.timeZoneId, 'UTC');
});
testWidgets( testWidgets(
'selected future-day schedule completion and uncomplete persist across reopen', 'selected future-day schedule completion and uncomplete persist across reopen',
(tester) async { (tester) async {
@ -128,7 +166,7 @@ void main() {
selectedDate: selectedDate, selectedDate: selectedDate,
clock: FixedClock(_instant(8, 10)), clock: FixedClock(_instant(8, 10)),
); );
var scheduled = await _readTodayItem( final scheduled = await _readTodayItem(
tester, tester,
composition, composition,
tomorrow, tomorrow,
@ -169,6 +207,7 @@ void main() {
expect(completed.item.completedAt, isNotNull); expect(completed.item.completedAt, isNotNull);
final completedData = TodayScreenData.fromTodayState( final completedData = TodayScreenData.fromTodayState(
await _readToday(tester, composition, tomorrow), await _readToday(tester, composition, tomorrow),
now: DateTime.utc(2026, 1, 4, 8, 20),
); );
final completedCard = completedData.cards.singleWhere( final completedCard = completedData.cards.singleWhere(
(card) => card.title == title, (card) => card.title == title,
@ -208,6 +247,239 @@ void main() {
expect((await _readBacklog(tester, composition)).items, isEmpty); 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<SchedulerCommandSuccess>());
final pushTomorrowItem = await _readTodayItem(
tester,
composition,
_date,
pushTomorrowTitle,
);
await tester.runAsync(
() => commandController.pushTaskToTomorrow(
taskId: pushTomorrowItem.id,
expectedUpdatedAt: pushTomorrowItem.item.updatedAt,
),
);
expect(commandController.state, isA<SchedulerCommandSuccess>());
final pushBacklogItem = await _readTodayItem(
tester,
composition,
_date,
pushBacklogTitle,
);
await tester.runAsync(
() => commandController.pushTaskToBacklog(
taskId: pushBacklogItem.id,
expectedUpdatedAt: pushBacklogItem.item.updatedAt,
),
);
expect(commandController.state, isA<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
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. /// Fixed date used by persistent Flutter tests.
@ -233,6 +505,7 @@ Future<PersistentSchedulerComposition> _openComposition(
required String path, required String path,
required CivilDate selectedDate, required CivilDate selectedDate,
required Clock clock, required Clock clock,
FocusFlowUiTimeZone? uiTimeZone,
}) { }) {
return _runAsync( return _runAsync(
tester, tester,
@ -240,6 +513,7 @@ Future<PersistentSchedulerComposition> _openComposition(
sqlitePath: path, sqlitePath: path,
selectedDate: selectedDate, selectedDate: selectedDate,
clock: clock, clock: clock,
uiTimeZone: uiTimeZone,
), ),
); );
} }
@ -283,10 +557,35 @@ SchedulerCommandController _commandController(
refreshReads: () async {}, refreshReads: () async {},
quickCaptureProjectId: composition.defaultProjectId, quickCaptureProjectId: composition.defaultProjectId,
operationIdPrefix: operationIdPrefix, operationIdPrefix: operationIdPrefix,
currentDateFor: composition.uiTimeZone.toLocalCivilDate,
now: now, now: now,
); );
} }
/// Captures [title] into Backlog and schedules it on the selected test date.
Future<void> _captureAndSchedule(
WidgetTester tester,
PersistentSchedulerComposition composition,
SchedulerCommandController commandController,
String title,
) async {
await tester.runAsync(() => commandController.quickCaptureToBacklog(title));
expect(commandController.state, isA<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
}
/// Reads one Today timeline item by [title]. /// Reads one Today timeline item by [title].
Future<TodayTimelineItem> _readTodayItem( Future<TodayTimelineItem> _readTodayItem(
WidgetTester tester, WidgetTester tester,

View file

@ -4,6 +4,8 @@
/// Tests Widget behavior in the FocusFlow Flutter app. /// Tests Widget behavior in the FocusFlow Flutter app.
library; library;
import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/models/today_screen_models.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.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/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/difficulty_bars.dart';
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.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', 'visual token adapter maps seeded items to expected visual kinds',
() async { () async {
final state = await _readSeededToday(); 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, 'clean-coffee-maker'), TaskVisualKind.flexible);
expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible); expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible);
@ -90,7 +96,7 @@ void main() {
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); 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, tester,
) async { ) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
@ -104,14 +110,18 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.textContaining('Required'), findsWidgets); expect(find.textContaining('Required'), findsWidgets);
expect(find.text('Reward level 2'), findsOneWidget); expect(find.byTooltip('Reward level 2'), findsOneWidget);
expect(find.text('Medium effort'), findsOneWidget); expect(find.byTooltip('Medium effort'), findsOneWidget);
expect(find.text('Done'), findsOneWidget); expect(find.byTooltip('Mark complete'), findsNothing);
expect(find.text('Push'), findsOneWidget); expect(find.text('Reward level 2'), findsNothing);
expect(find.text('Move to Backlog'), findsOneWidget); 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); expect(find.text('Break up'), findsOneWidget);
await tester.tap(find.text('Done')); await tester.tap(find.text('Break up'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
@ -132,6 +142,91 @@ void main() {
expect(find.text('Pay bill'), findsOneWidget); 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<void> 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', () { test('Today screen timeline range covers the full day', () {
expect(TodayScreenData.defaultRange.startMinutes, 0); expect(TodayScreenData.defaultRange.startMinutes, 0);
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
@ -193,7 +288,7 @@ void main() {
}); });
testWidgets( testWidgets(
'timeline defaults near the current time while keeping full day', 'timeline defaults with current time one quarter down the viewport',
(tester) async { (tester) async {
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -213,10 +308,48 @@ void main() {
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable)); final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final position = scrollable.position; final position = scrollable.position;
expect(position.maxScrollExtent, greaterThan(3000)); 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<ScrollableState>(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 { testWidgets('timeline hour labels stay on one line', (tester) async {
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -357,7 +490,10 @@ void main() {
tester, tester,
) async { ) async {
final state = await _readSeededToday(); final state = await _readSeededToday();
final data = TodayScreenData.fromTodayState(state); final data = TodayScreenData.fromTodayState(
state,
now: DateTime.utc(2025, 5, 20, 15, 20),
);
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -422,6 +558,7 @@ void main() {
var previousCount = 0; var previousCount = 0;
var nextCount = 0; var nextCount = 0;
var datePressedCount = 0; var datePressedCount = 0;
var dateLabelPressedCount = 0;
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -437,18 +574,24 @@ void main() {
onDatePressed: () { onDatePressed: () {
datePressedCount += 1; datePressedCount += 1;
}, },
onDateLabelPressed: () {
dateLabelPressedCount += 1;
},
), ),
); );
await tester.tap(find.byTooltip('Previous day')); await tester.tap(find.byTooltip('Previous day'));
await tester.tap(find.byTooltip('Next 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.tap(find.byKey(const ValueKey('top-bar-date-button')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(previousCount, 1); expect(previousCount, 1);
expect(nextCount, 1); expect(nextCount, 1);
expect(datePressedCount, 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', ( testWidgets('required banner scales inside a compact header width', (
@ -507,7 +650,11 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect( expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity, tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
0, 0,
); );
@ -518,11 +665,15 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity, tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
1, 1,
); );
final actionBackground = tester.widget<AnimatedContainer>( final actionBackground = tester.widget<AnimatedContainer>(
find.byType(AnimatedContainer), find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
); );
final decoration = actionBackground.decoration; final decoration = actionBackground.decoration;
expect(decoration, isA<BoxDecoration>()); expect(decoration, isA<BoxDecoration>());
@ -540,6 +691,243 @@ void main() {
await gesture.removePointer(); 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<void> 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<void> 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<void>();
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 { testWidgets('short task card keeps time inline with title', (tester) async {
await _pumpTimelineCard( await _pumpTimelineCard(
tester, tester,
@ -593,6 +981,33 @@ void main() {
await _pumpPlanApp(tester, composition: composition); await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
var statusDecoration =
tester
.widget<AnimatedContainer>(
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<AnimatedContainer>(
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(); final beforeClick = DateTime.now().toUtc();
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -610,6 +1025,26 @@ void main() {
expect(completedTask.completedAt!.isAfter(afterClick), isFalse); expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
expect(find.textContaining('Completed at:'), findsWidgets); 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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -638,6 +1073,32 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing); expect(find.textContaining('Completed -'), findsNothing);
var modalStatusDecoration =
tester
.widget<AnimatedContainer>(
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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -652,6 +1113,26 @@ void main() {
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.byIcon(Icons.check), findsWidgets); 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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -739,8 +1220,13 @@ Future<void> _pumpTimelineCard(
WidgetTester tester, { WidgetTester tester, {
required TimelineCardModel card, required TimelineCardModel card,
required Size size, required Size size,
Future<void> Function(TimelineCardModel card)? onPushToNext,
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
}) async { }) 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)); addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -753,6 +1239,9 @@ Future<void> _pumpTimelineCard(
card: card, card: card,
onTap: () {}, onTap: () {},
onStatusPressed: () {}, onStatusPressed: () {},
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
), ),
), ),
), ),
@ -796,7 +1285,12 @@ TimelineCardModel _timelineCard({
TaskVisualKind visualKind = TaskVisualKind.flexible, TaskVisualKind visualKind = TaskVisualKind.flexible,
TaskType? taskType, TaskType? taskType,
bool showsQuickActions = true, bool showsQuickActions = true,
bool showPastTaskPushButton = false,
bool isCompleted = false,
String? completedTimeText, String? completedTimeText,
TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium,
TimelineDifficultyIconToken difficultyIconToken =
TimelineDifficultyIconToken.medium,
}) { }) {
final resolvedTaskType = final resolvedTaskType =
taskType ?? taskType ??
@ -817,12 +1311,13 @@ TimelineCardModel _timelineCard({
endMinutes: endMinutes, endMinutes: endMinutes,
taskType: resolvedTaskType, taskType: resolvedTaskType,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium, rewardIconToken: rewardIconToken,
difficultyIconToken: TimelineDifficultyIconToken.medium, difficultyIconToken: difficultyIconToken,
completedTimeText: completedTimeText, completedTimeText: completedTimeText,
isCompleted: false, isCompleted: isCompleted,
isSelectable: true, isSelectable: true,
showsQuickActions: showsQuickActions, showsQuickActions: showPastTaskPushButton ? false : showsQuickActions,
showPastTaskPushButton: showPastTaskPushButton,
durationMinutes: endMinutes - startMinutes, durationMinutes: endMinutes - startMinutes,
); );
} }

View file

@ -10,6 +10,7 @@ import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cryptography/cryptography.dart'; 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/errors/backup_decryption_exception.dart';
part 'encrypted_backup/codec/backup_encryption_codec.dart'; part 'encrypted_backup/codec/backup_encryption_codec.dart';
part 'encrypted_backup/errors/backup_exception.dart'; part 'encrypted_backup/errors/backup_exception.dart';

View file

@ -56,9 +56,19 @@ Future<List<int>> _decryptBytes({
SecretBox(cipherText, nonce: nonce, mac: mac), SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: key, secretKey: key,
); );
} on BackupDecryptionException { } on BackupDecryptionException catch (error, stackTrace) {
logger.error(
() => 'Encrypted backup decryption failed with known backup error.',
error: error,
stackTrace: stackTrace,
);
rethrow; rethrow;
} on Object { } on Object catch (error, stackTrace) {
logger.error(
() => 'Encrypted backup decryption failed.',
error: error,
stackTrace: stackTrace,
);
throw const BackupDecryptionException(); throw const BackupDecryptionException();
} }
} }

View file

@ -16,7 +16,10 @@ Future<File> createEncryptedBackup({
DateTime? now, DateTime? now,
}) async { }) async {
final source = sqliteFile ?? defaultSchedulerSqliteFile(); final source = sqliteFile ?? defaultSchedulerSqliteFile();
logger.info(() => 'Encrypted backup started. sourcePath=${source.path}');
if (!await source.exists()) { if (!await source.exists()) {
logger.warn(() =>
'Encrypted backup source file missing. sourcePath=${source.path}');
throw BackupException('SQLite file does not exist: ${source.path}'); throw BackupException('SQLite file does not exist: ${source.path}');
} }
@ -35,7 +38,11 @@ Future<File> createEncryptedBackup({
passphrase: passphrase, passphrase: passphrase,
plaintext: plaintext, 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. /// Restore an encrypted backup over the scheduler SQLite file.
@ -47,7 +54,11 @@ Future<void> restoreEncryptedBackup(
String passphrase, { String passphrase, {
File? targetFile, File? targetFile,
}) async { }) async {
logger.info(
() => 'Encrypted backup restore started. backupPath=${backup.path}');
if (!await backup.exists()) { if (!await backup.exists()) {
logger.warn(() =>
'Encrypted backup restore source missing. backupPath=${backup.path}');
throw BackupException('Backup file does not exist: ${backup.path}'); throw BackupException('Backup file does not exist: ${backup.path}');
} }
@ -64,4 +75,7 @@ Future<void> restoreEncryptedBackup(
await target.delete(); await target.delete();
} }
await temporary.rename(target.path); await temporary.rename(target.path);
logger.info(
() => 'Encrypted backup restore completed. backupPath=${backup.path} '
'targetPath=${target.path} restoredBytes=${plaintext.length}');
} }

View file

@ -28,6 +28,8 @@ Directory _homeDirectory() {
Platform.environment['USERPROFILE'] ?? Platform.environment['USERPROFILE'] ??
Platform.environment['HOMEDRIVE']; Platform.environment['HOMEDRIVE'];
if (home == null || home.trim().isEmpty) { 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.'); throw const BackupException('Home directory could not be resolved.');
} }
return Directory(home); return Directory(home);

View file

@ -13,6 +13,7 @@ environment:
dependencies: dependencies:
cryptography: ^2.7.0 cryptography: ^2.7.0
encrypt: ^5.0.3 encrypt: ^5.0.3
scheduler_core: any
dev_dependencies: dev_dependencies:
lints: ^5.0.0 lints: ^5.0.0

View file

@ -33,6 +33,7 @@ export 'src/persistence/document_mapping.dart';
export 'src/persistence/document_migration.dart'; export 'src/persistence/document_migration.dart';
export 'src/scheduling/free_slots.dart'; export 'src/scheduling/free_slots.dart';
export 'src/domain/locked_time.dart'; export 'src/domain/locked_time.dart';
export 'src/logging/focus_flow_logger.dart';
export 'src/domain/occupancy_policy.dart'; export 'src/domain/occupancy_policy.dart';
export 'src/persistence/persistence_contract.dart'; export 'src/persistence/persistence_contract.dart';
export 'src/domain/project_statistics.dart'; export 'src/domain/project_statistics.dart';

View file

@ -21,6 +21,8 @@ import '../scheduling/scheduling_engine.dart';
import '../scheduling/task_actions.dart'; import '../scheduling/task_actions.dart';
import '../scheduling/task_lifecycle.dart'; import '../scheduling/task_lifecycle.dart';
import '../domain/time_contracts.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/codes/application_command_code.dart';
part 'application_commands/messages/application_child_task_draft.dart'; part 'application_commands/messages/application_child_task_draft.dart';
part 'application_commands/messages/application_command_read_hint.dart'; part 'application_commands/messages/application_command_read_hint.dart';

View file

@ -29,6 +29,10 @@ enum ApplicationCommandCode {
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
moveFlexibleToBacklog, 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. /// 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
completeFlexibleTask, completeFlexibleTask,

View file

@ -80,6 +80,10 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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( final result = quickCaptureService.capture(
QuickCaptureRequest( QuickCaptureRequest(
id: context.idGenerator.nextId(), id: context.idGenerator.nextId(),
@ -136,6 +140,11 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 = final state =
await _loadPlanningState(repositories, context, localDate); await _loadPlanningState(repositories, context, localDate);
final taskId = context.idGenerator.nextId(); final taskId = context.idGenerator.nextId();
@ -199,9 +208,16 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId); var task = _taskById(state.tasks, taskId);
if (task == null) { 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); task = await repositories.tasks.findById(taskId);
if (task != null) { if (task != null) {
state = state.withTask(task); state = state.withTask(task);
@ -316,6 +332,9 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { action: (repositories) async {
logger.debug(
() => 'Application command executing. command=${commandCode.name} '
'operationId=${context.operationId} taskId=$taskId');
final task = await repositories.tasks.findById(taskId); final task = await repositories.tasks.findById(taskId);
if (task == null) { if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId); return _failure(ApplicationFailureCode.notFound, entityId: taskId);
@ -356,6 +375,54 @@ class V1ApplicationCommandUseCases {
); );
} }
/// Permanently remove a task from persistence.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.removeTask;
return applicationStore.run<ApplicationCommandResult>(
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 <Task>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: true,
),
),
);
},
);
}
/// Complete a flexible task. /// Complete a flexible task.
Future<ApplicationResult<ApplicationCommandResult>> completeFlexibleTask({ Future<ApplicationResult<ApplicationCommandResult>> completeFlexibleTask({
required ApplicationOperationContext context, required ApplicationOperationContext context,
@ -370,9 +437,15 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId); var task = _taskById(state.tasks, taskId);
if (task == null) { 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); task = await repositories.tasks.findById(taskId);
if (task != null) { if (task != null) {
state = state.withTask(task); state = state.withTask(task);
@ -432,9 +505,15 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId); var task = _taskById(state.tasks, taskId);
if (task == null) { 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); task = await repositories.tasks.findById(taskId);
if (task != null) { if (task != null) {
state = state.withTask(task); state = state.withTask(task);
@ -492,9 +571,16 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId); var task = _taskById(state.tasks, taskId);
if (task == null) { 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); task = await repositories.tasks.findById(taskId);
if (task != null) { if (task != null) {
state = state.withTask(task); state = state.withTask(task);
@ -560,6 +646,11 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 = final state =
await _loadPlanningState(repositories, context, localDate); await _loadPlanningState(repositories, context, localDate);
final logResult = surpriseTaskLogService.log( final logResult = surpriseTaskLogService.log(
@ -613,6 +704,11 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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( final freeSlot = freeSlotService.create(
id: context.idGenerator.nextId(), id: context.idGenerator.nextId(),
title: title, title: title,
@ -654,6 +750,11 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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); final task = await repositories.tasks.findById(taskId);
if (task == null) { if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId); return _failure(ApplicationFailureCode.notFound, entityId: taskId);
@ -698,6 +799,9 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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); final task = await repositories.tasks.findById(taskId);
if (task == null) { if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId); return _failure(ApplicationFailureCode.notFound, entityId: taskId);
@ -746,6 +850,10 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 tasks = await repositories.tasks.findAll();
final parent = _taskById(tasks, parentTaskId); final parent = _taskById(tasks, parentTaskId);
if (parent == null) { if (parent == null) {
@ -882,9 +990,16 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { 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 state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId); var task = _taskById(state.tasks, taskId);
if (task == null) { 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); task = await repositories.tasks.findById(taskId);
if (task != null) { if (task != null) {
state = state.withTask(task); state = state.withTask(task);
@ -903,6 +1018,9 @@ class V1ApplicationCommandUseCases {
input: state.input, input: state.input,
taskId: taskId, taskId: taskId,
updatedAt: context.now, updatedAt: context.now,
earliestStart: destination == PushDestination.nextAvailableSlot
? context.now
: null,
operationId: context.operationId, operationId: context.operationId,
existingActivities: existingActivities, existingActivities: existingActivities,
); );
@ -947,6 +1065,9 @@ class V1ApplicationCommandUseCases {
context: context, context: context,
operationName: commandCode.name, operationName: commandCode.name,
action: (repositories) async { action: (repositories) async {
logger.debug(
() => 'Application command executing. command=${commandCode.name} '
'operationId=${context.operationId} taskId=$taskId');
final tasks = await repositories.tasks.findAll(); final tasks = await repositories.tasks.findAll();
final task = _taskById(tasks, taskId); final task = _taskById(tasks, taskId);
if (task == null) { if (task == null) {
@ -986,20 +1107,24 @@ class V1ApplicationCommandUseCases {
CivilDate localDate, CivilDate localDate,
) async { ) async {
final ownerId = context.ownerTimeZone.ownerId; 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) ?? final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ??
OwnerSettings( OwnerSettings(
ownerId: ownerId, ownerId: ownerId,
timeZoneId: context.ownerTimeZone.timeZoneId, timeZoneId: context.ownerTimeZone.timeZoneId,
); );
final timeZoneId = context.ownerTimeZone.timeZoneId;
final effectiveSettings = settings.copyWith(timeZoneId: timeZoneId);
final dayStart = _resolve( final dayStart = _resolve(
date: localDate, date: localDate,
wallTime: settings.dayStart, wallTime: effectiveSettings.dayStart,
timeZoneId: settings.timeZoneId, timeZoneId: timeZoneId,
); );
final dayEnd = _resolve( final dayEnd = _resolve(
date: localDate, date: localDate,
wallTime: settings.dayEnd, wallTime: effectiveSettings.dayEnd,
timeZoneId: settings.timeZoneId, timeZoneId: timeZoneId,
); );
final window = SchedulingWindow(start: dayStart, end: dayEnd); final window = SchedulingWindow(start: dayStart, end: dayEnd);
final tasks = (await repositories.tasks.findForLocalDay( final tasks = (await repositories.tasks.findForLocalDay(
@ -1019,11 +1144,16 @@ class V1ApplicationCommandUseCases {
)) ))
.items, .items,
date: localDate, date: localDate,
timeZoneId: settings.timeZoneId, timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver, timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions, 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( return _PlanningState(
tasks: tasks, tasks: tasks,
window: window, window: window,
@ -1064,6 +1194,11 @@ class V1ApplicationCommandUseCases {
List<String> childTaskIds = const <String>[], List<String> childTaskIds = const <String>[],
}) async { }) async {
final changedTasks = _changedTasks(beforeTasks, afterTasks); 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.tasks.saveAll(changedTasks);
await repositories.taskActivities.saveAll(activities); await repositories.taskActivities.saveAll(activities);
final projectStats = await _saveProjectStatistics( final projectStats = await _saveProjectStatistics(
@ -1072,6 +1207,11 @@ class V1ApplicationCommandUseCases {
tasks: afterTasks, tasks: afterTasks,
); );
logger.debug(() =>
'Application command persisted. command=${commandCode.name} '
'operationId=${context.operationId} changedTaskCount=${changedTasks.length} '
'projectStatisticsCount=${projectStats.length}');
return ApplicationResult.success( return ApplicationResult.success(
ApplicationCommandResult( ApplicationCommandResult(
commandCode: commandCode, commandCode: commandCode,
@ -1101,6 +1241,8 @@ class V1ApplicationCommandUseCases {
.where((activity) => activity.code == TaskActivityCode.completed) .where((activity) => activity.code == TaskActivityCode.completed)
.map((activity) => activity.projectId) .map((activity) => activity.projectId)
.toSet(); .toSet();
logger.finer(() => 'Saving project statistics after command. '
'projectCount=${projectIds.length} activityCount=${activities.length}');
final updated = <ProjectStatistics>[]; final updated = <ProjectStatistics>[];
for (final projectId in projectIds) { for (final projectId in projectIds) {
@ -1116,6 +1258,8 @@ class V1ApplicationCommandUseCases {
if (result.appliedActivityIds.isEmpty) { if (result.appliedActivityIds.isEmpty) {
continue; continue;
} }
logger.finer(() => 'Project statistics updated. projectId=$projectId '
'appliedActivityCount=${result.appliedActivityIds.length}');
await repositories.projectStatistics.save(result.statistics); await repositories.projectStatistics.save(result.statistics);
updated.add(result.statistics); updated.add(result.statistics);
} }
@ -1123,3 +1267,33 @@ class V1ApplicationCommandUseCases {
return List<ProjectStatistics>.unmodifiable(updated); return List<ProjectStatistics>.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,
),
};
}

View file

@ -18,6 +18,7 @@ import '../persistence/repositories.dart';
import '../scheduling/scheduling_engine.dart'; import '../scheduling/scheduling_engine.dart';
import '../scheduling/task_lifecycle.dart'; import '../scheduling/task_lifecycle.dart';
import '../domain/time_contracts.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/owner_time_zone_context.dart';
part 'application_layer/context/application_operation_context.dart'; part 'application_layer/context/application_operation_context.dart';
part 'application_layer/results/application_failure_code.dart'; part 'application_layer/results/application_failure_code.dart';

View file

@ -195,7 +195,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
ApplicationUnitOfWorkRepositories repositories, ApplicationUnitOfWorkRepositories repositories,
) action, ) action,
}) async { }) 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)) { 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( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.duplicateOperation, code: ApplicationFailureCode.duplicateOperation,
@ -210,6 +216,9 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
try { try {
final result = await action(repositories); final result = await action(repositories);
if (result.isFailure) { 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; return result;
} }
@ -222,14 +231,29 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
if (_failNextCommit) { if (_failNextCommit) {
_failNextCommit = false; _failNextCommit = false;
logger.error(() => 'In-memory unit of work commit failed by test hook. '
'operationId=${context.operationId} operationName=$operationName');
throw const ApplicationPersistenceException(); throw const ApplicationPersistenceException();
} }
_state = staged; _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; return result;
} on ApplicationFailureException catch (error) { } 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); return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) { } 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( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
@ -237,19 +261,35 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
), ),
); );
} on ArgumentError catch (error) { } 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( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
detailCode: error.name, 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( return ApplicationResult.failure(
const ApplicationFailure( const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure, 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( return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected), const ApplicationFailure(code: ApplicationFailureCode.unexpected),
); );
@ -264,14 +304,34 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
ApplicationUnitOfWorkRepositories repositories, ApplicationUnitOfWorkRepositories repositories,
) action, ) action,
}) async { }) 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 staged = _state.copy();
final repositories = _InMemoryApplicationRepositories(staged); final repositories = _InMemoryApplicationRepositories(staged);
try { 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) { } 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); return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) { } on DomainValidationException catch (error) {
logger.warn(
() => 'In-memory unit of work read caught domain validation failure. '
'code=${error.code.name}');
return ApplicationResult.failure( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
@ -279,19 +339,32 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
), ),
); );
} on ArgumentError catch (error) { } on ArgumentError catch (error) {
logger.warn(() =>
'In-memory unit of work read caught argument validation failure. '
'argumentName=${error.name}');
return ApplicationResult.failure( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
detailCode: error.name, 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( return ApplicationResult.failure(
const ApplicationFailure( const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure, code: ApplicationFailureCode.persistenceFailure,
), ),
); );
} catch (_) { } catch (error, stackTrace) {
logger.error(
() => 'In-memory unit of work read unexpected failure.',
error: error,
stackTrace: stackTrace,
);
return ApplicationResult.failure( return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected), const ApplicationFailure(code: ApplicationFailureCode.unexpected),
); );

View file

@ -251,6 +251,38 @@ class _UnitOfWorkTaskRepository implements TaskRepository {
return RepositoryMutationResult.success(revision: nextRevision); 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<RepositoryMutationResult> 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. /// 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. /// 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'; String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';

View file

@ -17,6 +17,7 @@ import '../domain/locked_time.dart';
import '../domain/models.dart'; import '../domain/models.dart';
import '../domain/project_statistics.dart'; import '../domain/project_statistics.dart';
import '../domain/time_contracts.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/application_management_command_code.dart';
part 'application_management/codes/owner_settings_warning_code.dart'; part 'application_management/codes/owner_settings_warning_code.dart';
part 'application_management/requests/get_backlog_request.dart'; part 'application_management/requests/get_backlog_request.dart';

View file

@ -26,6 +26,8 @@ class V1ApplicationManagementUseCases {
return applicationStore.read( return applicationStore.read(
action: (repositories) async { action: (repositories) async {
final ownerId = request.context.ownerTimeZone.ownerId; 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( final settings = await _ownerSettingsOrDefault(
repositories, repositories,
request.context.ownerTimeZone, request.context.ownerTimeZone,
@ -46,6 +48,9 @@ class V1ApplicationManagementUseCases {
stalenessSettings: settings.backlogStalenessSettings, stalenessSettings: settings.backlogStalenessSettings,
); );
final sorted = view.sorted(request.sortKey); 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( return ApplicationResult.success(
BacklogQueryResult( BacklogQueryResult(
@ -70,6 +75,9 @@ class V1ApplicationManagementUseCases {
) { ) {
return applicationStore.read( return applicationStore.read(
action: (repositories) async { action: (repositories) async {
logger.debug(() =>
'Loading project defaults. ownerId=${request.context.ownerTimeZone.ownerId} '
'includeArchived=${request.includeArchived}');
final projects = (await repositories.projects.findByOwner( final projects = (await repositories.projects.findByOwner(
ownerId: request.context.ownerTimeZone.ownerId, ownerId: request.context.ownerTimeZone.ownerId,
includeArchived: request.includeArchived, 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( return ApplicationResult.success(
ProjectDefaultsQueryResult( ProjectDefaultsQueryResult(
ownerId: request.context.ownerTimeZone.ownerId, ownerId: request.context.ownerTimeZone.ownerId,
@ -115,8 +127,14 @@ class V1ApplicationManagementUseCases {
operationName: ApplicationManagementCommandCode.createProject.name, operationName: ApplicationManagementCommandCode.createProject.name,
action: (repositories) async { action: (repositories) async {
final id = draft.id ?? context.idGenerator.nextId(); 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); final existing = await repositories.projects.findById(id);
if (existing != null) { if (existing != null) {
logger.warn(() => 'Create project failed: project already exists. '
'operationId=${context.operationId} projectId=$id');
return _failure( return _failure(
ApplicationFailureCode.conflict, ApplicationFailureCode.conflict,
entityId: id, entityId: id,
@ -134,6 +152,8 @@ class V1ApplicationManagementUseCases {
defaultDurationMinutes: draft.defaultDurationMinutes, defaultDurationMinutes: draft.defaultDurationMinutes,
); );
await repositories.projects.save(project); await repositories.projects.save(project);
logger.debug(() =>
'Project created. operationId=${context.operationId} projectId=$id');
return ApplicationResult.success(project); return ApplicationResult.success(project);
}, },
); );
@ -150,6 +170,10 @@ class V1ApplicationManagementUseCases {
context: context, context: context,
operationName: ApplicationManagementCommandCode.updateProject.name, operationName: ApplicationManagementCommandCode.updateProject.name,
action: (repositories) async { 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 project = await _requireProject(repositories, projectId);
final updated = project.copyWith( final updated = project.copyWith(
name: update.name, name: update.name,
@ -162,6 +186,8 @@ class V1ApplicationManagementUseCases {
clearDefaultDuration: update.clearDefaultDuration, clearDefaultDuration: update.clearDefaultDuration,
); );
await repositories.projects.save(updated); await repositories.projects.save(updated);
logger.debug(() =>
'Project updated. operationId=${context.operationId} projectId=$projectId');
return ApplicationResult.success(updated); return ApplicationResult.success(updated);
}, },
); );
@ -177,9 +203,14 @@ class V1ApplicationManagementUseCases {
context: context, context: context,
operationName: ApplicationManagementCommandCode.archiveProject.name, operationName: ApplicationManagementCommandCode.archiveProject.name,
action: (repositories) async { action: (repositories) async {
logger.debug(() =>
'Management command executing. command=${ApplicationManagementCommandCode.archiveProject.name} '
'operationId=${context.operationId} projectId=$projectId');
final project = await _requireProject(repositories, projectId); final project = await _requireProject(repositories, projectId);
final archived = project.copyWith(archivedAt: context.now); final archived = project.copyWith(archivedAt: context.now);
await repositories.projects.save(archived); await repositories.projects.save(archived);
logger.debug(() =>
'Project archived. operationId=${context.operationId} projectId=$projectId');
return ApplicationResult.success(archived); return ApplicationResult.success(archived);
}, },
); );
@ -196,8 +227,14 @@ class V1ApplicationManagementUseCases {
operationName: ApplicationManagementCommandCode.createLockedBlock.name, operationName: ApplicationManagementCommandCode.createLockedBlock.name,
action: (repositories) async { action: (repositories) async {
final id = draft.id ?? context.idGenerator.nextId(); 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); final existing = await repositories.lockedBlocks.findBlockById(id);
if (existing != null) { if (existing != null) {
logger.warn(() => 'Create locked block failed: block already exists. '
'operationId=${context.operationId} lockedBlockId=$id');
return _failure( return _failure(
ApplicationFailureCode.conflict, ApplicationFailureCode.conflict,
entityId: id, entityId: id,
@ -217,6 +254,8 @@ class V1ApplicationManagementUseCases {
updatedAt: context.now, updatedAt: context.now,
); );
await repositories.lockedBlocks.saveBlock(block); await repositories.lockedBlocks.saveBlock(block);
logger.debug(() =>
'Locked block created. operationId=${context.operationId} lockedBlockId=$id');
return ApplicationResult.success(block); return ApplicationResult.success(block);
}, },
); );
@ -233,6 +272,11 @@ class V1ApplicationManagementUseCases {
context: context, context: context,
operationName: ApplicationManagementCommandCode.updateLockedBlock.name, operationName: ApplicationManagementCommandCode.updateLockedBlock.name,
action: (repositories) async { 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 block = await _requireLockedBlock(repositories, lockedBlockId);
final updated = block.copyWith( final updated = block.copyWith(
name: update.name, name: update.name,
@ -245,6 +289,8 @@ class V1ApplicationManagementUseCases {
updatedAt: context.now, updatedAt: context.now,
); );
await repositories.lockedBlocks.saveBlock(updated); await repositories.lockedBlocks.saveBlock(updated);
logger.debug(() =>
'Locked block updated. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
return ApplicationResult.success(updated); return ApplicationResult.success(updated);
}, },
); );
@ -260,12 +306,17 @@ class V1ApplicationManagementUseCases {
context: context, context: context,
operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, operationName: ApplicationManagementCommandCode.archiveLockedBlock.name,
action: (repositories) async { action: (repositories) async {
logger.debug(() =>
'Management command executing. command=${ApplicationManagementCommandCode.archiveLockedBlock.name} '
'operationId=${context.operationId} lockedBlockId=$lockedBlockId');
final block = await _requireLockedBlock(repositories, lockedBlockId); final block = await _requireLockedBlock(repositories, lockedBlockId);
final archived = block.copyWith( final archived = block.copyWith(
updatedAt: context.now, updatedAt: context.now,
archivedAt: context.now, archivedAt: context.now,
); );
await repositories.lockedBlocks.saveBlock(archived); await repositories.lockedBlocks.saveBlock(archived);
logger.debug(() =>
'Locked block archived. operationId=${context.operationId} lockedBlockId=$lockedBlockId');
return ApplicationResult.success(archived); return ApplicationResult.success(archived);
}, },
); );
@ -287,6 +338,10 @@ class V1ApplicationManagementUseCases {
operationName: operationName:
ApplicationManagementCommandCode.addLockedBlockOverride.name, ApplicationManagementCommandCode.addLockedBlockOverride.name,
action: (repositories) async { 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( final override = LockedBlockOverride.add(
id: context.idGenerator.nextId(), id: context.idGenerator.nextId(),
date: date, date: date,
@ -298,6 +353,8 @@ class V1ApplicationManagementUseCases {
createdAt: context.now, createdAt: context.now,
); );
await repositories.lockedBlocks.saveOverride(override); await repositories.lockedBlocks.saveOverride(override);
logger.debug(() =>
'Locked block override added. operationId=${context.operationId} overrideId=${override.id}');
return ApplicationResult.success(override); return ApplicationResult.success(override);
}, },
); );
@ -315,6 +372,9 @@ class V1ApplicationManagementUseCases {
operationName: operationName:
ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, ApplicationManagementCommandCode.removeLockedBlockOccurrence.name,
action: (repositories) async { action: (repositories) async {
logger.debug(() =>
'Management command executing. command=${ApplicationManagementCommandCode.removeLockedBlockOccurrence.name} '
'operationId=${context.operationId} lockedBlockId=$lockedBlockId date=${date.toIsoString()}');
await _requireLockedBlock(repositories, lockedBlockId); await _requireLockedBlock(repositories, lockedBlockId);
final override = LockedBlockOverride.remove( final override = LockedBlockOverride.remove(
id: context.idGenerator.nextId(), id: context.idGenerator.nextId(),
@ -323,6 +383,9 @@ class V1ApplicationManagementUseCases {
createdAt: context.now, createdAt: context.now,
); );
await repositories.lockedBlocks.saveOverride(override); await repositories.lockedBlocks.saveOverride(override);
logger.debug(() =>
'Locked block occurrence removed. operationId=${context.operationId} '
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
return ApplicationResult.success(override); return ApplicationResult.success(override);
}, },
); );
@ -345,6 +408,10 @@ class V1ApplicationManagementUseCases {
operationName: operationName:
ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name,
action: (repositories) async { 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); await _requireLockedBlock(repositories, lockedBlockId);
final override = LockedBlockOverride.replace( final override = LockedBlockOverride.replace(
id: context.idGenerator.nextId(), id: context.idGenerator.nextId(),
@ -358,6 +425,9 @@ class V1ApplicationManagementUseCases {
createdAt: context.now, createdAt: context.now,
); );
await repositories.lockedBlocks.saveOverride(override); await repositories.lockedBlocks.saveOverride(override);
logger.debug(() =>
'Locked block occurrence replaced. operationId=${context.operationId} '
'lockedBlockId=$lockedBlockId overrideId=${override.id}');
return ApplicationResult.success(override); return ApplicationResult.success(override);
}, },
); );
@ -373,6 +443,11 @@ class V1ApplicationManagementUseCases {
context: context, context: context,
operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, operationName: ApplicationManagementCommandCode.updateOwnerSettings.name,
action: (repositories) async { 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( final current = await _ownerSettingsOrDefault(
repositories, repositories,
context.ownerTimeZone, context.ownerTimeZone,
@ -382,6 +457,9 @@ class V1ApplicationManagementUseCases {
(stalenessSettings.greenMaxAge.isNegative || (stalenessSettings.greenMaxAge.isNegative ||
stalenessSettings.blueMaxAge.isNegative || stalenessSettings.blueMaxAge.isNegative ||
stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) {
logger.warn(() =>
'Owner settings update failed: invalid staleness thresholds. '
'operationId=${context.operationId} ownerId=${context.ownerTimeZone.ownerId}');
return _failure( return _failure(
ApplicationFailureCode.validation, ApplicationFailureCode.validation,
detailCode: 'invalidBacklogStalenessThresholds', detailCode: 'invalidBacklogStalenessThresholds',
@ -404,6 +482,9 @@ class V1ApplicationManagementUseCases {
OwnerSettingsWarningCode.planningWindowChanged, OwnerSettingsWarningCode.planningWindowChanged,
]; ];
await repositories.ownerSettings.save(updated); await repositories.ownerSettings.save(updated);
logger.debug(() =>
'Owner settings updated. operationId=${context.operationId} '
'ownerId=${context.ownerTimeZone.ownerId} warningCount=${warnings.length}');
return ApplicationResult.success( return ApplicationResult.success(
OwnerSettingsUpdateResult(settings: updated, warnings: warnings), OwnerSettingsUpdateResult(settings: updated, warnings: warnings),
); );
@ -422,8 +503,13 @@ class V1ApplicationManagementUseCases {
operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, operationName: ApplicationManagementCommandCode.acknowledgeNotice.name,
action: (repositories) async { action: (repositories) async {
final ownerId = context.ownerTimeZone.ownerId; 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); final parsed = _parseNoticeId(noticeId);
if (parsed == null) { if (parsed == null) {
logger.warn(() => 'Notice acknowledgement failed: invalid notice id. '
'operationId=${context.operationId} noticeId=$noticeId');
return _failure( return _failure(
ApplicationFailureCode.validation, ApplicationFailureCode.validation,
entityId: noticeId, entityId: noticeId,
@ -433,6 +519,9 @@ class V1ApplicationManagementUseCases {
final snapshot = final snapshot =
await repositories.schedulingSnapshots.findById(parsed.snapshotId); await repositories.schedulingSnapshots.findById(parsed.snapshotId);
if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { 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( return _failure(
ApplicationFailureCode.notFound, ApplicationFailureCode.notFound,
entityId: noticeId, entityId: noticeId,
@ -444,6 +533,9 @@ class V1ApplicationManagementUseCases {
noticeId: noticeId, noticeId: noticeId,
); );
if (existing != null) { if (existing != null) {
logger.finer(() =>
'Notice already acknowledged. operationId=${context.operationId} '
'noticeId=$noticeId ownerId=$ownerId');
return ApplicationResult.success( return ApplicationResult.success(
NoticeAcknowledgementResult( NoticeAcknowledgementResult(
record: existing, record: existing,
@ -458,6 +550,9 @@ class V1ApplicationManagementUseCases {
acknowledgedAt: context.now, acknowledgedAt: context.now,
); );
await repositories.noticeAcknowledgements.save(record); await repositories.noticeAcknowledgements.save(record);
logger.debug(
() => 'Notice acknowledged. operationId=${context.operationId} '
'noticeId=$noticeId ownerId=$ownerId');
return ApplicationResult.success( return ApplicationResult.success(
NoticeAcknowledgementResult( NoticeAcknowledgementResult(
record: record, record: record,
@ -476,6 +571,7 @@ class V1ApplicationManagementUseCases {
) async { ) async {
final project = await repositories.projects.findById(projectId); final project = await repositories.projects.findById(projectId);
if (project == null) { if (project == null) {
logger.warn(() => 'Required project not found. projectId=$projectId');
throw ApplicationFailureException( throw ApplicationFailureException(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.notFound, code: ApplicationFailureCode.notFound,
@ -495,6 +591,8 @@ class V1ApplicationManagementUseCases {
) async { ) async {
final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); final block = await repositories.lockedBlocks.findBlockById(lockedBlockId);
if (block == null) { if (block == null) {
logger.warn(() =>
'Required locked block not found. lockedBlockId=$lockedBlockId');
throw ApplicationFailureException( throw ApplicationFailureException(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.notFound, code: ApplicationFailureCode.notFound,
@ -513,9 +611,14 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories, ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone, OwnerTimeZoneContext ownerTimeZone,
) async { ) async {
return await repositories.ownerSettings final settings =
.findByOwnerId(ownerTimeZone.ownerId) ?? await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
OwnerSettings( if (settings != null) {
return settings;
}
logger.finer(() => 'Owner settings missing; using defaults. '
'ownerId=${ownerTimeZone.ownerId} timeZoneId=${ownerTimeZone.timeZoneId}');
return OwnerSettings(
ownerId: ownerTimeZone.ownerId, ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId, timeZoneId: ownerTimeZone.timeZoneId,
); );

View file

@ -17,6 +17,7 @@ import '../persistence/repositories.dart';
import '../scheduling/scheduling_engine.dart'; import '../scheduling/scheduling_engine.dart';
import '../scheduling/task_lifecycle.dart'; import '../scheduling/task_lifecycle.dart';
import '../domain/time_contracts.dart'; import '../domain/time_contracts.dart';
import '../logging/focus_flow_logger.dart';
part 'application_recovery/app_open_recovery_outcome.dart'; part 'application_recovery/app_open_recovery_outcome.dart';
part 'application_recovery/run_app_open_recovery_request.dart'; part 'application_recovery/run_app_open_recovery_request.dart';
part 'application_recovery/app_open_recovery_result.dart'; part 'application_recovery/app_open_recovery_result.dart';

View file

@ -42,9 +42,16 @@ class V1AppOpenRecoveryUseCases {
ownerId: ownerId, ownerId: ownerId,
sourceDate: sourceDate, sourceDate: sourceDate,
); );
logger.info(() => 'App-open recovery started. ownerId=$ownerId '
'operationId=${context.operationId} sourceDate=${sourceDate.toIsoString()} '
'targetDate=${targetDate.toIsoString()} snapshotId=$snapshotId');
final existingSnapshot = final existingSnapshot =
await repositories.schedulingSnapshots.findById(snapshotId); await repositories.schedulingSnapshots.findById(snapshotId);
if (existingSnapshot != null) { 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( return ApplicationResult.success(
AppOpenRecoveryResult( AppOpenRecoveryResult(
outcome: AppOpenRecoveryOutcome.alreadyProcessed, outcome: AppOpenRecoveryOutcome.alreadyProcessed,
@ -63,8 +70,11 @@ class V1AppOpenRecoveryUseCases {
repositories, repositories,
context.ownerTimeZone, context.ownerTimeZone,
); );
final sourceWindow = _windowForDate(sourceDate, settings); final effectiveSettings = settings.copyWith(
final targetWindow = _windowForDate(targetDate, settings); timeZoneId: context.ownerTimeZone.timeZoneId,
);
final sourceWindow = _windowForDate(sourceDate, effectiveSettings);
final targetWindow = _windowForDate(targetDate, effectiveSettings);
final tasks = await _loadSourceAndTargetTasks( final tasks = await _loadSourceAndTargetTasks(
repositories, repositories,
sourceWindow: sourceWindow, sourceWindow: sourceWindow,
@ -83,10 +93,14 @@ class V1AppOpenRecoveryUseCases {
blocks: blocks, blocks: blocks,
overrides: overrides, overrides: overrides,
date: targetDate, date: targetDate,
timeZoneId: settings.timeZoneId, timeZoneId: effectiveSettings.timeZoneId,
timeZoneResolver: timeZoneResolver, timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions, 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( final input = SchedulingInput(
tasks: tasks, tasks: tasks,
window: targetWindow, window: targetWindow,
@ -101,6 +115,10 @@ class V1AppOpenRecoveryUseCases {
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { 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( return ApplicationResult.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.noSlot, code: ApplicationFailureCode.noSlot,
@ -135,9 +153,16 @@ class V1AppOpenRecoveryUseCases {
operationName: 'appOpenRecovery', 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.tasks.saveAll(changedTasks);
await repositories.taskActivities.saveAll(activities); await repositories.taskActivities.saveAll(activities);
await repositories.schedulingSnapshots.save(snapshot); 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( return ApplicationResult.success(
AppOpenRecoveryResult( AppOpenRecoveryResult(
@ -198,9 +223,14 @@ Future<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories, ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone, OwnerTimeZoneContext ownerTimeZone,
) async { ) async {
return await repositories.ownerSettings final settings =
.findByOwnerId(ownerTimeZone.ownerId) ?? await repositories.ownerSettings.findByOwnerId(ownerTimeZone.ownerId);
OwnerSettings( 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, ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId, timeZoneId: ownerTimeZone.timeZoneId,
); );
@ -213,6 +243,11 @@ Future<List<Task>> _loadSourceAndTargetTasks(
required SchedulingWindow sourceWindow, required SchedulingWindow sourceWindow,
required SchedulingWindow targetWindow, required SchedulingWindow targetWindow,
}) async { }) 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 = <String, Task>{}; final byId = <String, Task>{};
for (final task in await repositories.tasks.findScheduledInWindow( for (final task in await repositories.tasks.findScheduledInWindow(
sourceWindow, sourceWindow,
@ -224,6 +259,8 @@ Future<List<Task>> _loadSourceAndTargetTasks(
)) { )) {
byId[task.id] = task; byId[task.id] = task;
} }
logger.debug(() =>
'Recovery source and target tasks loaded. taskCount=${byId.length}');
return List<Task>.unmodifiable(byId.values); return List<Task>.unmodifiable(byId.values);
} }
@ -249,8 +286,12 @@ SchedulingNotice? _rolloverNotice({
.map((change) => change.taskId) .map((change) => change.taskId)
.toSet(); .toSet();
if (rolledTaskIds.isEmpty) { if (rolledTaskIds.isEmpty) {
logger.finer(
() => 'No rollover notice needed; no tasks moved from source day.');
return null; return null;
} }
logger.debug(
() => 'Rollover notice created. rolledTaskCount=${rolledTaskIds.length}');
return SchedulingNotice( return SchedulingNotice(
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.', '${rolledTaskIds.length} unfinished flexible tasks were moved to today.',

View file

@ -44,6 +44,8 @@ class Task {
this.actualStart, this.actualStart,
this.actualEnd, this.actualEnd,
this.completedAt, this.completedAt,
this.backlogEnteredAt,
this.backlogEnteredAtProvenance,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
this.reminderOverride, this.reminderOverride,
@ -127,6 +129,7 @@ class Task {
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
final entersBacklog = status == TaskStatus.backlog;
return Task( return Task(
id: id, id: id,
title: title.trim(), title: title.trim(),
@ -138,10 +141,19 @@ class Task {
difficulty: difficulty, difficulty: difficulty,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt ?? createdAt, updatedAt: updatedAt ?? createdAt,
backlogEnteredAt: entersBacklog ? createdAt : null,
backlogEnteredAtProvenance:
entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null,
backlogTags: backlogTags, 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. /// Stable identifier used by persistence, UI selection, and scheduler changes.
final String id; final String id;
@ -186,6 +198,12 @@ class Task {
/// Explicit completion timestamp, distinct from the generic update timestamp. /// Explicit completion timestamp, distinct from the generic update timestamp.
final DateTime? completedAt; 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. /// Parent task id when this task is a child/subtask. Null means top-level task.
final String? parentTaskId; final String? parentTaskId;
@ -219,6 +237,9 @@ class Task {
/// Backlog status means the task is stored for later and has no active slot. /// Backlog status means the task is stored for later and has no active slot.
bool get isBacklog => status == TaskStatus.backlog; bool get isBacklog => status == TaskStatus.backlog;
/// Timestamp used for Backlog freshness and age filters.
DateTime get backlogFreshnessAnchorAt => backlogEnteredAt ?? createdAt;
/// Return a copy with selected fields changed. /// Return a copy with selected fields changed.
/// ///
/// The core uses this instead of mutation. That matters because scheduling /// The core uses this instead of mutation. That matters because scheduling
@ -247,6 +268,8 @@ class Task {
DateTime? actualStart, DateTime? actualStart,
DateTime? actualEnd, DateTime? actualEnd,
DateTime? completedAt, DateTime? completedAt,
DateTime? backlogEnteredAt,
String? backlogEnteredAtProvenance,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag>? backlogTags, Set<BacklogTag>? backlogTags,
ReminderProfile? reminderOverride, ReminderProfile? reminderOverride,
@ -258,6 +281,7 @@ class Task {
bool clearSchedule = false, bool clearSchedule = false,
bool clearActualInterval = false, bool clearActualInterval = false,
bool clearCompletion = false, bool clearCompletion = false,
bool clearBacklogEntry = false,
bool clearParentTask = false, bool clearParentTask = false,
bool clearReminderOverride = false, bool clearReminderOverride = false,
}) { }) {
@ -279,6 +303,12 @@ class Task {
clearActualInterval ? null : (actualStart ?? this.actualStart), clearActualInterval ? null : (actualStart ?? this.actualStart),
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), completedAt: clearCompletion ? null : (completedAt ?? this.completedAt),
backlogEnteredAt: clearBacklogEntry
? null
: (backlogEnteredAt ?? this.backlogEnteredAt),
backlogEnteredAtProvenance: clearBacklogEntry
? null
: (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance),
parentTaskId: parentTaskId:
clearParentTask ? null : (parentTaskId ?? this.parentTaskId), clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
backlogTags: backlogTags ?? this.backlogTags, backlogTags: backlogTags ?? this.backlogTags,

View file

@ -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<String> callerFrameIgnores = const <String>[],
}) : _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<String> _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<String> callerFrameIgnores = const <String>[],
}) {
_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 <String>[]);
}
/// 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<String> _normalizedCallerFrameIgnores(
Iterable<String> callerFrameIgnores,
) {
final ignores = <String>{'focus_flow_logger.dart'};
for (final ignore in callerFrameIgnores) {
final trimmed = ignore.trim();
if (trimmed.isNotEmpty) {
ignores.add(trimmed);
}
}
return List<String>.unmodifiable(ignores);
}
/// Keeps diagnostic text on one physical log line.
String _sanitizeForSingleLine(String value) {
return value.replaceAll('\n', r'\n');
}

View file

@ -55,8 +55,10 @@ abstract final class TaskDocumentMapping {
TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument(
task.stats, task.stats,
), ),
TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), TaskDocumentFields.backlogEnteredAt:
TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, _dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt),
TaskDocumentFields.backlogEnteredAtProvenance:
backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance,
}; };
} }
@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping {
document, document,
TaskDocumentFields.completedAt, TaskDocumentFields.completedAt,
), ),
backlogEnteredAt: _requiredNullableDateTime(
document,
TaskDocumentFields.backlogEnteredAt,
),
backlogEnteredAtProvenance: _requiredNullableString(
document,
TaskDocumentFields.backlogEnteredAtProvenance,
),
parentTaskId: _requiredNullableString( parentTaskId: _requiredNullableString(
document, document,
TaskDocumentFields.parentTaskId, TaskDocumentFields.parentTaskId,

View file

@ -13,6 +13,7 @@ library;
import 'document_mapping.dart'; import 'document_mapping.dart';
import 'persistence_contract.dart'; import 'persistence_contract.dart';
import '../domain/time_contracts.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_entity.dart';
part 'document_migration/codes/document_migration_status.dart'; part 'document_migration/codes/document_migration_status.dart';
part 'document_migration/codes/document_migration_failure_code.dart'; part 'document_migration/codes/document_migration_failure_code.dart';

View file

@ -80,8 +80,14 @@ class V1DocumentMigrationService {
required void Function(Map<String, Object?> document) validateCurrent, required void Function(Map<String, Object?> document) validateCurrent,
}) { }) {
final documentId = _documentId(document); final documentId = _documentId(document);
logger.debug(() => 'Document migration started. '
'entity=${entity.name} documentId=$documentId '
'ownerId=$ownerId migratedAt=${migratedAt.toIso8601String()}');
final schemaRead = _readSchemaVersion(document); final schemaRead = _readSchemaVersion(document);
if (schemaRead.failure != null) { if (schemaRead.failure != null) {
logger.warn(() => 'Document migration failed schema version read. '
'entity=${entity.name} documentId=$documentId '
'detailCode=${schemaRead.failure}');
return _failed( return _failed(
entity: entity, entity: entity,
documentId: documentId, documentId: documentId,
@ -93,10 +99,15 @@ class V1DocumentMigrationService {
} }
final fromVersion = schemaRead.version; final fromVersion = schemaRead.version;
logger.finer(() => 'Document migration schema version resolved. '
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
if (fromVersion == v1SchemaVersion) { if (fromVersion == v1SchemaVersion) {
try { try {
final copy = _deepCopyDocument(document); final copy = _deepCopyDocument(document);
validateCurrent(copy); validateCurrent(copy);
logger.debug(() =>
'Document migration skipped; document already current. '
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
return DocumentMigrationResult( return DocumentMigrationResult(
document: copy, document: copy,
report: DocumentMigrationReport( report: DocumentMigrationReport(
@ -107,6 +118,11 @@ class V1DocumentMigrationService {
), ),
); );
} on DocumentMappingException catch (error) { } 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( return _failedFromMapping(
entity: entity, entity: entity,
documentId: documentId, documentId: documentId,
@ -118,6 +134,9 @@ class V1DocumentMigrationService {
} }
if (fromVersion > v1SchemaVersion || fromVersion < 0) { if (fromVersion > v1SchemaVersion || fromVersion < 0) {
logger.warn(() =>
'Document migration rejected unsupported schema version. '
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
return _failed( return _failed(
entity: entity, entity: entity,
documentId: documentId, documentId: documentId,
@ -129,6 +148,9 @@ class V1DocumentMigrationService {
} }
if (fromVersion != 0) { if (fromVersion != 0) {
logger.warn(() =>
'Document migration rejected unsupported schema version. '
'entity=${entity.name} documentId=$documentId fromSchemaVersion=$fromVersion');
return _failed( return _failed(
entity: entity, entity: entity,
documentId: documentId, documentId: documentId,
@ -144,6 +166,10 @@ class V1DocumentMigrationService {
try { try {
migrated = migrateV0(document, notes); migrated = migrateV0(document, notes);
} on _LegacyDocumentException catch (error) { } 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( return _failed(
entity: entity, entity: entity,
documentId: documentId, documentId: documentId,
@ -157,6 +183,11 @@ class V1DocumentMigrationService {
try { try {
validateCurrent(migrated); validateCurrent(migrated);
} on DocumentMappingException catch (error) { } 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( return _failedFromMapping(
entity: entity, entity: entity,
documentId: documentId, 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( return DocumentMigrationResult(
document: migrated, document: migrated,
report: DocumentMigrationReport( report: DocumentMigrationReport(

View file

@ -284,6 +284,58 @@ class InMemoryTaskRepository implements TaskRepository {
return RepositoryMutationResult.success(revision: nextRevision); 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<RepositoryMutationResult> 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. /// 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. /// 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; String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId;

View file

@ -79,4 +79,11 @@ abstract interface class TaskRepository {
required String ownerId, required String ownerId,
required int expectedRevision, required int expectedRevision,
}); });
/// Delete [taskId] by expected repository revision.
Future<RepositoryMutationResult> deleteIfRevision({
required String taskId,
required String ownerId,
required int expectedRevision,
});
} }

View file

@ -25,14 +25,14 @@ class BacklogStalenessSettings {
/// Return the visual age marker for [task] relative to [now]. /// Return the visual age marker for [task] relative to [now].
/// ///
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is /// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because
/// meant to show how long the idea has existed in the system. A task edited /// ordinary edits should not make an old backlog item look fresh. Commands
/// yesterday but created two months ago should still feel old in the backlog. /// that deliberately re-enter Backlog reset the anchor.
BacklogStalenessMarker markerFor({ BacklogStalenessMarker markerFor({
required Task task, required Task task,
required DateTime now, required DateTime now,
}) { }) {
final age = now.difference(task.createdAt); final age = now.difference(task.backlogFreshnessAnchorAt);
if (age <= greenMaxAge) { if (age <= greenMaxAge) {
return BacklogStalenessMarker.green; return BacklogStalenessMarker.green;

View file

@ -26,11 +26,11 @@ class BacklogView {
/// Clock value supplied by the caller so age/staleness behavior is testable. /// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now; 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 /// Tasks persisted before the anchor existed fall back to [Task.createdAt].
/// persistence adds that field, both stale filtering and visual staleness use /// This keeps older data readable while letting Push to Backlog make a task
/// task creation age so they do not disagree after a task edit. /// fresh without rewriting original creation history.
final Duration staleAfter; final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators. /// Color-bucket threshold configuration for backlog aging indicators.
@ -101,7 +101,8 @@ class BacklogView {
BacklogFilter.criticalMissed => BacklogFilter.criticalMissed =>
task.type == TaskType.critical && task.stats.missedCount > 0, task.type == TaskType.critical && task.stats.missedCount > 0,
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), 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, BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
}; };
} }
@ -116,7 +117,8 @@ class BacklogView {
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)), _priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
BacklogSortKey.rewardVsEffort => BacklogSortKey.rewardVsEffort =>
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), _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.project => a.projectId.compareTo(b.projectId),
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
}; };

View file

@ -13,6 +13,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import 'task_lifecycle.dart'; import 'task_lifecycle.dart';
import '../domain/time_contracts.dart'; import '../domain/time_contracts.dart';
import '../logging/focus_flow_logger.dart';
part 'child_tasks/models/child_task_entry.dart'; part 'child_tasks/models/child_task_entry.dart';
part 'child_tasks/requests/child_task_break_up_request.dart'; part 'child_tasks/requests/child_task_break_up_request.dart';
part 'child_tasks/results/child_task_break_up_result.dart'; part 'child_tasks/results/child_task_break_up_result.dart';

View file

@ -17,8 +17,14 @@ class ChildTaskBreakUpService {
required List<Task> tasks, required List<Task> tasks,
required ChildTaskBreakUpRequest request, 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); final parent = _taskById(tasks, request.parentTaskId);
if (parent == null) { if (parent == null) {
logger.warn(() => 'Child task break-up failed: parent not found. '
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
throw ArgumentError.value( throw ArgumentError.value(
request.parentTaskId, request.parentTaskId,
'parentTaskId', 'parentTaskId',
@ -26,6 +32,8 @@ class ChildTaskBreakUpService {
); );
} }
if (request.entries.isEmpty) { if (request.entries.isEmpty) {
logger.warn(() => 'Child task break-up failed: no child entries. '
'parentTaskId=${request.parentTaskId} operationId=${request.operationId}');
throw ArgumentError.value( throw ArgumentError.value(
request.entries, request.entries,
'entries', 'entries',
@ -38,9 +46,14 @@ class ChildTaskBreakUpService {
for (final entry in request.entries) { for (final entry in request.entries) {
final childId = entry.id.trim(); final childId = entry.id.trim();
if (childId.isEmpty) { 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.'); throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
} }
if (childId == parent.id) { if (childId == parent.id) {
logger
.warn(() => 'Child task break-up failed: child id matched parent. '
'parentTaskId=${parent.id} operationId=${request.operationId}');
throw ArgumentError.value( throw ArgumentError.value(
entry.id, entry.id,
'id', 'id',
@ -48,6 +61,9 @@ class ChildTaskBreakUpService {
); );
} }
if (!requestedIds.add(childId)) { 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( throw ArgumentError.value(
entry.id, entry.id,
'id', 'id',
@ -55,6 +71,10 @@ class ChildTaskBreakUpService {
); );
} }
if (existingIds.contains(childId)) { 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( throw ArgumentError.value(
entry.id, entry.id,
'id', 'id',
@ -66,6 +86,9 @@ class ChildTaskBreakUpService {
final childTasks = request.entries final childTasks = request.entries
.map((entry) => entry.toTask(parent: parent)) .map((entry) => entry.toTask(parent: parent))
.toList(growable: false); .toList(growable: false);
logger.debug(() => 'Parent task break-up produced children. '
'parentTaskId=${parent.id} childCount=${childTasks.length} '
'operationId=${request.operationId}');
return ChildTaskBreakUpResult( return ChildTaskBreakUpResult(
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]), tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),

View file

@ -32,19 +32,28 @@ class ChildTaskCompletionService {
Iterable<String> appliedActivityIds = const <String>[], Iterable<String> appliedActivityIds = const <String>[],
}) { }) {
final now = updatedAt ?? clock.now(); 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); final child = _taskById(tasks, childTaskId);
if (child == null) { if (child == null) {
logger.warn(() => 'Child completion failed: task not found. '
'childTaskId=$childTaskId operationId=$operationId');
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
} }
final parentId = child.parentTaskId; final parentId = child.parentTaskId;
if (parentId == null) { if (parentId == null) {
logger.warn(() => 'Child completion failed: task has no parent. '
'childTaskId=$childTaskId operationId=$operationId');
throw ArgumentError.value( throw ArgumentError.value(
childTaskId, 'childTaskId', 'Task is not a child.'); childTaskId, 'childTaskId', 'Task is not a child.');
} }
final parent = _taskById(tasks, parentId); final parent = _taskById(tasks, parentId);
if (parent == null) { 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.'); throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
} }
@ -69,6 +78,9 @@ class ChildTaskCompletionService {
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode]; final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
if (!shouldCompleteParent) { if (!shouldCompleteParent) {
logger.debug(() => 'Child task completed without parent auto-complete. '
'childTaskId=${child.id} parentTaskId=${parent.id} '
'childApplied=${childTransition.applied} operationId=$opId');
return ChildTaskCompletionResult( return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted), tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds: childTransition.applied ? [child.id] : const <String>[], changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
@ -100,6 +112,10 @@ class ChildTaskCompletionService {
if (parentTransition.applied) parent.id, 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( return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(completedTasks), tasks: List<Task>.unmodifiable(completedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds), changedTaskIds: List<String>.unmodifiable(changedTaskIds),
@ -120,12 +136,19 @@ class ChildTaskCompletionService {
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[], Iterable<String> appliedActivityIds = const <String>[],
}) { }) {
logger.debug(() => 'Completing parent from child. childTaskId=$childTaskId '
'taskCount=${tasks.length} operationId=$operationId');
final child = _taskById(tasks, childTaskId); final child = _taskById(tasks, childTaskId);
if (child == null) { 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.'); throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
} }
final parentId = child.parentTaskId; final parentId = child.parentTaskId;
if (parentId == null) { if (parentId == null) {
logger.warn(
() => 'Parent-from-child completion failed: task has no parent. '
'childTaskId=$childTaskId operationId=$operationId');
throw ArgumentError.value( throw ArgumentError.value(
childTaskId, childTaskId,
'childTaskId', 'childTaskId',
@ -155,8 +178,13 @@ class ChildTaskCompletionService {
Iterable<String> appliedActivityIds = const <String>[], Iterable<String> appliedActivityIds = const <String>[],
}) { }) {
final now = updatedAt ?? clock.now(); 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); final parent = _taskById(tasks, parentTaskId);
if (parent == null) { if (parent == null) {
logger.warn(() => 'Parent completion failed: parent not found. '
'parentTaskId=$parentTaskId operationId=$operationId');
throw ArgumentError.value( throw ArgumentError.value(
parentTaskId, 'parentTaskId', 'Parent not found.'); parentTaskId, 'parentTaskId', 'Parent not found.');
} }
@ -164,6 +192,10 @@ class ChildTaskCompletionService {
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
if (initiatingChildTaskId != null && if (initiatingChildTaskId != null &&
!directChildren.any((child) => child.id == initiatingChildTaskId)) { !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( throw ArgumentError.value(
initiatingChildTaskId, initiatingChildTaskId,
'initiatingChildTaskId', 'initiatingChildTaskId',
@ -234,6 +266,12 @@ class ChildTaskCompletionService {
updatedTasks.add(task); 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( return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds), changedTaskIds: List<String>.unmodifiable(changedTaskIds),

View file

@ -13,6 +13,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import '../domain/occupancy_policy.dart'; import '../domain/occupancy_policy.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
import '../logging/focus_flow_logger.dart';
part 'free_slots/required_commitment_schedule_status.dart'; part 'free_slots/required_commitment_schedule_status.dart';
part 'free_slots/protected_free_slot_conflict.dart'; part 'free_slots/protected_free_slot_conflict.dart';
part 'free_slots/required_commitment_schedule_result.dart'; part 'free_slots/required_commitment_schedule_result.dart';

View file

@ -19,6 +19,9 @@ class FreeSlotService {
String projectId = 'inbox', String projectId = 'inbox',
DateTime? updatedAt, 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); final interval = TimeInterval(start: start, end: end, label: id);
return Task( return Task(
@ -45,7 +48,12 @@ class FreeSlotService {
String? title, String? title,
String? projectId, 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) { if (freeSlot.type != TaskType.freeSlot) {
logger.error(() => 'Free slot update failed: invalid task type. '
'taskId=${freeSlot.id} taskType=${freeSlot.type.name}');
throw ArgumentError.value( throw ArgumentError.value(
freeSlot.type, freeSlot.type,
'freeSlot.type', 'freeSlot.type',
@ -78,8 +86,14 @@ class FreeSlotService {
required DateTime end, required DateTime end,
DateTime? updatedAt, 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); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
logger
.warn(() => 'Required commitment scheduling failed: task not found. '
'taskId=$taskId');
return _unchangedRequiredResult( return _unchangedRequiredResult(
input: input, input: input,
status: RequiredCommitmentScheduleStatus.taskNotFound, status: RequiredCommitmentScheduleStatus.taskNotFound,
@ -94,6 +108,9 @@ class FreeSlotService {
} }
if (!task.isRequiredVisible) { if (!task.isRequiredVisible) {
logger.warn(
() => 'Required commitment scheduling failed: invalid task type. '
'taskId=${task.id} taskType=${task.type.name}');
return _unchangedRequiredResult( return _unchangedRequiredResult(
input: input, input: input,
status: RequiredCommitmentScheduleStatus.invalidTaskType, 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( return RequiredCommitmentScheduleResult(
status: conflicts.isEmpty status: conflicts.isEmpty
? RequiredCommitmentScheduleStatus.scheduled ? RequiredCommitmentScheduleStatus.scheduled

View file

@ -13,6 +13,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
import '../domain/time_contracts.dart'; import '../domain/time_contracts.dart';
import '../logging/focus_flow_logger.dart';
part 'quick_capture/quick_capture_status.dart'; part 'quick_capture/quick_capture_status.dart';
part 'quick_capture/quick_capture_request.dart'; part 'quick_capture/quick_capture_request.dart';
part 'quick_capture/quick_capture_result.dart'; part 'quick_capture/quick_capture_result.dart';

View file

@ -41,6 +41,10 @@ class QuickCaptureService {
SchedulingInput? schedulingInput, SchedulingInput? schedulingInput,
DateTime? updatedAt, 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( final capturedTaskWithoutDuration = Task.quickCapture(
id: request.id, id: request.id,
title: request.title, title: request.title,
@ -55,6 +59,9 @@ class QuickCaptureService {
); );
if (request.durationMinutes != null && request.durationMinutes! <= 0) { if (request.durationMinutes != null && request.durationMinutes! <= 0) {
logger.warn(() =>
'Quick capture validation failed: non-positive duration. '
'taskId=${request.id} durationMinutes=${request.durationMinutes}');
return QuickCaptureResult( return QuickCaptureResult(
task: capturedTaskWithoutDuration, task: capturedTaskWithoutDuration,
status: QuickCaptureStatus.validationError, status: QuickCaptureStatus.validationError,
@ -67,6 +74,8 @@ class QuickCaptureService {
); );
if (!request.addToNextAvailableSlot) { if (!request.addToNextAvailableSlot) {
logger.debug(
() => 'Quick capture added to backlog. taskId=${capturedTask.id}');
return QuickCaptureResult( return QuickCaptureResult(
task: capturedTask, task: capturedTask,
status: QuickCaptureStatus.addedToBacklog, status: QuickCaptureStatus.addedToBacklog,
@ -74,6 +83,9 @@ class QuickCaptureService {
} }
if (request.durationMinutes == null || request.durationMinutes! <= 0) { if (request.durationMinutes == null || request.durationMinutes! <= 0) {
logger.warn(
() => 'Quick capture scheduling validation failed: missing duration. '
'taskId=${capturedTask.id}');
return QuickCaptureResult( return QuickCaptureResult(
task: capturedTask, task: capturedTask,
status: QuickCaptureStatus.validationError, status: QuickCaptureStatus.validationError,
@ -82,6 +94,9 @@ class QuickCaptureService {
} }
if (request.type != TaskType.flexible) { if (request.type != TaskType.flexible) {
logger.warn(() =>
'Quick capture scheduling validation failed: non-flexible task. '
'taskId=${capturedTask.id} taskType=${request.type.name}');
return QuickCaptureResult( return QuickCaptureResult(
task: capturedTask, task: capturedTask,
status: QuickCaptureStatus.validationError, status: QuickCaptureStatus.validationError,
@ -90,6 +105,9 @@ class QuickCaptureService {
} }
if (schedulingInput == null) { if (schedulingInput == null) {
logger.warn(() =>
'Quick capture scheduling validation failed: missing scheduling input. '
'taskId=${capturedTask.id}');
return QuickCaptureResult( return QuickCaptureResult(
task: capturedTask, task: capturedTask,
status: QuickCaptureStatus.validationError, status: QuickCaptureStatus.validationError,
@ -110,6 +128,9 @@ class QuickCaptureService {
final scheduledTask = _taskById(result.tasks, capturedTask.id); final scheduledTask = _taskById(result.tasks, capturedTask.id);
if (scheduledTask == null || scheduledTask.isBacklog) { 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( return QuickCaptureResult(
task: capturedTask, task: capturedTask,
status: QuickCaptureStatus.validationError, 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( return QuickCaptureResult(
task: scheduledTask, task: scheduledTask,
status: QuickCaptureStatus.scheduled, status: QuickCaptureStatus.scheduled,
@ -142,10 +167,16 @@ class QuickCaptureService {
}) { }) {
final generator = idGenerator; final generator = idGenerator;
if (generator == null) { if (generator == null) {
logger.error(
() => 'Quick capture rejected because id generator is missing.');
throw StateError('Quick capture needs an id generator.'); throw StateError('Quick capture needs an id generator.');
} }
final now = createdAt ?? updatedAt ?? clock.now(); 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( return capture(
QuickCaptureRequest( QuickCaptureRequest(

View file

@ -12,6 +12,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import '../domain/occupancy_policy.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_action.dart';
part 'reminder_policy/directives/reminder_directive_reason.dart'; part 'reminder_policy/directives/reminder_directive_reason.dart';
part 'reminder_policy/profiles/effective_reminder_profile.dart'; part 'reminder_policy/profiles/effective_reminder_profile.dart';

View file

@ -25,6 +25,8 @@ class ReminderPolicyService {
}) { }) {
final override = task.reminderOverride; final override = task.reminderOverride;
if (override != null) { if (override != null) {
logger.finer(() => 'Reminder profile resolved from task override. '
'taskId=${task.id} profile=${override.name}');
return EffectiveReminderProfile( return EffectiveReminderProfile(
profile: override, profile: override,
source: EffectiveReminderProfileSource.taskOverride, source: EffectiveReminderProfileSource.taskOverride,
@ -32,12 +34,17 @@ class ReminderPolicyService {
} }
if (project != null) { if (project != null) {
logger.finer(() => 'Reminder profile resolved from project default. '
'taskId=${task.id} projectId=${project.id} '
'profile=${project.defaultReminderProfile.name}');
return EffectiveReminderProfile( return EffectiveReminderProfile(
profile: project.defaultReminderProfile, profile: project.defaultReminderProfile,
source: EffectiveReminderProfileSource.projectDefault, source: EffectiveReminderProfileSource.projectDefault,
); );
} }
logger.finer(() => 'Reminder profile resolved from fallback. '
'taskId=${task.id} profile=${fallbackProfile.name}');
return EffectiveReminderProfile( return EffectiveReminderProfile(
profile: fallbackProfile, profile: fallbackProfile,
source: EffectiveReminderProfileSource.fallback, source: EffectiveReminderProfileSource.fallback,
@ -51,11 +58,19 @@ class ReminderPolicyService {
ProjectProfile? project, ProjectProfile? project,
Iterable<Task> contextTasks = const <Task>[], Iterable<Task> contextTasks = const <Task>[],
}) { }) {
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 effective = resolveEffectiveProfile(task: task, project: project);
final activeFreeSlot = _activeProtectedFreeSlot( final activeFreeSlot = _activeProtectedFreeSlot(
now: now, now: now,
contextTasks: contextTasks, 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( return _directiveForTask(
task: task, task: task,
@ -81,6 +96,12 @@ class ReminderPolicyService {
required ReminderDirectiveReason reason, required ReminderDirectiveReason reason,
DateTime? nextEligibleAt, 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( return ReminderDirective(
taskId: task.id, taskId: task.id,
action: action, action: action,
@ -165,6 +186,8 @@ class ReminderPolicyService {
if (task.type == TaskType.critical && if (task.type == TaskType.critical &&
effective.profile == ReminderProfile.strict) { effective.profile == ReminderProfile.strict) {
logger.finer(() => 'Reminder directive requires acknowledgement. '
'taskId=${task.id} profile=${effective.profile.name}');
return build( return build(
action: ReminderDirectiveAction.requireAcknowledgement, action: ReminderDirectiveAction.requireAcknowledgement,
reason: ReminderDirectiveReason.requireRequiredAcknowledgement, reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
@ -191,6 +214,9 @@ class ReminderPolicyService {
}) { }) {
if (task.type == TaskType.critical && if (task.type == TaskType.critical &&
effective.profile == ReminderProfile.strict) { effective.profile == ReminderProfile.strict) {
logger.finer(() =>
'Reminder directive requires acknowledgement during protected rest. '
'taskId=${task.id} profile=${effective.profile.name}');
return build( return build(
action: ReminderDirectiveAction.requireAcknowledgement, action: ReminderDirectiveAction.requireAcknowledgement,
reason: ReminderDirectiveReason.requireRequiredAcknowledgement, reason: ReminderDirectiveReason.requireRequiredAcknowledgement,
@ -199,6 +225,10 @@ class ReminderPolicyService {
if (task.type == TaskType.inflexible && if (task.type == TaskType.inflexible &&
effective.profile == ReminderProfile.gentle) { 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( return build(
action: ReminderDirectiveAction.defer, action: ReminderDirectiveAction.defer,
reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest,

View file

@ -22,6 +22,7 @@ import '../domain/models.dart';
import '../domain/occupancy_policy.dart'; import '../domain/occupancy_policy.dart';
import 'task_lifecycle.dart'; import 'task_lifecycle.dart';
import '../domain/time_contracts.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/notices/scheduling_notice_type.dart';
part 'scheduling_engine/codes/operations/scheduling_operation_code.dart'; part 'scheduling_engine/codes/operations/scheduling_operation_code.dart';
part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart'; part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart';

View file

@ -45,10 +45,16 @@ class SchedulingEngine {
required String taskId, required String taskId,
DateTime? updatedAt, 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; // 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. // quick capture or persistence code is responsible for adding it to the list.
final task = _taskById(input.tasks, taskId); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
logger.warn(
() => 'Backlog scheduling failed: task not found. taskId=$taskId');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -64,6 +70,8 @@ class SchedulingEngine {
} }
if (!task.isFlexible || !task.isBacklog) { 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( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -80,6 +88,8 @@ class SchedulingEngine {
final taskDuration = _durationFromMinutes(task.durationMinutes); final taskDuration = _durationFromMinutes(task.durationMinutes);
if (taskDuration == null) { if (taskDuration == null) {
logger.warn(() => 'Backlog scheduling failed: missing positive duration. '
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -103,6 +113,8 @@ class SchedulingEngine {
); );
if (placement == null) { if (placement == null) {
logger.warn(() => 'Backlog scheduling failed: no available slot. '
'taskId=${task.id} blockedIntervalCount=${input.blockedIntervals.length}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -137,12 +149,18 @@ class SchedulingEngine {
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? earliestStart,
bool countAsManualPush = true, 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 // Resolve the selected task by id so UI code only needs to pass a stable
// identifier, not object references. // identifier, not object references.
final task = _taskById(input.tasks, taskId); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
logger.warn(() => 'Flexible push failed: task not found. taskId=$taskId');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -158,6 +176,8 @@ class SchedulingEngine {
} }
if (!task.isFlexible || task.status != TaskStatus.planned) { 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( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -173,7 +193,11 @@ class SchedulingEngine {
} }
final currentInterval = _scheduledIntervalFor(task); 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( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -189,6 +213,9 @@ class SchedulingEngine {
} }
if (currentInterval.duration.inMicroseconds <= 0) { if (currentInterval.duration.inMicroseconds <= 0) {
logger.warn(() =>
'Flexible push failed: non-positive scheduled duration. '
'taskId=${task.id} durationMicroseconds=${currentInterval.duration.inMicroseconds}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -207,9 +234,12 @@ class SchedulingEngine {
input: input, input: input,
task: task, task: task,
currentInterval: currentInterval, currentInterval: currentInterval,
earliestStart: earliestStart,
); );
if (placement == null) { if (placement == null) {
logger.warn(() =>
'Flexible push failed: no available slot today. taskId=${task.id}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -250,8 +280,13 @@ class SchedulingEngine {
required String taskId, required String taskId,
DateTime? updatedAt, 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); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
logger.warn(() => 'Tomorrow push failed: task not found. taskId=$taskId');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -267,6 +302,8 @@ class SchedulingEngine {
} }
if (!task.isFlexible || task.status != TaskStatus.planned) { 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( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -284,6 +321,8 @@ class SchedulingEngine {
final taskDuration = _scheduledIntervalFor(task)?.duration ?? final taskDuration = _scheduledIntervalFor(task)?.duration ??
_durationFromMinutes(task.durationMinutes); _durationFromMinutes(task.durationMinutes);
if (taskDuration == null || taskDuration.inMicroseconds <= 0) { if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
logger.warn(() => 'Tomorrow push failed: missing positive duration. '
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -305,6 +344,8 @@ class SchedulingEngine {
); );
if (placement == null) { if (placement == null) {
logger.warn(
() => 'Tomorrow push failed: no available slot. taskId=${task.id}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -349,6 +390,10 @@ class SchedulingEngine {
SchedulingWindow? sourceWindow, SchedulingWindow? sourceWindow,
DateTime? updatedAt, 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 // Build the explicit queue of tasks to roll before asking the planner to
// place anything. This keeps selection separate from placement. // place anything. This keeps selection separate from placement.
final rolledItems = <_PlacementItem>[]; final rolledItems = <_PlacementItem>[];
@ -385,6 +430,8 @@ class SchedulingEngine {
} }
if (rolledItems.isEmpty) { if (rolledItems.isEmpty) {
logger
.debug(() => 'Rollover finished with no unfinished flexible tasks.');
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
@ -405,6 +452,9 @@ class SchedulingEngine {
); );
if (placement == null) { if (placement == null) {
logger.warn(
() => 'Rollover failed: unfinished flexible tasks could not fit. '
'rolledCount=${rolledItems.length}');
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -431,6 +481,8 @@ class SchedulingEngine {
/// reports any overlap with blocked intervals. It deliberately returns the /// reports any overlap with blocked intervals. It deliberately returns the
/// original task list unchanged. /// original task list unchanged.
SchedulingResult analyzeSchedule(SchedulingInput input) { SchedulingResult analyzeSchedule(SchedulingInput input) {
logger.debug(() => 'Analyzing schedule. taskCount=${input.tasks.length} '
'conflictOccupancyCount=${input.conflictReportingOccupancies.length}');
final overlaps = <SchedulingOverlap>[]; final overlaps = <SchedulingOverlap>[];
final notices = <SchedulingNotice>[]; final notices = <SchedulingNotice>[];
final conflictOccupancies = input.conflictReportingOccupancies; final conflictOccupancies = input.conflictReportingOccupancies;
@ -474,6 +526,9 @@ class SchedulingEngine {
} }
} }
logger.debug(
() => 'Schedule analysis finished. overlapCount=${overlaps.length} '
'noticeCount=${notices.length}');
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.analyzeSchedule, operationCode: SchedulingOperationCode.analyzeSchedule,
@ -492,6 +547,8 @@ class SchedulingEngine {
/// reports can distinguish this from a task that was never scheduled. /// reports can distinguish this from a task that was never scheduled.
Task moveToBacklog(Task task, {DateTime? updatedAt}) { Task moveToBacklog(Task task, {DateTime? updatedAt}) {
final now = updatedAt ?? clock.now(); 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( final result = _transitionService.apply(
task: task, task: task,
transitionCode: TaskTransitionCode.moveToBacklog, transitionCode: TaskTransitionCode.moveToBacklog,
@ -500,6 +557,8 @@ class SchedulingEngine {
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now, 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; return result.applied ? result.task : task;
} }
@ -509,6 +568,9 @@ class SchedulingEngine {
/// actual scheduled slot should change. /// actual scheduled slot should change.
Task markManuallyPushed(Task task, {DateTime? updatedAt}) { Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
final now = updatedAt ?? clock.now(); final now = updatedAt ?? clock.now();
logger.debug(
() => 'Legacy manual push statistic requested. taskId=${task.id} '
'occurredAt=${now.toIso8601String()}');
final result = _transitionService.apply( final result = _transitionService.apply(
task: task, task: task,
transitionCode: TaskTransitionCode.manualPush, transitionCode: TaskTransitionCode.manualPush,
@ -517,6 +579,9 @@ class SchedulingEngine {
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now, occurredAt: now,
); );
logger
.debug(() => 'Legacy manual push statistic finished. taskId=${task.id} '
'outcome=${result.outcomeCode.name}');
return result.applied ? result.task : task; return result.applied ? result.task : task;
} }
@ -527,6 +592,9 @@ class SchedulingEngine {
/// or time block that cannot simply be rescheduled automatically. /// or time block that cannot simply be rescheduled automatically.
Task markMissed(Task task, {DateTime? updatedAt}) { Task markMissed(Task task, {DateTime? updatedAt}) {
final now = updatedAt ?? clock.now(); 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( final result = _transitionService.apply(
task: task, task: task,
transitionCode: TaskTransitionCode.miss, transitionCode: TaskTransitionCode.miss,
@ -534,6 +602,8 @@ class SchedulingEngine {
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now, 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; return result.applied ? result.task : task;
} }
@ -550,6 +620,10 @@ class SchedulingEngine {
required Duration duration, required Duration duration,
required List<TimeInterval> blocked, required List<TimeInterval> blocked,
}) { }) {
logger.debug(() => 'Finding first open interval. '
'windowStart=${windowStart.toIso8601String()} '
'windowEnd=${windowEnd.toIso8601String()} '
'durationMinutes=${duration.inMinutes} blockedCount=${blocked.length}');
final sortedBlocked = [...blocked] final sortedBlocked = [...blocked]
..sort((a, b) => a.start.compareTo(b.start)); ..sort((a, b) => a.start.compareTo(b.start));
var cursor = windowStart; var cursor = windowStart;
@ -738,7 +812,12 @@ _BacklogInsertionPlan? _planFlexiblePush({
required SchedulingInput input, required SchedulingInput input,
required Task task, required Task task,
required TimeInterval currentInterval, required TimeInterval currentInterval,
DateTime? earliestStart,
}) { }) {
final pushPoint = _laterOf(
currentInterval.end,
earliestStart ?? input.window.start,
);
final fixedBlocks = <TimeInterval>[ final fixedBlocks = <TimeInterval>[
...input.blockedIntervals, ...input.blockedIntervals,
]; ];
@ -746,7 +825,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
_PlacementItem( _PlacementItem(
task: task, task: task,
duration: currentInterval.duration, duration: currentInterval.duration,
earliestStart: currentInterval.end, earliestStart: pushPoint,
), ),
]; ];
@ -766,7 +845,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
continue; continue;
} }
final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); final startsBeforePushPoint = interval.start.isBefore(pushPoint);
final startsAfterWindow = interval.start.isAfter(input.window.end) || final startsAfterWindow = interval.start.isAfter(input.window.end) ||
interval.start.isAtSameMomentAs(input.window.end); interval.start.isAtSameMomentAs(input.window.end);
@ -786,7 +865,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
var cursor = currentInterval.end; var cursor = pushPoint;
final placements = <String, TimeInterval>{}; final placements = <String, TimeInterval>{};
for (final item in queue) { for (final item in queue) {
@ -944,6 +1023,7 @@ SchedulingResult _applyPlacement({
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
clearBacklogEntry: isInsertedTask,
); );
final updatedTask = _applySchedulingActivity( final updatedTask = _applySchedulingActivity(
originalTask: task, 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( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: operationCode:
@ -1039,6 +1122,7 @@ SchedulingResult _applyPushPlacement({
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
clearBacklogEntry: true,
); );
final updatedTask = _applySchedulingActivity( final updatedTask = _applySchedulingActivity(
originalTask: task, 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( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: operationCode, operationCode: operationCode,
@ -1183,6 +1270,8 @@ SchedulingResult _applyRolloverPlacement({
), ),
); );
logger.debug(() => 'Rollover placement applied. rolledCount=$rolledCount '
'changeCount=${changes.length} noticeCount=${notices.length}');
return SchedulingResult( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,

View file

@ -13,6 +13,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import '../domain/occupancy_policy.dart'; import '../domain/occupancy_policy.dart';
import '../logging/focus_flow_logger.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
import 'task_lifecycle.dart'; import 'task_lifecycle.dart';
import '../domain/time_contracts.dart'; import '../domain/time_contracts.dart';

View file

@ -43,15 +43,35 @@ class FlexibleTaskActionService {
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
if (!task.isFlexible) { 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( throw ArgumentError.value(
task.type, 'task.type', 'Task must be flexible.'); 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) { switch (action) {
case FlexibleTaskQuickAction.done: case FlexibleTaskQuickAction.done:
final now = updatedAt ?? clock.now(); final now = updatedAt ?? clock.now();
final opId = final opId =
operationId ?? _defaultOperationId(action.name, task.id, now); 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( final transition = transitionService.apply(
task: task, task: task,
transitionCode: TaskTransitionCode.complete, transitionCode: TaskTransitionCode.complete,
@ -67,6 +87,17 @@ class FlexibleTaskActionService {
lockedIntervals: lockedIntervals, lockedIntervals: lockedIntervals,
existingActivities: existingActivities, 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( return FlexibleTaskActionResult(
action: action, action: action,
task: transition.task, task: transition.task,
@ -74,6 +105,10 @@ class FlexibleTaskActionService {
transitionOutcomeCode: transition.outcomeCode, transitionOutcomeCode: transition.outcomeCode,
); );
case FlexibleTaskQuickAction.push: 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( return FlexibleTaskActionResult(
action: action, action: action,
task: task, task: task,
@ -87,6 +122,10 @@ class FlexibleTaskActionService {
final now = updatedAt ?? clock.now(); final now = updatedAt ?? clock.now();
final opId = final opId =
operationId ?? _defaultOperationId(action.name, task.id, now); 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( final transition = transitionService.apply(
task: task, task: task,
transitionCode: TaskTransitionCode.moveToBacklog, transitionCode: TaskTransitionCode.moveToBacklog,
@ -99,6 +138,17 @@ class FlexibleTaskActionService {
occurredAt: now, occurredAt: now,
existingActivities: existingActivities, 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( return FlexibleTaskActionResult(
action: action, action: action,
task: transition.task, task: transition.task,
@ -106,6 +156,8 @@ class FlexibleTaskActionService {
transitionOutcomeCode: transition.outcomeCode, transitionOutcomeCode: transition.outcomeCode,
); );
case FlexibleTaskQuickAction.breakUp: case FlexibleTaskQuickAction.breakUp:
logger.debug(() => 'Flexible break-up flow requested. '
'taskId=${task.id} taskStatus=${task.status.name}');
return FlexibleTaskActionResult( return FlexibleTaskActionResult(
action: action, action: action,
task: task, task: task,
@ -123,17 +175,28 @@ class FlexibleTaskActionService {
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? earliestStart,
String? operationId, String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
final now = updatedAt ?? clock.now(); final now = updatedAt ?? clock.now();
final opId = operationId ?? final opId = operationId ??
_defaultOperationId('push-${destination.name}', taskId, now); _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( if (_hasDuplicateActivity(
existingActivities: existingActivities, existingActivities: existingActivities,
operationId: opId, operationId: opId,
taskId: taskId, taskId: taskId,
)) { )) {
logger.warn(() => 'Duplicate flexible push operation ignored. '
'destination=${destination.name} taskId=$taskId operationId=$opId');
return PushDestinationResult( return PushDestinationResult(
destination: destination, destination: destination,
schedulingResult: SchedulingResult( 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) { final result = switch (destination) {
PushDestination.nextAvailableSlot => PushDestination.nextAvailableSlot =>
schedulingEngine.pushFlexibleTaskToNextAvailableSlot( schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: input, input: input,
taskId: taskId, taskId: taskId,
updatedAt: now, updatedAt: now,
earliestStart: earliestStart,
), ),
PushDestination.tomorrowTopOfQueue => PushDestination.tomorrowTopOfQueue =>
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
@ -166,11 +232,22 @@ class FlexibleTaskActionService {
updatedAt: now, 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');
return PushDestinationResult( final activities = _activitiesForPushDestination(
destination: destination,
schedulingResult: result,
activities: _activitiesForPushDestination(
destination: destination, destination: destination,
input: input, input: input,
result: result, result: result,
@ -178,7 +255,15 @@ class FlexibleTaskActionService {
operationId: opId, operationId: opId,
occurredAt: now, occurredAt: now,
existingActivities: existingActivities, existingActivities: existingActivities,
), );
logger.debug(() => 'Flexible push destination activities prepared. '
'destination=${destination.name} taskId=$taskId '
'activityCount=${activities.length}');
return PushDestinationResult(
destination: destination,
schedulingResult: result,
activities: activities,
); );
} }
@ -191,8 +276,15 @@ class FlexibleTaskActionService {
required String taskId, required String taskId,
DateTime? updatedAt, 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); final task = _taskById(input.tasks, taskId);
if (task == null) { if (task == null) {
logger.warn(() => 'Handled move-to-backlog issue: task not found. '
'taskId=$taskId');
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
@ -209,6 +301,9 @@ class FlexibleTaskActionService {
} }
if (!task.isFlexible || task.status != TaskStatus.planned) { 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( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
@ -228,6 +323,10 @@ class FlexibleTaskActionService {
task, task,
updatedAt: updatedAt, 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( return SchedulingResult(
tasks: List<Task>.unmodifiable( tasks: List<Task>.unmodifiable(

View file

@ -37,7 +37,12 @@ class RequiredTaskActionService {
List<TimeInterval> lockedIntervals = const <TimeInterval>[], List<TimeInterval> lockedIntervals = const <TimeInterval>[],
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
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) { 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( throw ArgumentError.value(
task.type, task.type,
'task.type', 'task.type',
@ -61,6 +66,10 @@ class RequiredTaskActionService {
RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
}; };
final opId = operationId ?? _defaultOperationId(action.name, task.id, now); 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( final transition = transitionService.apply(
task: task, task: task,
transitionCode: transitionCode, transitionCode: transitionCode,
@ -73,6 +82,11 @@ class RequiredTaskActionService {
existingActivities: existingActivities, 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( return RequiredTaskActionResult(
action: action, action: action,
task: transition.task, task: transition.task,

View file

@ -39,9 +39,16 @@ class SurpriseTaskLogService {
bool revealHiddenLockedDetails = false, bool revealHiddenLockedDetails = false,
}) { }) {
final now = updatedAt ?? clock.now(); 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); final existingTask = _taskById(input.tasks, request.id);
if (existingTask != null) { if (existingTask != null) {
if (existingTask.type != TaskType.surprise) { 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( throw ArgumentError.value(
request.id, request.id,
'request.id', 'request.id',
@ -49,6 +56,8 @@ class SurpriseTaskLogService {
); );
} }
logger.warn(
() => 'Duplicate surprise task log ignored. taskId=${request.id}');
return SurpriseTaskLogResult( return SurpriseTaskLogResult(
surpriseTask: existingTask, surpriseTask: existingTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
@ -82,6 +91,8 @@ class SurpriseTaskLogService {
var currentTasks = <Task>[surpriseTask, ...input.tasks]; var currentTasks = <Task>[surpriseTask, ...input.tasks];
if (surpriseInterval == null) { if (surpriseInterval == null) {
logger.debug(() => 'Surprise task logged without occupying interval. '
'taskId=${surpriseTask.id} activityCount=1');
return SurpriseTaskLogResult( return SurpriseTaskLogResult(
surpriseTask: surpriseTask, surpriseTask: surpriseTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
@ -113,6 +124,12 @@ class SurpriseTaskLogService {
final movementNotices = <SchedulingNotice>[]; final movementNotices = <SchedulingNotice>[];
var usedNormalPushRules = false; 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) { for (final taskId in flexibleTaskIds) {
final currentTask = _taskById(currentTasks, taskId); final currentTask = _taskById(currentTasks, taskId);
final currentInterval = final currentInterval =
@ -120,9 +137,14 @@ class SurpriseTaskLogService {
if (currentTask == null || if (currentTask == null ||
currentInterval == null || currentInterval == null ||
!currentInterval.overlaps(surpriseInterval)) { !currentInterval.overlaps(surpriseInterval)) {
logger.finer(() => 'Surprise task repair skipped flexible candidate. '
'surpriseTaskId=${surpriseTask.id} taskId=$taskId '
'hasCurrentTask=${currentTask != null} hasInterval=${currentInterval != null}');
continue; continue;
} }
logger.finer(() => 'Surprise task repair pushing flexible overlap. '
'surpriseTaskId=${surpriseTask.id} taskId=$taskId');
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: SchedulingInput( input: SchedulingInput(
tasks: currentTasks, tasks: currentTasks,
@ -157,6 +179,11 @@ class SurpriseTaskLogService {
) )
.toList(growable: false); .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( return SurpriseTaskLogResult(
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
@ -196,10 +223,15 @@ class SurpriseTaskLogService {
}) { }) {
final generator = idGenerator; final generator = idGenerator;
if (generator == null) { if (generator == null) {
logger.error(() => 'Surprise task logNew failed: missing id generator.');
throw StateError('Surprise task logging needs an id generator.'); throw StateError('Surprise task logging needs an id generator.');
} }
final now = createdAt ?? updatedAt ?? clock.now(); 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( return log(
request: SurpriseTaskLogRequest( request: SurpriseTaskLogRequest(
@ -227,6 +259,8 @@ class SurpriseTaskLogService {
}) { }) {
final trimmedTitle = request.title.trim(); final trimmedTitle = request.title.trim();
if (trimmedTitle.isEmpty) { if (trimmedTitle.isEmpty) {
logger.warn(() => 'Surprise task creation failed: blank title. '
'taskId=${request.id}');
throw ArgumentError.value(request.title, 'title', 'Title is required.'); throw ArgumentError.value(request.title, 'title', 'Title is required.');
} }

View file

@ -13,6 +13,7 @@ library;
import '../domain/models.dart'; import '../domain/models.dart';
import '../domain/task_statistics.dart'; import '../domain/task_statistics.dart';
import '../domain/time_contracts.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_transition_code.dart';
part 'task_lifecycle/codes/task_activity_code.dart'; part 'task_lifecycle/codes/task_activity_code.dart';
part 'task_lifecycle/codes/task_transition_outcome_code.dart'; part 'task_lifecycle/codes/task_transition_outcome_code.dart';

Some files were not shown because too many files have changed in this diff Show more