diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..92ba521 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +# Blocks commits that do not meet the repository's formatting, analyzer, +# documentation, or SPDX/REUSE metadata gates. +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +dart format --set-exit-if-changed packages apps scripts tool test +dart analyze +dart run tool/check_reuse.dart +dart run tool/check_docs.dart --skip-dartdoc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22d7f96..723d4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + # Runs analyzer, tests, coverage, docs, and tagged desktop packaging. name: CI diff --git a/AGENTS.md b/AGENTS.md index 9abd7b4..4476fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,6 @@ + + + # AGENTS.md — Codex Project Rules (SQLite‑First, July 2026) This document supersedes all previous agent rule files. Treat it as the single source of truth. @@ -80,6 +83,17 @@ integrated. --- +## 6.1 File Hygiene + +All future project files must include the relevant SPDX metadata for their file +format. New Dart files must include file-level Dartdoc library docs and Dartdoc +comments for every class, enum, enum value, constructor, method, field, and +top-level declaration. When adding code, keep large feature surfaces organized +under descriptive subfolders instead of expanding flat top-level `src` or app +directories. + +--- + ## 7. Branch, Commit & CI * Start each new block from `main` on a block branch named diff --git a/Focus Flow Contributors.md b/Focus Flow Contributors.md new file mode 100644 index 0000000..ba8f7f1 --- /dev/null +++ b/Focus Flow Contributors.md @@ -0,0 +1,9 @@ + + + +# Focus Flow Contributors + +## Project Contact + +Ashley Eva Venn +ashleyevavenn@gmail.com diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..4e5f09f --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +version = 1 + +# The project license text is intentionally kept in the repository root as +# LICENSE.md. These annotations cover package and app files that cannot safely +# carry inline SPDX comments, such as generated platform files, binary icons, +# JSON manifests, Xcode project files, and XML/plist resources. +[[annotations]] +path = [ + "packages/**", + "apps/focus_flow_flutter/**", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 FocusFlow contributors" +SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1654f9e --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + public_member_api_docs: true + prefer_final_locals: true + prefer_final_fields: true + avoid_print: true diff --git a/apps/focus_flow_flutter/LICENSE.md b/apps/focus_flow_flutter/LICENSE.md new file mode 100644 index 0000000..c21e851 --- /dev/null +++ b/apps/focus_flow_flutter/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This app uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index eb08250..4d618a8 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -1,3 +1,6 @@ + + + # FocusFlow Flutter Flutter desktop app target for the FocusFlow UI work. diff --git a/apps/focus_flow_flutter/analysis_options.yaml b/apps/focus_flow_flutter/analysis_options.yaml index 0d29021..f28b8dc 100644 --- a/apps/focus_flow_flutter/analysis_options.yaml +++ b/apps/focus_flow_flutter/analysis_options.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # @@ -9,6 +12,12 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` @@ -21,6 +30,10 @@ linter: # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: + public_member_api_docs: true + prefer_final_locals: true + prefer_final_fields: true + avoid_print: true # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule diff --git a/apps/focus_flow_flutter/lib/app.dart b/apps/focus_flow_flutter/lib/app.dart index 364b815..14cdb0d 100644 --- a/apps/focus_flow_flutter/lib/app.dart +++ b/apps/focus_flow_flutter/lib/app.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Exports the FocusFlow Flutter application entry points. library; diff --git a/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart new file mode 100644 index 0000000..c78bdc1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../demo_scheduler_composition.dart'; + +/// Formats a civil date as a long month/day/year label. +String _formatDateLabel(CivilDate date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} + +/// Top-level helper that performs the `_instant` 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 _instant(CivilDate date, int hour, [int minute = 0]) { + return DateTime.utc(date.year, date.month, date.day, hour, minute); +} diff --git a/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart new file mode 100644 index 0000000..6aadc5d --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../demo_scheduler_composition.dart'; + +/// Top-level helper that performs the `_todaySeedTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _todaySeedTasks(CivilDate date, DateTime createdAt) { + return [ + _seedTask( + id: 'clean-coffee-maker', + title: 'Clean coffee maker', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 16, + startMinute: 15, + endHour: 16, + endMinute: 30, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + createdAt: createdAt, + ), + _seedTask( + id: 'cleaned-kitchen-counter', + title: 'Cleaned kitchen counter', + type: TaskType.flexible, + status: TaskStatus.completed, + date: date, + startHour: 8, + startMinute: 15, + endHour: 8, + endMinute: 30, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + completedAt: _instant(date, 8, 5), + createdAt: createdAt, + ), + _seedTask( + id: 'sort-mail', + title: 'Sort mail', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 14, + startMinute: 40, + endHour: 15, + endMinute: 20, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + createdAt: createdAt, + ), + _seedTask( + id: 'start-laundry', + title: 'Start laundry', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 15, + startMinute: 0, + endHour: 15, + endMinute: 40, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _seedTask( + id: 'water-plants', + title: 'Water plants', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 15, + startMinute: 20, + endHour: 16, + endMinute: 0, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _seedTask( + id: 'pay-bill', + title: 'Pay bill', + type: TaskType.critical, + status: TaskStatus.planned, + date: date, + startHour: 18, + startMinute: 0, + endHour: 18, + endMinute: 10, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _seedTask( + id: 'doctor-appointment', + title: 'Doctor appointment', + type: TaskType.inflexible, + status: TaskStatus.planned, + date: date, + startHour: 18, + startMinute: 30, + endHour: 19, + endMinute: 15, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.hard, + createdAt: createdAt, + ), + _seedTask( + id: 'free-slot', + title: 'Free Slot', + type: TaskType.freeSlot, + status: TaskStatus.planned, + date: date, + startHour: 19, + startMinute: 15, + endHour: 20, + endMinute: 15, + reward: RewardLevel.notSet, + difficulty: DifficultyLevel.notSet, + createdAt: createdAt, + ), + ]; +} + +/// Top-level helper that performs the `_seedTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Task _seedTask({ + required String id, + required String title, + required TaskType type, + required TaskStatus status, + required CivilDate date, + required int startHour, + required int startMinute, + required int endHour, + required int endMinute, + required RewardLevel reward, + required DifficultyLevel difficulty, + required DateTime createdAt, + DateTime? completedAt, +}) { + final start = _instant(date, startHour, startMinute); + final end = _instant(date, endHour, endMinute); + return Task( + id: id, + title: title, + projectId: DemoSchedulerComposition.projectId, + type: type, + status: status, + priority: PriorityLevel.medium, + reward: reward, + difficulty: difficulty, + durationMinutes: end.difference(start).inMinutes, + scheduledStart: start, + scheduledEnd: end, + actualStart: status == TaskStatus.completed ? start : null, + actualEnd: status == TaskStatus.completed ? end : null, + completedAt: completedAt, + createdAt: createdAt, + updatedAt: completedAt ?? createdAt, + ); +} diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 7672641..a614426 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Composes the FocusFlow Flutter application around Demo Scheduler Composition. library; @@ -7,7 +10,13 @@ import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_read_controller.dart'; import '../controllers/today_screen_controller.dart'; +part 'demo/demo_date_formatter.dart'; +part 'demo/demo_seed_tasks.dart'; + +/// Wires the Flutter demo UI to in-memory scheduler application use cases. class DemoSchedulerComposition { + /// Creates a `DemoSchedulerComposition._` 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. DemoSchedulerComposition._({ required this.store, required this.todayQuery, @@ -17,19 +26,37 @@ class DemoSchedulerComposition { required this.readAt, }); + /// Fixed owner used by the seeded demo data. static const ownerId = 'owner-1'; + + /// Fixed owner time zone used by the seeded demo data. static const timeZoneId = 'UTC'; + + /// Fixed project id used by the seeded demo data. static const projectId = 'home'; + /// In-memory application store backing the demo. final InMemoryApplicationUnitOfWork store; + + /// Query object used to read the Today state. final GetTodayStateQuery todayQuery; + + /// Management use cases used by backlog-facing UI surfaces. final V1ApplicationManagementUseCases managementUseCases; + + /// Command use cases used by interactive UI controls. final V1ApplicationCommandUseCases commandUseCases; + + /// Local date represented by the seeded demo. final CivilDate date; + + /// Stable read clock used for deterministic demo operations. final DateTime readAt; - String get dateLabel => formatDateLabel(date); + /// Human-readable label for [date]. + String get dateLabel => _formatDateLabel(date); + /// Creates a deterministic seeded composition for the demo app and tests. factory DemoSchedulerComposition.seeded({ Clock? clock, CivilDate? selectedDate, @@ -87,6 +114,7 @@ class DemoSchedulerComposition { ); } + /// Creates the controller used by the compact Today screen mockup. TodayScreenController createTodayScreenController() { return TodayScreenController( read: () { @@ -101,6 +129,7 @@ class DemoSchedulerComposition { ); } + /// Creates a generic read controller for Today-state panes. UiReadController createTodayController() { return ApplicationReadController( read: () { @@ -118,6 +147,7 @@ class DemoSchedulerComposition { ); } + /// Creates a generic read controller for backlog panes. UiReadController createBacklogController() { return ApplicationReadController( read: () { @@ -132,6 +162,7 @@ class DemoSchedulerComposition { ); } + /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, }) { @@ -143,165 +174,16 @@ class DemoSchedulerComposition { ); } - ApplicationOperationContext context(String operationId) { + /// Creates an application operation context for the supplied [operationId]. + ApplicationOperationContext context(String operationId, {DateTime? now}) { return ApplicationOperationContext.start( operationId: operationId, ownerTimeZone: OwnerTimeZoneContext( ownerId: ownerId, timeZoneId: timeZoneId, ), - clock: FixedClock(readAt), + clock: FixedClock(now ?? readAt), idGenerator: SequentialIdGenerator(prefix: operationId), ); } - - static String formatDateLabel(CivilDate date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - static List _todaySeedTasks(CivilDate date, DateTime createdAt) { - return [ - _task( - id: 'clean-coffee-maker', - title: 'Clean coffee maker', - type: TaskType.flexible, - status: TaskStatus.planned, - date: date, - startHour: 16, - startMinute: 15, - endHour: 16, - endMinute: 30, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.easy, - createdAt: createdAt, - ), - _task( - id: 'cleaned-kitchen-counter-early', - title: 'Cleaned kitchen counter', - type: TaskType.surprise, - status: TaskStatus.completed, - date: date, - startHour: 16, - startMinute: 45, - endHour: 17, - endMinute: 0, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 16, 20), - createdAt: createdAt, - ), - _task( - id: 'pay-bill', - title: 'Pay bill', - type: TaskType.critical, - status: TaskStatus.planned, - date: date, - startHour: 18, - startMinute: 0, - endHour: 18, - endMinute: 10, - reward: RewardLevel.low, - difficulty: DifficultyLevel.medium, - createdAt: createdAt, - ), - _task( - id: 'doctor-appointment', - title: 'Doctor appointment', - type: TaskType.inflexible, - status: TaskStatus.planned, - date: date, - startHour: 18, - startMinute: 30, - endHour: 19, - endMinute: 15, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.hard, - createdAt: createdAt, - ), - _task( - id: 'free-slot', - title: 'Free Slot', - type: TaskType.freeSlot, - status: TaskStatus.planned, - date: date, - startHour: 19, - startMinute: 15, - endHour: 20, - endMinute: 15, - reward: RewardLevel.notSet, - difficulty: DifficultyLevel.notSet, - createdAt: createdAt, - ), - _task( - id: 'cleaned-kitchen-counter-late', - title: 'Cleaned kitchen counter', - type: TaskType.surprise, - status: TaskStatus.completed, - date: date, - startHour: 20, - startMinute: 15, - endHour: 20, - endMinute: 30, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 20, 20), - createdAt: createdAt, - ), - ]; - } - - static Task _task({ - required String id, - required String title, - required TaskType type, - required TaskStatus status, - required CivilDate date, - required int startHour, - required int startMinute, - required int endHour, - required int endMinute, - required RewardLevel reward, - required DifficultyLevel difficulty, - required DateTime createdAt, - DateTime? completedAt, - }) { - final start = _instant(date, startHour, startMinute); - final end = _instant(date, endHour, endMinute); - return Task( - id: id, - title: title, - projectId: projectId, - type: type, - status: status, - priority: PriorityLevel.medium, - reward: reward, - difficulty: difficulty, - durationMinutes: end.difference(start).inMinutes, - scheduledStart: start, - scheduledEnd: end, - actualStart: status == TaskStatus.completed ? start : null, - actualEnd: status == TaskStatus.completed ? end : null, - completedAt: completedAt, - createdAt: createdAt, - updatedAt: completedAt ?? createdAt, - ); - } - - static DateTime _instant(CivilDate date, int hour, [int minute = 0]) { - return DateTime.utc(date.year, date.month, date.day, hour, minute); - } } diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 679f65c..f9be60f 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -1,8 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Composes the FocusFlow Flutter application around Focus Flow App. library; import 'package:flutter/material.dart'; +import '../controllers/scheduler_command_controller.dart'; import '../controllers/today_screen_controller.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_theme.dart'; @@ -15,11 +19,22 @@ import '../widgets/timeline/timeline_view.dart'; import '../widgets/top_bar.dart'; import 'demo_scheduler_composition.dart'; +part 'home/focus_flow_home.dart'; +part 'home/focus_flow_home_state.dart'; +part 'home/plan_issue.dart'; +part 'home/today_screen_scaffold.dart'; +part 'home/today_screen_scaffold_state.dart'; + +/// Root Material app for the FocusFlow desktop UI. class FocusFlowApp extends StatelessWidget { + /// Creates a FocusFlow app from an application composition. const FocusFlowApp({required this.composition, super.key}); + /// Factory and controller bundle used by the app tree. final DemoSchedulerComposition composition; + /// 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 MaterialApp( @@ -30,141 +45,3 @@ class FocusFlowApp extends StatelessWidget { ); } } - -class FocusFlowHome extends StatefulWidget { - const FocusFlowHome({required this.composition, super.key}); - - final DemoSchedulerComposition composition; - - @override - State createState() => _FocusFlowHomeState(); -} - -class _FocusFlowHomeState extends State { - late final TodayScreenController controller = widget.composition - .createTodayScreenController(); - - @override - void initState() { - super.initState(); - controller.load(); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: FocusFlowTokens.appBackground, - body: AppShell( - sidebar: const Sidebar(), - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - return switch (controller.state) { - TodayScreenLoading() => const Center( - child: CircularProgressIndicator(), - ), - TodayScreenFailure(:final message) => _PlanIssue( - message: message, - ), - TodayScreenEmpty() => _TodayScreenScaffold( - data: TodayScreenData.empty( - dateLabel: widget.composition.dateLabel, - ), - selectedCard: controller.selectedCard, - onSelectCard: controller.selectCard, - onClearSelection: controller.clearSelection, - ), - TodayScreenReady(:final data) => _TodayScreenScaffold( - data: data, - selectedCard: controller.selectedCard, - onSelectCard: controller.selectCard, - onClearSelection: controller.clearSelection, - ), - }; - }, - ), - ), - ); - } -} - -class _PlanIssue extends StatelessWidget { - const _PlanIssue({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Text(message, style: Theme.of(context).textTheme.titleMedium), - ); - } -} - -class _TodayScreenScaffold extends StatelessWidget { - const _TodayScreenScaffold({ - required this.data, - required this.selectedCard, - required this.onSelectCard, - required this.onClearSelection, - }); - - final TodayScreenData data; - final TimelineCardModel? selectedCard; - final ValueChanged onSelectCard; - final VoidCallback onClearSelection; - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - FocusFlowTokens.mainGutter, - 32, - FocusFlowTokens.mainGutter, - 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TopBar(dateLabel: data.dateLabel), - const SizedBox(height: 24), - RequiredBanner(model: data.requiredBanner), - const SizedBox(height: 22), - Expanded( - child: TimelineView( - cards: data.cards, - range: data.timelineRange, - onCardSelected: onSelectCard, - ), - ), - ], - ), - ), - if (selectedCard != null) ...[ - Positioned.fill( - child: GestureDetector( - key: const ValueKey('modal-click-away'), - behavior: HitTestBehavior.opaque, - onTap: onClearSelection, - child: const ColoredBox(color: Colors.transparent), - ), - ), - Center( - child: TaskSelectionModal( - card: selectedCard!, - onClose: onClearSelection, - ), - ), - ], - ], - ); - } -} diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart new file mode 100644 index 0000000..eb92ebc --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../focus_flow_app.dart'; + +/// Stateful home screen that owns the Today controller lifecycle. +class FocusFlowHome extends StatefulWidget { + /// Creates the FocusFlow home screen from an application composition. + const FocusFlowHome({required this.composition, super.key}); + + /// Factory and controller bundle used by the home screen. + final DemoSchedulerComposition composition; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _FocusFlowHomeState(); +} diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart new file mode 100644 index 0000000..11eaa7a --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../focus_flow_app.dart'; + +/// Private implementation type for `_FocusFlowHomeState` 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 _FocusFlowHomeState extends State { + /// Stores the `controller` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + late final TodayScreenController controller; + + /// Stores the `commandController` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + late final SchedulerCommandController commandController; + + /// Initializes state owned by this object before the first build. + /// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance. + @override + void initState() { + super.initState(); + controller = widget.composition.createTodayScreenController(); + commandController = widget.composition.createCommandController( + refreshReads: controller.load, + ); + controller.load(); + } + + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. + @override + void dispose() { + commandController.dispose(); + controller.dispose(); + super.dispose(); + } + + /// Runs the `_toggleCardCompletion` 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 _toggleCardCompletion(TimelineCardModel card) async { + if (!card.canToggleCompletion) { + return; + } + if (card.isCompleted) { + await commandController.uncompleteTask(taskId: card.id); + } else { + await commandController.completeTask( + taskId: card.id, + taskType: card.taskType, + ); + } + } + + /// 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 Scaffold( + backgroundColor: FocusFlowTokens.appBackground, + body: AppShell( + sidebar: const Sidebar(), + child: AnimatedBuilder( + animation: controller, + builder: (context, _) { + return switch (controller.state) { + TodayScreenLoading() => const Center( + child: CircularProgressIndicator(), + ), + TodayScreenFailure(:final message) => _PlanIssue( + message: message, + ), + TodayScreenEmpty() => _TodayScreenScaffold( + data: TodayScreenData.empty( + dateLabel: widget.composition.dateLabel, + ), + selectedCard: controller.selectedCard, + onSelectCard: controller.selectCard, + onCompleteCard: _toggleCardCompletion, + onClearSelection: controller.clearSelection, + ), + TodayScreenReady(:final data) => _TodayScreenScaffold( + data: data, + selectedCard: controller.selectedCard, + onSelectCard: controller.selectCard, + onCompleteCard: _toggleCardCompletion, + onClearSelection: controller.clearSelection, + ), + }; + }, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/app/home/plan_issue.dart b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart new file mode 100644 index 0000000..45173ef --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../focus_flow_app.dart'; + +/// Private implementation type for `_PlanIssue` 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 _PlanIssue extends StatelessWidget { + /// Creates a `_PlanIssue` 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 _PlanIssue({required this.message}); + + /// Stores the `message` 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 message; + + /// 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 Center( + child: Text(message, style: Theme.of(context).textTheme.titleMedium), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart new file mode 100644 index 0000000..8f28211 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../focus_flow_app.dart'; + +/// Private implementation type for `_TodayScreenScaffold` 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 _TodayScreenScaffold extends StatefulWidget { + /// Creates a `_TodayScreenScaffold` 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 _TodayScreenScaffold({ + required this.data, + required this.selectedCard, + required this.onSelectCard, + required this.onCompleteCard, + required this.onClearSelection, + }); + + /// Stores the `data` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TodayScreenData data; + + /// Stores the `selectedCard` 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? selectedCard; + + /// Stores the `onSelectCard` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ValueChanged onSelectCard; + + /// Stores the `onCompleteCard` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function(TimelineCardModel card) onCompleteCard; + + /// Stores the `onClearSelection` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onClearSelection; + + /// 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<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState(); +} diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart new file mode 100644 index 0000000..5fd50bf --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../focus_flow_app.dart'; + +/// Private implementation type for `_TodayScreenScaffoldState` 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 _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { + /// Private state stored as `_timelineScrollTargetCardId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _timelineScrollTargetCardId; + + /// Private state stored as `_timelineScrollRequest` 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 _timelineScrollRequest = 0; + + /// Runs the `_showUpcomingRequiredTask` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _showUpcomingRequiredTask() { + final targetCardId = widget.data.requiredBanner?.timelineCardId; + if (targetCardId == null) { + return; + } + setState(() { + _timelineScrollTargetCardId = targetCardId; + _timelineScrollRequest += 1; + }); + } + + /// 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 Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + FocusFlowTokens.mainGutter, + 32, + FocusFlowTokens.mainGutter, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TopBar(dateLabel: widget.data.dateLabel), + if (widget.data.requiredBanner == null) + const SizedBox(height: 16) + else ...[ + const SizedBox(height: 24), + RequiredBanner( + model: widget.data.requiredBanner, + onShowUpcoming: _showUpcomingRequiredTask, + ), + const SizedBox(height: 22), + ], + Expanded( + child: TimelineView( + cards: widget.data.cards, + range: widget.data.timelineRange, + scrollTargetCardId: _timelineScrollTargetCardId, + scrollRequest: _timelineScrollRequest, + onCardSelected: widget.onSelectCard, + onCardCompleted: widget.onCompleteCard, + ), + ), + ], + ), + ), + if (widget.selectedCard != null) ...[ + Positioned.fill( + child: GestureDetector( + key: const ValueKey('modal-click-away'), + behavior: HitTestBehavior.opaque, + onTap: widget.onClearSelection, + child: const ColoredBox(color: Colors.transparent), + ), + ), + Center( + child: TaskSelectionModal( + card: widget.selectedCard!, + onClose: widget.onClearSelection, + onStatusPressed: widget.selectedCard!.canToggleCompletion + ? () => widget.onCompleteCard(widget.selectedCard!) + : null, + ), + ), + ], + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart index db00ab7..cf2bb6a 100644 --- a/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Provides compatibility composition exports for FocusFlow Flutter. library; diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart new file mode 100644 index 0000000..275fd9b --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../scheduler_command_controller.dart'; + +/// Builds an application operation context for a UI command operation. +typedef OperationContextFactory = + ApplicationOperationContext Function(String operationId, {DateTime? now}); + +/// Refreshes any reads that should reflect a completed command. +typedef ReadRefresh = Future Function(); diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart new file mode 100644 index 0000000..1d3d5fa --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../scheduler_command_controller.dart'; + +/// Runs scheduler write commands and exposes command state to widgets. +class SchedulerCommandController extends ChangeNotifier { + /// Creates a command controller from scheduler use cases and UI callbacks. + SchedulerCommandController({ + required this.commands, + required this.contextFor, + required this.localDate, + required this.refreshReads, + DateTime Function()? now, + }) : now = now ?? _utcNow; + + /// Scheduler write use cases invoked by this controller. + final V1ApplicationCommandUseCases commands; + + /// Factory for command-scoped application operation contexts. + final OperationContextFactory contextFor; + + /// Local date commands should operate against. + final CivilDate localDate; + + /// Refresh callback invoked after commands that mutate read state. + final ReadRefresh refreshReads; + + /// Clock used by direct UI commands such as marking a task complete. + final DateTime Function() now; + + /// Private state stored as `_sequence` 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 _sequence = 0; + + /// 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. + SchedulerCommandState _state = const SchedulerCommandIdle(); + + /// Current command execution state. + SchedulerCommandState get state => _state; + + /// Captures [title] as a backlog task. + Future quickCaptureToBacklog(String title) async { + await _run( + label: 'Capturing task', + successMessage: 'Task captured', + refreshAfterSuccess: true, + action: (operationId) { + return commands.quickCaptureToBacklog( + context: contextFor(operationId), + title: title, + projectId: 'home', + ); + }, + ); + } + + /// Schedules a backlog task into the next available slot. + Future scheduleBacklogItem({ + required String taskId, + required int durationMinutes, + required DateTime expectedUpdatedAt, + }) async { + await _run( + label: 'Scheduling task', + successMessage: 'Task scheduled', + refreshAfterSuccess: true, + action: (operationId) { + return commands.scheduleBacklogItemToNextAvailableSlot( + context: contextFor(operationId), + localDate: localDate, + taskId: taskId, + durationMinutes: durationMinutes, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Completes a planned flexible task from the Today view. + Future completeFlexibleTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + await completeTask( + taskId: taskId, + taskType: TaskType.flexible, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Completes a planned task from the Today timeline. + Future completeTask({ + required String taskId, + required TaskType taskType, + DateTime? expectedUpdatedAt, + }) async { + final completedAt = now(); + await _run( + label: 'Completing task', + successMessage: 'Task completed', + refreshAfterSuccess: true, + action: (operationId) { + final context = contextFor(operationId, now: completedAt); + return switch (taskType) { + TaskType.flexible => commands.completeFlexibleTask( + context: context, + localDate: localDate, + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ), + TaskType.critical || + TaskType.inflexible => commands.applyRequiredTaskAction( + context: context, + localDate: localDate, + taskId: taskId, + action: RequiredTaskAction.done, + expectedUpdatedAt: expectedUpdatedAt, + ), + TaskType.locked || + TaskType.surprise || + TaskType.freeSlot => Future.value( + ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + entityId: taskId, + detailCode: 'unsupportedTaskType', + ), + ), + ), + }; + }, + ); + } + + /// Returns a completed task to its planned state. + Future uncompleteTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final occurredAt = now(); + await _run( + label: 'Reopening task', + successMessage: 'Task marked as not done', + refreshAfterSuccess: true, + action: (operationId) { + return commands.uncompleteTask( + context: contextFor(operationId, now: occurredAt), + localDate: localDate, + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Runs the `_run` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + Future _run({ + required String label, + required String successMessage, + required bool refreshAfterSuccess, + required Future> Function( + String operationId, + ) + action, + }) async { + final operationId = _nextOperationId(); + _setState(SchedulerCommandRunning(label)); + try { + final result = await action(operationId); + final failure = result.failure; + if (failure != null) { + _setState(SchedulerCommandFailure(failure: failure)); + return; + } + if (refreshAfterSuccess) { + await refreshReads(); + } + _setState(SchedulerCommandSuccess(successMessage)); + } on Object catch (error) { + _setState(SchedulerCommandFailure(error: error)); + } + } + + /// Runs the `_nextOperationId` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _nextOperationId() { + _sequence += 1; + return 'ui-command-$_sequence'; + } + + /// Runs the `_setState` 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 _setState(SchedulerCommandState next) { + _state = next; + notifyListeners(); + } + + /// Runs the `_utcNow` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + static DateTime _utcNow() => DateTime.now().toUtc(); +} diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart new file mode 100644 index 0000000..2c15d88 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../scheduler_command_controller.dart'; + +/// Base state for scheduler command execution in the UI. +sealed class SchedulerCommandState { + /// Creates a scheduler command state. + const SchedulerCommandState(); +} + +/// Indicates that no scheduler command is currently running. +class SchedulerCommandIdle extends SchedulerCommandState { + /// Creates an idle command state. + const SchedulerCommandIdle(); +} + +/// Indicates that a scheduler command is currently running. +class SchedulerCommandRunning extends SchedulerCommandState { + /// Creates a running command state with a user-facing [label]. + const SchedulerCommandRunning(this.label); + + /// User-facing description of the command in progress. + final String label; +} + +/// Indicates that a scheduler command completed successfully. +class SchedulerCommandSuccess extends SchedulerCommandState { + /// Creates a success command state with a user-facing [message]. + const SchedulerCommandSuccess(this.message); + + /// User-facing success message. + final String message; +} + +/// Indicates that a scheduler command failed. +class SchedulerCommandFailure extends SchedulerCommandState { + /// Creates a failure state from an application [failure] or unexpected [error]. + const SchedulerCommandFailure({this.failure, this.error}); + + /// Typed application failure returned by the scheduler core. + final ApplicationFailure? failure; + + /// Unexpected error thrown while running the command. + final Object? error; + + /// Stable label for display and diagnostics. + String get codeLabel { + final typed = failure; + if (typed != null) { + return typed.detailCode ?? typed.code.name; + } + return 'unexpected'; + } +} diff --git a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart index 96054ed..6be3f52 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -1,155 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Coordinates Scheduler Command Controller state for the FocusFlow Flutter UI. library; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; -typedef OperationContextFactory = - ApplicationOperationContext Function(String operationId); - -typedef ReadRefresh = Future Function(); - -sealed class SchedulerCommandState { - const SchedulerCommandState(); -} - -class SchedulerCommandIdle extends SchedulerCommandState { - const SchedulerCommandIdle(); -} - -class SchedulerCommandRunning extends SchedulerCommandState { - const SchedulerCommandRunning(this.label); - - final String label; -} - -class SchedulerCommandSuccess extends SchedulerCommandState { - const SchedulerCommandSuccess(this.message); - - final String message; -} - -class SchedulerCommandFailure extends SchedulerCommandState { - const SchedulerCommandFailure({this.failure, this.error}); - - final ApplicationFailure? failure; - final Object? error; - - String get codeLabel { - final typed = failure; - if (typed != null) { - return typed.detailCode ?? typed.code.name; - } - return 'unexpected'; - } -} - -class SchedulerCommandController extends ChangeNotifier { - SchedulerCommandController({ - required this.commands, - required this.contextFor, - required this.localDate, - required this.refreshReads, - }); - - final V1ApplicationCommandUseCases commands; - final OperationContextFactory contextFor; - final CivilDate localDate; - final ReadRefresh refreshReads; - var _sequence = 0; - SchedulerCommandState _state = const SchedulerCommandIdle(); - - SchedulerCommandState get state => _state; - - Future quickCaptureToBacklog(String title) async { - await _run( - label: 'Capturing task', - successMessage: 'Task captured', - refreshAfterSuccess: true, - action: (operationId) { - return commands.quickCaptureToBacklog( - context: contextFor(operationId), - title: title, - projectId: 'home', - ); - }, - ); - } - - Future scheduleBacklogItem({ - required String taskId, - required int durationMinutes, - required DateTime expectedUpdatedAt, - }) async { - await _run( - label: 'Scheduling task', - successMessage: 'Task scheduled', - refreshAfterSuccess: true, - action: (operationId) { - return commands.scheduleBacklogItemToNextAvailableSlot( - context: contextFor(operationId), - localDate: localDate, - taskId: taskId, - durationMinutes: durationMinutes, - expectedUpdatedAt: expectedUpdatedAt, - ); - }, - ); - } - - Future completeFlexibleTask({ - required String taskId, - DateTime? expectedUpdatedAt, - }) async { - await _run( - label: 'Completing task', - successMessage: 'Task completed', - refreshAfterSuccess: true, - action: (operationId) { - return commands.completeFlexibleTask( - context: contextFor(operationId), - localDate: localDate, - taskId: taskId, - expectedUpdatedAt: expectedUpdatedAt, - ); - }, - ); - } - - Future _run({ - required String label, - required String successMessage, - required bool refreshAfterSuccess, - required Future> Function( - String operationId, - ) - action, - }) async { - final operationId = _nextOperationId(); - _setState(SchedulerCommandRunning(label)); - try { - final result = await action(operationId); - final failure = result.failure; - if (failure != null) { - _setState(SchedulerCommandFailure(failure: failure)); - return; - } - if (refreshAfterSuccess) { - await refreshReads(); - } - _setState(SchedulerCommandSuccess(successMessage)); - } on Object catch (error) { - _setState(SchedulerCommandFailure(error: error)); - } - } - - String _nextOperationId() { - _sequence += 1; - return 'ui-command-$_sequence'; - } - - void _setState(SchedulerCommandState next) { - _state = next; - notifyListeners(); - } -} +part 'command/scheduler_command_callbacks.dart'; +part 'command/scheduler_command_controller_impl.dart'; +part 'command/scheduler_command_state.dart'; diff --git a/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart index 9b63eaf..b446e31 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart @@ -1,46 +1,72 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Coordinates Scheduler Read Controller state for the FocusFlow Flutter UI. library; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; +/// Reads an application value for a UI surface. typedef ApplicationReader = Future> Function(); + +/// Determines whether a successful read should be rendered as empty state. typedef EmptyPredicate = bool Function(T value); +/// Shared interface for read controllers consumed by UI widgets. abstract class UiReadController extends ChangeNotifier { + /// Current read state. SchedulerReadState get state; + /// Loads the backing value. Future load(); + /// Retries the latest read. Future retry(); } +/// Base state for scheduler read operations. sealed class SchedulerReadState { + /// Creates a scheduler read state. const SchedulerReadState(); } +/// Indicates that a scheduler read is in progress. class SchedulerReadLoading extends SchedulerReadState { + /// Creates a loading read state. const SchedulerReadLoading(); } +/// Indicates that a scheduler read returned renderable data. class SchedulerReadData extends SchedulerReadState { + /// Creates a data state from [value]. const SchedulerReadData(this.value); + /// Value returned by the read operation. final T value; } +/// Indicates that a scheduler read succeeded but has no primary content. class SchedulerReadEmpty extends SchedulerReadState { + /// Creates an empty state that still carries the read [value]. const SchedulerReadEmpty(this.value); + /// Value returned by the read operation. final T value; } +/// Indicates that a scheduler read failed. class SchedulerReadFailure extends SchedulerReadState { + /// Creates a failure state from an application [failure] or unexpected [error]. const SchedulerReadFailure({this.failure, this.error}); + /// Typed application failure returned by the scheduler core. final ApplicationFailure? failure; + + /// Unexpected error thrown while running the read. final Object? error; + /// Stable label for display and diagnostics. String get codeLabel { final typed = failure; if (typed != null) { @@ -50,17 +76,26 @@ class SchedulerReadFailure extends SchedulerReadState { } } +/// Default read controller backed by an [ApplicationReader]. class ApplicationReadController extends UiReadController { + /// Creates a controller from a read callback and empty-state predicate. ApplicationReadController({required this.read, required this.isEmpty}); + /// Callback used to load the application value. final ApplicationReader read; + + /// Predicate used to classify a successful value as empty or populated. final EmptyPredicate isEmpty; + /// 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. SchedulerReadState _state = SchedulerReadLoading(); + /// Current read state. @override SchedulerReadState get state => _state; + /// Loads the current value and updates [state]. @override Future load() async { _setState(SchedulerReadLoading()); @@ -82,9 +117,12 @@ class ApplicationReadController extends UiReadController { } } + /// Retries by running [load] again. @override Future retry() => load(); + /// Runs the `_setState` 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 _setState(SchedulerReadState next) { _state = next; notifyListeners(); diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index fb7e734..6994850 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Coordinates Today Screen Controller state for the FocusFlow Flutter UI. library; @@ -6,44 +9,68 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../models/today_screen_models.dart'; +/// Reads Today state from the scheduler application layer. typedef TodayStateReader = Future> Function(); +/// Base state for the compact Today screen. sealed class TodayScreenState { + /// Creates a Today screen state. const TodayScreenState(); } +/// Indicates that the Today screen is loading. class TodayScreenLoading extends TodayScreenState { + /// Creates a loading Today screen state. const TodayScreenLoading(); } +/// Indicates that the Today screen has renderable timeline data. class TodayScreenReady extends TodayScreenState { + /// Creates a ready state with mapped screen [data]. const TodayScreenReady(this.data); + /// Presentation model for the Today screen. final TodayScreenData data; } +/// Indicates that the Today screen loaded successfully with no cards. class TodayScreenEmpty extends TodayScreenState { + /// Creates an empty Today screen state. const TodayScreenEmpty(); } +/// Indicates that the Today screen failed to load. class TodayScreenFailure extends TodayScreenState { + /// Creates a failure state with a displayable [message]. const TodayScreenFailure(this.message); + /// User-facing failure message or code. final String message; } +/// Loads Today screen data and tracks selected timeline cards. class TodayScreenController extends ChangeNotifier { + /// Creates a Today screen controller from a read callback. TodayScreenController({required this.read}); + /// Callback used to read Today state. final TodayStateReader read; + /// Private state stored as `_state` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. TodayScreenState _state = const TodayScreenLoading(); + + /// Private state stored as `_selectedCard` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. TimelineCardModel? _selectedCard; + /// Current Today screen loading/data/failure state. TodayScreenState get state => _state; + /// Currently selected timeline card, if a modal should be visible. TimelineCardModel? get selectedCard => _selectedCard; + /// Loads Today state and maps it into presentation data. Future load() async { _state = const TodayScreenLoading(); notifyListeners(); @@ -57,6 +84,7 @@ class TodayScreenController extends ChangeNotifier { } final data = TodayScreenData.fromTodayState(result.requireValue); + _syncSelectedCard(data); _state = data.cards.isEmpty ? const TodayScreenEmpty() : TodayScreenReady(data); @@ -67,6 +95,7 @@ class TodayScreenController extends ChangeNotifier { } } + /// Selects [card] when the card allows selection. void selectCard(TimelineCardModel card) { if (!card.isSelectable) { return; @@ -75,6 +104,7 @@ class TodayScreenController extends ChangeNotifier { notifyListeners(); } + /// Clears the selected timeline card. void clearSelection() { if (_selectedCard == null) { return; @@ -82,4 +112,20 @@ class TodayScreenController extends ChangeNotifier { _selectedCard = null; notifyListeners(); } + + /// Runs the `_syncSelectedCard` 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 _syncSelectedCard(TodayScreenData data) { + final selected = _selectedCard; + if (selected == null) { + return; + } + for (final card in data.cards) { + if (card.id == selected.id) { + _selectedCard = card; + return; + } + } + _selectedCard = null; + } } diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index e1262e0..20c19ae 100644 --- a/apps/focus_flow_flutter/lib/main.dart +++ b/apps/focus_flow_flutter/lib/main.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Launches the FocusFlow Flutter desktop application. library; @@ -9,6 +12,7 @@ import 'app/focus_flow_app.dart'; export 'app/demo_scheduler_composition.dart'; export 'app/focus_flow_app.dart'; +/// Starts the FocusFlow app with the seeded demo scheduler composition. void main() { runApp(FocusFlowApp(composition: DemoSchedulerComposition.seeded())); } diff --git a/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart new file mode 100644 index 0000000..5e6166a --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Presentation model for the next-required-task banner. +class RequiredBannerModel { + /// Creates a required banner model. + const RequiredBannerModel({ + required this.timelineCardId, + required this.title, + required this.timeText, + }); + + /// Timeline card id linked to the highlighted required task. + final String timelineCardId; + + /// Title of the next required task. + final String title; + + /// Display-ready time for the next required task. + final String timeText; +} diff --git a/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart new file mode 100644 index 0000000..bc2d7fe --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Visual category used to style timeline cards. +enum TaskVisualKind { + /// Flexible planned work. + flexible, + + /// Critical required task. + required, + + /// Inflexible appointment-style task. + appointment, + + /// Intentional free time. + freeSlot, + + /// Completed surprise task. + completedSurprise, +} diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart new file mode 100644 index 0000000..1ed136d --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_screen_models.dart'; + +/// Top-level helper that performs the `_dateLabel` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _dateLabel(CivilDate date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} + +/// Top-level helper that performs the `_minutesSinceMidnight` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _minutesSinceMidnight(DateTime? value) { + if (value == null) { + return 0; + } + return value.hour * 60 + value.minute; +} + +/// Top-level helper that performs the `_formatTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _formatTime(DateTime? value) { + if (value == null) { + return ''; + } + final period = value.hour >= 12 ? 'PM' : 'AM'; + final rawHour = value.hour % 12; + final hour = rawHour == 0 ? 12 : rawHour; + final minute = value.minute.toString().padLeft(2, '0'); + return '$hour:$minute $period'; +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart new file mode 100644 index 0000000..bf1baa1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Presentation model for a single compact timeline card. +class TimelineCardModel { + /// Creates a timeline card model. + const TimelineCardModel({ + required this.id, + required this.title, + required this.typeLabel, + required this.subtitle, + required this.timeText, + required this.startMinutes, + required this.endMinutes, + required this.taskType, + required this.visualKind, + required this.rewardIconToken, + required this.difficultyIconToken, + required this.isCompleted, + required this.isSelectable, + required this.showsQuickActions, + this.completedTimeText, + this.durationMinutes, + }); + + /// Maps a scheduler timeline item into card presentation data. + factory TimelineCardModel.fromItem(TodayTimelineItem item) { + final start = item.start; + final end = item.end; + final visualKind = _visualKindFor(item); + final isCompleted = item.taskStatus == TaskStatus.completed; + final title = item.item.displayTitle; + final duration = item.item.durationMinutes; + final timeText = start == null || end == null + ? duration == null + ? '' + : '$duration min' + : '${_formatTime(start)} - ${_formatTime(end)}'; + final completedAt = item.item.completedAt ?? item.item.end ?? item.end; + return TimelineCardModel( + id: item.id, + title: title, + typeLabel: _typeLabelFor(item.taskType, visualKind), + subtitle: _subtitleFor(item, visualKind, timeText), + timeText: timeText, + startMinutes: _minutesSinceMidnight(start), + endMinutes: _minutesSinceMidnight(end), + taskType: item.taskType, + visualKind: visualKind, + rewardIconToken: item.item.rewardIconToken, + difficultyIconToken: item.item.difficultyIconToken, + durationMinutes: duration, + completedTimeText: isCompleted ? _formatTime(completedAt) : null, + isCompleted: isCompleted, + isSelectable: true, + showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, + ); + } + + /// Stable card identifier. + final String id; + + /// Primary task title shown on the card. + final String title; + + /// Short task type label. + final String typeLabel; + + /// Secondary line shown under the title. + final String subtitle; + + /// Display-ready time or duration text. + final String timeText; + + /// Display-ready completion time, if the task has been completed. + final String? completedTimeText; + + /// Card start position in minutes since midnight. + final int startMinutes; + + /// Card end position in minutes since midnight. + final int endMinutes; + + /// Optional task duration in minutes. + final int? durationMinutes; + + /// Source task scheduling behavior type. + final TaskType taskType; + + /// Visual category used for styling. + final TaskVisualKind visualKind; + + /// Reward icon token from the scheduler core. + final TimelineRewardIconToken rewardIconToken; + + /// Difficulty icon token from the scheduler core. + final TimelineDifficultyIconToken difficultyIconToken; + + /// Whether the card represents completed work. + final bool isCompleted; + + /// Whether tapping the card should open its selection modal. + final bool isSelectable; + + /// Whether the card should show compact quick-action controls. + final bool showsQuickActions; + + /// Whether the left status control can toggle this task's completion. + bool get canToggleCompletion { + return switch (taskType) { + TaskType.flexible || TaskType.critical || TaskType.inflexible => true, + TaskType.locked || TaskType.surprise || TaskType.freeSlot => false, + }; + } + + /// Whether the left status control can complete this task. + bool get canComplete => canToggleCompletion && !isCompleted; + + /// Whether the left status control can return this task to planned. + bool get canUncomplete => canToggleCompletion && isCompleted; + + /// Accessibility and modal label for the reward icon. + String get rewardLabel { + return switch (rewardIconToken) { + TimelineRewardIconToken.notSet => 'Reward not set', + TimelineRewardIconToken.veryLow => 'Reward level 1', + TimelineRewardIconToken.low => 'Reward level 2', + TimelineRewardIconToken.medium => 'Reward level 3', + TimelineRewardIconToken.high => 'Reward level 4', + TimelineRewardIconToken.veryHigh => 'Reward level 5', + }; + } + + /// Accessibility and modal label for the effort icon. + String get effortLabel { + return switch (difficultyIconToken) { + TimelineDifficultyIconToken.notSet => 'Effort not set', + TimelineDifficultyIconToken.veryEasy => 'Very easy effort', + TimelineDifficultyIconToken.easy => 'Easy effort', + TimelineDifficultyIconToken.medium => 'Medium effort', + TimelineDifficultyIconToken.hard => 'Hard effort', + TimelineDifficultyIconToken.veryHard => 'Very hard effort', + }; + } +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart new file mode 100644 index 0000000..edb96c4 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Top-level helper that performs the `_visualKindFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TaskVisualKind _visualKindFor(TodayTimelineItem item) { + if (item.taskStatus == TaskStatus.completed || + (item.taskType == TaskType.surprise && + item.taskStatus == TaskStatus.completed)) { + return TaskVisualKind.completedSurprise; + } + return switch (item.taskType) { + TaskType.flexible => TaskVisualKind.flexible, + TaskType.critical => TaskVisualKind.required, + TaskType.inflexible => TaskVisualKind.appointment, + TaskType.freeSlot => TaskVisualKind.freeSlot, + TaskType.surprise => TaskVisualKind.completedSurprise, + TaskType.locked => TaskVisualKind.completedSurprise, + }; +} + +/// Top-level helper that performs the `_typeLabelFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) { + if (visualKind == TaskVisualKind.completedSurprise) { + return 'Completed'; + } + return switch (taskType) { + TaskType.flexible => 'Flexible', + TaskType.critical => 'Required', + TaskType.inflexible => 'Required', + TaskType.freeSlot => 'Free Slot', + TaskType.surprise => 'Surprise task', + TaskType.locked => 'Locked', + }; +} + +/// Top-level helper that performs the `_subtitleFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _subtitleFor( + TodayTimelineItem item, + TaskVisualKind visualKind, + String timeText, +) { + if (visualKind == TaskVisualKind.freeSlot) { + return 'Intentional rest'; + } + if (item.taskStatus == TaskStatus.completed || + visualKind == TaskVisualKind.completedSurprise) { + return 'Completed at: ' + '${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}'; + } + if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { + return '${item.item.durationMinutes} min'; + } + return timeText; +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart new file mode 100644 index 0000000..059d236 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Visible timeline range expressed as minutes since midnight. +class TimelineRangeModel { + /// Creates a timeline range model. + const TimelineRangeModel({ + required this.startMinutes, + required this.endMinutes, + }); + + /// First visible minute from midnight. + final int startMinutes; + + /// Last visible minute from midnight. + final int endMinutes; +} diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart new file mode 100644 index 0000000..4455c8e --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../today_screen_models.dart'; + +/// Presentation model for the compact Today screen. +class TodayScreenData { + /// Creates Today screen data. + const TodayScreenData({ + required this.dateLabel, + required this.timelineRange, + required this.cards, + this.requiredBanner, + }); + + /// Creates an empty Today screen model for [dateLabel]. + factory TodayScreenData.empty({required String dateLabel}) { + return TodayScreenData( + dateLabel: dateLabel, + timelineRange: defaultRange, + cards: const [], + ); + } + + /// Maps scheduler core [TodayState] into UI presentation data. + factory TodayScreenData.fromTodayState(TodayState state) { + final cards = [ + for (final item in state.timelineItems) TimelineCardModel.fromItem(item), + ]; + final nextRequired = state.nextRequiredItem; + return TodayScreenData( + dateLabel: _dateLabel(state.date), + timelineRange: defaultRange, + requiredBanner: nextRequired == null + ? null + : RequiredBannerModel( + timelineCardId: nextRequired.id, + title: nextRequired.item.displayTitle, + timeText: _formatTime(nextRequired.start), + ), + cards: List.unmodifiable(cards), + ); + } + + /// Default visible range for the compact timeline. + static const defaultRange = TimelineRangeModel( + startMinutes: 0, + endMinutes: 24 * 60, + ); + + /// Display-ready date label. + final String dateLabel; + + /// Optional next-required-task banner model. + final RequiredBannerModel? requiredBanner; + + /// Visible timeline range. + final TimelineRangeModel timelineRange; + + /// Timeline cards to render. + final List cards; +} diff --git a/apps/focus_flow_flutter/lib/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index 839ba9f..2555b73 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -1,240 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Today Screen Models models for the FocusFlow Flutter UI. library; import 'package:scheduler_core/scheduler_core.dart'; -class RequiredBannerModel { - const RequiredBannerModel({required this.title, required this.timeText}); - - final String title; - final String timeText; -} - -class TimelineRangeModel { - const TimelineRangeModel({ - required this.startMinutes, - required this.endMinutes, - }); - - final int startMinutes; - final int endMinutes; -} - -enum TaskVisualKind { - flexible, - required, - appointment, - freeSlot, - completedSurprise, -} - -class TodayScreenData { - const TodayScreenData({ - required this.dateLabel, - required this.timelineRange, - required this.cards, - this.requiredBanner, - }); - - factory TodayScreenData.empty({required String dateLabel}) { - return TodayScreenData( - dateLabel: dateLabel, - timelineRange: defaultRange, - cards: const [], - ); - } - - factory TodayScreenData.fromTodayState(TodayState state) { - final cards = [ - for (final item in state.timelineItems) TimelineCardModel.fromItem(item), - ]; - final nextRequired = state.nextRequiredItem; - return TodayScreenData( - dateLabel: _dateLabel(state.date), - timelineRange: defaultRange, - requiredBanner: nextRequired == null - ? null - : RequiredBannerModel( - title: nextRequired.item.displayTitle, - timeText: _formatTime(nextRequired.start), - ), - cards: List.unmodifiable(cards), - ); - } - - static const defaultRange = TimelineRangeModel( - startMinutes: 16 * 60, - endMinutes: 20 * 60 + 45, - ); - - final String dateLabel; - final RequiredBannerModel? requiredBanner; - final TimelineRangeModel timelineRange; - final List cards; -} - -class TimelineCardModel { - const TimelineCardModel({ - required this.id, - required this.title, - required this.typeLabel, - required this.subtitle, - required this.timeText, - required this.startMinutes, - required this.endMinutes, - required this.visualKind, - required this.rewardIconToken, - required this.difficultyIconToken, - required this.isCompleted, - required this.isSelectable, - required this.showsQuickActions, - this.durationMinutes, - }); - - factory TimelineCardModel.fromItem(TodayTimelineItem item) { - final start = item.start; - final end = item.end; - final visualKind = _visualKindFor(item); - final isCompleted = item.taskStatus == TaskStatus.completed; - final title = item.item.displayTitle; - final duration = item.item.durationMinutes; - final timeText = start == null || end == null - ? duration == null - ? '' - : '$duration min' - : '${_formatTime(start)} - ${_formatTime(end)}'; - return TimelineCardModel( - id: item.id, - title: title, - typeLabel: _typeLabelFor(visualKind), - subtitle: _subtitleFor(item, visualKind, timeText), - timeText: timeText, - startMinutes: _minutesSinceMidnight(start), - endMinutes: _minutesSinceMidnight(end), - visualKind: visualKind, - rewardIconToken: item.item.rewardIconToken, - difficultyIconToken: item.item.difficultyIconToken, - durationMinutes: duration, - isCompleted: isCompleted, - isSelectable: true, - showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, - ); - } - - final String id; - final String title; - final String typeLabel; - final String subtitle; - final String timeText; - final int startMinutes; - final int endMinutes; - final int? durationMinutes; - final TaskVisualKind visualKind; - final TimelineRewardIconToken rewardIconToken; - final TimelineDifficultyIconToken difficultyIconToken; - final bool isCompleted; - final bool isSelectable; - final bool showsQuickActions; - - String get rewardLabel { - return switch (rewardIconToken) { - TimelineRewardIconToken.notSet => 'Reward not set', - TimelineRewardIconToken.veryLow => 'Reward level 1', - TimelineRewardIconToken.low => 'Reward level 2', - TimelineRewardIconToken.medium => 'Reward level 3', - TimelineRewardIconToken.high => 'Reward level 4', - TimelineRewardIconToken.veryHigh => 'Reward level 5', - }; - } - - String get effortLabel { - return switch (difficultyIconToken) { - TimelineDifficultyIconToken.notSet => 'Effort not set', - TimelineDifficultyIconToken.veryEasy => 'Very easy effort', - TimelineDifficultyIconToken.easy => 'Easy effort', - TimelineDifficultyIconToken.medium => 'Medium effort', - TimelineDifficultyIconToken.hard => 'Hard effort', - TimelineDifficultyIconToken.veryHard => 'Very hard effort', - }; - } -} - -TaskVisualKind _visualKindFor(TodayTimelineItem item) { - if (item.taskStatus == TaskStatus.completed || - (item.taskType == TaskType.surprise && - item.taskStatus == TaskStatus.completed)) { - return TaskVisualKind.completedSurprise; - } - return switch (item.taskType) { - TaskType.flexible => TaskVisualKind.flexible, - TaskType.critical => TaskVisualKind.required, - TaskType.inflexible => TaskVisualKind.appointment, - TaskType.freeSlot => TaskVisualKind.freeSlot, - TaskType.surprise => TaskVisualKind.completedSurprise, - TaskType.locked => TaskVisualKind.completedSurprise, - }; -} - -String _typeLabelFor(TaskVisualKind visualKind) { - return switch (visualKind) { - TaskVisualKind.flexible => 'Flexible', - TaskVisualKind.required => 'Required', - TaskVisualKind.appointment => 'Required', - TaskVisualKind.freeSlot => 'Free Slot', - TaskVisualKind.completedSurprise => 'Surprise task', - }; -} - -String _subtitleFor( - TodayTimelineItem item, - TaskVisualKind visualKind, - String timeText, -) { - if (visualKind == TaskVisualKind.freeSlot) { - return 'Intentional rest'; - } - if (visualKind == TaskVisualKind.completedSurprise) { - final completedAt = _formatTime(item.item.end ?? item.end); - return 'Surprise task - Completed at $completedAt'; - } - if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { - return '${item.item.durationMinutes} min'; - } - return timeText; -} - -String _dateLabel(CivilDate date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; -} - -int _minutesSinceMidnight(DateTime? value) { - if (value == null) { - return 0; - } - return value.hour * 60 + value.minute; -} - -String _formatTime(DateTime? value) { - if (value == null) { - return ''; - } - final period = value.hour >= 12 ? 'PM' : 'AM'; - final rawHour = value.hour % 12; - final hour = rawHour == 0 ? 12 : rawHour; - final minute = value.minute.toString().padLeft(2, '0'); - return '$hour:$minute $period'; -} +part 'today/required_banner_model.dart'; +part 'today/task_visual_kind.dart'; +part 'today/time/time_string_formatter.dart'; +part 'today/timeline_card_model.dart'; +part 'today/timeline_item_presentation_mapper.dart'; +part 'today/timeline_range_model.dart'; +part 'today/today_screen_data.dart'; diff --git a/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart b/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart index f4669e0..3c519c4 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Focus Flow Theme styling for the FocusFlow Flutter UI. library; @@ -5,7 +8,9 @@ import 'package:flutter/material.dart'; import 'focus_flow_tokens.dart'; +/// Theme factory for the FocusFlow Flutter app. abstract final class FocusFlowTheme { + /// Builds the dark Material theme used by the desktop app. static ThemeData dark() { final scheme = ColorScheme.fromSeed( seedColor: FocusFlowTokens.accentMagenta, diff --git a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart index 3fefe79..53d4ab7 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart @@ -1,50 +1,124 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Focus Flow Tokens styling for the FocusFlow Flutter UI. library; import 'package:flutter/material.dart'; +/// Shared visual constants for the FocusFlow Flutter app. abstract final class FocusFlowTokens { + /// Root application background color. static const appBackground = Color(0xFF070811); + + /// Border color for the app frame. static const appFrameBorder = Color(0xFF3B3D4E); + + /// Sidebar background color. static const sidebarBackground = Color(0xFF090A14); + + /// Standard panel background color. static const panelBackground = Color(0xFF11121D); + + /// Elevated panel background color. static const elevatedPanel = Color(0xFF181724); + + /// Translucent panel color for soft surfaces. static const glassPanel = Color(0x662A1025); + + /// Stronger translucent panel color for selected surfaces. static const glassPanelStrong = Color(0x992A1025); + + /// Timeline grid line color. static const gridLine = Color(0x243B3D4E); + + /// Timeline rail color. static const rail = Color(0xFF7D7F91); + + /// Subtle border color for controls and panels. static const subtleBorder = Color(0xFF3A3A4A); + + /// Active navigation border color. static const navBorder = Color(0xFFB72D82); + /// Primary accent color. static const accentMagenta = Color(0xFFFF5BA8); + + /// Primary text color. static const textPrimary = Color(0xFFF7F2FA); + + /// Muted text color. static const textMuted = Color(0xFFBBB5C8); + + /// Faint text color. static const textFaint = Color(0xFF8E889D); + + /// Text color for completed task labels. static const completedText = Color(0xFF8F8A99); + /// Accent color for flexible tasks. static const flexibleGreen = Color(0xFF4BE064); + + /// Accent color for required tasks. static const requiredPink = Color(0xFFFF4E7D); + + /// Accent color for appointment tasks. static const appointmentBlue = Color(0xFF38A8FF); + + /// Accent color for rest and free-slot surfaces. static const restPurple = Color(0xFFB45CFF); + + /// Accent color for completed tasks. static const completedGray = Color(0xFF777482); + + /// Warning accent color. static const warningYellow = Color(0xFFFFCC66); + /// Fixed sidebar width. static const sidebarWidth = 250.0; + + /// Horizontal gutter for main app content. static const mainGutter = 32.0; + + /// Width of the timeline time rail. static const timelineRailWidth = 120.0; + + /// Horizontal padding inside timeline cards. static const cardHorizontalPadding = 18.0; + /// Border radius for the outer app frame. static const appFrameRadius = 8.0; + + /// Border radius for navigation items. static const navRadius = 8.0; + + /// Border radius for required-task banners. static const bannerRadius = 8.0; - static const cardRadius = 8.0; + + /// Border radius for timeline cards. + static const cardRadius = 12.0; + + /// Border radius for task selection modals. static const modalRadius = 8.0; + + /// Border radius for compact controls. static const smallButtonRadius = 8.0; + /// Font size for page titles. static const pageTitleSize = 40.0; + + /// Font size for timeline card titles. static const cardTitleSize = 24.0; + + /// Font size for timeline card metadata. static const cardMetaSize = 16.0; - static const sidebarNavSize = 18.0; + + /// Font size for sidebar navigation labels. + static const sidebarNavSize = 16.0; + + /// Font size for modal titles. static const modalTitleSize = 26.0; + + /// Font size for modal action buttons. static const modalButtonSize = 18.0; } diff --git a/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart b/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart index 43404a8..175d94c 100644 --- a/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart @@ -1,10 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Scheduler Visual Tokens styling for the FocusFlow Flutter UI. library; import 'package:flutter/material.dart'; import 'package:scheduler_core/scheduler_core.dart'; +/// Maps scheduler domain tokens to Flutter colors and icons. abstract final class SchedulerVisualTokens { + /// Returns the color associated with a project color [token]. static Color projectColor(String token) { return switch (token) { 'home' => const Color(0xFF68B0AB), @@ -13,6 +18,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the background color associated with a timeline background token. static Color backgroundColor( TimelineBackgroundToken token, ColorScheme scheme, @@ -27,6 +33,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the icon associated with a task [type]. static IconData taskIcon(TaskType type) { return switch (type) { TaskType.flexible => Icons.task_alt, @@ -38,6 +45,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the icon associated with a reward token. static IconData rewardIcon(TimelineRewardIconToken token) { return switch (token) { TimelineRewardIconToken.notSet => Icons.remove_circle_outline, @@ -49,6 +57,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the icon associated with a difficulty token. static IconData difficultyIcon(TimelineDifficultyIconToken token) { return switch (token) { TimelineDifficultyIconToken.notSet => Icons.remove_circle_outline, @@ -60,6 +69,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the icon associated with a backlog staleness marker. static IconData stalenessIcon(BacklogStalenessMarker marker) { return switch (marker) { BacklogStalenessMarker.green => Icons.circle, @@ -68,6 +78,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the color associated with a backlog staleness marker. static Color stalenessColor( BacklogStalenessMarker marker, ColorScheme scheme, diff --git a/apps/focus_flow_flutter/lib/widgets/app_shell.dart b/apps/focus_flow_flutter/lib/widgets/app_shell.dart index df171c0..d9ced50 100644 --- a/apps/focus_flow_flutter/lib/widgets/app_shell.dart +++ b/apps/focus_flow_flutter/lib/widgets/app_shell.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the App Shell widget for the FocusFlow Flutter UI. library; @@ -5,12 +8,19 @@ import 'package:flutter/material.dart'; import '../theme/focus_flow_tokens.dart'; +/// Top-level shell that frames the sidebar and main content area. class AppShell extends StatelessWidget { + /// Creates an app shell with a [sidebar] and main [child]. const AppShell({required this.sidebar, required this.child, super.key}); + /// Sidebar widget displayed at the left edge of the app. final Widget sidebar; + + /// Primary content widget displayed beside the sidebar. 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 ColoredBox( diff --git a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart b/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart index 11d8216..b951467 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Backlog Pane widget for the FocusFlow Flutter UI. library; @@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart'; import 'read_state_view.dart'; import 'schedule_components.dart'; +/// Read-driven pane that renders backlog content and command controls. class BacklogPane extends StatelessWidget { + /// Creates a backlog pane from a read [controller]. const BacklogPane({ required this.controller, this.commandController, super.key, }); + /// Controller that loads backlog data. final UiReadController controller; + + /// Optional command controller for mutating backlog items. final SchedulerCommandController? commandController; + /// 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 ReadStateView( @@ -32,16 +42,23 @@ class BacklogPane extends StatelessWidget { } } +/// Populated backlog view for a loaded backlog query result. class BacklogContent extends StatelessWidget { + /// Creates backlog content for [result]. const BacklogContent({ required this.result, this.commandController, super.key, }); + /// Loaded backlog result to display. final BacklogQueryResult result; + + /// Optional command controller for capture and scheduling actions. final SchedulerCommandController? commandController; + /// 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 ListView( @@ -73,24 +90,37 @@ class BacklogContent extends StatelessWidget { } } +/// Small form for quickly capturing a task into the backlog. class QuickCaptureForm extends StatefulWidget { + /// Creates a quick capture form. const QuickCaptureForm({required this.commandController, super.key}); + /// Command controller used to submit captured task titles. final SchedulerCommandController commandController; + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override State createState() => _QuickCaptureFormState(); } +/// Private implementation type for `_QuickCaptureFormState` 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 _QuickCaptureFormState extends State { + /// Stores the `titleController` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final titleController = TextEditingController(); + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. @override void dispose() { titleController.dispose(); super.dispose(); } + /// 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 Row( @@ -121,11 +151,16 @@ class _QuickCaptureFormState extends State { } } +/// Displays the latest command state for backlog interactions. class CommandStatusBanner extends StatelessWidget { + /// Creates a command status banner. const CommandStatusBanner({required this.controller, super.key}); + /// Command controller whose state should be rendered. final SchedulerCommandController controller; + /// 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 AnimatedBuilder( diff --git a/apps/focus_flow_flutter/lib/widgets/read_state_view.dart b/apps/focus_flow_flutter/lib/widgets/read_state_view.dart index 5721af4..7463f47 100644 --- a/apps/focus_flow_flutter/lib/widgets/read_state_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/read_state_view.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Read State View widget for the FocusFlow Flutter UI. library; @@ -5,7 +8,9 @@ import 'package:flutter/material.dart'; import '../controllers/scheduler_read_controller.dart'; +/// Renders loading, empty, failure, and data states from a read controller. class ReadStateView extends StatelessWidget { + /// Creates a read-state view. const ReadStateView({ required this.controller, required this.title, @@ -15,12 +20,23 @@ class ReadStateView extends StatelessWidget { super.key, }); + /// Controller that owns the current read state. final UiReadController controller; + + /// Name of the surface being loaded, used in failure copy. final String title; + + /// Title shown when the read succeeds with empty content. final String emptyTitle; + + /// Message shown when the read succeeds with empty content. final String emptyMessage; + + /// Builds the populated content for a read value. final Widget Function(BuildContext context, T value) dataBuilder; + /// 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 AnimatedBuilder( @@ -52,7 +68,9 @@ class ReadStateView extends StatelessWidget { } } +/// Standard empty-state panel with optional surrounding content. class EmptyStatePanel extends StatelessWidget { + /// Creates an empty-state panel. const EmptyStatePanel({ required this.title, required this.message, @@ -60,10 +78,17 @@ class EmptyStatePanel extends StatelessWidget { super.key, }); + /// Primary empty-state title. final String title; + + /// Supporting empty-state message. final String message; + + /// Optional content shown alongside the empty-state panel. 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) { final panel = Padding( @@ -97,7 +122,9 @@ class EmptyStatePanel extends StatelessWidget { } } +/// Standard failure panel with a retry action. class FailureStatePanel extends StatelessWidget { + /// Creates a failure-state panel. const FailureStatePanel({ required this.title, required this.code, @@ -106,11 +133,20 @@ class FailureStatePanel extends StatelessWidget { super.key, }); + /// Primary failure title. final String title; + + /// Displayable failure code. final String code; + + /// Optional technical detail for unexpected errors. final String? technicalDetail; + + /// Callback invoked when the user retries the read. final Future Function() onRetry; + /// 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 Center( diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index de60125..a3a9897 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Required Banner widget for the FocusFlow Flutter UI. library; @@ -6,86 +9,242 @@ import 'package:flutter/material.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_tokens.dart'; +/// Banner that highlights the next required task, if one exists. class RequiredBanner extends StatelessWidget { - const RequiredBanner({this.model, super.key}); + /// Creates a required-task banner. + const RequiredBanner({this.model, this.onShowUpcoming, super.key}); + /// Optional required task model to render. final RequiredBannerModel? model; + /// Callback invoked when the highlighted required task should be shown. + final VoidCallback? onShowUpcoming; + + /// 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 banner = model; if (banner == null) { - return const SizedBox(height: 72); + return const SizedBox.shrink(); } - return Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 18), - decoration: BoxDecoration( - color: FocusFlowTokens.glassPanel, - borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius), - border: Border.all( + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth); + return Container( + height: _RequiredBannerMetrics.referenceHeight, + padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding), + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanel, + borderRadius: BorderRadius.circular(metrics.bannerRadius), + border: Border.all( + color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + width: metrics.borderWidth, + ), + ), + child: Row( + children: [ + Container( + width: metrics.iconCircleSize, + height: metrics.iconCircleSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: FocusFlowTokens.glassPanelStrong, + ), + child: Icon( + Icons.auto_awesome, + color: FocusFlowTokens.accentMagenta, + size: metrics.iconSize, + ), + ), + SizedBox(width: metrics.iconGap), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: RichText( + text: TextSpan( + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.textSize, + fontWeight: FontWeight.w700, + ), + children: [ + const TextSpan(text: 'Next required task: '), + TextSpan( + text: banner.title, + style: const TextStyle( + color: FocusFlowTokens.accentMagenta, + ), + ), + TextSpan( + text: ' at ${banner.timeText}', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: metrics.textSize, + ), + ), + ], + ), + ), + ), + ), + ), + SizedBox(width: metrics.buttonGap), + _UpcomingButton(metrics: metrics, onPressed: onShowUpcoming), + ], + ), + ); + }, + ); + } +} + +/// Private implementation type for `_RequiredBannerMetrics` 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 _RequiredBannerMetrics { + /// Creates a `_RequiredBannerMetrics` 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 _RequiredBannerMetrics(this.scale); + + /// Shared constant value for `referenceHeight`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const referenceHeight = 72.0; + + /// Shared constant value for `_referenceWidth`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _referenceWidth = 760.0; + + /// Shared constant value for `_minimumScale`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _minimumScale = 0.5; + + /// Creates a `_RequiredBannerMetrics.fromWidth` 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. + factory _RequiredBannerMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _RequiredBannerMetrics(scale.toDouble()); + } + + /// Stores the `scale` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double scale; + + /// Returns the derived `horizontalPadding` 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 horizontalPadding => 18 * scale; + + /// Returns the derived `bannerRadius` 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 bannerRadius => FocusFlowTokens.bannerRadius * scale; + + /// Returns the derived `borderWidth` 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 borderWidth => scale < 0.7 ? 0.8 : 1; + + /// Returns the derived `iconCircleSize` 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 iconCircleSize => 48 * scale; + + /// Returns the derived `iconSize` 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 iconSize => 24 * scale; + + /// Returns the derived `iconGap` 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 iconGap => 26 * scale; + + /// Returns the derived `buttonGap` 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 buttonGap => 20 * scale; + + /// Returns the derived `textSize` 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 textSize => (19 * scale).clamp(13.0, 14.0).toDouble(); + + /// Returns the derived `buttonWidth` 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 buttonWidth => 176 * scale; + + /// Returns the derived `buttonHeight` 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 buttonHeight => 46 * scale; + + /// Returns the derived `buttonRadius` 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 buttonRadius => FocusFlowTokens.smallButtonRadius * scale; + + /// Returns the derived `buttonTextSize` 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 buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble(); + + /// Returns the derived `buttonIconSize` 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 buttonIconSize => 20 * scale; + + /// Returns the derived `buttonTextGap` 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 buttonTextGap => 6 * scale; + + /// Returns the derived `buttonPadding` 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. + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} + +/// Private implementation type for `_UpcomingButton` 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 _UpcomingButton extends StatelessWidget { + /// Creates a `_UpcomingButton` 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 _UpcomingButton({required this.metrics, required this.onPressed}); + + /// 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 _RequiredBannerMetrics metrics; + + /// Stores the `onPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback? onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + width: metrics.borderWidth, + ), + fixedSize: Size(metrics.buttonWidth, metrics.buttonHeight), + minimumSize: Size.zero, + padding: metrics.buttonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(metrics.buttonRadius), ), ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: FocusFlowTokens.glassPanelStrong, - ), - child: const Icon( - Icons.auto_awesome, - color: FocusFlowTokens.accentMagenta, - ), - ), - const SizedBox(width: 26), - RichText( - text: TextSpan( - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 19, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Show upcoming', + style: TextStyle( + fontSize: metrics.buttonTextSize, fontWeight: FontWeight.w700, ), - children: [ - const TextSpan(text: 'Next required task: '), - TextSpan( - text: banner.title, - style: const TextStyle(color: FocusFlowTokens.accentMagenta), - ), - TextSpan( - text: ' at ${banner.timeText}', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ], ), - ), - const Spacer(), - OutlinedButton( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: BorderSide( - color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), - ), - fixedSize: const Size(176, 46), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, - ), - ), - ), - child: const FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Show upcoming'), Icon(Icons.chevron_right)], - ), - ), - ), - ], + SizedBox(width: metrics.buttonTextGap), + Icon(Icons.chevron_right, size: metrics.buttonIconSize), + ], + ), ), ); } diff --git a/apps/focus_flow_flutter/lib/widgets/schedule_components.dart b/apps/focus_flow_flutter/lib/widgets/schedule_components.dart index ed46286..37d9bc8 100644 --- a/apps/focus_flow_flutter/lib/widgets/schedule_components.dart +++ b/apps/focus_flow_flutter/lib/widgets/schedule_components.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Schedule Components widget for the FocusFlow Flutter UI. library; @@ -6,11 +9,16 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../theme/scheduler_visual_tokens.dart'; +/// Compact summary panel for timeline items. class CompactPanel extends StatelessWidget { + /// Creates a compact panel for [items]. const CompactPanel({required this.items, super.key}); + /// Timeline items to display in compact form. final List items; + /// 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) { if (items.isEmpty) { @@ -28,12 +36,19 @@ class CompactPanel extends StatelessWidget { } } +/// Card row for a single Today timeline item. class TimelineRow extends StatelessWidget { + /// Creates a timeline row. const TimelineRow({required this.item, this.onDone, super.key}); + /// Timeline item to render. final TodayTimelineItem item; + + /// Optional completion callback for eligible tasks. final Future Function()? onDone; + /// 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 scheme = Theme.of(context).colorScheme; @@ -85,25 +100,40 @@ class TimelineRow extends StatelessWidget { } } +/// Backlog row with optional scheduling controls. class BacklogRow extends StatefulWidget { + /// Creates a backlog row. const BacklogRow({required this.item, this.onSchedule, super.key}); + /// Backlog item to render. final BacklogItemReadModel item; + + /// Optional callback that schedules the item for a duration in minutes. final Future Function(int durationMinutes)? onSchedule; + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override State createState() => _BacklogRowState(); } +/// Private implementation type for `_BacklogRowState` 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 _BacklogRowState extends State { + /// Stores the `durationController` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final durationController = TextEditingController(); + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. @override void dispose() { durationController.dispose(); super.dispose(); } + /// 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 scheme = Theme.of(context).colorScheme; @@ -154,11 +184,16 @@ class _BacklogRowState extends State { } } +/// Icon marker for backlog item staleness. class StalenessMarker extends StatelessWidget { + /// Creates a staleness marker. const StalenessMarker({required this.marker, super.key}); + /// Staleness marker value to render. final BacklogStalenessMarker marker; + /// 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 Icon( @@ -168,11 +203,16 @@ class StalenessMarker extends StatelessWidget { } } +/// Notice banner for pending Today-state messages. class NoticeBanner extends StatelessWidget { + /// Creates a notice banner. const NoticeBanner({required this.message, super.key}); + /// Notice message to display. final String message; + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { return Container( @@ -187,6 +227,7 @@ class NoticeBanner extends StatelessWidget { } } +/// Formats a scheduler timeline item as display-ready time text. String timeText(TimelineItem item) { final start = item.start; final end = item.end; @@ -196,6 +237,8 @@ String timeText(TimelineItem item) { return '${_clockText(start)}-${_clockText(end)}'; } +/// Top-level helper that performs the `_clockText` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _clockText(DateTime value) { final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index 329b7df..4c4c74e 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Sidebar widget for the FocusFlow Flutter UI. library; @@ -5,9 +8,13 @@ import 'package:flutter/material.dart'; import '../theme/focus_flow_tokens.dart'; +/// Static sidebar for primary FocusFlow navigation. class Sidebar extends StatelessWidget { + /// Creates the FocusFlow sidebar. const Sidebar({super.key}); + /// 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 DecoratedBox( @@ -44,9 +51,15 @@ class Sidebar extends StatelessWidget { } } +/// Private implementation type for `_WindowControls` 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 _WindowControls extends StatelessWidget { + /// Creates a `_WindowControls` 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 _WindowControls(); + /// 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 const Row( @@ -61,11 +74,19 @@ class _WindowControls extends StatelessWidget { } } +/// Private implementation type for `_WindowDot` 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 _WindowDot extends StatelessWidget { + /// Creates a `_WindowDot` 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 _WindowDot({required this.color}); + /// 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; + /// 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 DecoratedBox( @@ -75,9 +96,15 @@ class _WindowDot extends StatelessWidget { } } +/// Private implementation type for `_BrandRow` 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 _BrandRow extends StatelessWidget { + /// Creates a `_BrandRow` 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 _BrandRow(); + /// 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 Row( @@ -109,7 +136,11 @@ class _BrandRow extends StatelessWidget { } } +/// Private implementation type for `_NavItem` 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 _NavItem extends StatelessWidget { + /// Creates a `_NavItem` 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 _NavItem({ required this.label, required this.icon, @@ -117,11 +148,24 @@ class _NavItem extends StatelessWidget { this.active = false, }); + /// 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 `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 `onTap` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final VoidCallback onTap; + + /// Stores the `active` 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 active; + /// 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 color = active diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart new file mode 100644 index 0000000..b2562a3 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_selection_modal.dart'; + +/// Private implementation type for `_ActionButton` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +class _ActionButton extends StatelessWidget { + /// Creates a `_ActionButton` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _ActionButton({required this.icon, required this.label}); + + /// 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; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + ), + ), + icon: Icon(icon), + label: Text( + label, + style: const TextStyle( + fontSize: FocusFlowTokens.modalButtonSize, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart new file mode 100644 index 0000000..ec6a2c3 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_selection_modal.dart'; + +/// Private implementation type for `_HeaderMetadata` 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 _HeaderMetadata extends StatelessWidget { + /// Creates a `_HeaderMetadata` 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 _HeaderMetadata({required this.card}); + + /// 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; + + /// 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) { + const metadataStyle = TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 17, + ); + if (!card.isCompleted) { + return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle); + } + final completedTime = card.completedTimeText; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + completedTime == null || completedTime.isEmpty + ? 'Completed' + : 'Completed - $completedTime', + style: metadataStyle, + ), + const SizedBox(height: 3), + Text( + 'Planned: ${card.timeText}', + style: metadataStyle.copyWith(fontSize: 15), + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart new file mode 100644 index 0000000..84ac17d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_selection_modal.dart'; + +/// Private implementation type for `_InfoTile` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +class _InfoTile extends StatelessWidget { + /// Creates a `_InfoTile` instance with the values required by its invariants. + /// 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}); + + /// 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; + + /// 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. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: [ + child, + const SizedBox(width: 18), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart new file mode 100644 index 0000000..8fe6fb0 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_selection_modal.dart'; + +/// Top-level helper that performs the `_accentFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Color _accentFor(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen, + TaskVisualKind.required => FocusFlowTokens.requiredPink, + TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue, + TaskVisualKind.freeSlot => FocusFlowTokens.restPurple, + TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray, + }; +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart new file mode 100644 index 0000000..aed36b8 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_selection_modal.dart'; + +/// Private implementation type for `_ModalStatusButton` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +class _ModalStatusButton extends StatelessWidget { + /// Creates a `_ModalStatusButton` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _ModalStatusButton({ + required this.card, + required this.color, + required this.onPressed, + }); + + /// 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 `onPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback? onPressed; + + /// 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 = Container( + key: const ValueKey('task-modal-status'), + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: Border.all(color: color, width: 4), + ), + child: card.isCompleted + ? const Icon( + Icons.check, + color: FocusFlowTokens.appBackground, + size: 26, + ) + : null, + ); + if (onPressed == null) { + return ring; + } + return Tooltip( + message: card.isCompleted ? 'Mark not done' : 'Mark complete', + child: Semantics( + button: true, + label: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: ring, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index a9b1d90..e2e7f5c 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Task Selection Modal widget for the FocusFlow Flutter UI. library; @@ -8,16 +11,33 @@ import '../theme/focus_flow_tokens.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; +part 'task_selection/action_button.dart'; +part 'task_selection/header_metadata.dart'; +part 'task_selection/info_tile.dart'; +part 'task_selection/modal_accent.dart'; +part 'task_selection/modal_status_button.dart'; + +/// Modal shown when a selectable timeline task card is opened. class TaskSelectionModal extends StatelessWidget { + /// Creates a task selection modal for [card]. const TaskSelectionModal({ required this.card, required this.onClose, + this.onStatusPressed, super.key, }); + /// Card model whose details and actions should be displayed. final TimelineCardModel card; + + /// Callback invoked when the modal should close. final VoidCallback onClose; + /// Callback invoked when the status circle should toggle completion. + final VoidCallback? onStatusPressed; + + /// 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 accent = _accentFor(card.visualKind); @@ -38,13 +58,10 @@ class TaskSelectionModal extends StatelessWidget { children: [ Row( children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: accent, width: 4), - ), + _ModalStatusButton( + card: card, + color: accent, + onPressed: onStatusPressed, ), const SizedBox(width: 28), Expanded( @@ -60,13 +77,7 @@ class TaskSelectionModal extends StatelessWidget { ), ), const SizedBox(height: 6), - Text( - '${card.typeLabel} - ${card.timeText}', - style: const TextStyle( - color: FocusFlowTokens.textMuted, - fontSize: 17, - ), - ), + _HeaderMetadata(card: card), ], ), ), @@ -131,81 +142,3 @@ class TaskSelectionModal extends StatelessWidget { ); } } - -class _InfoTile extends StatelessWidget { - const _InfoTile({required this.child, required this.label}); - - final Widget child; - final String label; - - @override - Widget build(BuildContext context) { - return Container( - height: 58, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: [ - child, - const SizedBox(width: 18), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ); - } -} - -class _ActionButton extends StatelessWidget { - const _ActionButton({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, - ), - ), - ), - icon: Icon(icon), - label: Text( - label, - style: const TextStyle( - fontSize: FocusFlowTokens.modalButtonSize, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -Color _accentFor(TaskVisualKind kind) { - return switch (kind) { - TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen, - TaskVisualKind.required => FocusFlowTokens.requiredPink, - TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue, - TaskVisualKind.freeSlot => FocusFlowTokens.restPurple, - TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray, - }; -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart new file mode 100644 index 0000000..e83fdcb --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../timeline_axis.dart'; + +/// Background grid aligned to timeline tick marks. +class TimelineGrid extends StatelessWidget { + /// Creates a timeline grid for [geometry]. + const TimelineGrid({required this.geometry, this.topInset = 12, super.key}); + + /// Geometry used to position grid lines. + final TimelineGeometry geometry; + + /// Vertical inset before the first timeline tick. + final double topInset; + + /// 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 CustomPaint( + painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), + size: Size.infinite, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart new file mode 100644 index 0000000..ee3833d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../timeline_axis.dart'; + +/// Private implementation type for `_TimelineGridPainter` 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 _TimelineGridPainter extends CustomPainter { + /// Creates a `_TimelineGridPainter` 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 _TimelineGridPainter({required this.geometry, required this.topInset}); + + /// Stores the `geometry` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineGeometry geometry; + + /// Stores the `topInset` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double topInset; + + /// Performs the `paint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = FocusFlowTokens.gridLine + ..strokeWidth = 1; + for (final tick in geometry.ticks()) { + canvas.drawLine( + Offset(0, tick.y + topInset), + Offset(size.width, tick.y + topInset), + paint + ..color = tick.major + ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) + : FocusFlowTokens.gridLine, + ); + } + } + + /// Performs the `shouldRepaint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { + return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart index d6c8a5a..3580a23 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Difficulty Bars pieces for the FocusFlow Flutter timeline. library; @@ -6,7 +9,9 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../../theme/focus_flow_tokens.dart'; +/// Five-bar effort indicator for a timeline task. class DifficultyBars extends StatelessWidget { + /// Creates difficulty bars for a scheduler difficulty token. const DifficultyBars({ required this.difficulty, this.color = FocusFlowTokens.restPurple, @@ -14,10 +19,16 @@ class DifficultyBars extends StatelessWidget { super.key, }); + /// Difficulty token that determines how many bars are filled. final TimelineDifficultyIconToken difficulty; + + /// Color used for filled bars. final Color color; + + /// Fixed paint size for the indicator. final Size size; + /// Converts a timeline difficulty token to a filled bar count. static int fillCount(TimelineDifficultyIconToken difficulty) { return switch (difficulty) { TimelineDifficultyIconToken.notSet => 0, @@ -29,6 +40,7 @@ class DifficultyBars extends StatelessWidget { }; } + /// Converts a domain difficulty level to a filled bar count. static int fillCountForLevel(DifficultyLevel difficulty) { return switch (difficulty) { DifficultyLevel.notSet => 0, @@ -40,6 +52,8 @@ class DifficultyBars extends StatelessWidget { }; } + /// 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 CustomPaint( @@ -52,15 +66,26 @@ class DifficultyBars extends StatelessWidget { } } +/// Private implementation type for `_DifficultyBarsPainter` 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 _DifficultyBarsPainter extends CustomPainter { + /// Creates a `_DifficultyBarsPainter` 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 _DifficultyBarsPainter({ required this.filledCount, required this.color, }); + /// Stores the `filledCount` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final int filledCount; + + /// 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; + /// Performs the `paint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override void paint(Canvas canvas, Size size) { final gap = size.width * 0.08; @@ -89,6 +114,8 @@ class _DifficultyBarsPainter extends CustomPainter { } } + /// Performs the `shouldRepaint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) { return oldDelegate.filledCount != filledCount || oldDelegate.color != color; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart index 82a557e..7f97f57 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Reward Icon pieces for the FocusFlow Flutter timeline. library; @@ -5,16 +8,23 @@ import 'package:flutter/material.dart'; import '../../theme/focus_flow_tokens.dart'; +/// Sparkle-style reward indicator for a timeline task. class RewardIcon extends StatelessWidget { + /// Creates a reward icon. const RewardIcon({ this.color = FocusFlowTokens.accentMagenta, this.size = 24, super.key, }); + /// Fill color used by the icon painter. final Color color; + + /// Square size of the painted icon. final double size; + /// 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 CustomPaint( @@ -24,11 +34,19 @@ class RewardIcon extends StatelessWidget { } } +/// Private implementation type for `_RewardIconPainter` 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 _RewardIconPainter extends CustomPainter { + /// Creates a `_RewardIconPainter` 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 _RewardIconPainter(this.color); + /// 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; + /// Performs the `paint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override void paint(Canvas canvas, Size size) { final paint = Paint() @@ -54,6 +72,8 @@ class _RewardIconPainter extends CustomPainter { ); } + /// Runs the `_drawSparkle` 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 _drawSparkle(Canvas canvas, Paint paint, Offset center, double radius) { final path = Path() ..moveTo(center.dx, center.dy - radius) @@ -68,6 +88,8 @@ class _RewardIconPainter extends CustomPainter { canvas.drawPath(path, paint); } + /// Performs the `shouldRepaint` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override bool shouldRepaint(covariant _RewardIconPainter oldDelegate) { return oldDelegate.color != color; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart new file mode 100644 index 0000000..5fe1fd7 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_CardColors` 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 _CardColors { + /// Creates a `_CardColors` 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 _CardColors({ + required this.accent, + required this.background, + required this.reward, + }); + + /// Stores the `accent` 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 accent; + + /// Stores the `background` 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 background; + + /// Stores the `reward` 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 reward; + + /// Creates a `_CardColors.forKind` 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. + factory _CardColors.forKind(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => const _CardColors( + accent: FocusFlowTokens.flexibleGreen, + background: Color(0xCC021A0C), + reward: FocusFlowTokens.flexibleGreen, + ), + TaskVisualKind.required => const _CardColors( + accent: FocusFlowTokens.requiredPink, + background: Color(0xCC230814), + reward: FocusFlowTokens.requiredPink, + ), + TaskVisualKind.appointment => const _CardColors( + accent: FocusFlowTokens.appointmentBlue, + background: Color(0xCC031429), + reward: FocusFlowTokens.appointmentBlue, + ), + TaskVisualKind.freeSlot => const _CardColors( + accent: FocusFlowTokens.restPurple, + background: Color(0xCC210A33), + reward: FocusFlowTokens.restPurple, + ), + TaskVisualKind.completedSurprise => const _CardColors( + accent: FocusFlowTokens.completedGray, + background: Color(0xAA1C1D25), + reward: FocusFlowTokens.restPurple, + ), + }; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart new file mode 100644 index 0000000..1e3a927 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_CardMetrics` 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 _CardMetrics { + /// Creates a `_CardMetrics` 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 _CardMetrics({required this.scale, required this.height}); + + /// Shared constant value for `_referenceWidth`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _referenceWidth = 640.0; + + /// Shared constant value for `_referenceHeight`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _referenceHeight = 88.0; + + /// Shared constant value for `_freeSlotReferenceHeight`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _freeSlotReferenceHeight = 108.0; + + /// Shared constant value for `_compactHeightThreshold`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _compactHeightThreshold = 56.0; + + /// Shared constant value for `_minimumScale`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _minimumScale = 0.16; + + /// Creates a `_CardMetrics.fromConstraints` 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. + factory _CardMetrics.fromConstraints( + BoxConstraints constraints, { + required TaskVisualKind kind, + }) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : _referenceWidth; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : _referenceHeight; + final widthScale = width / _referenceWidth; + final heightScale = + height / + (kind == TaskVisualKind.freeSlot + ? _freeSlotReferenceHeight + : _referenceHeight); + final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0); + return _CardMetrics(scale: scale.toDouble(), height: height); + } + + /// Stores the `scale` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double scale; + + /// Stores the `height` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double height; + + /// Returns the derived `isCompact` 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. + bool get isCompact => height < _compactHeightThreshold; + + /// Returns the derived `cardRadius` 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 cardRadius => + math.min(FocusFlowTokens.cardRadius * scale, height / 2); + + /// Returns the derived `borderWidth` 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 borderWidth => math.max(0.75, 1.2 * scale); + + /// Returns the derived `shadowBlur` 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 shadowBlur => isCompact ? 0 : 18 * scale; + + /// Returns the derived `shadowSpread` 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 shadowSpread => isCompact ? 0 : scale; + + /// Returns the derived `statusRingSize` 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 statusRingSize { + final availableHeight = math.max(8, height - padding.vertical); + return math.min(24, availableHeight * 0.78).toDouble(); + } + + /// Returns the derived `statusBorderWidth` 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 statusBorderWidth => math.max(1.4, statusRingSize * 0.08); + + /// Returns the derived `statusIconSize` 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 statusIconSize => statusRingSize * 0.58; + + /// Returns the derived `statusGap` 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 statusGap => isCompact ? 7 : 22 * scale; + + /// Returns the derived `actionButtonSize` 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 actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); + + /// Returns the derived `actionIconSize` 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 actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); + + /// Returns the derived `actionGap` 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 actionGap => math.max(3, 5 * scale); + + /// Returns the derived `actionsWidth` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get actionsWidth => actionButtonSize * 4 + actionGap; + + /// 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. + double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); + + /// Returns the derived `indicatorGap` 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 indicatorGap => math.max(3, 5 * scale); + + /// Returns the derived `rewardIconSize` 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 rewardIconSize => 14; + + /// Returns the derived `titleSize` 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 titleSize => 10; + + /// Returns the derived `metaSize` 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 metaSize => 7; + + /// Returns the derived `titleMetaGap` 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 titleMetaGap => 4 * scale; + + /// Returns the derived `freeSlotTimeGap` 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 freeSlotTimeGap => 10 * scale; + + /// Returns the derived `compactTextGap` 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 compactTextGap => math.max(3, 8 * scale); + + /// Returns the derived `indicatorWidth` 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 indicatorWidth => + rewardIconSize + indicatorGap + difficultySize.width; + + /// Returns the derived `expandedTrailingReserve` 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 expandedTrailingReserve => + indicatorWidth + math.max(8, 12 * scale); + + /// Returns the derived `compactTrailingReserve` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); + + /// Returns the derived `difficultySize` value for this object. + /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. + Size get difficultySize => Size(26, 14); + + /// Returns the derived `padding` 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. + EdgeInsets get padding { + if (isCompact) { + return EdgeInsets.symmetric( + horizontal: math.max(4, 8 * scale), + vertical: math.max(1, 3 * scale), + ); + } + return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart new file mode 100644 index 0000000..7c147ec --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_CardText` 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 _CardText extends StatelessWidget { + /// Creates a `_CardText` 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 _CardText({ + required this.card, + required this.color, + required this.metrics, + }); + + /// 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; + + /// 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 titleStyle = TextStyle( + color: card.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textPrimary, + fontSize: metrics.titleSize, + fontWeight: FontWeight.w800, + decoration: card.isCompleted ? TextDecoration.lineThrough : null, + decorationColor: FocusFlowTokens.completedText, + decorationThickness: 2 * metrics.scale, + ); + return LayoutBuilder( + builder: (context, constraints) { + final textBlock = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + SizedBox(height: metrics.titleMetaGap), + Text( + card.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: card.isCompleted ? FocusFlowTokens.completedText : color, + fontSize: metrics.metaSize, + fontStyle: card.visualKind == TaskVisualKind.freeSlot + ? FontStyle.italic + : FontStyle.normal, + fontWeight: card.visualKind == TaskVisualKind.flexible + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + if (card.visualKind == TaskVisualKind.freeSlot) ...[ + SizedBox(height: metrics.freeSlotTimeGap), + Text( + card.timeText, + style: TextStyle( + color: color, + fontSize: metrics.metaSize, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return textBlock; + } + return FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: textBlock), + ); + }, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart new file mode 100644 index 0000000..e9a7f58 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_CompactCardText` 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 _CompactCardText extends StatelessWidget { + /// Creates a `_CompactCardText` 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 _CompactCardText({ + required this.card, + required this.color, + required this.metrics, + required this.onStatusPressed, + }); + + /// 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 `onStatusPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback? onStatusPressed; + + /// 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 metaText = _compactMetaText(card); + final titleStyle = TextStyle( + color: card.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textPrimary, + fontSize: metrics.titleSize, + height: 1, + fontWeight: FontWeight.w800, + decoration: card.isCompleted ? TextDecoration.lineThrough : null, + decorationColor: FocusFlowTokens.completedText, + decorationThickness: math.max(1, 2 * metrics.scale), + ); + final metaStyle = TextStyle( + color: card.isCompleted ? FocusFlowTokens.completedText : color, + fontSize: metrics.metaSize, + height: 1, + fontWeight: FontWeight.w700, + ); + return LayoutBuilder( + builder: (context, constraints) { + final line = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (card.visualKind != TaskVisualKind.freeSlot) ...[ + _StatusRing( + card: card, + color: color, + metrics: metrics, + onPressed: card.canToggleCompletion ? onStatusPressed : null, + ), + SizedBox(width: metrics.statusGap), + ], + Flexible( + flex: 2, + fit: FlexFit.loose, + child: Text( + card.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + SizedBox(width: metrics.compactTextGap), + Flexible( + flex: 1, + fit: FlexFit.loose, + child: Text( + metaText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), + ), + SizedBox(width: metrics.compactTrailingReserve), + ], + ); + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return line; + } + return FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: line), + ); + }, + ); + } + + /// Runs the `_compactMetaText` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _compactMetaText(TimelineCardModel card) { + if (card.visualKind == TaskVisualKind.freeSlot && + card.timeText.isNotEmpty) { + return card.timeText; + } + if (card.subtitle.isNotEmpty) { + return card.subtitle; + } + return card.timeText; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart new file mode 100644 index 0000000..4dede80 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_ExpandedCardContent` 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 _ExpandedCardContent extends StatelessWidget { + /// Creates a `_ExpandedCardContent` 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 _ExpandedCardContent({ + required this.card, + required this.colors, + required this.metrics, + required this.onStatusPressed, + }); + + /// 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 `colors` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardColors colors; + + /// 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 `onStatusPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback? onStatusPressed; + + /// 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 Row( + children: [ + if (card.visualKind != TaskVisualKind.freeSlot) ...[ + _StatusRing( + card: card, + color: colors.accent, + metrics: metrics, + onPressed: card.canToggleCompletion ? onStatusPressed : null, + ), + SizedBox(width: metrics.statusGap), + ], + Expanded( + child: Padding( + padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), + ), + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart new file mode 100644 index 0000000..0ad674e --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_NoOpIcon` 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 _NoOpIcon extends StatelessWidget { + /// Creates a `_NoOpIcon` 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 _NoOpIcon({ + required this.icon, + required this.color, + required this.metrics, + }); + + /// 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 `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; + + /// 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: 'No-op action', + child: GestureDetector( + onTap: () {}, + behavior: HitTestBehavior.opaque, + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon(icon, color: color, size: metrics.actionIconSize), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart new file mode 100644 index 0000000..1f09d0f --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_QuickActions` 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 _QuickActions extends StatelessWidget { + /// Creates a `_QuickActions` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _QuickActions({ + required this.color, + required this.backgroundColor, + required this.metrics, + required this.visible, + }); + + /// 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 `visible` 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 visible; + + /// 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 ExcludeSemantics( + excluding: !visible, + child: IgnorePointer( + ignoring: !visible, + child: ClipRect( + child: AnimatedContainer( + width: visible ? metrics.actionsWidth : 0, + height: metrics.actionButtonSize, + decoration: BoxDecoration(color: backgroundColor), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: OverflowBox( + minWidth: metrics.actionsWidth, + maxWidth: metrics.actionsWidth, + alignment: Alignment.centerRight, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 90), + curve: Curves.easeOut, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _NoOpIcon( + icon: Icons.check, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.arrow_forward, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.calendar_today, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.wb_sunny_outlined, + color: color, + metrics: metrics, + ), + SizedBox(width: metrics.actionGap), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart new file mode 100644 index 0000000..97f0497 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_StatusRing` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. +class _StatusRing extends StatelessWidget { + /// Creates a `_StatusRing` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _StatusRing({ + required this.card, + required this.color, + required this.metrics, + this.onPressed, + }); + + /// 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 `onPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback? onPressed; + + /// 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 border = Border.all(color: color, width: metrics.statusBorderWidth); + final ring = Container( + key: ValueKey('${card.id}-status'), + width: metrics.statusRingSize, + height: metrics.statusRingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: border, + ), + child: card.isCompleted + ? Icon( + Icons.check, + color: FocusFlowTokens.appBackground, + size: metrics.statusIconSize, + ) + : null, + ); + if (onPressed == null) { + return ring; + } + return Tooltip( + message: card.isCompleted ? 'Mark not done' : 'Mark complete', + child: Semantics( + button: true, + label: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: ring, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart new file mode 100644 index 0000000..dac36c3 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_TaskTimelineCardState` 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 _TaskTimelineCardState extends State { + /// Private state stored as `_isHovered` 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 _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 card = widget.card; + final colors = _CardColors.forKind(card.visualKind); + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _CardMetrics.fromConstraints( + constraints, + kind: card.visualKind, + ); + final content = SizedBox.expand( + child: Stack( + children: [ + Positioned.fill( + child: metrics.isCompact + ? _CompactCardText( + card: card, + color: colors.accent, + metrics: metrics, + onStatusPressed: widget.onStatusPressed, + ) + : _ExpandedCardContent( + card: card, + colors: colors, + metrics: metrics, + onStatusPressed: widget.onStatusPressed, + ), + ), + Positioned( + top: metrics.indicatorTop, + right: 0, + child: _TrailingControls( + card: card, + colors: colors, + metrics: metrics, + actionsVisible: card.showsQuickActions && _isHovered, + ), + ), + ], + ), + ); + return MouseRegion( + onEnter: (_) { + setState(() { + _isHovered = true; + }); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(card.id), + borderRadius: BorderRadius.circular(metrics.cardRadius), + onTap: widget.onTap, + child: Container( + padding: metrics.padding, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(metrics.cardRadius), + border: Border.all( + color: colors.accent, + width: metrics.borderWidth, + ), + boxShadow: [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.12), + blurRadius: metrics.shadowBlur, + spreadRadius: metrics.shadowSpread, + ), + ], + ), + child: content, + ), + ), + ), + ); + }, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart new file mode 100644 index 0000000..79c59d7 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../task_timeline_card.dart'; + +/// Private implementation type for `_TrailingControls` 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 _TrailingControls extends StatelessWidget { + /// Creates a `_TrailingControls` 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 _TrailingControls({ + required this.card, + required this.colors, + required this.metrics, + required this.actionsVisible, + }); + + /// 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 `colors` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardColors colors; + + /// 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 `actionsVisible` 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 actionsVisible; + + /// 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 Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (card.showsQuickActions) + _QuickActions( + color: colors.accent, + backgroundColor: colors.background.withValues(alpha: 1), + metrics: metrics, + visible: actionsVisible, + ), + RewardIcon(color: colors.reward, size: metrics.rewardIconSize), + SizedBox(width: metrics.indicatorGap), + DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index 3d17b93..e75a043 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -1,6 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline. library; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../models/today_screen_models.dart'; @@ -8,227 +13,38 @@ import '../../theme/focus_flow_tokens.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; -class TaskTimelineCard extends StatelessWidget { - const TaskTimelineCard({required this.card, required this.onTap, super.key}); +part 'task_card/card_colors.dart'; +part 'task_card/card_metrics.dart'; +part 'task_card/card_text.dart'; +part 'task_card/compact_card_text.dart'; +part 'task_card/expanded_card_content.dart'; +part 'task_card/no_op_icon.dart'; +part 'task_card/quick_actions.dart'; +part 'task_card/status_ring.dart'; +part 'task_card/task_timeline_card_state.dart'; +part 'task_card/trailing_controls.dart'; - final TimelineCardModel card; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final colors = _CardColors.forKind(card.visualKind); - return Material( - color: Colors.transparent, - child: InkWell( - key: ValueKey(card.id), - borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius), - onTap: onTap, - child: Container( - padding: const EdgeInsets.fromLTRB(18, 10, 14, 10), - decoration: BoxDecoration( - color: colors.background, - borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius), - border: Border.all(color: colors.accent, width: 1.2), - boxShadow: [ - BoxShadow( - color: colors.accent.withValues(alpha: 0.12), - blurRadius: 18, - spreadRadius: 1, - ), - ], - ), - child: Row( - children: [ - _StatusRing(card: card, color: colors.accent), - const SizedBox(width: 22), - Expanded( - child: _CardText(card: card, color: colors.accent), - ), - if (card.showsQuickActions) ...[ - _NoOpIcon(icon: Icons.check, color: colors.accent), - _NoOpIcon(icon: Icons.arrow_forward, color: colors.accent), - _NoOpIcon(icon: Icons.calendar_today, color: colors.accent), - _NoOpIcon(icon: Icons.wb_sunny_outlined, color: colors.accent), - const SizedBox(width: 10), - ], - _Badge(child: RewardIcon(color: colors.reward, size: 26)), - const SizedBox(width: 8), - _Badge( - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _StatusRing extends StatelessWidget { - const _StatusRing({required this.card, required this.color}); - - final TimelineCardModel card; - final Color color; - - @override - Widget build(BuildContext context) { - final border = Border.all( - color: color, - width: card.visualKind == TaskVisualKind.freeSlot ? 2 : 3, - style: card.visualKind == TaskVisualKind.freeSlot - ? BorderStyle.solid - : BorderStyle.solid, - ); - return Container( - width: 34, - height: 34, - decoration: BoxDecoration(shape: BoxShape.circle, border: border), - child: card.isCompleted - ? Icon(Icons.check, color: color, size: 24) - : null, - ); - } -} - -class _CardText extends StatelessWidget { - const _CardText({required this.card, required this.color}); - - final TimelineCardModel card; - final Color color; - - @override - Widget build(BuildContext context) { - final titleStyle = TextStyle( - color: card.isCompleted - ? FocusFlowTokens.completedText - : FocusFlowTokens.textPrimary, - fontSize: FocusFlowTokens.cardTitleSize, - fontWeight: FontWeight.w800, - decoration: card.isCompleted ? TextDecoration.lineThrough : null, - decorationColor: FocusFlowTokens.completedText, - decorationThickness: 2, - ); - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - card.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - const SizedBox(height: 4), - Text( - card.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: card.isCompleted ? FocusFlowTokens.completedText : color, - fontSize: FocusFlowTokens.cardMetaSize, - fontStyle: card.visualKind == TaskVisualKind.freeSlot - ? FontStyle.italic - : FontStyle.normal, - fontWeight: card.visualKind == TaskVisualKind.flexible - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - if (card.visualKind == TaskVisualKind.freeSlot) ...[ - const SizedBox(height: 10), - Text( - card.timeText, - style: TextStyle( - color: color, - fontSize: FocusFlowTokens.cardMetaSize, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ); - } -} - -class _NoOpIcon extends StatelessWidget { - const _NoOpIcon({required this.icon, required this.color}); - - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () {}, - color: color, - icon: Icon(icon), - tooltip: 'No-op action', - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return Container( - width: 62, - height: 48, - alignment: Alignment.center, - decoration: BoxDecoration( - color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84), - borderRadius: BorderRadius.circular(7), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: child, - ); - } -} - -class _CardColors { - const _CardColors({ - required this.accent, - required this.background, - required this.reward, +/// Interactive card that renders a scheduled task on the compact timeline. +class TaskTimelineCard extends StatefulWidget { + /// Creates a task timeline card. + const TaskTimelineCard({ + required this.card, + required this.onTap, + this.onStatusPressed, + super.key, }); - final Color accent; - final Color background; - final Color reward; + /// Card model to render. + final TimelineCardModel card; - factory _CardColors.forKind(TaskVisualKind kind) { - return switch (kind) { - TaskVisualKind.flexible => const _CardColors( - accent: FocusFlowTokens.flexibleGreen, - background: Color(0xCC021A0C), - reward: FocusFlowTokens.flexibleGreen, - ), - TaskVisualKind.required => const _CardColors( - accent: FocusFlowTokens.requiredPink, - background: Color(0xCC230814), - reward: FocusFlowTokens.requiredPink, - ), - TaskVisualKind.appointment => const _CardColors( - accent: FocusFlowTokens.appointmentBlue, - background: Color(0xCC031429), - reward: FocusFlowTokens.appointmentBlue, - ), - TaskVisualKind.freeSlot => const _CardColors( - accent: FocusFlowTokens.restPurple, - background: Color(0xCC210A33), - reward: FocusFlowTokens.restPurple, - ), - TaskVisualKind.completedSurprise => const _CardColors( - accent: FocusFlowTokens.completedGray, - background: Color(0xAA1C1D25), - reward: FocusFlowTokens.restPurple, - ), - }; - } + /// Callback invoked when the card is tapped. + final VoidCallback onTap; + + /// Callback invoked when the left status control is clicked. + final VoidCallback? onStatusPressed; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _TaskTimelineCardState(); } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart index 72cbf99..0cde142 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Timeline Axis pieces for the FocusFlow Flutter timeline. library; @@ -6,32 +9,46 @@ import 'package:flutter/material.dart'; import '../../theme/focus_flow_tokens.dart'; import 'timeline_geometry.dart'; -class TimelineAxis extends StatelessWidget { - const TimelineAxis({required this.geometry, super.key}); +part 'axis/timeline_grid.dart'; +part 'axis/timeline_grid_painter.dart'; +/// Time labels and rail shown beside the compact timeline. +class TimelineAxis extends StatelessWidget { + /// Creates a timeline axis for [geometry]. + const TimelineAxis({required this.geometry, this.topInset = 12, super.key}); + + /// Geometry used to position labels and tick marks. final TimelineGeometry geometry; + /// Vertical inset before the first timeline tick. + final double topInset; + + /// 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 ticks = geometry.ticks(); return SizedBox( width: FocusFlowTokens.timelineRailWidth, - height: geometry.totalHeight + 24, + height: geometry.totalHeight + topInset + 24, child: Stack( children: [ Positioned( left: 92, - top: 0, + top: topInset, bottom: 0, child: Container(width: 2, color: FocusFlowTokens.rail), ), for (final tick in ticks) Positioned( - top: tick.y - 10, + top: tick.y + topInset - 10, left: 0, width: 86, child: Text( tick.label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, textAlign: TextAlign.right, style: TextStyle( color: tick.major @@ -44,7 +61,7 @@ class TimelineAxis extends StatelessWidget { ), for (final tick in ticks.where((tick) => tick.major)) Positioned( - top: tick.y - 6, + top: tick.y + topInset - 6, left: 87, child: const DecoratedBox( decoration: BoxDecoration( @@ -59,45 +76,3 @@ class TimelineAxis extends StatelessWidget { ); } } - -class TimelineGrid extends StatelessWidget { - const TimelineGrid({required this.geometry, super.key}); - - final TimelineGeometry geometry; - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _TimelineGridPainter(geometry), - size: Size.infinite, - ); - } -} - -class _TimelineGridPainter extends CustomPainter { - const _TimelineGridPainter(this.geometry); - - final TimelineGeometry geometry; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = FocusFlowTokens.gridLine - ..strokeWidth = 1; - for (final tick in geometry.ticks()) { - canvas.drawLine( - Offset(0, tick.y), - Offset(size.width, tick.y), - paint - ..color = tick.major - ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) - : FocusFlowTokens.gridLine, - ); - } - } - - @override - bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { - return oldDelegate.geometry != geometry; - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart index 511ebbd..c0407ef 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -1,36 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline. library; -import 'package:flutter/material.dart'; - +/// Converts timeline clock values into vertical pixel positions. class TimelineGeometry { + /// Creates geometry for a visible time range. const TimelineGeometry({ - required this.visibleStart, - required this.visibleEnd, + required this.startMinutes, + required this.endMinutes, required this.pixelsPerMinute, - this.minCardHeight = 72, - }); + this.minCardHeight = 0, + }) : assert(startMinutes >= 0), + assert(endMinutes <= 24 * 60), + assert(endMinutes > startMinutes), + assert(minCardHeight >= 0); - final TimeOfDay visibleStart; - final TimeOfDay visibleEnd; + /// First visible minute from midnight. + final int startMinutes; + + /// Last visible minute from midnight. + final int endMinutes; + + /// Vertical scale used to convert minutes into pixels. final double pixelsPerMinute; + + /// Minimum rendered height for a timeline card. + /// + /// Defaults to zero so scheduled cards match their exact duration on the + /// timeline. final double minCardHeight; - int get startMinutes => visibleStart.hour * 60 + visibleStart.minute; - - int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute; - + /// Total vertical height of the visible timeline. double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute; + /// Returns the vertical offset for [minutes] since midnight. double yForMinutesSinceMidnight(int minutes) { return (minutes - startMinutes) * pixelsPerMinute; } + /// Returns the rendered height for a duration in minutes. double heightForDuration(int minutes) { final height = minutes * pixelsPerMinute; return height < minCardHeight ? minCardHeight : height; } + /// Generates timeline ticks at [stepMinutes] intervals. List ticks({int stepMinutes = 15}) { return [ for ( @@ -47,8 +63,34 @@ class TimelineGeometry { ]; } + /// Performs the `operator ==` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool operator ==(Object other) { + return identical(this, other) || + other is TimelineGeometry && + other.startMinutes == startMinutes && + other.endMinutes == endMinutes && + other.pixelsPerMinute == pixelsPerMinute && + other.minCardHeight == minCardHeight; + } + + /// Returns the derived `hashCode` 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. + @override + int get hashCode { + return Object.hash( + startMinutes, + endMinutes, + pixelsPerMinute, + minCardHeight, + ); + } + + /// Runs the `_labelFor` 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 String _labelFor(int minutes) { - final hour24 = minutes ~/ 60; + final hour24 = (minutes ~/ 60) % 24; final minute = minutes % 60; final period = hour24 >= 12 ? 'PM' : 'AM'; final rawHour = hour24 % 12; @@ -60,7 +102,9 @@ class TimelineGeometry { } } +/// A labeled tick on the compact timeline axis. class TimelineTick { + /// Creates a timeline tick. const TimelineTick({ required this.minutesSinceMidnight, required this.y, @@ -68,8 +112,15 @@ class TimelineTick { required this.major, }); + /// Minute from midnight represented by this tick. final int minutesSinceMidnight; + + /// Vertical pixel offset for this tick. final double y; + + /// Display label for this tick. final String label; + + /// Whether this tick represents a major interval. final bool major; } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index ebe41a9..25e7b19 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -1,6 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Timeline View pieces for the FocusFlow Flutter timeline. library; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../models/today_screen_models.dart'; @@ -9,65 +13,47 @@ import 'task_timeline_card.dart'; import 'timeline_axis.dart'; import 'timeline_geometry.dart'; -class TimelineView extends StatelessWidget { +part 'timeline_view/timeline_card_placement.dart'; +part 'timeline_view/timeline_scroll_behavior.dart'; +part 'timeline_view/timeline_view_state.dart'; + +/// Scrollable compact timeline view for Today cards. +class TimelineView extends StatefulWidget { + /// Creates a timeline view. const TimelineView({ required this.cards, required this.range, required this.onCardSelected, + this.onCardCompleted, + this.now, + this.scrollTargetCardId, + this.scrollRequest = 0, super.key, }); + /// Cards to position on the timeline. final List cards; + + /// Visible range used to build the timeline geometry. final TimelineRangeModel range; + + /// Callback invoked when a card is selected. final ValueChanged onCardSelected; + /// Callback invoked when a task card status control requests completion. + final ValueChanged? onCardCompleted; + + /// Clock used to center the initial scroll position. + final DateTime Function()? now; + + /// Card id requested by another surface, such as the required banner. + final String? scrollTargetCardId; + + /// Monotonic request number that allows repeated jumps to the same card. + final int scrollRequest; + + /// 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 - Widget build(BuildContext context) { - final geometry = TimelineGeometry( - visibleStart: TimeOfDay( - hour: range.startMinutes ~/ 60, - minute: range.startMinutes % 60, - ), - visibleEnd: TimeOfDay( - hour: range.endMinutes ~/ 60, - minute: range.endMinutes % 60, - ), - pixelsPerMinute: 2.45, - minCardHeight: 88, - ); - final contentHeight = geometry.totalHeight + 24; - return SingleChildScrollView( - child: SizedBox( - height: contentHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TimelineAxis(geometry: geometry), - const SizedBox(width: 18), - Expanded( - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: TimelineGrid(geometry: geometry)), - for (final card in cards) - Positioned( - top: geometry.yForMinutesSinceMidnight(card.startMinutes), - left: FocusFlowTokens.cardHorizontalPadding, - right: 0, - height: geometry.heightForDuration( - card.endMinutes - card.startMinutes, - ), - child: TaskTimelineCard( - card: card, - onTap: () => onCardSelected(card), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } + State createState() => _TimelineViewState(); } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart new file mode 100644 index 0000000..cc1b702 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../timeline_view.dart'; + +/// Private implementation type for `_TimelineCardPlacement` 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 _TimelineCardPlacement { + /// Creates a `_TimelineCardPlacement` 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 _TimelineCardPlacement({ + required this.card, + required this.lane, + required this.laneCount, + required this.top, + required this.height, + }); + + /// Shared constant value for `_laneGap`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _laneGap = 8.0; + + /// 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 `lane` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int lane; + + /// Stores the `laneCount` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int laneCount; + + /// Stores the `top` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double top; + + /// Stores the `height` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double height; + + /// Performs the `withCard` 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. + _TimelineCardPlacement withCard(TimelineCardModel nextCard) { + if (identical(card, nextCard)) { + return this; + } + return _TimelineCardPlacement( + card: nextCard, + lane: lane, + laneCount: laneCount, + top: top, + height: height, + ); + } + + /// Performs the `pack` 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. + static List<_TimelineCardPlacement> pack( + List cards, { + required TimelineGeometry geometry, + }) { + if (cards.isEmpty) { + return const []; + } + final sorted = [...cards] + ..sort((a, b) { + final startComparison = a.startMinutes.compareTo(b.startMinutes); + if (startComparison != 0) { + return startComparison; + } + final endComparison = a.endMinutes.compareTo(b.endMinutes); + if (endComparison != 0) { + return endComparison; + } + return a.id.compareTo(b.id); + }); + final placements = <_TimelineCardPlacement>[]; + var groupStart = 0; + var groupEnd = _visualEnd(sorted.first, geometry); + for (var index = 1; index < sorted.length; index += 1) { + final card = sorted[index]; + final cardStart = _visualStart(card, geometry); + final cardEnd = _visualEnd(card, geometry); + if (cardStart < groupEnd) { + if (cardEnd > groupEnd) { + groupEnd = cardEnd; + } + continue; + } + placements.addAll( + _packGroup(sorted.sublist(groupStart, index), geometry: geometry), + ); + groupStart = index; + groupEnd = cardEnd; + } + placements.addAll( + _packGroup(sorted.sublist(groupStart), geometry: geometry), + ); + return placements; + } + + /// Runs the `_packGroup` 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 List<_TimelineCardPlacement> _packGroup( + List group, { + required TimelineGeometry geometry, + }) { + final laneEnds = []; + final laneByCard = {}; + for (final card in group) { + final cardStart = _visualStart(card, geometry); + final cardEnd = _visualEnd(card, geometry); + var assignedLane = -1; + for (var lane = 0; lane < laneEnds.length; lane += 1) { + if (laneEnds[lane] <= cardStart) { + assignedLane = lane; + break; + } + } + if (assignedLane == -1) { + assignedLane = laneEnds.length; + laneEnds.add(cardEnd); + } else { + laneEnds[assignedLane] = cardEnd; + } + laneByCard[card] = assignedLane; + } + final laneCount = laneEnds.length; + return [ + for (final card in group) + _TimelineCardPlacement( + card: card, + lane: laneByCard[card]!, + laneCount: laneCount, + top: _visualStart(card, geometry), + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), + ), + ]; + } + + /// Runs the `_visualStart` 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 double _visualStart( + TimelineCardModel card, + TimelineGeometry geometry, + ) { + return geometry.yForMinutesSinceMidnight(card.startMinutes); + } + + /// Runs the `_visualEnd` 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 double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { + final duration = card.endMinutes - card.startMinutes; + return _visualStart(card, geometry) + geometry.heightForDuration(duration); + } + + /// Performs the `left` 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. + double left(double trackWidth) { + final laneWidth = _laneWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); + } + + /// Performs the `width` 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. + double width(double trackWidth) { + return _laneWidth(trackWidth); + } + + /// Runs the `_availableWidth` 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 _availableWidth(double trackWidth) { + final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; + if (availableWidth < 0) { + return 0; + } + return availableWidth; + } + + /// Runs the `_laneWidth` 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 _laneWidth(double trackWidth) { + final availableWidth = _availableWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return (availableWidth - laneGap * (laneCount - 1)) / laneCount; + } + + /// Runs the `_laneGapForWidth` 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 _laneGapForWidth(double trackWidth) { + if (laneCount == 1) { + return 0; + } + final availableWidth = _availableWidth(trackWidth); + final totalGap = _laneGap * (laneCount - 1); + if (availableWidth <= totalGap) { + return 0; + } + return _laneGap; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart new file mode 100644 index 0000000..581ee38 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../timeline_view.dart'; + +/// Private implementation type for `_TimelineScrollBehavior` 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 _TimelineScrollBehavior extends MaterialScrollBehavior { + /// Creates a `_TimelineScrollBehavior` 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 _TimelineScrollBehavior(); + + /// Returns the derived `dragDevices` 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. + @override + Set get dragDevices { + return {...super.dragDevices, PointerDeviceKind.mouse}; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart new file mode 100644 index 0000000..c07a94b --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../timeline_view.dart'; + +/// Private implementation type for `_TimelineViewState` 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 _TimelineViewState extends State { + /// Shared constant value for `_topInset`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _topInset = 12.0; + + /// Private state stored as `_scrollController` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final _scrollController = ScrollController(); + + /// Private state stored as `_didSetInitialScroll` 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 _didSetInitialScroll = false; + + /// Private state stored as `_handledScrollRequest` 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 _handledScrollRequest = 0; + + /// Private state stored as `_geometry` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + late TimelineGeometry _geometry; + + /// Private state stored as `_contentHeight` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + late double _contentHeight; + + /// Private state stored as `_placements` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + late List<_TimelineCardPlacement> _placements; + + /// Private state stored as `_cardsLayoutHash` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + late int _cardsLayoutHash; + + /// Initializes state owned by this object before the first build. + /// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance. + @override + void initState() { + super.initState(); + _updateLayoutCache(); + } + + /// Performs the `didUpdateWidget` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + void didUpdateWidget(covariant TimelineView oldWidget) { + super.didUpdateWidget(oldWidget); + final rangeChanged = + oldWidget.range.startMinutes != widget.range.startMinutes || + oldWidget.range.endMinutes != widget.range.endMinutes; + if (rangeChanged) { + _didSetInitialScroll = false; + } + final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash; + if (rangeChanged || layoutChanged) { + _updateLayoutCache(); + } else if (!identical(oldWidget.cards, widget.cards)) { + _refreshPlacementCards(); + } + } + + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// 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 LayoutBuilder( + builder: (context, constraints) { + _scheduleInitialScroll(_geometry, constraints.maxHeight); + _scheduleTargetScroll(_geometry); + return ScrollConfiguration( + behavior: const _TimelineScrollBehavior(), + child: SingleChildScrollView( + controller: _scrollController, + child: SizedBox( + height: _contentHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RepaintBoundary( + child: TimelineAxis( + geometry: _geometry, + topInset: _topInset, + ), + ), + const SizedBox(width: 18), + Expanded( + child: LayoutBuilder( + builder: (context, cardConstraints) { + final trackWidth = cardConstraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: RepaintBoundary( + child: TimelineGrid( + geometry: _geometry, + topInset: _topInset, + ), + ), + ), + for (final placement in _placements) + Positioned( + top: placement.top + _topInset, + left: placement.left(trackWidth), + width: placement.width(trackWidth), + height: placement.height, + child: RepaintBoundary( + child: TaskTimelineCard( + card: placement.card, + onTap: () => + widget.onCardSelected(placement.card), + onStatusPressed: + widget.onCardCompleted == null + ? null + : () => widget.onCardCompleted!( + placement.card, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + /// Runs the `_updateLayoutCache` 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 _updateLayoutCache() { + _geometry = TimelineGeometry( + startMinutes: widget.range.startMinutes, + endMinutes: widget.range.endMinutes, + pixelsPerMinute: 2.45, + ); + _contentHeight = _geometry.totalHeight + _topInset + 24; + _placements = _TimelineCardPlacement.pack( + widget.cards, + geometry: _geometry, + ); + _cardsLayoutHash = _layoutHashFor(widget.cards); + } + + /// Runs the `_layoutHashFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _layoutHashFor(List cards) { + return Object.hashAll( + cards.map( + (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), + ), + ); + } + + /// Runs the `_refreshPlacementCards` 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 _refreshPlacementCards() { + final cardsById = {for (final card in widget.cards) card.id: card}; + _placements = [ + for (final placement in _placements) + placement.withCard(cardsById[placement.card.id] ?? placement.card), + ]; + } + + /// Runs the `_scheduleInitialScroll` 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 _scheduleInitialScroll( + TimelineGeometry geometry, + double viewportHeight, + ) { + if (_didSetInitialScroll || !viewportHeight.isFinite) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + final now = widget.now?.call() ?? DateTime.now(); + final currentMinute = now.hour * 60 + now.minute; + final clampedMinute = currentMinute + .clamp(geometry.startMinutes, geometry.endMinutes) + .toInt(); + final centeredOffset = + geometry.yForMinutesSinceMidnight(clampedMinute) + + _topInset - + viewportHeight / 2; + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = centeredOffset.clamp(0, maxOffset).toDouble(); + _scrollController.jumpTo(offset); + _didSetInitialScroll = true; + }); + } + + /// Runs the `_scheduleTargetScroll` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _scheduleTargetScroll(TimelineGeometry geometry) { + final targetCardId = widget.scrollTargetCardId; + if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + TimelineCardModel? targetCard; + for (final card in widget.cards) { + if (card.id == targetCardId) { + targetCard = card; + break; + } + } + if (targetCard == null) { + _handledScrollRequest = widget.scrollRequest; + return; + } + final targetOffset = + geometry.yForMinutesSinceMidnight(targetCard.startMinutes) + + _topInset - + 24; + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = targetOffset.clamp(0, maxOffset).toDouble(); + _scrollController.jumpTo(offset); + _handledScrollRequest = widget.scrollRequest; + }); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/today_pane.dart b/apps/focus_flow_flutter/lib/widgets/today_pane.dart index 0c0ff33..720adb9 100644 --- a/apps/focus_flow_flutter/lib/widgets/today_pane.dart +++ b/apps/focus_flow_flutter/lib/widgets/today_pane.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Today Pane widget for the FocusFlow Flutter UI. library; @@ -9,16 +12,23 @@ import '../controllers/scheduler_read_controller.dart'; import 'read_state_view.dart'; import 'schedule_components.dart'; +/// Read-driven pane that renders the Today view and optional commands. class TodayPane extends StatelessWidget { + /// Creates a Today pane from a read [controller]. const TodayPane({ required this.controller, this.commandController, super.key, }); + /// Controller that loads Today state. final UiReadController controller; + + /// Optional command controller for Today actions. final SchedulerCommandController? commandController; + /// 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 ReadStateView( @@ -32,12 +42,19 @@ class TodayPane extends StatelessWidget { } } +/// Populated Today view for a loaded Today state. class TodayContent extends StatelessWidget { + /// Creates Today content for [state]. const TodayContent({required this.state, this.commandController, super.key}); + /// Loaded Today state to display. final TodayState state; + + /// Optional command controller for completing tasks. final SchedulerCommandController? commandController; + /// 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 ListView( @@ -65,6 +82,8 @@ class TodayContent extends StatelessWidget { ); } + /// Runs the `_canComplete` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. bool _canComplete(TodayTimelineItem item) { return item.taskType == TaskType.flexible && item.taskStatus == TaskStatus.planned && diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index 6c52cb9..cf852bd 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders the Top Bar widget for the FocusFlow Flutter UI. library; @@ -5,107 +8,84 @@ import 'package:flutter/material.dart'; import '../theme/focus_flow_tokens.dart'; +part 'top_bar/top_bar_icon_button.dart'; +part 'top_bar/top_bar_metrics.dart'; +part 'top_bar/top_bar_mode_toggle.dart'; +part 'top_bar/top_bar_settings_button.dart'; + +/// Header bar for the compact Today view. class TopBar extends StatelessWidget { + /// Creates a top bar with a display-ready [dateLabel]. const TopBar({required this.dateLabel, super.key}); + /// Date label shown beside the date navigation controls. final String dateLabel; + /// 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 Row( - children: [ - Text('Today', style: Theme.of(context).textTheme.headlineLarge), - const SizedBox(width: 30), - _IconButton(icon: Icons.calendar_today, onPressed: () {}), - _IconButton(icon: Icons.chevron_left, onPressed: () {}), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - dateLabel, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - ), - ), - ), - _IconButton(icon: Icons.chevron_right, onPressed: () {}), - const Spacer(), - const _ModeToggle(), - const SizedBox(width: 28), - OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - fixedSize: const Size(136, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, - ), - ), - ), - icon: const Icon(Icons.wb_sunny_outlined), - label: const Text('Settings'), - ), - ], - ); - } -} - -class _IconButton extends StatelessWidget { - const _IconButton({required this.icon, required this.onPressed}); - - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: onPressed, - color: FocusFlowTokens.textPrimary, - icon: Icon(icon), - ); - } -} - -class _ModeToggle extends StatelessWidget { - const _ModeToggle(); - - @override - Widget build(BuildContext context) { - return Container( - height: 48, - width: 220, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: [ - Expanded( - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: FocusFlowTokens.glassPanelStrong, - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _TopBarMetrics.fromWidth(constraints.maxWidth); + return SizedBox( + height: _TopBarMetrics.referenceHeight, + child: Row( + children: [ + SizedBox( + width: metrics.titleWidth, + height: _TopBarMetrics.referenceHeight, + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + 'Today', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontSize: metrics.titleSize, + ), + ), ), - border: Border.all(color: FocusFlowTokens.navBorder), ), - child: const Text('Compact'), - ), - ), - Expanded( - child: TextButton( - onPressed: () {}, - child: const Text( - 'Normal', - style: TextStyle(color: FocusFlowTokens.textPrimary), + SizedBox(width: metrics.titleGap), + _IconButton( + icon: Icons.calendar_today, + metrics: metrics, + onPressed: () {}, ), - ), + _IconButton( + icon: Icons.chevron_left, + metrics: metrics, + onPressed: () {}, + ), + SizedBox( + width: metrics.dateWidth, + height: metrics.controlHeight, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + dateLabel, + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.dateSize, + ), + ), + ), + ), + ), + _IconButton( + icon: Icons.chevron_right, + metrics: metrics, + onPressed: () {}, + ), + const Spacer(), + _ModeToggle(metrics: metrics), + SizedBox(width: metrics.settingsGap), + _SettingsButton(metrics: metrics), + ], ), - ], - ), + ); + }, ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart new file mode 100644 index 0000000..2a6d6cd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../top_bar.dart'; + +/// Private implementation type for `_IconButton` 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 _IconButton extends StatelessWidget { + /// Creates a `_IconButton` 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 _IconButton({ + required this.icon, + required this.metrics, + required this.onPressed, + }); + + /// 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 `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 _TopBarMetrics metrics; + + /// Stores the `onPressed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onPressed; + + /// 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 IconButton( + onPressed: onPressed, + constraints: BoxConstraints.tightFor( + width: metrics.iconButtonSize, + height: metrics.iconButtonSize, + ), + color: FocusFlowTokens.textPrimary, + iconSize: metrics.iconSize, + padding: EdgeInsets.zero, + icon: Icon(icon), + visualDensity: VisualDensity.compact, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart new file mode 100644 index 0000000..d42e0a9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../top_bar.dart'; + +/// Private implementation type for `_TopBarMetrics` 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 _TopBarMetrics { + /// Creates a `_TopBarMetrics` 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 _TopBarMetrics(this.scale); + + /// Shared constant value for `referenceHeight`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const referenceHeight = 48.0; + + /// Shared constant value for `_referenceWidth`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _referenceWidth = 900.0; + + /// Shared constant value for `_minimumScale`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _minimumScale = 0.44; + + /// Creates a `_TopBarMetrics.fromWidth` 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. + factory _TopBarMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _TopBarMetrics(scale.toDouble()); + } + + /// Stores the `scale` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final double scale; + + /// Returns the derived `titleWidth` 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 titleWidth => 112 * scale; + + /// Returns the derived `titleSize` 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 titleSize => FocusFlowTokens.pageTitleSize * scale; + + /// Returns the derived `titleGap` 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 titleGap => 30 * scale; + + /// Returns the derived `controlHeight` 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 controlHeight => referenceHeight * scale; + + /// Returns the derived `iconButtonSize` 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 iconButtonSize => referenceHeight * scale; + + /// Returns the derived `iconSize` 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 iconSize => 24 * scale; + + /// Returns the derived `dateWidth` 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 dateWidth => 144 * scale; + + /// Returns the derived `dateSize` 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 dateSize => 17 * scale; + + /// Returns the derived `modeWidth` 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 modeWidth => 220 * scale; + + /// Returns the derived `modeHeight` 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 modeHeight => referenceHeight * scale; + + /// Returns the derived `modeLabelSize` 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 modeLabelSize => 17 * scale; + + /// Returns the derived `settingsGap` 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 settingsGap => 28 * scale; + + /// Returns the derived `settingsWidth` 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 settingsWidth => 136 * scale; + + /// Returns the derived `settingsHeight` 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 settingsHeight => referenceHeight * scale; + + /// Returns the derived `settingsIconSize` 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 settingsIconSize => 20 * scale; + + /// Returns the derived `settingsTextSize` 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 settingsTextSize => 15 * scale; + + /// Returns the derived `settingsTextGap` 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 settingsTextGap => 8 * scale; + + /// Returns the derived `borderRadius` 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 borderRadius => FocusFlowTokens.smallButtonRadius * scale; + + /// Returns the derived `borderWidth` 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 borderWidth => scale < 0.7 ? 0.8 : 1; + + /// Returns the derived `buttonPadding` 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. + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart new file mode 100644 index 0000000..304e679 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../top_bar.dart'; + +/// Private implementation type for `_ModeToggle` 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 _ModeToggle extends StatelessWidget { + /// Creates a `_ModeToggle` 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 _ModeToggle({required this.metrics}); + + /// 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 _TopBarMetrics metrics; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + height: metrics.modeHeight, + width: metrics.modeWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), + ), + child: Row( + children: [ + Expanded( + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanelStrong, + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.navBorder, + width: metrics.borderWidth, + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Compact', + style: TextStyle(fontSize: metrics.modeLabelSize), + ), + ), + ), + ), + Expanded( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Normal', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.modeLabelSize, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart new file mode 100644 index 0000000..c356dcb --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../top_bar.dart'; + +/// Private implementation type for `_SettingsButton` 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 _SettingsButton extends StatelessWidget { + /// Creates a `_SettingsButton` 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 _SettingsButton({required this.metrics}); + + /// 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 _TopBarMetrics metrics; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), + fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight), + minimumSize: Size.zero, + padding: metrics.buttonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(metrics.borderRadius), + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize), + SizedBox(width: metrics.settingsTextGap), + Text( + 'Settings', + style: TextStyle( + fontSize: metrics.settingsTextSize, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/pubspec.yaml b/apps/focus_flow_flutter/pubspec.yaml index 9c43a91..6e6cf68 100644 --- a/apps/focus_flow_flutter/pubspec.yaml +++ b/apps/focus_flow_flutter/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: focus_flow_flutter description: Provisional Flutter UI foundation for FocusFlow. publish_to: 'none' diff --git a/apps/focus_flow_flutter/test/forbidden_imports_test.dart b/apps/focus_flow_flutter/test/forbidden_imports_test.dart index 93e2390..02ead75 100644 --- a/apps/focus_flow_flutter/test/forbidden_imports_test.dart +++ b/apps/focus_flow_flutter/test/forbidden_imports_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Forbidden Imports behavior in the FocusFlow Flutter app. library; @@ -5,6 +8,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +/// Runs app package-boundary tests. void main() { test('Flutter UI imports stay inside the UI Plan 1 package boundary', () { final appRoot = Directory.current; @@ -32,6 +36,8 @@ void main() { }); } +/// Top-level helper that performs the `_dartFiles` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Iterable _dartFiles(Directory directory) { if (!directory.existsSync()) { return const []; @@ -42,6 +48,8 @@ Iterable _dartFiles(Directory directory) { .where((file) => file.path.endsWith('.dart')); } +/// Top-level helper that performs the `_forbiddenImportsFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) { return [ const _ForbiddenImport( @@ -68,12 +76,23 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) { ]; } +/// Private implementation type for `_ForbiddenImport` in this library. +/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. class _ForbiddenImport { + /// Creates a `_ForbiddenImport` 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 _ForbiddenImport(this.pattern, this.label); + /// Stores the `pattern` 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 pattern; + + /// 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; + /// Performs the `matches` 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. bool matches(String contents) { return RegExp( r'''import\s+['"]''' + RegExp.escape(pattern), diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 2824db8..fb84af7 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -1,6 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Widget behavior in the FocusFlow Flutter app. library; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:scheduler_core/scheduler_core.dart'; @@ -8,8 +12,17 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/widgets/required_banner.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; +import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; +import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart'; +import 'package:focus_flow_flutter/widgets/top_bar.dart'; +/// Runs FocusFlow widget and visual adapter tests. void main() { testWidgets('app shell renders compact Today frame', (tester) async { await _pumpPlanApp(tester); @@ -36,7 +49,12 @@ void main() { findsOneWidget, ); expect(find.text('Clean coffee maker'), findsOneWidget); - expect(find.text('Cleaned kitchen counter'), findsNWidgets(2)); + expect(find.text('Cleaned kitchen counter'), findsOneWidget); + expect(find.text('Completed at: 8:05 AM'), findsOneWidget); + expect(find.textContaining('Surprise task'), findsNothing); + expect(find.text('Sort mail'), findsOneWidget); + expect(find.text('Start laundry'), findsOneWidget); + expect(find.text('Water plants'), findsOneWidget); expect(find.text('Pay bill'), findsOneWidget); expect(find.text('Doctor appointment'), findsOneWidget); expect(find.text('Free Slot'), findsOneWidget); @@ -50,11 +68,14 @@ void main() { final data = TodayScreenData.fromTodayState(state); expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible); + expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible); + expect(_kindFor(data, 'start-laundry'), TaskVisualKind.flexible); + expect(_kindFor(data, 'water-plants'), TaskVisualKind.flexible); expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required); expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment); expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot); expect( - _kindFor(data, 'cleaned-kitchen-counter-early'), + _kindFor(data, 'cleaned-kitchen-counter'), TaskVisualKind.completedSurprise, ); }, @@ -76,6 +97,7 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle(); @@ -100,6 +122,7 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle(); await tester.tapAt(const Offset(1450, 140)); @@ -108,19 +131,525 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.text('Pay bill'), findsOneWidget); }); + + test('Today screen timeline range covers the full day', () { + expect(TodayScreenData.defaultRange.startMinutes, 0); + expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); + }); + + testWidgets('timeline task bubbles match duration height exactly', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 360), + child: TimelineView( + cards: [ + _timelineCard( + id: 'five-minute-task', + title: 'Five minute task', + subtitle: '5 min', + startMinutes: 8 * 60, + endMinutes: 8 * 60 + 5, + ), + _timelineCard( + id: 'fifteen-minute-task', + title: 'Fifteen minute task', + subtitle: '15 min', + startMinutes: 8 * 60 + 5, + endMinutes: 8 * 60 + 20, + ), + ], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 8), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final fiveMinute = tester.getRect( + find.byKey(const ValueKey('five-minute-task')), + ); + final fifteenMinute = tester.getRect( + find.byKey(const ValueKey('fifteen-minute-task')), + ); + + expect(fiveMinute.height, closeTo(5 * 2.45, 0.5)); + expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5)); + expect(fiveMinute.left, closeTo(fifteenMinute.left, 1)); + expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1)); + }); + + testWidgets('required banner jumps timeline to linked task', (tester) async { + await _pumpPlanApp(tester); + + final scrollable = tester.state(find.byType(Scrollable)); + await tester.tap(find.text('Show upcoming')); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, closeTo(2634, 1)); + expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget); + }); + + testWidgets( + 'timeline defaults near the current time while keeping full day', + (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 12), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('11:00 PM'), findsOneWidget); + expect(find.text('12:00 AM'), findsWidgets); + + final scrollable = tester.state(find.byType(Scrollable)); + final position = scrollable.position; + expect(position.maxScrollExtent, greaterThan(3000)); + expect(position.pixels, closeTo(1576, 1)); + }, + ); + + testWidgets('timeline hour labels stay on one line', (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(140, 220), + child: const TimelineAxis( + geometry: TimelineGeometry( + startMinutes: 0, + endMinutes: 60, + pixelsPerMinute: 2.45, + ), + ), + ); + + expect(tester.takeException(), isNull); + + final midnight = tester.widget(find.text('12:00 AM')); + final oneAm = tester.widget(find.text('1:00 AM')); + expect(midnight.maxLines, 1); + expect(oneAm.maxLines, 1); + expect(midnight.softWrap, isFalse); + expect(oneAm.softWrap, isFalse); + expect(midnight.overflow, TextOverflow.visible); + expect(oneAm.overflow, TextOverflow.visible); + expect( + tester.getSize(find.text('12:00 AM')).height, + closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1), + ); + expect( + tester.getRect(find.text('12:00 AM')).top, + greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top), + ); + }); + + testWidgets('timeline scrolls with a mouse drag', (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 12), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final scrollable = tester.state(find.byType(Scrollable)); + final beforeDrag = scrollable.position.pixels; + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -180), + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(beforeDrag + 80)); + }); + + testWidgets('timeline splits partial overlaps and reuses empty lanes', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 520), + child: TimelineView( + cards: [ + _timelineCard( + id: 'left-first', + title: 'Left first', + startMinutes: 9 * 60, + endMinutes: 10 * 60, + ), + _timelineCard( + id: 'right-middle', + title: 'Right middle', + startMinutes: 9 * 60 + 30, + endMinutes: 10 * 60 + 30, + ), + _timelineCard( + id: 'left-reused', + title: 'Left reused', + startMinutes: 10 * 60, + endMinutes: 11 * 60, + ), + ], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 9, 30), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final leftFirst = tester.getRect(find.byKey(const ValueKey('left-first'))); + final rightMiddle = tester.getRect( + find.byKey(const ValueKey('right-middle')), + ); + final leftReused = tester.getRect( + find.byKey(const ValueKey('left-reused')), + ); + + expect(rightMiddle.left, greaterThan(leftFirst.left)); + expect(leftReused.left, closeTo(leftFirst.left, 1)); + expect(leftFirst.width, closeTo(rightMiddle.width, 1)); + expect(leftReused.width, closeTo(leftFirst.width, 1)); + expect(leftFirst.right, lessThan(rightMiddle.left)); + expect(leftReused.right, lessThan(rightMiddle.left)); + }); + + testWidgets('seeded demo partial overlaps pack into two lanes', ( + tester, + ) async { + final state = await _readSeededToday(); + final data = TodayScreenData.fromTodayState(state); + + await _pumpConstrainedWidget( + tester, + size: const Size(900, 620), + child: TimelineView( + cards: data.cards, + range: data.timelineRange, + now: () => DateTime(2025, 5, 20, 15, 20), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final sortMail = tester.getRect(find.byKey(const ValueKey('sort-mail'))); + final startLaundry = tester.getRect( + find.byKey(const ValueKey('start-laundry')), + ); + final waterPlants = tester.getRect( + find.byKey(const ValueKey('water-plants')), + ); + final cleanCoffee = tester.getRect( + find.byKey(const ValueKey('clean-coffee-maker')), + ); + final cleanedKitchenCounter = tester.getRect( + find.byKey(const ValueKey('cleaned-kitchen-counter')), + ); + + expect(startLaundry.left, greaterThan(sortMail.left)); + expect(waterPlants.left, closeTo(sortMail.left, 1)); + expect(sortMail.width, closeTo(startLaundry.width, 1)); + expect(waterPlants.width, closeTo(sortMail.width, 1)); + expect(sortMail.right, lessThan(startLaundry.left)); + expect(waterPlants.right, lessThan(startLaundry.left)); + expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5)); + expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5)); + expect( + data.cards + .singleWhere((card) => card.id == 'cleaned-kitchen-counter') + .startMinutes, + 8 * 60 + 15, + ); + }); + + testWidgets('top bar controls scale inside a compact header width', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(520, 72), + child: const TopBar(dateLabel: 'June 30, 2026'), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Compact'), findsOneWidget); + expect(find.text('Normal'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(136)); + }); + + testWidgets('required banner scales inside a compact header width', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(440, 72), + child: const RequiredBanner( + model: RequiredBannerModel( + timelineCardId: 'pay-bill', + title: 'Pay bill', + timeText: '6:00 PM', + ), + ), + ); + + expect(tester.takeException(), isNull); + expect( + find.textContaining('Next required task:', findRichText: true), + findsOneWidget, + ); + expect(find.text('Show upcoming'), findsOneWidget); + expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176)); + }); + + testWidgets('empty required banner does not reserve vertical space', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(480, 160)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: const Scaffold( + body: Align(alignment: Alignment.topLeft, child: RequiredBanner()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(RequiredBanner)).height, 0); + }); + + testWidgets('task card controls scale inside a narrow task bubble', ( + tester, + ) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'narrow-actions', + title: 'A task title that would otherwise crowd the controls', + ), + size: const Size(320, 88), + ); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 0, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('narrow-actions'))), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 1, + ); + final actionBackground = tester.widget( + find.byType(AnimatedContainer), + ); + final decoration = actionBackground.decoration; + expect(decoration, isA()); + final boxDecoration = decoration! as BoxDecoration; + expect(boxDecoration.color, const Color(0xFF021A0C)); + expect(boxDecoration.border, isNull); + + final icon = tester.widget(find.byIcon(Icons.arrow_forward)); + expect(icon.size, lessThan(18)); + expect( + tester.getSize(find.byIcon(Icons.arrow_forward)).width, + lessThan(32), + ); + + await gesture.removePointer(); + }); + + testWidgets('short task card keeps time inline with title', (tester) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'short-inline-time', + title: 'Five minute reset', + subtitle: '5 min', + timeText: '6:00 PM - 6:05 PM', + startMinutes: 18 * 60, + endMinutes: 18 * 60 + 5, + ), + size: const Size(360, 24), + ); + + expect(tester.takeException(), isNull); + + final title = tester.getRect(find.text('Five minute reset')); + final time = tester.getRect(find.text('5 min')); + final statusCircle = tester.getRect( + find.byKey(const ValueKey('short-inline-time-status')), + ); + + expect(statusCircle.height, lessThan(20)); + expect(statusCircle.right, lessThan(title.left)); + expect(title.right, lessThan(time.left)); + expect((title.center.dy - time.center.dy).abs(), lessThan(2)); + expect(find.byType(RewardIcon), findsOneWidget); + expect(find.byType(DifficultyBars), findsOneWidget); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('short-inline-time')), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 1, + ); + expect(find.text('5 min'), findsOneWidget); + + await gesture.removePointer(); + }); + + testWidgets('status circle toggles task completion without opening modal', ( + tester, + ) async { + final composition = _composition(); + await _pumpPlanApp(tester, composition: composition); + + await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); + final beforeClick = DateTime.now().toUtc(); + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); + await tester.pumpAndSettle(); + final afterClick = DateTime.now().toUtc(); + + final completedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'clean-coffee-maker', + ); + final scheduledStart = completedTask.scheduledStart; + final scheduledEnd = completedTask.scheduledEnd; + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(completedTask.status, TaskStatus.completed); + expect(completedTask.completedAt, isNotNull); + expect(completedTask.completedAt!.isBefore(beforeClick), isFalse); + expect(completedTask.completedAt!.isAfter(afterClick), isFalse); + expect(find.textContaining('Completed at:'), findsWidgets); + + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); + await tester.pumpAndSettle(); + + final reopenedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'clean-coffee-maker', + ); + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(reopenedTask.status, TaskStatus.planned); + expect(reopenedTask.scheduledStart, scheduledStart); + expect(reopenedTask.scheduledEnd, scheduledEnd); + expect(reopenedTask.actualStart, isNull); + expect(reopenedTask.actualEnd, isNull); + expect(reopenedTask.completedAt, isNull); + }); + + testWidgets('task modal status circle toggles and keeps details in sync', ( + 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('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.textContaining('Completed -'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); + await tester.pumpAndSettle(); + + final completedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'water-plants', + ); + expect(completedTask.status, TaskStatus.completed); + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Water plants'), findsWidgets); + expect(find.textContaining('Completed -'), findsOneWidget); + expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.byIcon(Icons.check), findsWidgets); + + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); + await tester.pumpAndSettle(); + + final reopenedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'water-plants', + ); + expect(reopenedTask.status, TaskStatus.planned); + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.textContaining('Completed -'), findsNothing); + }); + + testWidgets('free slot text scales inside a short task bubble', ( + tester, + ) async { + await _pumpTimelineCard( + tester, + card: _timelineCard( + id: 'short-free-slot', + title: 'Free Slot', + subtitle: 'Intentional rest', + timeText: '6:15 PM - 6:30 PM', + visualKind: TaskVisualKind.freeSlot, + showsQuickActions: false, + ), + size: const Size(360, 88), + ); + + expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing); + expect(find.text('Intentional rest'), findsOneWidget); + expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget); + }); } -Future _pumpPlanApp(WidgetTester tester) async { +/// Top-level helper that performs the `_pumpPlanApp` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _pumpPlanApp( + WidgetTester tester, { + DemoSchedulerComposition? composition, +}) async { await tester.binding.setSurfaceSize(const Size(1586, 992)); addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(FocusFlowApp(composition: _composition())); + await tester.pumpWidget( + FocusFlowApp(composition: composition ?? _composition()), + ); await tester.pumpAndSettle(); } +/// Top-level helper that performs the `_composition` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. DemoSchedulerComposition _composition() { return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20)); } +/// Top-level helper that performs the `_readSeededToday` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _readSeededToday() async { final composition = _composition(); final result = await composition.todayQuery.execute( @@ -132,6 +661,109 @@ Future _readSeededToday() async { return result.requireValue; } +/// Top-level helper that performs the `_kindFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } + +/// Top-level helper that performs the `_scrollIntoView` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _scrollIntoView(WidgetTester tester, ValueKey key) async { + await tester.ensureVisible(find.byKey(key)); + await tester.pumpAndSettle(); +} + +/// Top-level helper that performs the `_pumpTimelineCard` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _pumpTimelineCard( + WidgetTester tester, { + required TimelineCardModel card, + required Size size, +}) async { + await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: size.width, + height: size.height, + child: TaskTimelineCard( + card: card, + onTap: () {}, + onStatusPressed: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// Top-level helper that performs the `_pumpConstrainedWidget` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _pumpConstrainedWidget( + WidgetTester tester, { + required Size size, + required Widget child, +}) async { + await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: Center( + child: SizedBox(width: size.width, height: size.height, child: child), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// Top-level helper that performs the `_timelineCard` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TimelineCardModel _timelineCard({ + required String id, + required String title, + String subtitle = '30 min', + String timeText = '6:00 PM - 6:30 PM', + int startMinutes = 18 * 60, + int endMinutes = 18 * 60 + 30, + TaskVisualKind visualKind = TaskVisualKind.flexible, + TaskType? taskType, + bool showsQuickActions = true, + String? completedTimeText, +}) { + final resolvedTaskType = + taskType ?? + switch (visualKind) { + TaskVisualKind.flexible => TaskType.flexible, + TaskVisualKind.required => TaskType.critical, + TaskVisualKind.appointment => TaskType.inflexible, + TaskVisualKind.freeSlot => TaskType.freeSlot, + TaskVisualKind.completedSurprise => TaskType.flexible, + }; + return TimelineCardModel( + id: id, + title: title, + typeLabel: 'Flexible', + subtitle: subtitle, + timeText: timeText, + startMinutes: startMinutes, + endMinutes: endMinutes, + taskType: resolvedTaskType, + visualKind: visualKind, + rewardIconToken: TimelineRewardIconToken.medium, + difficultyIconToken: TimelineDifficultyIconToken.medium, + completedTimeText: completedTimeText, + isCompleted: false, + isSelectable: true, + showsQuickActions: showsQuickActions, + durationMinutes: endMinutes - startMinutes, + ); +} diff --git a/packages/scheduler_backup/LICENSE.md b/packages/scheduler_backup/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_backup/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_backup/analysis_options.yaml b/packages/scheduler_backup/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_backup/analysis_options.yaml +++ b/packages/scheduler_backup/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_backup/bin/backup.dart b/packages/scheduler_backup/bin/backup.dart index 189ecc6..271c2e5 100644 --- a/packages/scheduler_backup/bin/backup.dart +++ b/packages/scheduler_backup/bin/backup.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Backup command for the Scheduler Backup package. library; @@ -5,6 +8,8 @@ import 'dart:io'; import 'package:scheduler_backup/backup.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. Future main(List arguments) async { final passphrase = _option(arguments, '--passphrase') ?? Platform.environment['SCHEDULER_BACKUP_PASSPHRASE']; @@ -25,6 +30,8 @@ Future main(List arguments) async { } } +/// Top-level helper that performs the `_option` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _option(List arguments, String name) { final index = arguments.indexOf(name); if (index == -1 || index + 1 >= arguments.length) { diff --git a/packages/scheduler_backup/lib/backup.dart b/packages/scheduler_backup/lib/backup.dart index d5c4e44..8877a48 100644 --- a/packages/scheduler_backup/lib/backup.dart +++ b/packages/scheduler_backup/lib/backup.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Encrypted SQLite backup helpers. library; diff --git a/packages/scheduler_backup/lib/scheduler_backup.dart b/packages/scheduler_backup/lib/scheduler_backup.dart index 4f65026..de9885e 100644 --- a/packages/scheduler_backup/lib/scheduler_backup.dart +++ b/packages/scheduler_backup/lib/scheduler_backup.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Package-name entry point for scheduler backup helpers. library; diff --git a/packages/scheduler_backup/lib/src/encrypted_backup.dart b/packages/scheduler_backup/lib/src/encrypted_backup.dart index 1bee79f..1169931 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Encrypted Backup behavior for the Scheduler Backup package. library; @@ -7,6 +10,12 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; +part 'encrypted_backup/errors/backup_decryption_exception.dart'; +part 'encrypted_backup/codec/backup_encryption_codec.dart'; +part 'encrypted_backup/errors/backup_exception.dart'; +part 'encrypted_backup/paths/backup_file_names.dart'; +part 'encrypted_backup/paths/backup_paths.dart'; +part 'encrypted_backup/operations/encrypted_backup_operations.dart'; /// Default scheduler data directory name under the user's home directory. const defaultSchedulerDirectoryName = 'ADHD_Scheduler'; @@ -17,249 +26,30 @@ const defaultSchedulerDatabaseFileName = 'scheduler.sqlite'; /// Default encrypted backup directory name under the scheduler data directory. const defaultSchedulerBackupDirectoryName = 'backups'; +/// Private file-level value for `_formatMagic`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _formatMagic = 'ADHD_SCHEDULER_BACKUP_V1'; + +/// Private file-level value for `_fileExtension`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _fileExtension = '.db.enc'; + +/// Private file-level value for `_kdfIterations`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _kdfIterations = 200000; + +/// Private file-level value for `_saltLength`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _saltLength = 16; + +/// Private file-level value for `_nonceLength`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _nonceLength = 12; + +/// Private file-level value for `_keyLength`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _keyLength = 32; +/// Private file-level value for `_utf8`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. final _utf8 = utf8.encoder; - -/// Thrown when an encrypted backup cannot be decrypted with the passphrase. -final class BackupDecryptionException implements Exception { - /// Creates a decryption failure with optional diagnostic [message]. - const BackupDecryptionException([ - this.message = 'Backup could not be decrypted.', - ]); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'BackupDecryptionException: $message'; -} - -/// Thrown when backup input paths or file contents are invalid. -final class BackupException implements Exception { - /// Creates a backup exception with diagnostic [message]. - const BackupException(this.message); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'BackupException: $message'; -} - -/// Create an encrypted backup of the scheduler SQLite file. -/// -/// Defaults: -/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite` -/// - Backup dir: `~/ADHD_Scheduler/backups` -/// - File name: `yyyymmdd_hhmm.db.enc` -Future createEncryptedBackup({ - required String passphrase, - File? sqliteFile, - Directory? backupDirectory, - DateTime? now, -}) async { - final source = sqliteFile ?? defaultSchedulerSqliteFile(); - if (!await source.exists()) { - throw BackupException('SQLite file does not exist: ${source.path}'); - } - - final destinationDirectory = - backupDirectory ?? defaultSchedulerBackupDirectory(); - await destinationDirectory.create(recursive: true); - - final timestamp = _backupTimestamp(now ?? DateTime.now()); - final destination = await _nextAvailableBackupFile( - destinationDirectory, - '$timestamp$_fileExtension', - ); - - final plaintext = await source.readAsBytes(); - final encoded = await _encryptBytes( - passphrase: passphrase, - plaintext: plaintext, - ); - return destination.writeAsBytes(encoded, flush: true); -} - -/// Restore an encrypted backup over the scheduler SQLite file. -/// -/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and -/// tools can pass [targetFile] to restore into a temp or alternate database. -Future restoreEncryptedBackup( - File backup, - String passphrase, { - File? targetFile, -}) async { - if (!await backup.exists()) { - throw BackupException('Backup file does not exist: ${backup.path}'); - } - - final plaintext = await _decryptBytes( - passphrase: passphrase, - encoded: await backup.readAsBytes(), - ); - final target = targetFile ?? defaultSchedulerSqliteFile(); - await target.parent.create(recursive: true); - - final temporary = File('${target.path}.restore.tmp'); - await temporary.writeAsBytes(plaintext, flush: true); - if (await target.exists()) { - await target.delete(); - } - await temporary.rename(target.path); -} - -/// Default scheduler SQLite database path. -File defaultSchedulerSqliteFile() { - return File(_joinPath( - _homeDirectory().path, - defaultSchedulerDirectoryName, - defaultSchedulerDatabaseFileName, - )); -} - -/// Default encrypted backup output directory. -Directory defaultSchedulerBackupDirectory() { - return Directory(_joinPath( - _homeDirectory().path, - defaultSchedulerDirectoryName, - defaultSchedulerBackupDirectoryName, - )); -} - -Future> _encryptBytes({ - required String passphrase, - required List plaintext, -}) async { - final salt = _randomBytes(_saltLength); - final nonce = _randomBytes(_nonceLength); - final key = await _deriveKey(passphrase: passphrase, salt: salt); - final algorithm = AesGcm.with256bits(); - final secretBox = await algorithm.encrypt( - plaintext, - secretKey: key, - nonce: nonce, - ); - final envelope = { - 'magic': _formatMagic, - 'kdf': 'pbkdf2-hmac-sha256', - 'iterations': _kdfIterations, - 'cipher': 'aes-256-gcm', - 'salt': base64Encode(salt), - 'nonce': base64Encode(secretBox.nonce), - 'ciphertext': base64Encode(secretBox.cipherText), - 'mac': base64Encode(secretBox.mac.bytes), - }; - return _utf8.convert(jsonEncode(envelope)); -} - -Future> _decryptBytes({ - required String passphrase, - required List encoded, -}) async { - try { - final envelope = jsonDecode(utf8.decode(encoded)) as Map; - if (envelope['magic'] != _formatMagic || - envelope['kdf'] != 'pbkdf2-hmac-sha256' || - envelope['iterations'] != _kdfIterations || - envelope['cipher'] != 'aes-256-gcm') { - throw const BackupDecryptionException('Unsupported backup format.'); - } - - final salt = base64Decode(envelope['salt'] as String); - final nonce = base64Decode(envelope['nonce'] as String); - final cipherText = base64Decode(envelope['ciphertext'] as String); - final mac = Mac(base64Decode(envelope['mac'] as String)); - final key = await _deriveKey(passphrase: passphrase, salt: salt); - final algorithm = AesGcm.with256bits(); - return await algorithm.decrypt( - SecretBox(cipherText, nonce: nonce, mac: mac), - secretKey: key, - ); - } on BackupDecryptionException { - rethrow; - } on Object { - throw const BackupDecryptionException(); - } -} - -Future _deriveKey({ - required String passphrase, - required List salt, -}) { - final algorithm = Pbkdf2( - macAlgorithm: Hmac.sha256(), - iterations: _kdfIterations, - bits: _keyLength * 8, - ); - return algorithm.deriveKey( - secretKey: SecretKey(utf8.encode(passphrase)), - nonce: salt, - ); -} - -List _randomBytes(int length) { - final random = Random.secure(); - return Uint8List.fromList( - List.generate(length, (_) => random.nextInt(256)), - ); -} - -Future _nextAvailableBackupFile(Directory directory, String name) async { - final preferred = File(_joinPath(directory.path, name)); - if (!await preferred.exists()) { - return preferred; - } - final dot = name.lastIndexOf('.'); - final stem = dot == -1 ? name : name.substring(0, dot); - final extension = dot == -1 ? '' : name.substring(dot); - var counter = 2; - while (true) { - final candidate = - File(_joinPath(directory.path, '$stem-$counter$extension')); - if (!await candidate.exists()) { - return candidate; - } - counter += 1; - } -} - -String _backupTimestamp(DateTime value) { - final local = value.toLocal(); - return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_' - '${_two(local.hour)}${_two(local.minute)}'; -} - -String _four(int value) => value.toString().padLeft(4, '0'); - -String _two(int value) => value.toString().padLeft(2, '0'); - -Directory _homeDirectory() { - final home = Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE'] ?? - Platform.environment['HOMEDRIVE']; - if (home == null || home.trim().isEmpty) { - throw const BackupException('Home directory could not be resolved.'); - } - return Directory(home); -} - -String _joinPath(String first, String second, [String? third]) { - final separator = Platform.pathSeparator; - final left = first.endsWith(separator) - ? first.substring(0, first.length - separator.length) - : first; - final middle = second.startsWith(separator) ? second.substring(1) : second; - final joined = '$left$separator$middle'; - if (third == null) { - return joined; - } - final right = third.startsWith(separator) ? third.substring(1) : third; - return '$joined$separator$right'; -} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart new file mode 100644 index 0000000..00b43ff --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Top-level helper that performs the `_encryptBytes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future> _encryptBytes({ + required String passphrase, + required List plaintext, +}) async { + final salt = _randomBytes(_saltLength); + final nonce = _randomBytes(_nonceLength); + final key = await _deriveKey(passphrase: passphrase, salt: salt); + final algorithm = AesGcm.with256bits(); + final secretBox = await algorithm.encrypt( + plaintext, + secretKey: key, + nonce: nonce, + ); + final envelope = { + 'magic': _formatMagic, + 'kdf': 'pbkdf2-hmac-sha256', + 'iterations': _kdfIterations, + 'cipher': 'aes-256-gcm', + 'salt': base64Encode(salt), + 'nonce': base64Encode(secretBox.nonce), + 'ciphertext': base64Encode(secretBox.cipherText), + 'mac': base64Encode(secretBox.mac.bytes), + }; + return _utf8.convert(jsonEncode(envelope)); +} + +/// Top-level helper that performs the `_decryptBytes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future> _decryptBytes({ + required String passphrase, + required List encoded, +}) async { + try { + final envelope = jsonDecode(utf8.decode(encoded)) as Map; + if (envelope['magic'] != _formatMagic || + envelope['kdf'] != 'pbkdf2-hmac-sha256' || + envelope['iterations'] != _kdfIterations || + envelope['cipher'] != 'aes-256-gcm') { + throw const BackupDecryptionException('Unsupported backup format.'); + } + + final salt = base64Decode(envelope['salt'] as String); + final nonce = base64Decode(envelope['nonce'] as String); + final cipherText = base64Decode(envelope['ciphertext'] as String); + final mac = Mac(base64Decode(envelope['mac'] as String)); + final key = await _deriveKey(passphrase: passphrase, salt: salt); + final algorithm = AesGcm.with256bits(); + return await algorithm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: mac), + secretKey: key, + ); + } on BackupDecryptionException { + rethrow; + } on Object { + throw const BackupDecryptionException(); + } +} + +/// Top-level helper that performs the `_deriveKey` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _deriveKey({ + required String passphrase, + required List salt, +}) { + final algorithm = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: _kdfIterations, + bits: _keyLength * 8, + ); + return algorithm.deriveKey( + secretKey: SecretKey(utf8.encode(passphrase)), + nonce: salt, + ); +} + +/// Top-level helper that performs the `_randomBytes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _randomBytes(int length) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart new file mode 100644 index 0000000..6820bb1 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Thrown when an encrypted backup cannot be decrypted with the passphrase. +final class BackupDecryptionException implements Exception { + /// Creates a decryption failure with optional diagnostic [message]. + const BackupDecryptionException([ + this.message = 'Backup could not be decrypted.', + ]); + + /// Diagnostic text for logs and tests. + final String message; + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => 'BackupDecryptionException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart new file mode 100644 index 0000000..7133193 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Thrown when backup input paths or file contents are invalid. +final class BackupException implements Exception { + /// Creates a backup exception with diagnostic [message]. + const BackupException(this.message); + + /// Diagnostic text for logs and tests. + final String message; + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => 'BackupException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart new file mode 100644 index 0000000..9174859 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Create an encrypted backup of the scheduler SQLite file. +/// +/// Defaults: +/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite` +/// - Backup dir: `~/ADHD_Scheduler/backups` +/// - File name: `yyyymmdd_hhmm.db.enc` +Future createEncryptedBackup({ + required String passphrase, + File? sqliteFile, + Directory? backupDirectory, + DateTime? now, +}) async { + final source = sqliteFile ?? defaultSchedulerSqliteFile(); + if (!await source.exists()) { + throw BackupException('SQLite file does not exist: ${source.path}'); + } + + final destinationDirectory = + backupDirectory ?? defaultSchedulerBackupDirectory(); + await destinationDirectory.create(recursive: true); + + final timestamp = _backupTimestamp(now ?? DateTime.now()); + final destination = await _nextAvailableBackupFile( + destinationDirectory, + '$timestamp$_fileExtension', + ); + + final plaintext = await source.readAsBytes(); + final encoded = await _encryptBytes( + passphrase: passphrase, + plaintext: plaintext, + ); + return destination.writeAsBytes(encoded, flush: true); +} + +/// Restore an encrypted backup over the scheduler SQLite file. +/// +/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and +/// tools can pass [targetFile] to restore into a temp or alternate database. +Future restoreEncryptedBackup( + File backup, + String passphrase, { + File? targetFile, +}) async { + if (!await backup.exists()) { + throw BackupException('Backup file does not exist: ${backup.path}'); + } + + final plaintext = await _decryptBytes( + passphrase: passphrase, + encoded: await backup.readAsBytes(), + ); + final target = targetFile ?? defaultSchedulerSqliteFile(); + await target.parent.create(recursive: true); + + final temporary = File('${target.path}.restore.tmp'); + await temporary.writeAsBytes(plaintext, flush: true); + if (await target.exists()) { + await target.delete(); + } + await temporary.rename(target.path); +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart new file mode 100644 index 0000000..2c91a8e --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Top-level helper that performs the `_nextAvailableBackupFile` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Future _nextAvailableBackupFile(Directory directory, String name) async { + final preferred = File(_joinPath(directory.path, name)); + if (!await preferred.exists()) { + return preferred; + } + final dot = name.lastIndexOf('.'); + final stem = dot == -1 ? name : name.substring(0, dot); + final extension = dot == -1 ? '' : name.substring(dot); + var counter = 2; + while (true) { + final candidate = + File(_joinPath(directory.path, '$stem-$counter$extension')); + if (!await candidate.exists()) { + return candidate; + } + counter += 1; + } +} + +/// Top-level helper that performs the `_backupTimestamp` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _backupTimestamp(DateTime value) { + final local = value.toLocal(); + return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_' + '${_two(local.hour)}${_two(local.minute)}'; +} + +/// Top-level helper that performs the `_four` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _four(int value) => value.toString().padLeft(4, '0'); + +/// Top-level helper that performs the `_two` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _two(int value) => value.toString().padLeft(2, '0'); diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart new file mode 100644 index 0000000..2bd5e85 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../encrypted_backup.dart'; + +/// Default scheduler SQLite database path. +File defaultSchedulerSqliteFile() { + return File(_joinPath( + _homeDirectory().path, + defaultSchedulerDirectoryName, + defaultSchedulerDatabaseFileName, + )); +} + +/// Default encrypted backup output directory. +Directory defaultSchedulerBackupDirectory() { + return Directory(_joinPath( + _homeDirectory().path, + defaultSchedulerDirectoryName, + defaultSchedulerBackupDirectoryName, + )); +} + +/// Top-level helper that performs the `_homeDirectory` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Directory _homeDirectory() { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + Platform.environment['HOMEDRIVE']; + if (home == null || home.trim().isEmpty) { + throw const BackupException('Home directory could not be resolved.'); + } + return Directory(home); +} + +/// Top-level helper that performs the `_joinPath` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _joinPath(String first, String second, [String? third]) { + final separator = Platform.pathSeparator; + final left = first.endsWith(separator) + ? first.substring(0, first.length - separator.length) + : first; + final middle = second.startsWith(separator) ? second.substring(1) : second; + final joined = '$left$separator$middle'; + if (third == null) { + return joined; + } + final right = third.startsWith(separator) ? third.substring(1) : third; + return '$joined$separator$right'; +} diff --git a/packages/scheduler_backup/pubspec.yaml b/packages/scheduler_backup/pubspec.yaml index 277051a..1c8bf80 100644 --- a/packages/scheduler_backup/pubspec.yaml +++ b/packages/scheduler_backup/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_backup description: Encrypted SQLite backup and restore helpers for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_backup/test/encrypted_backup_test.dart b/packages/scheduler_backup/test/encrypted_backup_test.dart index ad8b370..3917e92 100644 --- a/packages/scheduler_backup/test/encrypted_backup_test.dart +++ b/packages/scheduler_backup/test/encrypted_backup_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Encrypted Backup behavior for the Scheduler Backup package. library; @@ -7,6 +10,8 @@ import 'dart:io'; import 'package:scheduler_backup/backup.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { test('encrypted backup round trips a SQLite file using temp paths', () async { final temp = diff --git a/packages/scheduler_core/LICENSE.md b/packages/scheduler_core/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_core/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_core/analysis_options.yaml b/packages/scheduler_core/analysis_options.yaml index bcfdce3..7df02e8 100644 --- a/packages/scheduler_core/analysis_options.yaml +++ b/packages/scheduler_core/analysis_options.yaml @@ -1,13 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only -analyzer: - language: - strict-casts: true - strict-inference: true - strict-raw-types: true - -linter: - rules: - prefer_final_locals: true - prefer_final_fields: true - avoid_print: true +include: ../../analysis_options.yaml diff --git a/packages/scheduler_core/lib/scheduler_core.dart b/packages/scheduler_core/lib/scheduler_core.dart index 3cc60dd..8736dd5 100644 --- a/packages/scheduler_core/lib/scheduler_core.dart +++ b/packages/scheduler_core/lib/scheduler_core.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines the Scheduler Core public library for the Scheduler Core package. library; @@ -19,28 +22,28 @@ library; // // Keep this file small. Implementation details belong in `lib/src/`. -export 'src/models.dart'; -export 'src/application_commands.dart'; -export 'src/application_layer.dart'; -export 'src/application_management.dart'; -export 'src/application_recovery.dart'; -export 'src/backlog.dart'; -export 'src/child_tasks.dart'; -export 'src/document_mapping.dart'; -export 'src/document_migration.dart'; -export 'src/free_slots.dart'; -export 'src/locked_time.dart'; -export 'src/occupancy_policy.dart'; -export 'src/persistence_contract.dart'; -export 'src/project_statistics.dart'; -export 'src/quick_capture.dart'; -export 'src/reminder_policy.dart'; -export 'src/repositories.dart'; -export 'src/repository_values.dart'; -export 'src/scheduling_engine.dart'; -export 'src/task_actions.dart'; -export 'src/task_lifecycle.dart'; -export 'src/task_statistics.dart'; -export 'src/time_contracts.dart'; -export 'src/today_state.dart'; -export 'src/timeline_state.dart'; +export 'src/domain/models.dart'; +export 'src/application/application_commands.dart'; +export 'src/application/application_layer.dart'; +export 'src/application/application_management.dart'; +export 'src/application/application_recovery.dart'; +export 'src/scheduling/backlog.dart'; +export 'src/scheduling/child_tasks.dart'; +export 'src/persistence/document_mapping.dart'; +export 'src/persistence/document_migration.dart'; +export 'src/scheduling/free_slots.dart'; +export 'src/domain/locked_time.dart'; +export 'src/domain/occupancy_policy.dart'; +export 'src/persistence/persistence_contract.dart'; +export 'src/domain/project_statistics.dart'; +export 'src/scheduling/quick_capture.dart'; +export 'src/scheduling/reminder_policy.dart'; +export 'src/persistence/repositories.dart'; +export 'src/persistence/repository_values.dart'; +export 'src/scheduling/scheduling_engine.dart'; +export 'src/scheduling/task_actions.dart'; +export 'src/scheduling/task_lifecycle.dart'; +export 'src/domain/task_statistics.dart'; +export 'src/domain/time_contracts.dart'; +export 'src/scheduling/today_state.dart'; +export 'src/scheduling/timeline_state.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_commands.dart b/packages/scheduler_core/lib/src/application/application_commands.dart new file mode 100644 index 0000000..4c0d3b7 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands.dart @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Application Commands behavior for the Scheduler Core package. +library; + +// Atomic V1 application commands. +// +// These use cases are the UI-facing mutation boundary. They load authoritative +// repository state, invoke pure domain services, and persist all resulting task, +// activity, project-statistic, and operation-record changes in one unit of work. + +import 'application_layer.dart'; +import '../scheduling/child_tasks.dart'; +import '../scheduling/free_slots.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../domain/project_statistics.dart'; +import '../scheduling/quick_capture.dart'; +import '../scheduling/scheduling_engine.dart'; +import '../scheduling/task_actions.dart'; +import '../scheduling/task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'application_commands/codes/application_command_code.dart'; +part 'application_commands/messages/application_child_task_draft.dart'; +part 'application_commands/messages/application_command_read_hint.dart'; +part 'application_commands/messages/application_command_result.dart'; +part 'application_commands/use_cases/v1_application_command_use_cases.dart'; +part 'application_commands/planning/planning_state.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart new file mode 100644 index 0000000..4cc4509 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_commands.dart'; + +/// Stable V1 application command identifiers. +enum ApplicationCommandCode { + /// Selects the `quickCaptureToBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + quickCaptureToBacklog, + + /// Selects the `quickCaptureToNextAvailableSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + quickCaptureToNextAvailableSlot, + + /// Selects the `scheduleBacklogItemToNextAvailableSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + scheduleBacklogItemToNextAvailableSlot, + + /// Selects the `pushFlexibleToNextAvailableSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + pushFlexibleToNextAvailableSlot, + + /// Selects the `pushFlexibleToTomorrowTopOfQueue` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + pushFlexibleToTomorrowTopOfQueue, + + /// Selects the `moveFlexibleToBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + moveFlexibleToBacklog, + + /// Selects the `completeFlexibleTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completeFlexibleTask, + + /// Selects the `uncompleteTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + uncompleteTask, + + /// Selects the `applyRequiredTaskAction` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + applyRequiredTaskAction, + + /// Selects the `logSurpriseTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + logSurpriseTask, + + /// Selects the `createProtectedFreeSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + createProtectedFreeSlot, + + /// Selects the `updateProtectedFreeSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + updateProtectedFreeSlot, + + /// Selects the `removeProtectedFreeSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + removeProtectedFreeSlot, + + /// Selects the `breakUpTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + breakUpTask, + + /// Selects the `completeChildTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completeChildTask, + + /// Selects the `completeParentTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completeParentTask, + + /// Selects the `completeParentFromChild` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completeParentFromChild, +} diff --git a/packages/scheduler_core/lib/src/application/application_commands/messages/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_child_task_draft.dart new file mode 100644 index 0000000..49a9086 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands/messages/application_child_task_draft.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_commands.dart'; + +/// Draft row for creating one child task from an application command. +class ApplicationChildTaskDraft { + /// Creates a `ApplicationChildTaskDraft` 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 ApplicationChildTaskDraft({ + required this.title, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.durationMinutes, + this.projectId, + }); + + /// User-entered child title. + final String title; + + /// Optional child priority. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; + + /// Optional duration estimate. + final int? durationMinutes; + + /// Optional project override. Null inherits from the parent. + final String? projectId; +} diff --git a/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_read_hint.dart new file mode 100644 index 0000000..9f339cd --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_read_hint.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_commands.dart'; + +/// Hint for read models that should be refreshed after a command. +class ApplicationCommandReadHint { + /// Creates a `ApplicationCommandReadHint` 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. + ApplicationCommandReadHint({ + CivilDate? localDate, + Iterable affectedDates = const [], + Iterable affectedTaskIds = const [], + this.refreshToday = true, + }) : localDate = localDate, + affectedDates = List.unmodifiable( + { + if (localDate != null) localDate, + ...affectedDates, + }, + ), + affectedTaskIds = List.unmodifiable(affectedTaskIds); + + /// Primary local date for a Today refresh, when known. + final CivilDate? localDate; + + /// All local dates touched by the command. + final List affectedDates; + + /// Task ids changed or created by the command. + final List affectedTaskIds; + + /// Whether Today-style read models should be refreshed. + final bool refreshToday; +} diff --git a/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_result.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_result.dart new file mode 100644 index 0000000..e957201 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_result.dart @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_commands.dart'; + +/// Structured result returned by V1 application commands. +class ApplicationCommandResult { + /// Creates a `ApplicationCommandResult` 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. + ApplicationCommandResult({ + required this.commandCode, + required String operationId, + required List changedTasks, + required this.readHint, + List activities = const [], + List projectStatistics = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + this.schedulingResult, + }) : operationId = _requiredTrimmed(operationId, 'operationId'), + changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + projectStatistics = List.unmodifiable( + projectStatistics, + ), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps), + protectedFreeSlotConflicts = + List.unmodifiable( + protectedFreeSlotConflicts, + ), + childTaskIds = List.unmodifiable(childTaskIds); + + /// Command that produced this result. + final ApplicationCommandCode commandCode; + + /// Exactly-once operation id. + final String operationId; + + /// Tasks inserted or changed by the command. + final List changedTasks; + + /// New internal activities persisted with the command. + final List activities; + + /// Project aggregates updated by completion activities. + final List projectStatistics; + + /// Scheduling notices returned by domain services. + final List notices; + + /// Scheduling movement records returned by domain services. + final List changes; + + /// Scheduling overlaps returned by domain services. + final List overlaps; + + /// Protected rest conflicts caused by an explicit required placement. + final List protectedFreeSlotConflicts; + + /// Child task ids created or affected by child-task commands. + final List childTaskIds; + + /// Full scheduling result when the command invoked the scheduler. + final SchedulingResult? schedulingResult; + + /// Read-model refresh hint for UI/state management. + final ApplicationCommandReadHint readHint; +} diff --git a/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart new file mode 100644 index 0000000..9e1be77 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_commands.dart'; + +/// Private implementation type for `_PlanningState` 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 _PlanningState { + /// Creates a `_PlanningState` 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. + _PlanningState({ + required List tasks, + required this.window, + required List lockedIntervals, + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals); + + /// Stores the `tasks` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final List tasks; + + /// Stores the `window` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final SchedulingWindow window; + + /// Stores the `lockedIntervals` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final List lockedIntervals; + + /// Returns the derived `input` 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. + SchedulingInput get input { + return SchedulingInput( + tasks: tasks, + window: window, + lockedIntervals: lockedIntervals, + ); + } + + /// Performs the `withTask` 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. + _PlanningState withTask(Task task) { + if (tasks.any((existing) => existing.id == task.id)) { + return this; + } + return _PlanningState( + tasks: [task, ...tasks], + window: window, + lockedIntervals: lockedIntervals, + ); + } + + /// Performs the `replaceTask` 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. + _PlanningState replaceTask(Task replacement) { + return _PlanningState( + tasks: _replaceTask(tasks, replacement), + window: window, + lockedIntervals: lockedIntervals, + ); + } +} + +/// Top-level helper that performs the `_failure` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +/// Top-level helper that performs the `_failureForQuickCapture` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ApplicationResult _failureForQuickCapture( + QuickCaptureResult result, +) { + final schedulingResult = result.schedulingResult; + if (schedulingResult != null) { + final failure = _failureForSchedulingResult( + schedulingResult, + entityId: result.task.id, + ); + if (failure != null) { + return ApplicationResult.failure(failure); + } + } + + return _failure( + ApplicationFailureCode.validation, + entityId: result.task.id, + detailCode: result.status.name, + ); +} + +/// Top-level helper that performs the `_failureForSchedulingResult` 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? _failureForSchedulingResult( + SchedulingResult result, { + required String entityId, +}) { + return switch (result.outcomeCode) { + SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null, + SchedulingOutcomeCode.conflict => null, + SchedulingOutcomeCode.notFound => ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.noSlot || + SchedulingOutcomeCode.overflow => + ApplicationFailure( + code: ApplicationFailureCode.noSlot, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.invalidState => ApplicationFailure( + code: ApplicationFailureCode.conflict, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + }; +} + +/// Top-level helper that performs the `_staleFailure` 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? _staleFailure(Task task, DateTime? expectedUpdatedAt) { + if (expectedUpdatedAt == null || + _sameNullableInstant(task.updatedAt, expectedUpdatedAt)) { + return null; + } + + return ApplicationFailure( + code: ApplicationFailureCode.staleRevision, + entityId: task.id, + ); +} + +/// Top-level helper that performs the `_activitiesForSchedulingChanges` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _activitiesForSchedulingChanges({ + required SchedulingResult? result, + required List beforeTasks, + required String operationId, + required DateTime occurredAt, + required String selectedTaskId, + required TaskActivityCode selectedActivityCode, + required TaskActivityCode movedActivityCode, +}) { + if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + final original = _taskById(beforeTasks, change.taskId); + if (original == null) { + continue; + } + final code = change.taskId == selectedTaskId + ? selectedActivityCode + : movedActivityCode; + activities.add( + TaskActivity( + id: '$operationId:${change.taskId}:${code.name}', + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: original.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': original.status.name, + 'nextStatus': _nextStatusForChange(original, code).name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +/// Top-level helper that performs the `_nextStatusForChange` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { + if (code == TaskActivityCode.restoredFromBacklog) { + return TaskStatus.planned; + } + if (code == TaskActivityCode.movedToBacklog) { + return TaskStatus.backlog; + } + + return task.status; +} + +/// Top-level helper that performs the `_changedTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _changedTasks(List beforeTasks, List afterTasks) { + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final changed = []; + + for (final task in afterTasks) { + final before = beforeById[task.id]; + if (before == null || _taskChanged(before, task)) { + changed.add(task); + } + } + + return List.unmodifiable(changed); +} + +/// Top-level helper that performs the `_changedTaskIds` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _changedTaskIds(List beforeTasks, List afterTasks) { + return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks)); +} + +/// Top-level helper that performs the `_taskChanged` 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 _taskChanged(Task before, Task after) { + return before.title != after.title || + before.projectId != after.projectId || + before.type != after.type || + before.status != after.status || + before.priority != after.priority || + before.reward != after.reward || + before.difficulty != after.difficulty || + before.durationMinutes != after.durationMinutes || + !_sameNullableInstant(before.scheduledStart, after.scheduledStart) || + !_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) || + !_sameNullableInstant(before.actualStart, after.actualStart) || + !_sameNullableInstant(before.actualEnd, after.actualEnd) || + !_sameNullableInstant(before.completedAt, after.completedAt) || + before.parentTaskId != after.parentTaskId || + !_sameNullableInstant(before.updatedAt, after.updatedAt) || + before.stats != after.stats; +} + +/// Top-level helper that performs the `_sameNullableInstant` 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 _sameNullableInstant(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} + +/// Top-level helper that performs the `_taskById` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Top-level helper that performs the `_replaceTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _replaceTask(List tasks, Task replacement) { + return tasks + .map((task) => task.id == replacement.id ? replacement : task) + .toList(growable: false); +} + +/// Top-level helper that performs the `_taskIdsFromChanges` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _taskIdsFromChanges(List changes) { + return List.unmodifiable( + changes.map((change) => change.taskId).toSet(), + ); +} + +/// Top-level helper that performs the `_taskIdsFromTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _taskIdsFromTasks(List tasks) { + return List.unmodifiable(tasks.map((task) => task.id).toSet()); +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart similarity index 74% rename from packages/scheduler_core/lib/src/application_commands.dart rename to packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index fda6996..245753c 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -1,173 +1,12 @@ -/// Implements Application Commands behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Atomic V1 application commands. -// -// These use cases are the UI-facing mutation boundary. They load authoritative -// repository state, invoke pure domain services, and persist all resulting task, -// activity, project-statistic, and operation-record changes in one unit of work. - -import 'application_layer.dart'; -import 'child_tasks.dart'; -import 'free_slots.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'project_statistics.dart'; -import 'quick_capture.dart'; -import 'scheduling_engine.dart'; -import 'task_actions.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -/// Stable V1 application command identifiers. -enum ApplicationCommandCode { - quickCaptureToBacklog, - quickCaptureToNextAvailableSlot, - scheduleBacklogItemToNextAvailableSlot, - pushFlexibleToNextAvailableSlot, - pushFlexibleToTomorrowTopOfQueue, - moveFlexibleToBacklog, - completeFlexibleTask, - applyRequiredTaskAction, - logSurpriseTask, - createProtectedFreeSlot, - updateProtectedFreeSlot, - removeProtectedFreeSlot, - breakUpTask, - completeChildTask, - completeParentTask, - completeParentFromChild, -} - -/// Draft row for creating one child task from an application command. -class ApplicationChildTaskDraft { - const ApplicationChildTaskDraft({ - required this.title, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.durationMinutes, - this.projectId, - }); - - /// User-entered child title. - final String title; - - /// Optional child priority. - final PriorityLevel? priority; - - /// Optional reward estimate. - final RewardLevel reward; - - /// Optional difficulty estimate. - final DifficultyLevel difficulty; - - /// Optional duration estimate. - final int? durationMinutes; - - /// Optional project override. Null inherits from the parent. - final String? projectId; -} - -/// Hint for read models that should be refreshed after a command. -class ApplicationCommandReadHint { - ApplicationCommandReadHint({ - CivilDate? localDate, - Iterable affectedDates = const [], - Iterable affectedTaskIds = const [], - this.refreshToday = true, - }) : localDate = localDate, - affectedDates = List.unmodifiable( - { - if (localDate != null) localDate, - ...affectedDates, - }, - ), - affectedTaskIds = List.unmodifiable(affectedTaskIds); - - /// Primary local date for a Today refresh, when known. - final CivilDate? localDate; - - /// All local dates touched by the command. - final List affectedDates; - - /// Task ids changed or created by the command. - final List affectedTaskIds; - - /// Whether Today-style read models should be refreshed. - final bool refreshToday; -} - -/// Structured result returned by V1 application commands. -class ApplicationCommandResult { - ApplicationCommandResult({ - required this.commandCode, - required String operationId, - required List changedTasks, - required this.readHint, - List activities = const [], - List projectStatistics = const [], - List notices = const [], - List changes = const [], - List overlaps = const [], - List protectedFreeSlotConflicts = - const [], - List childTaskIds = const [], - this.schedulingResult, - }) : operationId = _requiredTrimmed(operationId, 'operationId'), - changedTasks = List.unmodifiable(changedTasks), - activities = List.unmodifiable(activities), - projectStatistics = List.unmodifiable( - projectStatistics, - ), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps), - protectedFreeSlotConflicts = - List.unmodifiable( - protectedFreeSlotConflicts, - ), - childTaskIds = List.unmodifiable(childTaskIds); - - /// Command that produced this result. - final ApplicationCommandCode commandCode; - - /// Exactly-once operation id. - final String operationId; - - /// Tasks inserted or changed by the command. - final List changedTasks; - - /// New internal activities persisted with the command. - final List activities; - - /// Project aggregates updated by completion activities. - final List projectStatistics; - - /// Scheduling notices returned by domain services. - final List notices; - - /// Scheduling movement records returned by domain services. - final List changes; - - /// Scheduling overlaps returned by domain services. - final List overlaps; - - /// Protected rest conflicts caused by an explicit required placement. - final List protectedFreeSlotConflicts; - - /// Child task ids created or affected by child-task commands. - final List childTaskIds; - - /// Full scheduling result when the command invoked the scheduler. - final SchedulingResult? schedulingResult; - - /// Read-model refresh hint for UI/state management. - final ApplicationCommandReadHint readHint; -} +part of '../../application_commands.dart'; /// UI-facing V1 command use cases. class V1ApplicationCommandUseCases { + /// Creates a `V1ApplicationCommandUseCases` 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 V1ApplicationCommandUseCases({ required this.applicationStore, required this.timeZoneResolver, @@ -192,13 +31,36 @@ class V1ApplicationCommandUseCases { /// DST/nonexistent/repeated local time policy. final TimeZoneResolutionOptions resolutionOptions; + /// Stores the `quickCaptureService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final QuickCaptureService quickCaptureService; + + /// Stores the `flexibleTaskActionService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final FlexibleTaskActionService flexibleTaskActionService; + + /// Stores the `requiredTaskActionService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final RequiredTaskActionService requiredTaskActionService; + + /// Stores the `surpriseTaskLogService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final SurpriseTaskLogService surpriseTaskLogService; + + /// Stores the `freeSlotService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final FreeSlotService freeSlotService; + + /// Stores the `childTaskBreakUpService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final ChildTaskBreakUpService childTaskBreakUpService; + + /// Stores the `childTaskCompletionService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final ChildTaskCompletionService childTaskCompletionService; + + /// Stores the `projectStatisticsAggregationService` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final ProjectStatisticsAggregationService projectStatisticsAggregationService; /// Quick capture a task into backlog. @@ -558,6 +420,63 @@ class V1ApplicationCommandUseCases { ); } + /// Mark a completed scheduled task as still needing to be done. + Future> uncompleteTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.uncompleteTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.completed || + (!task.isFlexible && !task.isRequiredVisible)) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + final uncompleted = task.copyWith( + status: TaskStatus.planned, + updatedAt: context.now, + clearActualInterval: true, + clearCompletion: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, uncompleted), + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + /// Apply a required visible task lifecycle action. Future> applyRequiredTaskAction({ required ApplicationOperationContext context, @@ -949,6 +868,8 @@ class V1ApplicationCommandUseCases { ); } + /// Runs the `_pushFlexible` 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> _pushFlexible({ required ApplicationCommandCode commandCode, required ApplicationOperationContext context, @@ -1010,6 +931,8 @@ class V1ApplicationCommandUseCases { ); } + /// Runs the `_completeChildOrParent` 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> _completeChildOrParent({ required ApplicationCommandCode commandCode, required ApplicationOperationContext context, @@ -1055,6 +978,8 @@ class V1ApplicationCommandUseCases { ); } + /// Runs the `_loadPlanningState` 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<_PlanningState> _loadPlanningState( ApplicationUnitOfWorkRepositories repositories, ApplicationOperationContext context, @@ -1106,6 +1031,8 @@ class V1ApplicationCommandUseCases { ); } + /// Runs the `_resolve` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. DateTime _resolve({ required CivilDate date, required WallTime wallTime, @@ -1121,6 +1048,8 @@ class V1ApplicationCommandUseCases { .instant; } + /// Runs the `_persist` 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> _persist({ required ApplicationUnitOfWorkRepositories repositories, required ApplicationCommandCode commandCode, @@ -1161,6 +1090,8 @@ class V1ApplicationCommandUseCases { ); } + /// Runs the `_saveProjectStatistics` 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> _saveProjectStatistics({ required ApplicationUnitOfWorkRepositories repositories, required List activities, @@ -1192,254 +1123,3 @@ class V1ApplicationCommandUseCases { return List.unmodifiable(updated); } } - -class _PlanningState { - _PlanningState({ - required List tasks, - required this.window, - required List lockedIntervals, - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals); - - final List tasks; - final SchedulingWindow window; - final List lockedIntervals; - - SchedulingInput get input { - return SchedulingInput( - tasks: tasks, - window: window, - lockedIntervals: lockedIntervals, - ); - } - - _PlanningState withTask(Task task) { - if (tasks.any((existing) => existing.id == task.id)) { - return this; - } - return _PlanningState( - tasks: [task, ...tasks], - window: window, - lockedIntervals: lockedIntervals, - ); - } - - _PlanningState replaceTask(Task replacement) { - return _PlanningState( - tasks: _replaceTask(tasks, replacement), - window: window, - lockedIntervals: lockedIntervals, - ); - } -} - -ApplicationResult _failure( - ApplicationFailureCode code, { - String? entityId, - String? detailCode, -}) { - return ApplicationResult.failure( - ApplicationFailure( - code: code, - entityId: entityId, - detailCode: detailCode, - ), - ); -} - -ApplicationResult _failureForQuickCapture( - QuickCaptureResult result, -) { - final schedulingResult = result.schedulingResult; - if (schedulingResult != null) { - final failure = _failureForSchedulingResult( - schedulingResult, - entityId: result.task.id, - ); - if (failure != null) { - return ApplicationResult.failure(failure); - } - } - - return _failure( - ApplicationFailureCode.validation, - entityId: result.task.id, - detailCode: result.status.name, - ); -} - -ApplicationFailure? _failureForSchedulingResult( - SchedulingResult result, { - required String entityId, -}) { - return switch (result.outcomeCode) { - SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null, - SchedulingOutcomeCode.conflict => null, - SchedulingOutcomeCode.notFound => ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - SchedulingOutcomeCode.noSlot || - SchedulingOutcomeCode.overflow => - ApplicationFailure( - code: ApplicationFailureCode.noSlot, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - SchedulingOutcomeCode.invalidState => ApplicationFailure( - code: ApplicationFailureCode.conflict, - entityId: entityId, - detailCode: result.outcomeCode.name, - ), - }; -} - -ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) { - if (expectedUpdatedAt == null || - _sameNullableInstant(task.updatedAt, expectedUpdatedAt)) { - return null; - } - - return ApplicationFailure( - code: ApplicationFailureCode.staleRevision, - entityId: task.id, - ); -} - -List _activitiesForSchedulingChanges({ - required SchedulingResult? result, - required List beforeTasks, - required String operationId, - required DateTime occurredAt, - required String selectedTaskId, - required TaskActivityCode selectedActivityCode, - required TaskActivityCode movedActivityCode, -}) { - if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - - final activities = []; - for (final change in result.changes) { - final original = _taskById(beforeTasks, change.taskId); - if (original == null) { - continue; - } - final code = change.taskId == selectedTaskId - ? selectedActivityCode - : movedActivityCode; - activities.add( - TaskActivity( - id: '$operationId:${change.taskId}:${code.name}', - operationId: operationId, - code: code, - taskId: change.taskId, - projectId: original.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': original.status.name, - 'nextStatus': _nextStatusForChange(original, code).name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { - if (code == TaskActivityCode.restoredFromBacklog) { - return TaskStatus.planned; - } - if (code == TaskActivityCode.movedToBacklog) { - return TaskStatus.backlog; - } - - return task.status; -} - -List _changedTasks(List beforeTasks, List afterTasks) { - final beforeById = { - for (final task in beforeTasks) task.id: task, - }; - final changed = []; - - for (final task in afterTasks) { - final before = beforeById[task.id]; - if (before == null || _taskChanged(before, task)) { - changed.add(task); - } - } - - return List.unmodifiable(changed); -} - -List _changedTaskIds(List beforeTasks, List afterTasks) { - return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks)); -} - -bool _taskChanged(Task before, Task after) { - return before.title != after.title || - before.projectId != after.projectId || - before.type != after.type || - before.status != after.status || - before.priority != after.priority || - before.reward != after.reward || - before.difficulty != after.difficulty || - before.durationMinutes != after.durationMinutes || - !_sameNullableInstant(before.scheduledStart, after.scheduledStart) || - !_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) || - !_sameNullableInstant(before.actualStart, after.actualStart) || - !_sameNullableInstant(before.actualEnd, after.actualEnd) || - !_sameNullableInstant(before.completedAt, after.completedAt) || - before.parentTaskId != after.parentTaskId || - !_sameNullableInstant(before.updatedAt, after.updatedAt) || - before.stats != after.stats; -} - -bool _sameNullableInstant(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -List _replaceTask(List tasks, Task replacement) { - return tasks - .map((task) => task.id == replacement.id ? replacement : task) - .toList(growable: false); -} - -List _taskIdsFromChanges(List changes) { - return List.unmodifiable( - changes.map((change) => change.taskId).toSet(), - ); -} - -List _taskIdsFromTasks(List tasks) { - return List.unmodifiable(tasks.map((task) => task.id).toSet()); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} diff --git a/packages/scheduler_core/lib/src/application/application_layer.dart b/packages/scheduler_core/lib/src/application/application_layer.dart new file mode 100644 index 0000000..5d67f36 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer.dart @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Application Layer behavior for the Scheduler Core package. +library; + +// Application orchestration contracts for UI-facing use cases. +// +// This layer sits above the pure domain services and repository interfaces. It +// gives future Flutter/use-case code one atomic boundary per user command +// without importing widgets, database clients, or platform notification APIs. + +import '../scheduling/backlog.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../domain/project_statistics.dart'; +import '../persistence/repositories.dart'; +import '../scheduling/scheduling_engine.dart'; +import '../scheduling/task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'application_layer/context/owner_time_zone_context.dart'; +part 'application_layer/context/application_operation_context.dart'; +part 'application_layer/results/application_failure_code.dart'; +part 'application_layer/results/application_failure.dart'; +part 'application_layer/results/application_result.dart'; +part 'application_layer/results/application_failure_exception.dart'; +part 'application_layer/results/application_persistence_exception.dart'; +part 'application_layer/records/application_operation_record.dart'; +part 'application_layer/records/notice_acknowledgement_record.dart'; +part 'application_layer/records/owner_settings.dart'; +part 'application_layer/repositories/interfaces/tasks/task_activity_repository.dart'; +part 'application_layer/repositories/interfaces/projects/project_statistics_repository.dart'; +part 'application_layer/repositories/interfaces/application/owner_settings_repository.dart'; +part 'application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart'; +part 'application_layer/repositories/interfaces/application/application_operation_repository.dart'; +part 'application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart'; +part 'application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart'; +part 'application_layer/loading/application_scheduling_loader.dart'; +part 'application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart'; +part 'application_layer/repositories/in_memory/in_memory_application_state.dart'; +part 'application_layer/repositories/in_memory/in_memory_application_repositories.dart'; +part 'application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart'; +part 'application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart'; +part 'application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart'; +part 'application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart'; +part 'application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart'; +part 'application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart'; +part 'application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart'; +part 'application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart'; +part 'application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_layer/context/application_operation_context.dart b/packages/scheduler_core/lib/src/application/application_layer/context/application_operation_context.dart new file mode 100644 index 0000000..23ffa0e --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/context/application_operation_context.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Per-operation application context. +class ApplicationOperationContext { + /// Creates a `ApplicationOperationContext` 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. + ApplicationOperationContext({ + required String operationId, + required this.now, + required this.ownerTimeZone, + required this.clock, + required this.idGenerator, + }) : operationId = _requiredTrimmed(operationId, 'operationId'); + + /// Convenience constructor that captures [Clock.now] once. + factory ApplicationOperationContext.start({ + required String operationId, + required OwnerTimeZoneContext ownerTimeZone, + required Clock clock, + required IdGenerator idGenerator, + }) { + return ApplicationOperationContext( + operationId: operationId, + now: clock.now(), + ownerTimeZone: ownerTimeZone, + clock: clock, + idGenerator: idGenerator, + ); + } + + /// Exactly-once key for this user command. + final String operationId; + + /// Instant used by the operation. Use this instead of repeatedly reading the + /// clock during one command. + final DateTime now; + + /// Owner scope and time-zone data for date-sensitive rules. + final OwnerTimeZoneContext ownerTimeZone; + + /// Injected clock available to lower-level services that need one. + final Clock clock; + + /// Injected ID generator for records created by the operation. + final IdGenerator idGenerator; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/context/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application/application_layer/context/owner_time_zone_context.dart new file mode 100644 index 0000000..59ad45e --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/context/owner_time_zone_context.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Owner-local time context supplied to application operations. +class OwnerTimeZoneContext { + /// Creates a `OwnerTimeZoneContext` 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. + OwnerTimeZoneContext({ + required String ownerId, + required String timeZoneId, + }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), + timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'); + + /// Authorization-neutral owner scope. V1 does not implement accounts. + final String ownerId; + + /// IANA-style time-zone identifier chosen by the app boundary. + final String timeZoneId; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/loading/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application/application_layer/loading/application_scheduling_loader.dart new file mode 100644 index 0000000..8b29ed1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/loading/application_scheduling_loader.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Repository loader for common scheduler input assembly. +class ApplicationSchedulingLoader { + /// Creates a `ApplicationSchedulingLoader` 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 ApplicationSchedulingLoader(this.repositories); + + /// Repositories for the current application operation. + final ApplicationUnitOfWorkRepositories repositories; + + /// Load scheduler input for [window] from the repository boundary. + /// + /// UI code should not persist partial [SchedulingResult] objects. Commands + /// should load authoritative state here, invoke domain services, then commit + /// resulting entities through a unit of work. + Future loadInputForWindow({ + required SchedulingWindow window, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) async { + final scheduledTasks = await repositories.tasks.findScheduledInWindow( + window, + ); + return SchedulingInput( + tasks: scheduledTasks, + window: window, + lockedIntervals: lockedIntervals, + requiredVisibleIntervals: requiredVisibleIntervals, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/records/application_operation_record.dart b/packages/scheduler_core/lib/src/application/application_layer/records/application_operation_record.dart new file mode 100644 index 0000000..4f256c5 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/records/application_operation_record.dart @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Persisted exactly-once operation record. +class ApplicationOperationRecord { + /// Creates a `ApplicationOperationRecord` 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. + ApplicationOperationRecord({ + required String operationId, + required String ownerId, + required String operationName, + required this.committedAt, + }) : operationId = _requiredTrimmed(operationId, 'operationId'), + ownerId = _requiredTrimmed(ownerId, 'ownerId'), + operationName = _requiredTrimmed(operationName, 'operationName'); + + /// Exactly-once operation id. + final String operationId; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Stable use-case/command label. + final String operationName; + + /// Commit instant. + final DateTime committedAt; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/records/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application/application_layer/records/notice_acknowledgement_record.dart new file mode 100644 index 0000000..637268d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/records/notice_acknowledgement_record.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Owner-scoped acknowledgement for a one-time scheduling notice. +class NoticeAcknowledgementRecord { + /// Creates a `NoticeAcknowledgementRecord` 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. + NoticeAcknowledgementRecord({ + required String noticeId, + required String ownerId, + required this.acknowledgedAt, + }) : noticeId = _requiredTrimmed(noticeId, 'noticeId'), + ownerId = _requiredTrimmed(ownerId, 'ownerId'); + + /// Stable notice id generated by the read/use-case boundary. + final String noticeId; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Instant the notice was consumed. + final DateTime acknowledgedAt; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/records/owner_settings.dart b/packages/scheduler_core/lib/src/application/application_layer/records/owner_settings.dart new file mode 100644 index 0000000..9c9d24f --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/records/owner_settings.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Owner-level settings needed by application use cases. +class OwnerSettings { + /// Creates a `OwnerSettings` 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. + OwnerSettings({ + required String ownerId, + required String timeZoneId, + WallTime? dayStart, + WallTime? dayEnd, + this.compactModeEnabled = false, + this.backlogStalenessSettings = const BacklogStalenessSettings(), + }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), + timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'), + dayStart = dayStart ?? WallTime(hour: 0, minute: 0), + dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) { + if (this.dayStart.compareTo(this.dayEnd) >= 0) { + throw ArgumentError.value( + {'dayStart': this.dayStart, 'dayEnd': this.dayEnd}, + 'dayEnd', + 'Day end must be after day start.', + ); + } + } + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Owner time-zone id used by date-sensitive application use cases. + final String timeZoneId; + + /// Default local day start. + final WallTime dayStart; + + /// Default local day end. + final WallTime dayEnd; + + /// Persisted compact-mode preference. + final bool compactModeEnabled; + + /// Configurable backlog age thresholds. + final BacklogStalenessSettings backlogStalenessSettings; + + /// Return a copy with selected settings changed. + OwnerSettings copyWith({ + String? ownerId, + String? timeZoneId, + WallTime? dayStart, + WallTime? dayEnd, + bool? compactModeEnabled, + BacklogStalenessSettings? backlogStalenessSettings, + }) { + return OwnerSettings( + ownerId: ownerId ?? this.ownerId, + timeZoneId: timeZoneId ?? this.timeZoneId, + dayStart: dayStart ?? this.dayStart, + dayEnd: dayEnd ?? this.dayEnd, + compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled, + backlogStalenessSettings: + backlogStalenessSettings ?? this.backlogStalenessSettings, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_repositories.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_repositories.dart new file mode 100644 index 0000000..a572a2a --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_repositories.dart @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_layer.dart'; + +/// Private implementation type for `_InMemoryApplicationRepositories` 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 _InMemoryApplicationRepositories + implements ApplicationUnitOfWorkRepositories { + /// Creates a `_InMemoryApplicationRepositories` 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. + _InMemoryApplicationRepositories(_InMemoryApplicationState state) + : tasks = _UnitOfWorkTaskRepository( + tasksById: state.tasksById, + ownersById: state.taskOwnersById, + revisionsById: state.taskRevisionsById, + ), + projects = _UnitOfWorkProjectRepository( + projectsById: state.projectsById, + ownersById: state.projectOwnersById, + revisionsById: state.projectRevisionsById, + ), + lockedBlocks = _UnitOfWorkLockedBlockRepository( + blocksById: state.lockedBlocksById, + blockOwnersById: state.lockedBlockOwnersById, + blockRevisionsById: state.lockedBlockRevisionsById, + overridesById: state.lockedOverridesById, + overrideOwnersById: state.lockedOverrideOwnersById, + overrideRevisionsById: state.lockedOverrideRevisionsById, + ), + schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository( + state.schedulingSnapshotsById, + ), + taskActivities = _UnitOfWorkTaskActivityRepository( + activitiesById: state.taskActivitiesById, + ownersById: state.taskActivityOwnersById, + ), + projectStatistics = _UnitOfWorkProjectStatisticsRepository( + statisticsByProjectId: state.projectStatisticsByProjectId, + ownersByProjectId: state.projectStatisticsOwnersByProjectId, + revisionsByProjectId: state.projectStatisticsRevisionsByProjectId, + ), + ownerSettings = _UnitOfWorkOwnerSettingsRepository( + settingsByOwnerId: state.ownerSettingsByOwnerId, + revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId, + ), + noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( + recordsByScopedId: state.noticeAcknowledgementsByScopedId, + revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId, + ), + operations = _UnitOfWorkOperationRepository(state.operationsById); + + /// Stores the `tasks` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final TaskRepository tasks; + + /// Stores the `projects` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final ProjectRepository projects; + + /// Stores the `lockedBlocks` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final LockedBlockRepository lockedBlocks; + + /// Stores the `schedulingSnapshots` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final SchedulingSnapshotRepository schedulingSnapshots; + + /// Stores the `taskActivities` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final TaskActivityRepository taskActivities; + + /// Stores the `projectStatistics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final ProjectStatisticsRepository projectStatistics; + + /// Stores the `ownerSettings` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final OwnerSettingsRepository ownerSettings; + + /// Stores the `noticeAcknowledgements` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final NoticeAcknowledgementRepository noticeAcknowledgements; + + /// Stores the `operations` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final ApplicationOperationRepository operations; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_state.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_state.dart new file mode 100644 index 0000000..effc9ca --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_state.dart @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_layer.dart'; + +/// Private implementation type for `_InMemoryApplicationState` 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 _InMemoryApplicationState { + /// Creates a `_InMemoryApplicationState` 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. + _InMemoryApplicationState({ + required this.tasksById, + required this.taskOwnersById, + required this.taskRevisionsById, + required this.projectsById, + required this.projectOwnersById, + required this.projectRevisionsById, + required this.lockedBlocksById, + required this.lockedBlockOwnersById, + required this.lockedBlockRevisionsById, + required this.lockedOverridesById, + required this.lockedOverrideOwnersById, + required this.lockedOverrideRevisionsById, + required this.schedulingSnapshotsById, + required this.taskActivitiesById, + required this.taskActivityOwnersById, + required this.projectStatisticsByProjectId, + required this.projectStatisticsOwnersByProjectId, + required this.projectStatisticsRevisionsByProjectId, + required this.ownerSettingsByOwnerId, + required this.ownerSettingsRevisionsByOwnerId, + required this.noticeAcknowledgementsByScopedId, + required this.noticeAcknowledgementRevisionsByScopedId, + required this.operationsById, + }); + + /// Stores the `tasksById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map tasksById; + + /// Stores the `taskOwnersById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map taskOwnersById; + + /// Stores the `taskRevisionsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map taskRevisionsById; + + /// Stores the `projectsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectsById; + + /// Stores the `projectOwnersById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectOwnersById; + + /// Stores the `projectRevisionsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectRevisionsById; + + /// Stores the `lockedBlocksById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedBlocksById; + + /// Stores the `lockedBlockOwnersById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedBlockOwnersById; + + /// Stores the `lockedBlockRevisionsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedBlockRevisionsById; + + /// Stores the `lockedOverridesById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedOverridesById; + + /// Stores the `lockedOverrideOwnersById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedOverrideOwnersById; + + /// Stores the `lockedOverrideRevisionsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map lockedOverrideRevisionsById; + + /// Stores the `schedulingSnapshotsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map schedulingSnapshotsById; + + /// Stores the `taskActivitiesById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map taskActivitiesById; + + /// Stores the `taskActivityOwnersById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map taskActivityOwnersById; + + /// Stores the `projectStatisticsByProjectId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectStatisticsByProjectId; + + /// Stores the `projectStatisticsOwnersByProjectId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectStatisticsOwnersByProjectId; + + /// Stores the `projectStatisticsRevisionsByProjectId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map projectStatisticsRevisionsByProjectId; + + /// Stores the `ownerSettingsByOwnerId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map ownerSettingsByOwnerId; + + /// Stores the `ownerSettingsRevisionsByOwnerId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map ownerSettingsRevisionsByOwnerId; + + /// Stores the `noticeAcknowledgementsByScopedId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map + noticeAcknowledgementsByScopedId; + + /// Stores the `noticeAcknowledgementRevisionsByScopedId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map noticeAcknowledgementRevisionsByScopedId; + + /// Stores the `operationsById` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Map operationsById; + + /// Performs the `copy` 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. + _InMemoryApplicationState copy() { + return _InMemoryApplicationState( + tasksById: Map.of(tasksById), + taskOwnersById: Map.of(taskOwnersById), + taskRevisionsById: Map.of(taskRevisionsById), + projectsById: Map.of(projectsById), + projectOwnersById: Map.of(projectOwnersById), + projectRevisionsById: Map.of(projectRevisionsById), + lockedBlocksById: Map.of(lockedBlocksById), + lockedBlockOwnersById: Map.of(lockedBlockOwnersById), + lockedBlockRevisionsById: Map.of( + lockedBlockRevisionsById, + ), + lockedOverridesById: + Map.of(lockedOverridesById), + lockedOverrideOwnersById: Map.of( + lockedOverrideOwnersById, + ), + lockedOverrideRevisionsById: Map.of( + lockedOverrideRevisionsById, + ), + schedulingSnapshotsById: + Map.of(schedulingSnapshotsById), + taskActivitiesById: Map.of(taskActivitiesById), + taskActivityOwnersById: Map.of(taskActivityOwnersById), + projectStatisticsByProjectId: + Map.of(projectStatisticsByProjectId), + projectStatisticsOwnersByProjectId: Map.of( + projectStatisticsOwnersByProjectId, + ), + projectStatisticsRevisionsByProjectId: Map.of( + projectStatisticsRevisionsByProjectId, + ), + ownerSettingsByOwnerId: Map.of( + ownerSettingsByOwnerId, + ), + ownerSettingsRevisionsByOwnerId: Map.of( + ownerSettingsRevisionsByOwnerId, + ), + noticeAcknowledgementsByScopedId: + Map.of( + noticeAcknowledgementsByScopedId, + ), + noticeAcknowledgementRevisionsByScopedId: Map.of( + noticeAcknowledgementRevisionsByScopedId, + ), + operationsById: Map.of( + operationsById, + ), + ); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart new file mode 100644 index 0000000..d3049dc --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_layer.dart'; + +/// In-memory unit-of-work implementation for deterministic application tests. +class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { + /// Creates a `InMemoryApplicationUnitOfWork` 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. + InMemoryApplicationUnitOfWork({ + Iterable initialTasks = const [], + Iterable initialProjects = const [], + Iterable initialLockedBlocks = const [], + Iterable initialLockedOverrides = + const [], + Iterable initialSchedulingSnapshots = + const [], + Iterable initialTaskActivities = const [], + Iterable initialProjectStatistics = + const [], + Iterable initialOwnerSettings = const [], + Iterable initialNoticeAcknowledgements = + const [], + Iterable initialOperations = + const [], + }) : _state = _InMemoryApplicationState( + tasksById: { + for (final task in initialTasks) task.id: task, + }, + taskOwnersById: { + for (final task in initialTasks) task.id: 'owner-1', + }, + taskRevisionsById: { + for (final task in initialTasks) task.id: 1, + }, + projectsById: { + for (final project in initialProjects) project.id: project, + }, + projectOwnersById: { + for (final project in initialProjects) project.id: 'owner-1', + }, + projectRevisionsById: { + for (final project in initialProjects) project.id: 1, + }, + lockedBlocksById: { + for (final block in initialLockedBlocks) block.id: block, + }, + lockedBlockOwnersById: { + for (final block in initialLockedBlocks) block.id: 'owner-1', + }, + lockedBlockRevisionsById: { + for (final block in initialLockedBlocks) block.id: 1, + }, + lockedOverridesById: { + for (final override in initialLockedOverrides) + override.id: override, + }, + lockedOverrideOwnersById: { + for (final override in initialLockedOverrides) + override.id: 'owner-1', + }, + lockedOverrideRevisionsById: { + for (final override in initialLockedOverrides) override.id: 1, + }, + schedulingSnapshotsById: { + for (final snapshot in initialSchedulingSnapshots) + snapshot.id: snapshot, + }, + taskActivitiesById: { + for (final activity in initialTaskActivities) activity.id: activity, + }, + taskActivityOwnersById: { + for (final activity in initialTaskActivities) + activity.id: 'owner-1', + }, + projectStatisticsByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: statistics, + }, + projectStatisticsOwnersByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 'owner-1', + }, + projectStatisticsRevisionsByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 1, + }, + ownerSettingsByOwnerId: { + for (final settings in initialOwnerSettings) + settings.ownerId: settings, + }, + ownerSettingsRevisionsByOwnerId: { + for (final settings in initialOwnerSettings) settings.ownerId: 1, + }, + noticeAcknowledgementsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): acknowledgement, + }, + noticeAcknowledgementRevisionsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): 1, + }, + operationsById: { + for (final operation in initialOperations) + operation.operationId: operation, + }, + ); + + /// 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. + _InMemoryApplicationState _state; + + /// Private state stored as `_failNextCommit` 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 _failNextCommit = false; + + /// Current committed tasks, useful for contract tests. + List get currentTasks => + List.unmodifiable(_state.tasksById.values); + + /// Current committed projects, useful for contract tests. + List get currentProjects { + return List.unmodifiable(_state.projectsById.values); + } + + /// Current committed locked blocks, useful for contract tests. + List get currentLockedBlocks { + return List.unmodifiable(_state.lockedBlocksById.values); + } + + /// Current committed locked overrides, useful for contract tests. + List get currentLockedOverrides { + return List.unmodifiable( + _state.lockedOverridesById.values, + ); + } + + /// Current committed scheduling snapshots, useful for contract tests. + List get currentSchedulingSnapshots { + return List.unmodifiable( + _state.schedulingSnapshotsById.values, + ); + } + + /// Current committed activities, useful for contract tests. + List get currentTaskActivities { + return List.unmodifiable(_state.taskActivitiesById.values); + } + + /// Current committed project statistics, useful for contract tests. + List get currentProjectStatistics { + return List.unmodifiable( + _state.projectStatisticsByProjectId.values, + ); + } + + /// Current committed owner settings, useful for contract tests. + List get currentOwnerSettings { + return List.unmodifiable( + _state.ownerSettingsByOwnerId.values); + } + + /// Current committed notice acknowledgements, useful for contract tests. + List get currentNoticeAcknowledgements { + return List.unmodifiable( + _state.noticeAcknowledgementsByScopedId.values, + ); + } + + /// Current committed operation records, useful for contract tests. + List get currentOperations { + return List.unmodifiable( + _state.operationsById.values, + ); + } + + /// Cause the next successful transaction to fail at commit time. + void failNextCommitForTesting() { + _failNextCommit = true; + } + + /// 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. + @override + Future> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }) async { + if (_state.operationsById.containsKey(context.operationId)) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.duplicateOperation, + entityId: context.operationId, + ), + ); + } + + final staged = _state.copy(); + final repositories = _InMemoryApplicationRepositories(staged); + + try { + final result = await action(repositories); + if (result.isFailure) { + return result; + } + + staged.operationsById[context.operationId] = ApplicationOperationRecord( + operationId: context.operationId, + ownerId: context.ownerTimeZone.ownerId, + operationName: operationName, + committedAt: context.now, + ); + + if (_failNextCommit) { + _failNextCommit = false; + throw const ApplicationPersistenceException(); + } + + _state = staged; + return result; + } on ApplicationFailureException catch (error) { + return ApplicationResult.failure(error.failure); + } on DomainValidationException catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.code.name, + ), + ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); + } on ApplicationPersistenceException { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.persistenceFailure, + ), + ); + } catch (_) { + return ApplicationResult.failure( + const ApplicationFailure(code: ApplicationFailureCode.unexpected), + ); + } + } + + /// Runs the `read` 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> read({ + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }) async { + final staged = _state.copy(); + final repositories = _InMemoryApplicationRepositories(staged); + + try { + return await action(repositories); + } on ApplicationFailureException catch (error) { + return ApplicationResult.failure(error.failure); + } on DomainValidationException catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.code.name, + ), + ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); + } on ApplicationPersistenceException { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.persistenceFailure, + ), + ); + } catch (_) { + return ApplicationResult.failure( + const ApplicationFailure(code: ApplicationFailureCode.unexpected), + ); + } + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/application_operation_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/application_operation_repository.dart new file mode 100644 index 0000000..096348b --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/application_operation_repository.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository contract for exactly-once operation records. +abstract interface class ApplicationOperationRepository { + /// Runs the `findById` 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 findById(String operationId); + + /// Runs the `findByIdempotencyKey` 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 findByIdempotencyKey({ + required String ownerId, + required String operationId, + }); + + /// Runs the `save` 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 save(ApplicationOperationRecord operation); + + /// Runs the `insertIfAbsent` 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 insertIfAbsent( + ApplicationOperationRecord operation, + ); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart new file mode 100644 index 0000000..5785bc6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository contract for consumed one-time notices. +abstract interface class NoticeAcknowledgementRepository { + /// Runs the `findById` 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 findById({ + required String ownerId, + required String noticeId, + }); + + /// Runs the `findByOwnerId` 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> findByOwnerId(String ownerId); + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Runs the `save` 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 save(NoticeAcknowledgementRecord record); + + /// Runs the `insert` 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 insert( + NoticeAcknowledgementRecord record, + ); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/owner_settings_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/owner_settings_repository.dart new file mode 100644 index 0000000..770066d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/owner_settings_repository.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository contract for owner settings. +abstract interface class OwnerSettingsRepository { + /// Runs the `findByOwnerId` 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 findByOwnerId(String ownerId); + + /// Runs the `findRecordByOwnerId` 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?> findRecordByOwnerId(String ownerId); + + /// Runs the `save` 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 save(OwnerSettings settings); + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/projects/project_statistics_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/projects/project_statistics_repository.dart new file mode 100644 index 0000000..3d1719e --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/projects/project_statistics_repository.dart @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository contract for project statistics aggregates. +abstract interface class ProjectStatisticsRepository { + /// Runs the `findByProjectId` 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 findByProjectId(String projectId); + + /// Runs the `findRecordByProjectId` 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?> findRecordByProjectId( + String projectId, + ); + + /// Runs the `findAll` 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> findAll(); + + /// Runs the `save` 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 save(ProjectStatistics statistics); + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/tasks/task_activity_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/tasks/task_activity_repository.dart new file mode 100644 index 0000000..2e3ffe0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/tasks/task_activity_repository.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository contract for internal task activities. +abstract interface class TaskActivityRepository { + /// Runs the `findById` 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 findById(String id); + + /// Runs the `findByOperationId` 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> findByOperationId(String operationId); + + /// Runs the `findByTaskId` 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> findByTaskId(String taskId); + + /// Runs the `findByProjectId` 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> findByProjectId(String projectId); + + /// Runs the `findAll` 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> findAll(); + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Runs the `save` 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 save(TaskActivity activity); + + /// Runs the `saveAll` 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 saveAll(Iterable activities); + + /// Runs the `append` 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 append({ + required TaskActivity activity, + required String ownerId, + }); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart new file mode 100644 index 0000000..206fefe --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Atomic application transaction boundary. +abstract interface class ApplicationUnitOfWork { + /// Runs the `run` operation and completes after its asynchronous work finishes. + /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. + Future> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }); + + /// Runs the `read` 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> read({ + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart new file mode 100644 index 0000000..a8dbbfc --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Repository set available inside one application unit of work. +abstract interface class ApplicationUnitOfWorkRepositories { + /// Returns the derived `tasks` 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. + TaskRepository get tasks; + + /// Returns the derived `projects` 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. + ProjectRepository get projects; + + /// Returns the derived `lockedBlocks` 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. + LockedBlockRepository get lockedBlocks; + + /// Returns the derived `schedulingSnapshots` 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. + SchedulingSnapshotRepository get schedulingSnapshots; + + /// Returns the derived `taskActivities` 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. + TaskActivityRepository get taskActivities; + + /// Returns the derived `projectStatistics` 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. + ProjectStatisticsRepository get projectStatistics; + + /// Returns the derived `ownerSettings` 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. + OwnerSettingsRepository get ownerSettings; + + /// Returns the derived `noticeAcknowledgements` 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. + NoticeAcknowledgementRepository get noticeAcknowledgements; + + /// Returns the derived `operations` 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. + ApplicationOperationRepository get operations; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart new file mode 100644 index 0000000..d40eb21 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkNoticeAcknowledgementRepository` 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 _UnitOfWorkNoticeAcknowledgementRepository + implements NoticeAcknowledgementRepository { + /// Creates a `_UnitOfWorkNoticeAcknowledgementRepository` 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. + _UnitOfWorkNoticeAcknowledgementRepository({ + required Map recordsByScopedId, + required Map revisionsByScopedId, + }) : _recordsByScopedId = recordsByScopedId, + _revisionsByScopedId = revisionsByScopedId; + + /// Private state stored as `_recordsByScopedId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _recordsByScopedId; + + /// Private state stored as `_revisionsByScopedId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsByScopedId; + + /// Runs the `findById` 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 findById({ + required String ownerId, + required String noticeId, + }) async { + return _recordsByScopedId[ + _noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)]; + } + + /// Runs the `findByOwnerId` 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> findByOwnerId( + String ownerId, + ) async { + return List.unmodifiable( + _recordsByScopedId.values.where((record) => record.ownerId == ownerId), + ); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final records = _recordsByScopedId.values + .where((record) => record.ownerId == ownerId) + .toList(growable: false) + ..sort(_compareNoticeAcknowledgements); + return _page(records, page); + } + + /// Runs the `save` 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 save(NoticeAcknowledgementRecord record) async { + final key = _noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + ); + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1; + } + + /// Runs the `insert` 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 insert( + NoticeAcknowledgementRecord record, + ) async { + final key = _noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + ); + if (_recordsByScopedId.containsKey(key)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: record.noticeId, + ), + ); + } + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = 1; + return RepositoryMutationResult.success(revision: 1); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart new file mode 100644 index 0000000..a49d974 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkOperationRepository` 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 _UnitOfWorkOperationRepository implements ApplicationOperationRepository { + /// Creates a `_UnitOfWorkOperationRepository` 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. + _UnitOfWorkOperationRepository(this._operationsById); + + /// Private state stored as `_operationsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _operationsById; + + /// Runs the `findById` 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 findById(String operationId) async { + return _operationsById[operationId]; + } + + /// Runs the `findByIdempotencyKey` 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 findByIdempotencyKey({ + required String ownerId, + required String operationId, + }) async { + final operation = _operationsById[operationId]; + if (operation == null || operation.ownerId != ownerId) { + return null; + } + return operation; + } + + /// Runs the `save` 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 save(ApplicationOperationRecord operation) async { + _operationsById[operation.operationId] = operation; + } + + /// Runs the `insertIfAbsent` 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 insertIfAbsent( + ApplicationOperationRecord operation, + ) async { + if (_operationsById.containsKey(operation.operationId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateOperation, + entityId: operation.operationId, + ), + ); + } + _operationsById[operation.operationId] = operation; + return RepositoryMutationResult.success(revision: 1); + } +} + +/// Top-level helper that performs the `_noticeAcknowledgementKey` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _noticeAcknowledgementKey({ + required String ownerId, + required String noticeId, +}) { + return '$ownerId::$noticeId'; +} + +/// Top-level helper that performs the `_page` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +/// Top-level helper that performs the `_taskOverlapsWindow` 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 _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +/// Top-level helper that performs the `_compareTaskIds` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +/// Top-level helper that performs the `_compareScheduledTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareBacklogCandidates` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareProjects` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareLockedBlocks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareLockedOverrides` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareActivities` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareActivities(TaskActivity left, TaskActivity right) { + final occurredComparison = left.occurredAt.compareTo(right.occurredAt); + if (occurredComparison != 0) { + return occurredComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareNoticeAcknowledgements` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareNoticeAcknowledgements( + NoticeAcknowledgementRecord left, + NoticeAcknowledgementRecord right, +) { + final acknowledgedComparison = + left.acknowledgedAt.compareTo(right.acknowledgedAt); + if (acknowledgedComparison != 0) { + return acknowledgedComparison; + } + return left.noticeId.compareTo(right.noticeId); +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart new file mode 100644 index 0000000..cba7866 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkOwnerSettingsRepository` 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 _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { + /// Creates a `_UnitOfWorkOwnerSettingsRepository` 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. + _UnitOfWorkOwnerSettingsRepository({ + required Map settingsByOwnerId, + required Map revisionsByOwnerId, + }) : _settingsByOwnerId = settingsByOwnerId, + _revisionsByOwnerId = revisionsByOwnerId; + + /// Private state stored as `_settingsByOwnerId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _settingsByOwnerId; + + /// Private state stored as `_revisionsByOwnerId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsByOwnerId; + + /// Runs the `findByOwnerId` 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 findByOwnerId(String ownerId) async { + return _settingsByOwnerId[ownerId]; + } + + /// Runs the `findRecordByOwnerId` 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?> findRecordByOwnerId( + String ownerId, + ) async { + final settings = _settingsByOwnerId[ownerId]; + if (settings == null) { + return null; + } + return RepositoryRecord( + value: settings, + ownerId: ownerId, + revision: _revisionsByOwnerId[ownerId] ?? 1, + ); + } + + /// Runs the `save` 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 save(OwnerSettings settings) async { + _settingsByOwnerId[settings.ownerId] = settings; + _revisionsByOwnerId[settings.ownerId] = + (_revisionsByOwnerId[settings.ownerId] ?? 1) + 1; + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }) async { + final ownerId = settings.ownerId; + if (!_settingsByOwnerId.containsKey(ownerId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: ownerId, + ), + ); + } + final actualRevision = _revisionsByOwnerId[ownerId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: ownerId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _settingsByOwnerId[ownerId] = settings; + _revisionsByOwnerId[ownerId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart new file mode 100644 index 0000000..c3b4908 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkProjectRepository` 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 _UnitOfWorkProjectRepository implements ProjectRepository { + /// Creates a `_UnitOfWorkProjectRepository` 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. + _UnitOfWorkProjectRepository({ + required Map projectsById, + required Map ownersById, + required Map revisionsById, + }) : _projectsById = projectsById, + _ownersById = ownersById, + _revisionsById = revisionsById; + + /// Private state stored as `_projectsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _projectsById; + + /// Private state stored as `_ownersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersById; + + /// Private state stored as `_revisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsById; + + /// Runs the `findById` 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 findById(String id) async => _projectsById[id]; + + /// Runs the `findRecordById` 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?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + + /// Runs the `save` 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 save(ProjectProfile project) async { + _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => 'owner-1'); + _revisionsById[project.id] = _revisionFor(project.id) + 1; + } + + /// Runs the `insert` 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 insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + if (!_projectsById.containsKey(project.id) || + _ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `archive` 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 archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + /// Runs the `_ownerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + /// Runs the `_revisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart new file mode 100644 index 0000000..5bd8e63 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkProjectStatisticsRepository` 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 _UnitOfWorkProjectStatisticsRepository + implements ProjectStatisticsRepository { + /// Creates a `_UnitOfWorkProjectStatisticsRepository` 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. + _UnitOfWorkProjectStatisticsRepository({ + required Map statisticsByProjectId, + required Map ownersByProjectId, + required Map revisionsByProjectId, + }) : _statisticsByProjectId = statisticsByProjectId, + _ownersByProjectId = ownersByProjectId, + _revisionsByProjectId = revisionsByProjectId; + + /// Private state stored as `_statisticsByProjectId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _statisticsByProjectId; + + /// Private state stored as `_ownersByProjectId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersByProjectId; + + /// Private state stored as `_revisionsByProjectId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsByProjectId; + + /// Runs the `findByProjectId` 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 findByProjectId(String projectId) async { + return _statisticsByProjectId[projectId]; + } + + /// Runs the `findRecordByProjectId` 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?> findRecordByProjectId( + String projectId, + ) async { + final statistics = _statisticsByProjectId[projectId]; + if (statistics == null) { + return null; + } + return RepositoryRecord( + value: statistics, + ownerId: _ownersByProjectId[projectId] ?? 'owner-1', + revision: _revisionsByProjectId[projectId] ?? 1, + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_statisticsByProjectId.values); + } + + /// Runs the `save` 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 save(ProjectStatistics statistics) async { + _statisticsByProjectId[statistics.projectId] = statistics; + _ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1'); + _revisionsByProjectId[statistics.projectId] = + (_revisionsByProjectId[statistics.projectId] ?? 1) + 1; + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }) async { + final projectId = statistics.projectId; + if (!_statisticsByProjectId.containsKey(projectId) || + (_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + final actualRevision = _revisionsByProjectId[projectId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: projectId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _statisticsByProjectId[projectId] = statistics; + _ownersByProjectId[projectId] = ownerId; + _revisionsByProjectId[projectId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart new file mode 100644 index 0000000..31dc001 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkLockedBlockRepository` 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 _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { + /// Creates a `_UnitOfWorkLockedBlockRepository` 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. + _UnitOfWorkLockedBlockRepository({ + required Map blocksById, + required Map blockOwnersById, + required Map blockRevisionsById, + required Map overridesById, + required Map overrideOwnersById, + required Map overrideRevisionsById, + }) : _blocksById = blocksById, + _blockOwnersById = blockOwnersById, + _blockRevisionsById = blockRevisionsById, + _overridesById = overridesById, + _overrideOwnersById = overrideOwnersById, + _overrideRevisionsById = overrideRevisionsById; + + /// Private state stored as `_blocksById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blocksById; + + /// Private state stored as `_blockOwnersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blockOwnersById; + + /// Private state stored as `_blockRevisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blockRevisionsById; + + /// Private state stored as `_overridesById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overridesById; + + /// Private state stored as `_overrideOwnersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overrideOwnersById; + + /// Private state stored as `_overrideRevisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overrideRevisionsById; + + /// Runs the `findBlockById` 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 findBlockById(String id) async => _blocksById[id]; + + /// Runs the `findBlockRecordById` 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?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + + /// Runs the `findAllBlocks` 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> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + /// Runs the `findBlocksByOwner` 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> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + + /// Runs the `findOverrideById` 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 findOverrideById(String id) async { + return _overridesById[id]; + } + + /// Runs the `findOverrideRecordById` 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?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + + /// Runs the `findAllOverrides` 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> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + /// Runs the `findOverridesForDate` 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> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + + /// Runs the `saveBlock` 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 saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => 'owner-1'); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; + } + + /// Runs the `saveOverride` 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 saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => 'owner-1'); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; + } + + /// Runs the `insertBlock` 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 insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveBlockIfRevision` 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 saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + if (!_blocksById.containsKey(block.id) || + _blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `archiveBlock` 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 archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + /// Runs the `insertOverride` 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 insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveOverrideIfRevision` 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 saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + if (!_overridesById.containsKey(override.id) || + _overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `_blockOwnerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1'; + + /// Runs the `_blockRevisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + /// Runs the `_overrideOwnerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1'; + + /// Runs the `_overrideRevisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..b7f2fa0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkSchedulingSnapshotRepository` 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 _UnitOfWorkSchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + /// Creates a `_UnitOfWorkSchedulingSnapshotRepository` 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. + _UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById); + + /// Private state stored as `_snapshotsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _snapshotsById; + + /// Runs the `findById` 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 findById(String id) async { + return _snapshotsById[id]; + } + + /// Runs the `findInWindow` 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> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + /// Runs the `save` 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 save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart new file mode 100644 index 0000000..510c361 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkTaskActivityRepository` 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 _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { + /// Creates a `_UnitOfWorkTaskActivityRepository` 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. + _UnitOfWorkTaskActivityRepository({ + required Map activitiesById, + required Map ownersById, + }) : _activitiesById = activitiesById, + _ownersById = ownersById; + + /// Private state stored as `_activitiesById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _activitiesById; + + /// Private state stored as `_ownersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersById; + + /// Runs the `findById` 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 findById(String id) async => _activitiesById[id]; + + /// Runs the `findByOperationId` 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> findByOperationId(String operationId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.operationId == operationId, + ), + ); + } + + /// Runs the `findByTaskId` 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> findByTaskId(String taskId) async { + return List.unmodifiable( + _activitiesById.values.where((activity) => activity.taskId == taskId), + ); + } + + /// Runs the `findByProjectId` 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> findByProjectId(String projectId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.projectId == projectId, + ), + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_activitiesById.values); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final activities = _activitiesById.values + .where((activity) => _ownerFor(activity.id) == ownerId) + .toList(growable: false) + ..sort(_compareActivities); + return _page(activities, page); + } + + /// Runs the `save` 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 save(TaskActivity activity) async { + _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); + } + + /// Runs the `saveAll` 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 saveAll(Iterable activities) async { + for (final activity in activities) { + _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); + } + } + + /// Runs the `append` 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 append({ + required TaskActivity activity, + required String ownerId, + }) async { + if (_activitiesById.containsKey(activity.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: activity.id, + ), + ); + } + _activitiesById[activity.id] = activity; + _ownersById[activity.id] = ownerId; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `_ownerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart new file mode 100644 index 0000000..181abe5 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../../application_layer.dart'; + +/// Private implementation type for `_UnitOfWorkTaskRepository` 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 _UnitOfWorkTaskRepository implements TaskRepository { + /// Creates a `_UnitOfWorkTaskRepository` 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. + _UnitOfWorkTaskRepository({ + required Map tasksById, + required Map ownersById, + required Map revisionsById, + }) : _tasksById = tasksById, + _ownersById = ownersById, + _revisionsById = revisionsById; + + /// Private state stored as `_tasksById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _tasksById; + + /// Private state stored as `_ownersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersById; + + /// Private state stored as `_revisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsById; + + /// Runs the `findById` 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 findById(String id) async => _tasksById[id]; + + /// Runs the `findRecordById` 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?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + /// Runs the `findByStatus` 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> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findByProjectId` 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> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findByParentTaskId` 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> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findBacklogCandidates` 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> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + + /// Runs the `findScheduledInWindow` 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> findScheduledInWindow(SchedulingWindow window) async { + return List.unmodifiable( + _tasksById.values.where((task) { + return _taskOverlapsWindow(task, window); + }), + ); + } + + /// Runs the `findScheduledInWindowForOwner` 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> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow(task, window), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + /// Runs the `findForLocalDay` 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> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + + /// Runs the `save` 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 save(Task task) async { + _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => 'owner-1'); + _revisionsById[task.id] = _revisionFor(task.id) + 1; + } + + /// Runs the `saveAll` 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 saveAll(Iterable tasks) async { + for (final task in tasks) { + await save(task); + } + } + + /// Runs the `insert` 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 insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: task.id), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `_ownerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + /// Runs the `_revisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/results/application_failure.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure.dart new file mode 100644 index 0000000..5d09c67 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Typed failure returned by application commands and queries. +class ApplicationFailure { + /// Creates a `ApplicationFailure` 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 ApplicationFailure({ + required this.code, + this.entityId, + this.detailCode, + }); + + /// Stable machine-readable category. + final ApplicationFailureCode code; + + /// Optional stable entity id related to the failure. + final String? entityId; + + /// Optional narrower stable reason, such as a domain validation code name. + final String? detailCode; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_code.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_code.dart new file mode 100644 index 0000000..e427cca --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_code.dart @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Stable application-layer result categories. +enum ApplicationFailureCode { + /// Selects the `validation` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + validation, + + /// Selects the `notFound` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + notFound, + + /// Selects the `conflict` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + conflict, + + /// Selects the `staleRevision` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + staleRevision, + + /// Selects the `noSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noSlot, + + /// Selects the `duplicateOperation` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + duplicateOperation, + + /// Selects the `persistenceFailure` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + persistenceFailure, + + /// Selects the `unexpected` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + unexpected, +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_exception.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_exception.dart new file mode 100644 index 0000000..2f30565 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_exception.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Exception for expected failures raised inside a unit-of-work callback. +class ApplicationFailureException implements Exception { + /// Creates a `ApplicationFailureException` 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 ApplicationFailureException(this.failure); + + /// Failure to return from the application boundary. + final ApplicationFailure failure; +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/results/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_persistence_exception.dart new file mode 100644 index 0000000..a033e8d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/results/application_persistence_exception.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Exception representing persistence/driver failures at repository boundaries. +class ApplicationPersistenceException implements Exception { + /// Creates a `ApplicationPersistenceException` 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 ApplicationPersistenceException(); +} diff --git a/packages/scheduler_core/lib/src/application/application_layer/results/application_result.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_result.dart new file mode 100644 index 0000000..a433378 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_layer/results/application_result.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_layer.dart'; + +/// Result wrapper for expected application success/failure paths. +class ApplicationResult { + /// Creates a `ApplicationResult._` 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 ApplicationResult._({ + this.value, + this.failure, + }); + + /// Successful result carrying [value]. + factory ApplicationResult.success(T value) { + return ApplicationResult._(value: value); + } + + /// Expected failure result. + factory ApplicationResult.failure(ApplicationFailure failure) { + return ApplicationResult._(failure: failure); + } + + /// Successful value, if any. + final T? value; + + /// Typed failure, if the operation did not succeed. + final ApplicationFailure? failure; + + /// Whether the operation succeeded. + bool get isSuccess => failure == null; + + /// Whether the operation failed with a typed application failure. + bool get isFailure => failure != null; + + /// Return the value or throw if this result is a failure. + T get requireValue { + if (failure != null) { + throw StateError( + 'Application result is a failure: ${failure!.code.name}'); + } + return value as T; + } +} diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart new file mode 100644 index 0000000..5016520 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Application Management behavior for the Scheduler Core package. +library; + +// V1 management queries and commands. +// +// These use cases cover non-scheduling surfaces such as backlog views, project +// defaults, locked-time definitions, owner settings, and one-time notice +// acknowledgement. They stay UI-independent and use the same atomic +// application unit-of-work boundary as task commands. + +import 'application_layer.dart'; +import '../scheduling/backlog.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../domain/project_statistics.dart'; +import '../domain/time_contracts.dart'; +part 'application_management/codes/application_management_command_code.dart'; +part 'application_management/codes/owner_settings_warning_code.dart'; +part 'application_management/requests/get_backlog_request.dart'; +part 'application_management/read_models/backlog_item_read_model.dart'; +part 'application_management/read_models/backlog_query_result.dart'; +part 'application_management/requests/get_project_defaults_request.dart'; +part 'application_management/read_models/project_defaults_query_result.dart'; +part 'application_management/drafts/project_profile_draft.dart'; +part 'application_management/updates/project_profile_update.dart'; +part 'application_management/drafts/locked_block_draft.dart'; +part 'application_management/updates/locked_block_update.dart'; +part 'application_management/results/owner_settings_update_result.dart'; +part 'application_management/updates/owner_settings_update.dart'; +part 'application_management/results/notice_acknowledgement_result.dart'; +part 'application_management/use_cases/v1_application_management_use_cases.dart'; +part 'application_management/parsing/parsed_notice_id.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_management/codes/application_management_command_code.dart b/packages/scheduler_core/lib/src/application/application_management/codes/application_management_command_code.dart new file mode 100644 index 0000000..7aaea08 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/codes/application_management_command_code.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Stable management command identifiers. +enum ApplicationManagementCommandCode { + /// Selects the `createProject` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + createProject, + + /// Selects the `updateProject` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + updateProject, + + /// Selects the `archiveProject` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + archiveProject, + + /// Selects the `createLockedBlock` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + createLockedBlock, + + /// Selects the `updateLockedBlock` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + updateLockedBlock, + + /// Selects the `archiveLockedBlock` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + archiveLockedBlock, + + /// Selects the `addLockedBlockOverride` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + addLockedBlockOverride, + + /// Selects the `removeLockedBlockOccurrence` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + removeLockedBlockOccurrence, + + /// Selects the `replaceLockedBlockOccurrence` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + replaceLockedBlockOccurrence, + + /// Selects the `updateOwnerSettings` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + updateOwnerSettings, + + /// Selects the `acknowledgeNotice` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + acknowledgeNotice, +} diff --git a/packages/scheduler_core/lib/src/application/application_management/codes/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application/application_management/codes/owner_settings_warning_code.dart new file mode 100644 index 0000000..5483a3a --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/codes/owner_settings_warning_code.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Typed warning codes for settings changes that reinterpret local dates/times. +enum OwnerSettingsWarningCode { + /// Selects the `timeZoneChanged` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + timeZoneChanged, + + /// Selects the `planningWindowChanged` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + planningWindowChanged, +} diff --git a/packages/scheduler_core/lib/src/application/application_management/drafts/locked_block_draft.dart b/packages/scheduler_core/lib/src/application/application_management/drafts/locked_block_draft.dart new file mode 100644 index 0000000..6771e3d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/drafts/locked_block_draft.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Draft used when creating a locked block. +class LockedBlockDraft { + /// Creates a `LockedBlockDraft` 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 LockedBlockDraft({ + this.id, + required this.name, + required this.startTime, + required this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + }); + + /// Stores the `id` 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? id; + + /// Stores the `name` 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 name; + + /// Stores the `startTime` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ClockTime startTime; + + /// Stores the `endTime` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ClockTime endTime; + + /// Stores the `date` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final CivilDate? date; + + /// Stores the `recurrence` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final LockedBlockRecurrence? recurrence; + + /// Stores the `hiddenByDefault` 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 hiddenByDefault; + + /// Stores the `projectId` 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? projectId; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/drafts/project_profile_draft.dart b/packages/scheduler_core/lib/src/application/application_management/drafts/project_profile_draft.dart new file mode 100644 index 0000000..da7b1a4 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/drafts/project_profile_draft.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Draft used when creating a project. +class ProjectProfileDraft { + /// Creates a `ProjectProfileDraft` 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 ProjectProfileDraft({ + this.id, + required this.name, + required this.colorKey, + this.defaultPriority = PriorityLevel.medium, + this.defaultReward = RewardLevel.notSet, + this.defaultDifficulty = DifficultyLevel.notSet, + this.defaultReminderProfile = ReminderProfile.gentle, + this.defaultDurationMinutes, + }); + + /// Stores the `id` 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? id; + + /// Stores the `name` 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 name; + + /// Stores the `colorKey` 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 colorKey; + + /// Stores the `defaultPriority` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final PriorityLevel defaultPriority; + + /// Stores the `defaultReward` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RewardLevel defaultReward; + + /// Stores the `defaultDifficulty` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DifficultyLevel defaultDifficulty; + + /// Stores the `defaultReminderProfile` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ReminderProfile defaultReminderProfile; + + /// Stores the `defaultDurationMinutes` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? defaultDurationMinutes; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/parsing/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application/application_management/parsing/parsed_notice_id.dart new file mode 100644 index 0000000..fa1b6ec --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/parsing/parsed_notice_id.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Private implementation type for `_ParsedNoticeId` 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 _ParsedNoticeId { + /// Creates a `_ParsedNoticeId` 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 _ParsedNoticeId({ + required this.snapshotId, + required this.noticeIndex, + }); + + /// Stores the `snapshotId` 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 snapshotId; + + /// Stores the `noticeIndex` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int noticeIndex; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_item_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_item_read_model.dart new file mode 100644 index 0000000..800f1e6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_item_read_model.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// One backlog row with its configured staleness marker. +class BacklogItemReadModel { + /// Creates a `BacklogItemReadModel` 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 BacklogItemReadModel({ + required this.task, + required this.stalenessMarker, + }); + + /// Source backlog task. + final Task task; + + /// Green/blue/purple marker using owner settings. + final BacklogStalenessMarker stalenessMarker; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_query_result.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_query_result.dart new file mode 100644 index 0000000..0d12f17 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_query_result.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Query result for the backlog surface. +class BacklogQueryResult { + /// Creates a `BacklogQueryResult` 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. + BacklogQueryResult({ + required this.ownerId, + required this.settings, + required List items, + }) : items = List.unmodifiable(items); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Effective owner settings used by the query. + final OwnerSettings settings; + + /// Filtered and sorted backlog rows. + final List items; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/project_defaults_query_result.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/project_defaults_query_result.dart new file mode 100644 index 0000000..bd16284 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/project_defaults_query_result.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Project defaults query result. +class ProjectDefaultsQueryResult { + /// Creates a `ProjectDefaultsQueryResult` 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. + ProjectDefaultsQueryResult({ + required this.ownerId, + required List projects, + }) : projects = List.unmodifiable(projects); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Configured defaults plus separately surfaced learned suggestions. + final List projects; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_request.dart new file mode 100644 index 0000000..33e0b40 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_request.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Backlog query request. +class GetBacklogRequest { + /// Creates a `GetBacklogRequest` 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 GetBacklogRequest({ + required this.context, + this.filter, + this.sortKey = BacklogSortKey.priority, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Optional user-facing backlog filter. + final BacklogFilter? filter; + + /// User-facing sort order. + final BacklogSortKey sortKey; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_project_defaults_request.dart new file mode 100644 index 0000000..b355c66 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_project_defaults_request.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Query request for project defaults and learned suggestions. +class GetProjectDefaultsRequest { + /// Creates a `GetProjectDefaultsRequest` 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 GetProjectDefaultsRequest({ + required this.context, + this.includeArchived = false, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Whether archived projects should be included. + final bool includeArchived; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/results/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application/application_management/results/notice_acknowledgement_result.dart new file mode 100644 index 0000000..fab8293 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/results/notice_acknowledgement_result.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Result for notice acknowledgement commands. +class NoticeAcknowledgementResult { + /// Creates a `NoticeAcknowledgementResult` 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 NoticeAcknowledgementResult({ + required this.record, + required this.alreadyAcknowledged, + }); + + /// Persisted acknowledgement record. + final NoticeAcknowledgementRecord record; + + /// Whether the notice had already been acknowledged before this command. + final bool alreadyAcknowledged; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/results/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application/application_management/results/owner_settings_update_result.dart new file mode 100644 index 0000000..97cce91 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/results/owner_settings_update_result.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Result for owner settings updates. +class OwnerSettingsUpdateResult { + /// Creates a `OwnerSettingsUpdateResult` 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. + OwnerSettingsUpdateResult({ + required this.settings, + Iterable warnings = + const [], + }) : warnings = List.unmodifiable(warnings); + + /// Persisted owner settings. + final OwnerSettings settings; + + /// Typed warnings for changes that may need migration/UI confirmation. + final List warnings; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/updates/locked_block_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/locked_block_update.dart new file mode 100644 index 0000000..adbb0bf --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/updates/locked_block_update.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Patch used when updating a locked block definition. +class LockedBlockUpdate { + /// Creates a `LockedBlockUpdate` 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 LockedBlockUpdate({ + this.name, + this.startTime, + this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault, + this.projectId, + }); + + /// Stores the `name` 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? name; + + /// Stores the `startTime` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ClockTime? startTime; + + /// Stores the `endTime` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ClockTime? endTime; + + /// Stores the `date` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final CivilDate? date; + + /// Stores the `recurrence` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final LockedBlockRecurrence? recurrence; + + /// Stores the `hiddenByDefault` 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? hiddenByDefault; + + /// Stores the `projectId` 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? projectId; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/updates/owner_settings_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/owner_settings_update.dart new file mode 100644 index 0000000..e7f3913 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/updates/owner_settings_update.dart @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Patch used when updating owner settings. +class OwnerSettingsUpdate { + /// Creates a `OwnerSettingsUpdate` 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 OwnerSettingsUpdate({ + this.timeZoneId, + this.dayStart, + this.dayEnd, + this.compactModeEnabled, + this.backlogStalenessSettings, + }); + + /// Stores the `timeZoneId` 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? timeZoneId; + + /// Stores the `dayStart` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final WallTime? dayStart; + + /// Stores the `dayEnd` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final WallTime? dayEnd; + + /// Stores the `compactModeEnabled` 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? compactModeEnabled; + + /// Stores the `backlogStalenessSettings` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final BacklogStalenessSettings? backlogStalenessSettings; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/updates/project_profile_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/project_profile_update.dart new file mode 100644 index 0000000..2b07d10 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/updates/project_profile_update.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Patch used when updating configured project defaults. +class ProjectProfileUpdate { + /// Creates a `ProjectProfileUpdate` 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 ProjectProfileUpdate({ + this.name, + this.colorKey, + this.defaultPriority, + this.defaultReward, + this.defaultDifficulty, + this.defaultReminderProfile, + this.defaultDurationMinutes, + this.clearDefaultDuration = false, + }); + + /// Stores the `name` 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? name; + + /// Stores the `colorKey` 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? colorKey; + + /// Stores the `defaultPriority` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final PriorityLevel? defaultPriority; + + /// Stores the `defaultReward` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RewardLevel? defaultReward; + + /// Stores the `defaultDifficulty` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DifficultyLevel? defaultDifficulty; + + /// Stores the `defaultReminderProfile` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ReminderProfile? defaultReminderProfile; + + /// Stores the `defaultDurationMinutes` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? defaultDurationMinutes; + + /// Stores the `clearDefaultDuration` 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 clearDefaultDuration; +} diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart similarity index 71% rename from packages/scheduler_core/lib/src/application_management.dart rename to packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index 26bcda1..20cbe71 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -1,257 +1,12 @@ -/// Implements Application Management behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// V1 management queries and commands. -// -// These use cases cover non-scheduling surfaces such as backlog views, project -// defaults, locked-time definitions, owner settings, and one-time notice -// acknowledgement. They stay UI-independent and use the same atomic -// application unit-of-work boundary as task commands. - -import 'application_layer.dart'; -import 'backlog.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'project_statistics.dart'; -import 'time_contracts.dart'; - -/// Stable management command identifiers. -enum ApplicationManagementCommandCode { - createProject, - updateProject, - archiveProject, - createLockedBlock, - updateLockedBlock, - archiveLockedBlock, - addLockedBlockOverride, - removeLockedBlockOccurrence, - replaceLockedBlockOccurrence, - updateOwnerSettings, - acknowledgeNotice, -} - -/// Typed warning codes for settings changes that reinterpret local dates/times. -enum OwnerSettingsWarningCode { - timeZoneChanged, - planningWindowChanged, -} - -/// Backlog query request. -class GetBacklogRequest { - const GetBacklogRequest({ - required this.context, - this.filter, - this.sortKey = BacklogSortKey.priority, - }); - - /// Read context containing owner scope and read instant. - final ApplicationOperationContext context; - - /// Optional user-facing backlog filter. - final BacklogFilter? filter; - - /// User-facing sort order. - final BacklogSortKey sortKey; -} - -/// One backlog row with its configured staleness marker. -class BacklogItemReadModel { - const BacklogItemReadModel({ - required this.task, - required this.stalenessMarker, - }); - - /// Source backlog task. - final Task task; - - /// Green/blue/purple marker using owner settings. - final BacklogStalenessMarker stalenessMarker; -} - -/// Query result for the backlog surface. -class BacklogQueryResult { - BacklogQueryResult({ - required this.ownerId, - required this.settings, - required List items, - }) : items = List.unmodifiable(items); - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Effective owner settings used by the query. - final OwnerSettings settings; - - /// Filtered and sorted backlog rows. - final List items; -} - -/// Query request for project defaults and learned suggestions. -class GetProjectDefaultsRequest { - const GetProjectDefaultsRequest({ - required this.context, - this.includeArchived = false, - }); - - /// Read context containing owner scope and read instant. - final ApplicationOperationContext context; - - /// Whether archived projects should be included. - final bool includeArchived; -} - -/// Project defaults query result. -class ProjectDefaultsQueryResult { - ProjectDefaultsQueryResult({ - required this.ownerId, - required List projects, - }) : projects = List.unmodifiable(projects); - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Configured defaults plus separately surfaced learned suggestions. - final List projects; -} - -/// Draft used when creating a project. -class ProjectProfileDraft { - const ProjectProfileDraft({ - this.id, - required this.name, - required this.colorKey, - this.defaultPriority = PriorityLevel.medium, - this.defaultReward = RewardLevel.notSet, - this.defaultDifficulty = DifficultyLevel.notSet, - this.defaultReminderProfile = ReminderProfile.gentle, - this.defaultDurationMinutes, - }); - - final String? id; - final String name; - final String colorKey; - final PriorityLevel defaultPriority; - final RewardLevel defaultReward; - final DifficultyLevel defaultDifficulty; - final ReminderProfile defaultReminderProfile; - final int? defaultDurationMinutes; -} - -/// Patch used when updating configured project defaults. -class ProjectProfileUpdate { - const ProjectProfileUpdate({ - this.name, - this.colorKey, - this.defaultPriority, - this.defaultReward, - this.defaultDifficulty, - this.defaultReminderProfile, - this.defaultDurationMinutes, - this.clearDefaultDuration = false, - }); - - final String? name; - final String? colorKey; - final PriorityLevel? defaultPriority; - final RewardLevel? defaultReward; - final DifficultyLevel? defaultDifficulty; - final ReminderProfile? defaultReminderProfile; - final int? defaultDurationMinutes; - final bool clearDefaultDuration; -} - -/// Draft used when creating a locked block. -class LockedBlockDraft { - const LockedBlockDraft({ - this.id, - required this.name, - required this.startTime, - required this.endTime, - this.date, - this.recurrence, - this.hiddenByDefault = true, - this.projectId, - }); - - final String? id; - final String name; - final ClockTime startTime; - final ClockTime endTime; - final CivilDate? date; - final LockedBlockRecurrence? recurrence; - final bool hiddenByDefault; - final String? projectId; -} - -/// Patch used when updating a locked block definition. -class LockedBlockUpdate { - const LockedBlockUpdate({ - this.name, - this.startTime, - this.endTime, - this.date, - this.recurrence, - this.hiddenByDefault, - this.projectId, - }); - - final String? name; - final ClockTime? startTime; - final ClockTime? endTime; - final CivilDate? date; - final LockedBlockRecurrence? recurrence; - final bool? hiddenByDefault; - final String? projectId; -} - -/// Result for owner settings updates. -class OwnerSettingsUpdateResult { - OwnerSettingsUpdateResult({ - required this.settings, - Iterable warnings = - const [], - }) : warnings = List.unmodifiable(warnings); - - /// Persisted owner settings. - final OwnerSettings settings; - - /// Typed warnings for changes that may need migration/UI confirmation. - final List warnings; -} - -/// Patch used when updating owner settings. -class OwnerSettingsUpdate { - const OwnerSettingsUpdate({ - this.timeZoneId, - this.dayStart, - this.dayEnd, - this.compactModeEnabled, - this.backlogStalenessSettings, - }); - - final String? timeZoneId; - final WallTime? dayStart; - final WallTime? dayEnd; - final bool? compactModeEnabled; - final BacklogStalenessSettings? backlogStalenessSettings; -} - -/// Result for notice acknowledgement commands. -class NoticeAcknowledgementResult { - const NoticeAcknowledgementResult({ - required this.record, - required this.alreadyAcknowledged, - }); - - /// Persisted acknowledgement record. - final NoticeAcknowledgementRecord record; - - /// Whether the notice had already been acknowledged before this command. - final bool alreadyAcknowledged; -} +part of '../../application_management.dart'; /// V1 management facade used by future Flutter state management. class V1ApplicationManagementUseCases { + /// Creates a `V1ApplicationManagementUseCases` 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 V1ApplicationManagementUseCases({ required this.applicationStore, this.projectSuggestionService = const ProjectSuggestionService(), @@ -349,6 +104,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `createProject` 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> createProject({ required ApplicationOperationContext context, required ProjectProfileDraft draft, @@ -382,6 +139,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `updateProject` 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> updateProject({ required ApplicationOperationContext context, required String projectId, @@ -408,6 +167,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `archiveProject` 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> archiveProject({ required ApplicationOperationContext context, required String projectId, @@ -424,6 +185,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `createLockedBlock` 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> createLockedBlock({ required ApplicationOperationContext context, required LockedBlockDraft draft, @@ -459,6 +222,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `updateLockedBlock` 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> updateLockedBlock({ required ApplicationOperationContext context, required String lockedBlockId, @@ -485,6 +250,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `archiveLockedBlock` 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> archiveLockedBlock({ required ApplicationOperationContext context, required String lockedBlockId, @@ -504,6 +271,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `addOneDayLockedOverride` 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> addOneDayLockedOverride({ required ApplicationOperationContext context, required CivilDate date, @@ -534,6 +303,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `removeOneDayLockedOccurrence` 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> removeOneDayLockedOccurrence({ required ApplicationOperationContext context, required String lockedBlockId, @@ -557,6 +328,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `replaceOneDayLockedOccurrence` 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> replaceOneDayLockedOccurrence({ required ApplicationOperationContext context, required String lockedBlockId, @@ -590,6 +363,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `updateOwnerSettings` 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> updateOwnerSettings({ required ApplicationOperationContext context, required OwnerSettingsUpdate update, @@ -636,6 +411,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `acknowledgeNotice` 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> acknowledgeNotice({ required ApplicationOperationContext context, required String noticeId, @@ -691,6 +468,8 @@ class V1ApplicationManagementUseCases { ); } + /// Runs the `_requireProject` 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 _requireProject( ApplicationUnitOfWorkRepositories repositories, String projectId, @@ -708,6 +487,8 @@ class V1ApplicationManagementUseCases { return project; } + /// Runs the `_requireLockedBlock` 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 _requireLockedBlock( ApplicationUnitOfWorkRepositories repositories, String lockedBlockId, @@ -726,6 +507,8 @@ class V1ApplicationManagementUseCases { } } +/// Top-level helper that performs the `_ownerSettingsOrDefault` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _ownerSettingsOrDefault( ApplicationUnitOfWorkRepositories repositories, OwnerTimeZoneContext ownerTimeZone, @@ -738,6 +521,8 @@ Future _ownerSettingsOrDefault( ); } +/// Top-level helper that performs the `_failure` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. ApplicationResult _failure( ApplicationFailureCode code, { String? entityId, @@ -752,6 +537,8 @@ ApplicationResult _failure( ); } +/// Top-level helper that performs the `_parseNoticeId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. _ParsedNoticeId? _parseNoticeId(String noticeId) { final separator = noticeId.lastIndexOf(':'); if (separator <= 0 || separator == noticeId.length - 1) { @@ -766,13 +553,3 @@ _ParsedNoticeId? _parseNoticeId(String noticeId) { noticeIndex: noticeIndex, ); } - -class _ParsedNoticeId { - const _ParsedNoticeId({ - required this.snapshotId, - required this.noticeIndex, - }); - - final String snapshotId; - final int noticeIndex; -} diff --git a/packages/scheduler_core/lib/src/application/application_recovery.dart b/packages/scheduler_core/lib/src/application/application_recovery.dart new file mode 100644 index 0000000..021891c --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_recovery.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Application Recovery behavior for the Scheduler Core package. +library; + +// App-open/start-day recovery orchestration. +// +// This layer makes rollover explicit. Reading Today never runs recovery; a UI or +// app lifecycle boundary calls this use case when it wants to check one source +// day and persist any resulting V1 flexible-task rollover. + +import 'application_layer.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../persistence/repositories.dart'; +import '../scheduling/scheduling_engine.dart'; +import '../scheduling/task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'application_recovery/app_open_recovery_outcome.dart'; +part 'application_recovery/run_app_open_recovery_request.dart'; +part 'application_recovery/app_open_recovery_result.dart'; +part 'application_recovery/v1_app_open_recovery_use_cases.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_outcome.dart b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_outcome.dart new file mode 100644 index 0000000..c41b829 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_outcome.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../application_recovery.dart'; + +/// Stable app-open recovery outcomes. +enum AppOpenRecoveryOutcome { + /// Selects the `rolledOver` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + rolledOver, + + /// Selects the `noUnfinishedFlexibleTasks` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noUnfinishedFlexibleTasks, + + /// Selects the `alreadyProcessed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + alreadyProcessed, +} diff --git a/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_result.dart b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_result.dart new file mode 100644 index 0000000..f7f545d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_result.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../application_recovery.dart'; + +/// Result returned by app-open recovery. +class AppOpenRecoveryResult { + /// Creates a `AppOpenRecoveryResult` 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. + AppOpenRecoveryResult({ + required this.outcome, + required this.sourceDate, + required this.targetDate, + required this.snapshot, + required List changedTasks, + required List activities, + required List notices, + required List changes, + }) : changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes); + + /// High-level deterministic outcome. + final AppOpenRecoveryOutcome outcome; + + /// Owner-local source day that was checked. + final CivilDate sourceDate; + + /// Owner-local target day used for placement and notice surfacing. + final CivilDate targetDate; + + /// Persisted deterministic source-date snapshot. + final SchedulingStateSnapshot snapshot; + + /// Tasks changed by this operation. + final List changedTasks; + + /// Movement activities persisted with changed tasks. + final List activities; + + /// Notices returned to the caller. + final List notices; + + /// Machine-readable movement records. + final List changes; +} diff --git a/packages/scheduler_core/lib/src/application/application_recovery/run_app_open_recovery_request.dart b/packages/scheduler_core/lib/src/application/application_recovery/run_app_open_recovery_request.dart new file mode 100644 index 0000000..41a8746 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_recovery/run_app_open_recovery_request.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../application_recovery.dart'; + +/// Request for explicit app-open/start-day rollover recovery. +class RunAppOpenRecoveryRequest { + /// Creates a `RunAppOpenRecoveryRequest` 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 RunAppOpenRecoveryRequest({ + required this.context, + required this.sourceDate, + this.targetDate, + }); + + /// Atomic operation context. + final ApplicationOperationContext context; + + /// Owner-local day to inspect for unfinished flexible work. + final CivilDate sourceDate; + + /// Owner-local target day. Defaults to the next local day. + final CivilDate? targetDate; + + /// Resolved target day. + CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1); +} diff --git a/packages/scheduler_core/lib/src/application_recovery.dart b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart similarity index 79% rename from packages/scheduler_core/lib/src/application_recovery.dart rename to packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart index daf37ce..b84239a 100644 --- a/packages/scheduler_core/lib/src/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart @@ -1,91 +1,12 @@ -/// Implements Application Recovery behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// App-open/start-day recovery orchestration. -// -// This layer makes rollover explicit. Reading Today never runs recovery; a UI or -// app lifecycle boundary calls this use case when it wants to check one source -// day and persist any resulting V1 flexible-task rollover. - -import 'application_layer.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'repositories.dart'; -import 'scheduling_engine.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -/// Stable app-open recovery outcomes. -enum AppOpenRecoveryOutcome { - rolledOver, - noUnfinishedFlexibleTasks, - alreadyProcessed, -} - -/// Request for explicit app-open/start-day rollover recovery. -class RunAppOpenRecoveryRequest { - const RunAppOpenRecoveryRequest({ - required this.context, - required this.sourceDate, - this.targetDate, - }); - - /// Atomic operation context. - final ApplicationOperationContext context; - - /// Owner-local day to inspect for unfinished flexible work. - final CivilDate sourceDate; - - /// Owner-local target day. Defaults to the next local day. - final CivilDate? targetDate; - - /// Resolved target day. - CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1); -} - -/// Result returned by app-open recovery. -class AppOpenRecoveryResult { - AppOpenRecoveryResult({ - required this.outcome, - required this.sourceDate, - required this.targetDate, - required this.snapshot, - required List changedTasks, - required List activities, - required List notices, - required List changes, - }) : changedTasks = List.unmodifiable(changedTasks), - activities = List.unmodifiable(activities), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes); - - /// High-level deterministic outcome. - final AppOpenRecoveryOutcome outcome; - - /// Owner-local source day that was checked. - final CivilDate sourceDate; - - /// Owner-local target day used for placement and notice surfacing. - final CivilDate targetDate; - - /// Persisted deterministic source-date snapshot. - final SchedulingStateSnapshot snapshot; - - /// Tasks changed by this operation. - final List changedTasks; - - /// Movement activities persisted with changed tasks. - final List activities; - - /// Notices returned to the caller. - final List notices; - - /// Machine-readable movement records. - final List changes; -} +part of '../application_recovery.dart'; /// Explicit app-open recovery facade. class V1AppOpenRecoveryUseCases { + /// Creates a `V1AppOpenRecoveryUseCases` 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 V1AppOpenRecoveryUseCases({ required this.applicationStore, required this.timeZoneResolver, @@ -236,6 +157,8 @@ class V1AppOpenRecoveryUseCases { ); } + /// Runs the `_windowForDate` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) { return SchedulingWindow( start: _resolve( @@ -251,6 +174,8 @@ class V1AppOpenRecoveryUseCases { ); } + /// Runs the `_resolve` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. DateTime _resolve({ required CivilDate date, required WallTime wallTime, @@ -267,6 +192,8 @@ class V1AppOpenRecoveryUseCases { } } +/// Top-level helper that performs the `_ownerSettingsOrDefault` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _ownerSettingsOrDefault( ApplicationUnitOfWorkRepositories repositories, OwnerTimeZoneContext ownerTimeZone, @@ -279,6 +206,8 @@ Future _ownerSettingsOrDefault( ); } +/// Top-level helper that performs the `_loadSourceAndTargetTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future> _loadSourceAndTargetTasks( ApplicationUnitOfWorkRepositories repositories, { required SchedulingWindow sourceWindow, @@ -298,6 +227,8 @@ Future> _loadSourceAndTargetTasks( return List.unmodifiable(byId.values); } +/// Top-level helper that performs the `_rolloverNotice` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. SchedulingNotice? _rolloverNotice({ required SchedulingWindow sourceWindow, required SchedulingResult result, @@ -332,6 +263,8 @@ SchedulingNotice? _rolloverNotice({ ); } +/// Top-level helper that performs the `_activitiesForRolloverChanges` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. List _activitiesForRolloverChanges({ required SchedulingResult result, required List beforeTasks, @@ -379,6 +312,8 @@ List _activitiesForRolloverChanges({ return List.unmodifiable(activities); } +/// Top-level helper that performs the `_changedTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. List _changedTasks(List beforeTasks, List afterTasks) { final beforeById = { for (final task in beforeTasks) task.id: task, @@ -395,6 +330,8 @@ List _changedTasks(List beforeTasks, List afterTasks) { return List.unmodifiable(changed); } +/// Top-level helper that performs the `_taskChanged` 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 _taskChanged(Task before, Task after) { return before.status != after.status || before.scheduledStart != after.scheduledStart || @@ -403,6 +340,8 @@ bool _taskChanged(Task before, Task after) { before.stats != after.stats; } +/// Top-level helper that performs the `_rolloverSnapshotId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _rolloverSnapshotId({ required String ownerId, required CivilDate sourceDate, diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application_layer.dart deleted file mode 100644 index c40e9c9..0000000 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ /dev/null @@ -1,1984 +0,0 @@ -/// Implements Application Layer behavior for the Scheduler Core package. -library; - -// Application orchestration contracts for UI-facing use cases. -// -// This layer sits above the pure domain services and repository interfaces. It -// gives future Flutter/use-case code one atomic boundary per user command -// without importing widgets, database clients, or platform notification APIs. - -import 'backlog.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'project_statistics.dart'; -import 'repositories.dart'; -import 'scheduling_engine.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -/// Owner-local time context supplied to application operations. -class OwnerTimeZoneContext { - OwnerTimeZoneContext({ - required String ownerId, - required String timeZoneId, - }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), - timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'); - - /// Authorization-neutral owner scope. V1 does not implement accounts. - final String ownerId; - - /// IANA-style time-zone identifier chosen by the app boundary. - final String timeZoneId; -} - -/// Per-operation application context. -class ApplicationOperationContext { - ApplicationOperationContext({ - required String operationId, - required this.now, - required this.ownerTimeZone, - required this.clock, - required this.idGenerator, - }) : operationId = _requiredTrimmed(operationId, 'operationId'); - - /// Convenience constructor that captures [Clock.now] once. - factory ApplicationOperationContext.start({ - required String operationId, - required OwnerTimeZoneContext ownerTimeZone, - required Clock clock, - required IdGenerator idGenerator, - }) { - return ApplicationOperationContext( - operationId: operationId, - now: clock.now(), - ownerTimeZone: ownerTimeZone, - clock: clock, - idGenerator: idGenerator, - ); - } - - /// Exactly-once key for this user command. - final String operationId; - - /// Instant used by the operation. Use this instead of repeatedly reading the - /// clock during one command. - final DateTime now; - - /// Owner scope and time-zone data for date-sensitive rules. - final OwnerTimeZoneContext ownerTimeZone; - - /// Injected clock available to lower-level services that need one. - final Clock clock; - - /// Injected ID generator for records created by the operation. - final IdGenerator idGenerator; -} - -/// Stable application-layer result categories. -enum ApplicationFailureCode { - validation, - notFound, - conflict, - staleRevision, - noSlot, - duplicateOperation, - persistenceFailure, - unexpected, -} - -/// Typed failure returned by application commands and queries. -class ApplicationFailure { - const ApplicationFailure({ - required this.code, - this.entityId, - this.detailCode, - }); - - /// Stable machine-readable category. - final ApplicationFailureCode code; - - /// Optional stable entity id related to the failure. - final String? entityId; - - /// Optional narrower stable reason, such as a domain validation code name. - final String? detailCode; -} - -/// Result wrapper for expected application success/failure paths. -class ApplicationResult { - const ApplicationResult._({ - this.value, - this.failure, - }); - - /// Successful result carrying [value]. - factory ApplicationResult.success(T value) { - return ApplicationResult._(value: value); - } - - /// Expected failure result. - factory ApplicationResult.failure(ApplicationFailure failure) { - return ApplicationResult._(failure: failure); - } - - /// Successful value, if any. - final T? value; - - /// Typed failure, if the operation did not succeed. - final ApplicationFailure? failure; - - /// Whether the operation succeeded. - bool get isSuccess => failure == null; - - /// Whether the operation failed with a typed application failure. - bool get isFailure => failure != null; - - /// Return the value or throw if this result is a failure. - T get requireValue { - if (failure != null) { - throw StateError( - 'Application result is a failure: ${failure!.code.name}'); - } - return value as T; - } -} - -/// Exception for expected failures raised inside a unit-of-work callback. -class ApplicationFailureException implements Exception { - const ApplicationFailureException(this.failure); - - /// Failure to return from the application boundary. - final ApplicationFailure failure; -} - -/// Exception representing persistence/driver failures at repository boundaries. -class ApplicationPersistenceException implements Exception { - const ApplicationPersistenceException(); -} - -/// Persisted exactly-once operation record. -class ApplicationOperationRecord { - ApplicationOperationRecord({ - required String operationId, - required String ownerId, - required String operationName, - required this.committedAt, - }) : operationId = _requiredTrimmed(operationId, 'operationId'), - ownerId = _requiredTrimmed(ownerId, 'ownerId'), - operationName = _requiredTrimmed(operationName, 'operationName'); - - /// Exactly-once operation id. - final String operationId; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Stable use-case/command label. - final String operationName; - - /// Commit instant. - final DateTime committedAt; -} - -/// Owner-scoped acknowledgement for a one-time scheduling notice. -class NoticeAcknowledgementRecord { - NoticeAcknowledgementRecord({ - required String noticeId, - required String ownerId, - required this.acknowledgedAt, - }) : noticeId = _requiredTrimmed(noticeId, 'noticeId'), - ownerId = _requiredTrimmed(ownerId, 'ownerId'); - - /// Stable notice id generated by the read/use-case boundary. - final String noticeId; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Instant the notice was consumed. - final DateTime acknowledgedAt; -} - -/// Owner-level settings needed by application use cases. -class OwnerSettings { - OwnerSettings({ - required String ownerId, - required String timeZoneId, - WallTime? dayStart, - WallTime? dayEnd, - this.compactModeEnabled = false, - this.backlogStalenessSettings = const BacklogStalenessSettings(), - }) : ownerId = _requiredTrimmed(ownerId, 'ownerId'), - timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'), - dayStart = dayStart ?? WallTime(hour: 0, minute: 0), - dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) { - if (this.dayStart.compareTo(this.dayEnd) >= 0) { - throw ArgumentError.value( - {'dayStart': this.dayStart, 'dayEnd': this.dayEnd}, - 'dayEnd', - 'Day end must be after day start.', - ); - } - } - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Owner time-zone id used by date-sensitive application use cases. - final String timeZoneId; - - /// Default local day start. - final WallTime dayStart; - - /// Default local day end. - final WallTime dayEnd; - - /// Persisted compact-mode preference. - final bool compactModeEnabled; - - /// Configurable backlog age thresholds. - final BacklogStalenessSettings backlogStalenessSettings; - - /// Return a copy with selected settings changed. - OwnerSettings copyWith({ - String? ownerId, - String? timeZoneId, - WallTime? dayStart, - WallTime? dayEnd, - bool? compactModeEnabled, - BacklogStalenessSettings? backlogStalenessSettings, - }) { - return OwnerSettings( - ownerId: ownerId ?? this.ownerId, - timeZoneId: timeZoneId ?? this.timeZoneId, - dayStart: dayStart ?? this.dayStart, - dayEnd: dayEnd ?? this.dayEnd, - compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled, - backlogStalenessSettings: - backlogStalenessSettings ?? this.backlogStalenessSettings, - ); - } -} - -/// Repository contract for internal task activities. -abstract interface class TaskActivityRepository { - Future findById(String id); - - Future> findByOperationId(String operationId); - - Future> findByTaskId(String taskId); - - Future> findByProjectId(String projectId); - - Future> findAll(); - - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - Future save(TaskActivity activity); - - Future saveAll(Iterable activities); - - Future append({ - required TaskActivity activity, - required String ownerId, - }); -} - -/// Repository contract for project statistics aggregates. -abstract interface class ProjectStatisticsRepository { - Future findByProjectId(String projectId); - - Future?> findRecordByProjectId( - String projectId, - ); - - Future> findAll(); - - Future save(ProjectStatistics statistics); - - Future saveIfRevision({ - required ProjectStatistics statistics, - required String ownerId, - required int expectedRevision, - }); -} - -/// Repository contract for owner settings. -abstract interface class OwnerSettingsRepository { - Future findByOwnerId(String ownerId); - - Future?> findRecordByOwnerId(String ownerId); - - Future save(OwnerSettings settings); - - Future saveIfRevision({ - required OwnerSettings settings, - required int expectedRevision, - }); -} - -/// Repository contract for consumed one-time notices. -abstract interface class NoticeAcknowledgementRepository { - Future findById({ - required String ownerId, - required String noticeId, - }); - - Future> findByOwnerId(String ownerId); - - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - Future save(NoticeAcknowledgementRecord record); - - Future insert( - NoticeAcknowledgementRecord record, - ); -} - -/// Repository contract for exactly-once operation records. -abstract interface class ApplicationOperationRepository { - Future findById(String operationId); - - Future findByIdempotencyKey({ - required String ownerId, - required String operationId, - }); - - Future save(ApplicationOperationRecord operation); - - Future insertIfAbsent( - ApplicationOperationRecord operation, - ); -} - -/// Repository set available inside one application unit of work. -abstract interface class ApplicationUnitOfWorkRepositories { - TaskRepository get tasks; - - ProjectRepository get projects; - - LockedBlockRepository get lockedBlocks; - - SchedulingSnapshotRepository get schedulingSnapshots; - - TaskActivityRepository get taskActivities; - - ProjectStatisticsRepository get projectStatistics; - - OwnerSettingsRepository get ownerSettings; - - NoticeAcknowledgementRepository get noticeAcknowledgements; - - ApplicationOperationRepository get operations; -} - -/// Atomic application transaction boundary. -abstract interface class ApplicationUnitOfWork { - Future> run({ - required ApplicationOperationContext context, - required String operationName, - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }); - - Future> read({ - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }); -} - -/// Repository loader for common scheduler input assembly. -class ApplicationSchedulingLoader { - const ApplicationSchedulingLoader(this.repositories); - - /// Repositories for the current application operation. - final ApplicationUnitOfWorkRepositories repositories; - - /// Load scheduler input for [window] from the repository boundary. - /// - /// UI code should not persist partial [SchedulingResult] objects. Commands - /// should load authoritative state here, invoke domain services, then commit - /// resulting entities through a unit of work. - Future loadInputForWindow({ - required SchedulingWindow window, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - }) async { - final scheduledTasks = await repositories.tasks.findScheduledInWindow( - window, - ); - return SchedulingInput( - tasks: scheduledTasks, - window: window, - lockedIntervals: lockedIntervals, - requiredVisibleIntervals: requiredVisibleIntervals, - ); - } -} - -/// In-memory unit-of-work implementation for deterministic application tests. -class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { - InMemoryApplicationUnitOfWork({ - Iterable initialTasks = const [], - Iterable initialProjects = const [], - Iterable initialLockedBlocks = const [], - Iterable initialLockedOverrides = - const [], - Iterable initialSchedulingSnapshots = - const [], - Iterable initialTaskActivities = const [], - Iterable initialProjectStatistics = - const [], - Iterable initialOwnerSettings = const [], - Iterable initialNoticeAcknowledgements = - const [], - Iterable initialOperations = - const [], - }) : _state = _InMemoryApplicationState( - tasksById: { - for (final task in initialTasks) task.id: task, - }, - taskOwnersById: { - for (final task in initialTasks) task.id: 'owner-1', - }, - taskRevisionsById: { - for (final task in initialTasks) task.id: 1, - }, - projectsById: { - for (final project in initialProjects) project.id: project, - }, - projectOwnersById: { - for (final project in initialProjects) project.id: 'owner-1', - }, - projectRevisionsById: { - for (final project in initialProjects) project.id: 1, - }, - lockedBlocksById: { - for (final block in initialLockedBlocks) block.id: block, - }, - lockedBlockOwnersById: { - for (final block in initialLockedBlocks) block.id: 'owner-1', - }, - lockedBlockRevisionsById: { - for (final block in initialLockedBlocks) block.id: 1, - }, - lockedOverridesById: { - for (final override in initialLockedOverrides) - override.id: override, - }, - lockedOverrideOwnersById: { - for (final override in initialLockedOverrides) - override.id: 'owner-1', - }, - lockedOverrideRevisionsById: { - for (final override in initialLockedOverrides) override.id: 1, - }, - schedulingSnapshotsById: { - for (final snapshot in initialSchedulingSnapshots) - snapshot.id: snapshot, - }, - taskActivitiesById: { - for (final activity in initialTaskActivities) activity.id: activity, - }, - taskActivityOwnersById: { - for (final activity in initialTaskActivities) - activity.id: 'owner-1', - }, - projectStatisticsByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: statistics, - }, - projectStatisticsOwnersByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: 'owner-1', - }, - projectStatisticsRevisionsByProjectId: { - for (final statistics in initialProjectStatistics) - statistics.projectId: 1, - }, - ownerSettingsByOwnerId: { - for (final settings in initialOwnerSettings) - settings.ownerId: settings, - }, - ownerSettingsRevisionsByOwnerId: { - for (final settings in initialOwnerSettings) settings.ownerId: 1, - }, - noticeAcknowledgementsByScopedId: { - for (final acknowledgement in initialNoticeAcknowledgements) - _noticeAcknowledgementKey( - ownerId: acknowledgement.ownerId, - noticeId: acknowledgement.noticeId, - ): acknowledgement, - }, - noticeAcknowledgementRevisionsByScopedId: { - for (final acknowledgement in initialNoticeAcknowledgements) - _noticeAcknowledgementKey( - ownerId: acknowledgement.ownerId, - noticeId: acknowledgement.noticeId, - ): 1, - }, - operationsById: { - for (final operation in initialOperations) - operation.operationId: operation, - }, - ); - - _InMemoryApplicationState _state; - bool _failNextCommit = false; - - /// Current committed tasks, useful for contract tests. - List get currentTasks => - List.unmodifiable(_state.tasksById.values); - - /// Current committed projects, useful for contract tests. - List get currentProjects { - return List.unmodifiable(_state.projectsById.values); - } - - /// Current committed locked blocks, useful for contract tests. - List get currentLockedBlocks { - return List.unmodifiable(_state.lockedBlocksById.values); - } - - /// Current committed locked overrides, useful for contract tests. - List get currentLockedOverrides { - return List.unmodifiable( - _state.lockedOverridesById.values, - ); - } - - /// Current committed scheduling snapshots, useful for contract tests. - List get currentSchedulingSnapshots { - return List.unmodifiable( - _state.schedulingSnapshotsById.values, - ); - } - - /// Current committed activities, useful for contract tests. - List get currentTaskActivities { - return List.unmodifiable(_state.taskActivitiesById.values); - } - - /// Current committed project statistics, useful for contract tests. - List get currentProjectStatistics { - return List.unmodifiable( - _state.projectStatisticsByProjectId.values, - ); - } - - /// Current committed owner settings, useful for contract tests. - List get currentOwnerSettings { - return List.unmodifiable( - _state.ownerSettingsByOwnerId.values); - } - - /// Current committed notice acknowledgements, useful for contract tests. - List get currentNoticeAcknowledgements { - return List.unmodifiable( - _state.noticeAcknowledgementsByScopedId.values, - ); - } - - /// Current committed operation records, useful for contract tests. - List get currentOperations { - return List.unmodifiable( - _state.operationsById.values, - ); - } - - /// Cause the next successful transaction to fail at commit time. - void failNextCommitForTesting() { - _failNextCommit = true; - } - - @override - Future> run({ - required ApplicationOperationContext context, - required String operationName, - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }) async { - if (_state.operationsById.containsKey(context.operationId)) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.duplicateOperation, - entityId: context.operationId, - ), - ); - } - - final staged = _state.copy(); - final repositories = _InMemoryApplicationRepositories(staged); - - try { - final result = await action(repositories); - if (result.isFailure) { - return result; - } - - staged.operationsById[context.operationId] = ApplicationOperationRecord( - operationId: context.operationId, - ownerId: context.ownerTimeZone.ownerId, - operationName: operationName, - committedAt: context.now, - ); - - if (_failNextCommit) { - _failNextCommit = false; - throw const ApplicationPersistenceException(); - } - - _state = staged; - return result; - } on ApplicationFailureException catch (error) { - return ApplicationResult.failure(error.failure); - } on DomainValidationException catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.code.name, - ), - ); - } on ArgumentError catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.name, - ), - ); - } on ApplicationPersistenceException { - return ApplicationResult.failure( - const ApplicationFailure( - code: ApplicationFailureCode.persistenceFailure, - ), - ); - } catch (_) { - return ApplicationResult.failure( - const ApplicationFailure(code: ApplicationFailureCode.unexpected), - ); - } - } - - @override - Future> read({ - required Future> Function( - ApplicationUnitOfWorkRepositories repositories, - ) action, - }) async { - final staged = _state.copy(); - final repositories = _InMemoryApplicationRepositories(staged); - - try { - return await action(repositories); - } on ApplicationFailureException catch (error) { - return ApplicationResult.failure(error.failure); - } on DomainValidationException catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.code.name, - ), - ); - } on ArgumentError catch (error) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - detailCode: error.name, - ), - ); - } on ApplicationPersistenceException { - return ApplicationResult.failure( - const ApplicationFailure( - code: ApplicationFailureCode.persistenceFailure, - ), - ); - } catch (_) { - return ApplicationResult.failure( - const ApplicationFailure(code: ApplicationFailureCode.unexpected), - ); - } - } -} - -class _InMemoryApplicationState { - _InMemoryApplicationState({ - required this.tasksById, - required this.taskOwnersById, - required this.taskRevisionsById, - required this.projectsById, - required this.projectOwnersById, - required this.projectRevisionsById, - required this.lockedBlocksById, - required this.lockedBlockOwnersById, - required this.lockedBlockRevisionsById, - required this.lockedOverridesById, - required this.lockedOverrideOwnersById, - required this.lockedOverrideRevisionsById, - required this.schedulingSnapshotsById, - required this.taskActivitiesById, - required this.taskActivityOwnersById, - required this.projectStatisticsByProjectId, - required this.projectStatisticsOwnersByProjectId, - required this.projectStatisticsRevisionsByProjectId, - required this.ownerSettingsByOwnerId, - required this.ownerSettingsRevisionsByOwnerId, - required this.noticeAcknowledgementsByScopedId, - required this.noticeAcknowledgementRevisionsByScopedId, - required this.operationsById, - }); - - final Map tasksById; - final Map taskOwnersById; - final Map taskRevisionsById; - final Map projectsById; - final Map projectOwnersById; - final Map projectRevisionsById; - final Map lockedBlocksById; - final Map lockedBlockOwnersById; - final Map lockedBlockRevisionsById; - final Map lockedOverridesById; - final Map lockedOverrideOwnersById; - final Map lockedOverrideRevisionsById; - final Map schedulingSnapshotsById; - final Map taskActivitiesById; - final Map taskActivityOwnersById; - final Map projectStatisticsByProjectId; - final Map projectStatisticsOwnersByProjectId; - final Map projectStatisticsRevisionsByProjectId; - final Map ownerSettingsByOwnerId; - final Map ownerSettingsRevisionsByOwnerId; - final Map - noticeAcknowledgementsByScopedId; - final Map noticeAcknowledgementRevisionsByScopedId; - final Map operationsById; - - _InMemoryApplicationState copy() { - return _InMemoryApplicationState( - tasksById: Map.of(tasksById), - taskOwnersById: Map.of(taskOwnersById), - taskRevisionsById: Map.of(taskRevisionsById), - projectsById: Map.of(projectsById), - projectOwnersById: Map.of(projectOwnersById), - projectRevisionsById: Map.of(projectRevisionsById), - lockedBlocksById: Map.of(lockedBlocksById), - lockedBlockOwnersById: Map.of(lockedBlockOwnersById), - lockedBlockRevisionsById: Map.of( - lockedBlockRevisionsById, - ), - lockedOverridesById: - Map.of(lockedOverridesById), - lockedOverrideOwnersById: Map.of( - lockedOverrideOwnersById, - ), - lockedOverrideRevisionsById: Map.of( - lockedOverrideRevisionsById, - ), - schedulingSnapshotsById: - Map.of(schedulingSnapshotsById), - taskActivitiesById: Map.of(taskActivitiesById), - taskActivityOwnersById: Map.of(taskActivityOwnersById), - projectStatisticsByProjectId: - Map.of(projectStatisticsByProjectId), - projectStatisticsOwnersByProjectId: Map.of( - projectStatisticsOwnersByProjectId, - ), - projectStatisticsRevisionsByProjectId: Map.of( - projectStatisticsRevisionsByProjectId, - ), - ownerSettingsByOwnerId: Map.of( - ownerSettingsByOwnerId, - ), - ownerSettingsRevisionsByOwnerId: Map.of( - ownerSettingsRevisionsByOwnerId, - ), - noticeAcknowledgementsByScopedId: - Map.of( - noticeAcknowledgementsByScopedId, - ), - noticeAcknowledgementRevisionsByScopedId: Map.of( - noticeAcknowledgementRevisionsByScopedId, - ), - operationsById: Map.of( - operationsById, - ), - ); - } -} - -class _InMemoryApplicationRepositories - implements ApplicationUnitOfWorkRepositories { - _InMemoryApplicationRepositories(_InMemoryApplicationState state) - : tasks = _UnitOfWorkTaskRepository( - tasksById: state.tasksById, - ownersById: state.taskOwnersById, - revisionsById: state.taskRevisionsById, - ), - projects = _UnitOfWorkProjectRepository( - projectsById: state.projectsById, - ownersById: state.projectOwnersById, - revisionsById: state.projectRevisionsById, - ), - lockedBlocks = _UnitOfWorkLockedBlockRepository( - blocksById: state.lockedBlocksById, - blockOwnersById: state.lockedBlockOwnersById, - blockRevisionsById: state.lockedBlockRevisionsById, - overridesById: state.lockedOverridesById, - overrideOwnersById: state.lockedOverrideOwnersById, - overrideRevisionsById: state.lockedOverrideRevisionsById, - ), - schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository( - state.schedulingSnapshotsById, - ), - taskActivities = _UnitOfWorkTaskActivityRepository( - activitiesById: state.taskActivitiesById, - ownersById: state.taskActivityOwnersById, - ), - projectStatistics = _UnitOfWorkProjectStatisticsRepository( - statisticsByProjectId: state.projectStatisticsByProjectId, - ownersByProjectId: state.projectStatisticsOwnersByProjectId, - revisionsByProjectId: state.projectStatisticsRevisionsByProjectId, - ), - ownerSettings = _UnitOfWorkOwnerSettingsRepository( - settingsByOwnerId: state.ownerSettingsByOwnerId, - revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId, - ), - noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( - recordsByScopedId: state.noticeAcknowledgementsByScopedId, - revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId, - ), - operations = _UnitOfWorkOperationRepository(state.operationsById); - - @override - final TaskRepository tasks; - - @override - final ProjectRepository projects; - - @override - final LockedBlockRepository lockedBlocks; - - @override - final SchedulingSnapshotRepository schedulingSnapshots; - - @override - final TaskActivityRepository taskActivities; - - @override - final ProjectStatisticsRepository projectStatistics; - - @override - final OwnerSettingsRepository ownerSettings; - - @override - final NoticeAcknowledgementRepository noticeAcknowledgements; - - @override - final ApplicationOperationRepository operations; -} - -class _UnitOfWorkTaskRepository implements TaskRepository { - _UnitOfWorkTaskRepository({ - required Map tasksById, - required Map ownersById, - required Map revisionsById, - }) : _tasksById = tasksById, - _ownersById = ownersById, - _revisionsById = revisionsById; - - final Map _tasksById; - final Map _ownersById; - final Map _revisionsById; - - @override - Future findById(String id) async => _tasksById[id]; - - @override - Future?> findRecordById(String id) async { - final task = _tasksById[id]; - if (task == null) { - return null; - } - return RepositoryRecord( - value: task, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_tasksById.values); - } - - @override - Future> findByStatus(TaskStatus status) async { - return List.unmodifiable( - _tasksById.values.where((task) => task.status == status), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where((task) => _ownerFor(task.id) == ownerId) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && task.projectId == projectId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - task.parentTaskId == parentTaskId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == query.ownerId && - task.status == TaskStatus.backlog && - (query.projectId == null || task.projectId == query.projectId) && - query.tags.every(task.backlogTags.contains), - ) - .toList(growable: false) - ..sort(_compareBacklogCandidates); - return _page(tasks, query.page); - } - - @override - Future> findScheduledInWindow(SchedulingWindow window) async { - return List.unmodifiable( - _tasksById.values.where((task) { - return _taskOverlapsWindow(task, window); - }), - ); - } - - @override - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - _taskOverlapsWindow(task, window), - ) - .toList(growable: false) - ..sort(_compareScheduledTasks); - return _page(tasks, page); - } - - @override - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) { - return findScheduledInWindowForOwner( - ownerId: ownerId, - window: window, - page: page, - ); - } - - @override - Future save(Task task) async { - _tasksById[task.id] = task; - _ownersById.putIfAbsent(task.id, () => 'owner-1'); - _revisionsById[task.id] = _revisionFor(task.id) + 1; - } - - @override - Future saveAll(Iterable tasks) async { - for (final task in tasks) { - await save(task); - } - } - - @override - Future insert({ - required Task task, - required String ownerId, - }) async { - if (_tasksById.containsKey(task.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: task.id, - ), - ); - } - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }) async { - if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, entityId: task.id), - ); - } - final actualRevision = _revisionFor(task.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: task.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -class _UnitOfWorkProjectRepository implements ProjectRepository { - _UnitOfWorkProjectRepository({ - required Map projectsById, - required Map ownersById, - required Map revisionsById, - }) : _projectsById = projectsById, - _ownersById = ownersById, - _revisionsById = revisionsById; - - final Map _projectsById; - final Map _ownersById; - final Map _revisionsById; - - @override - Future findById(String id) async => _projectsById[id]; - - @override - Future?> findRecordById(String id) async { - final project = _projectsById[id]; - if (project == null) { - return null; - } - return RepositoryRecord( - value: project, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - archivedAt: project.archivedAt, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_projectsById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final projects = _projectsById.values - .where( - (project) => - _ownerFor(project.id) == ownerId && - (includeArchived || !project.isArchived), - ) - .toList(growable: false) - ..sort(_compareProjects); - return _page(projects, page); - } - - @override - Future save(ProjectProfile project) async { - _projectsById[project.id] = project; - _ownersById.putIfAbsent(project.id, () => 'owner-1'); - _revisionsById[project.id] = _revisionFor(project.id) + 1; - } - - @override - Future insert({ - required ProjectProfile project, - required String ownerId, - }) async { - if (_projectsById.containsKey(project.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: project.id, - ), - ); - } - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }) async { - if (!_projectsById.containsKey(project.id) || - _ownerFor(project.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: project.id, - ), - ); - } - final actualRevision = _revisionFor(project.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: project.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final project = _projectsById[projectId]; - if (project == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - return saveIfRevision( - project: project.copyWith(archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { - _UnitOfWorkLockedBlockRepository({ - required Map blocksById, - required Map blockOwnersById, - required Map blockRevisionsById, - required Map overridesById, - required Map overrideOwnersById, - required Map overrideRevisionsById, - }) : _blocksById = blocksById, - _blockOwnersById = blockOwnersById, - _blockRevisionsById = blockRevisionsById, - _overridesById = overridesById, - _overrideOwnersById = overrideOwnersById, - _overrideRevisionsById = overrideRevisionsById; - - final Map _blocksById; - final Map _blockOwnersById; - final Map _blockRevisionsById; - final Map _overridesById; - final Map _overrideOwnersById; - final Map _overrideRevisionsById; - - @override - Future findBlockById(String id) async => _blocksById[id]; - - @override - Future?> findBlockRecordById(String id) async { - final block = _blocksById[id]; - if (block == null) { - return null; - } - return RepositoryRecord( - value: block, - ownerId: _blockOwnerFor(id), - revision: _blockRevisionFor(id), - archivedAt: block.archivedAt, - ); - } - - @override - Future> findAllBlocks() async { - return List.unmodifiable(_blocksById.values); - } - - @override - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final blocks = _blocksById.values - .where( - (block) => - _blockOwnerFor(block.id) == ownerId && - (includeArchived || !block.isArchived), - ) - .toList(growable: false) - ..sort(_compareLockedBlocks); - return _page(blocks, page); - } - - @override - Future findOverrideById(String id) async { - return _overridesById[id]; - } - - @override - Future?> findOverrideRecordById( - String id, - ) async { - final override = _overridesById[id]; - if (override == null) { - return null; - } - return RepositoryRecord( - value: override, - ownerId: _overrideOwnerFor(id), - revision: _overrideRevisionFor(id), - ); - } - - @override - Future> findAllOverrides() async { - return List.unmodifiable(_overridesById.values); - } - - @override - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final overrides = _overridesById.values - .where( - (override) => - _overrideOwnerFor(override.id) == ownerId && - override.date == date, - ) - .toList(growable: false) - ..sort(_compareLockedOverrides); - return _page(overrides, page); - } - - @override - Future saveBlock(LockedBlock block) async { - _blocksById[block.id] = block; - _blockOwnersById.putIfAbsent(block.id, () => 'owner-1'); - _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; - } - - @override - Future saveOverride(LockedBlockOverride override) async { - _overridesById[override.id] = override; - _overrideOwnersById.putIfAbsent(override.id, () => 'owner-1'); - _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; - } - - @override - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }) async { - if (_blocksById.containsKey(block.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: block.id, - ), - ); - } - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }) async { - if (!_blocksById.containsKey(block.id) || - _blockOwnerFor(block.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: block.id, - ), - ); - } - final actualRevision = _blockRevisionFor(block.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: block.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final block = _blocksById[blockId]; - if (block == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: blockId, - ), - ); - } - return saveBlockIfRevision( - block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - @override - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }) async { - if (_overridesById.containsKey(override.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: override.id, - ), - ); - } - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }) async { - if (!_overridesById.containsKey(override.id) || - _overrideOwnerFor(override.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: override.id, - ), - ); - } - final actualRevision = _overrideRevisionFor(override.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: override.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1'; - - int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; - - String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1'; - - int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; -} - -class _UnitOfWorkSchedulingSnapshotRepository - implements SchedulingSnapshotRepository { - _UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById); - - final Map _snapshotsById; - - @override - Future findById(String id) async { - return _snapshotsById[id]; - } - - @override - Future> findInWindow( - SchedulingWindow window, - ) async { - return List.unmodifiable( - _snapshotsById.values.where( - (snapshot) => snapshot.window.interval.overlaps(window.interval), - ), - ); - } - - @override - Future save(SchedulingStateSnapshot snapshot) async { - _snapshotsById[snapshot.id] = snapshot; - } -} - -class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { - _UnitOfWorkTaskActivityRepository({ - required Map activitiesById, - required Map ownersById, - }) : _activitiesById = activitiesById, - _ownersById = ownersById; - - final Map _activitiesById; - final Map _ownersById; - - @override - Future findById(String id) async => _activitiesById[id]; - - @override - Future> findByOperationId(String operationId) async { - return List.unmodifiable( - _activitiesById.values.where( - (activity) => activity.operationId == operationId, - ), - ); - } - - @override - Future> findByTaskId(String taskId) async { - return List.unmodifiable( - _activitiesById.values.where((activity) => activity.taskId == taskId), - ); - } - - @override - Future> findByProjectId(String projectId) async { - return List.unmodifiable( - _activitiesById.values.where( - (activity) => activity.projectId == projectId, - ), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_activitiesById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final activities = _activitiesById.values - .where((activity) => _ownerFor(activity.id) == ownerId) - .toList(growable: false) - ..sort(_compareActivities); - return _page(activities, page); - } - - @override - Future save(TaskActivity activity) async { - _activitiesById[activity.id] = activity; - _ownersById.putIfAbsent(activity.id, () => 'owner-1'); - } - - @override - Future saveAll(Iterable activities) async { - for (final activity in activities) { - _activitiesById[activity.id] = activity; - _ownersById.putIfAbsent(activity.id, () => 'owner-1'); - } - } - - @override - Future append({ - required TaskActivity activity, - required String ownerId, - }) async { - if (_activitiesById.containsKey(activity.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: activity.id, - ), - ); - } - _activitiesById[activity.id] = activity; - _ownersById[activity.id] = ownerId; - return RepositoryMutationResult.success(revision: 1); - } - - String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; -} - -class _UnitOfWorkProjectStatisticsRepository - implements ProjectStatisticsRepository { - _UnitOfWorkProjectStatisticsRepository({ - required Map statisticsByProjectId, - required Map ownersByProjectId, - required Map revisionsByProjectId, - }) : _statisticsByProjectId = statisticsByProjectId, - _ownersByProjectId = ownersByProjectId, - _revisionsByProjectId = revisionsByProjectId; - - final Map _statisticsByProjectId; - final Map _ownersByProjectId; - final Map _revisionsByProjectId; - - @override - Future findByProjectId(String projectId) async { - return _statisticsByProjectId[projectId]; - } - - @override - Future?> findRecordByProjectId( - String projectId, - ) async { - final statistics = _statisticsByProjectId[projectId]; - if (statistics == null) { - return null; - } - return RepositoryRecord( - value: statistics, - ownerId: _ownersByProjectId[projectId] ?? 'owner-1', - revision: _revisionsByProjectId[projectId] ?? 1, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_statisticsByProjectId.values); - } - - @override - Future save(ProjectStatistics statistics) async { - _statisticsByProjectId[statistics.projectId] = statistics; - _ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1'); - _revisionsByProjectId[statistics.projectId] = - (_revisionsByProjectId[statistics.projectId] ?? 1) + 1; - } - - @override - Future saveIfRevision({ - required ProjectStatistics statistics, - required String ownerId, - required int expectedRevision, - }) async { - final projectId = statistics.projectId; - if (!_statisticsByProjectId.containsKey(projectId) || - (_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - final actualRevision = _revisionsByProjectId[projectId] ?? 1; - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: projectId, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _statisticsByProjectId[projectId] = statistics; - _ownersByProjectId[projectId] = ownerId; - _revisionsByProjectId[projectId] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } -} - -class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { - _UnitOfWorkOwnerSettingsRepository({ - required Map settingsByOwnerId, - required Map revisionsByOwnerId, - }) : _settingsByOwnerId = settingsByOwnerId, - _revisionsByOwnerId = revisionsByOwnerId; - - final Map _settingsByOwnerId; - final Map _revisionsByOwnerId; - - @override - Future findByOwnerId(String ownerId) async { - return _settingsByOwnerId[ownerId]; - } - - @override - Future?> findRecordByOwnerId( - String ownerId, - ) async { - final settings = _settingsByOwnerId[ownerId]; - if (settings == null) { - return null; - } - return RepositoryRecord( - value: settings, - ownerId: ownerId, - revision: _revisionsByOwnerId[ownerId] ?? 1, - ); - } - - @override - Future save(OwnerSettings settings) async { - _settingsByOwnerId[settings.ownerId] = settings; - _revisionsByOwnerId[settings.ownerId] = - (_revisionsByOwnerId[settings.ownerId] ?? 1) + 1; - } - - @override - Future saveIfRevision({ - required OwnerSettings settings, - required int expectedRevision, - }) async { - final ownerId = settings.ownerId; - if (!_settingsByOwnerId.containsKey(ownerId)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: ownerId, - ), - ); - } - final actualRevision = _revisionsByOwnerId[ownerId] ?? 1; - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: ownerId, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _settingsByOwnerId[ownerId] = settings; - _revisionsByOwnerId[ownerId] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } -} - -class _UnitOfWorkNoticeAcknowledgementRepository - implements NoticeAcknowledgementRepository { - _UnitOfWorkNoticeAcknowledgementRepository({ - required Map recordsByScopedId, - required Map revisionsByScopedId, - }) : _recordsByScopedId = recordsByScopedId, - _revisionsByScopedId = revisionsByScopedId; - - final Map _recordsByScopedId; - final Map _revisionsByScopedId; - - @override - Future findById({ - required String ownerId, - required String noticeId, - }) async { - return _recordsByScopedId[ - _noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)]; - } - - @override - Future> findByOwnerId( - String ownerId, - ) async { - return List.unmodifiable( - _recordsByScopedId.values.where((record) => record.ownerId == ownerId), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final records = _recordsByScopedId.values - .where((record) => record.ownerId == ownerId) - .toList(growable: false) - ..sort(_compareNoticeAcknowledgements); - return _page(records, page); - } - - @override - Future save(NoticeAcknowledgementRecord record) async { - final key = _noticeAcknowledgementKey( - ownerId: record.ownerId, - noticeId: record.noticeId, - ); - _recordsByScopedId[key] = record; - _revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1; - } - - @override - Future insert( - NoticeAcknowledgementRecord record, - ) async { - final key = _noticeAcknowledgementKey( - ownerId: record.ownerId, - noticeId: record.noticeId, - ); - if (_recordsByScopedId.containsKey(key)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: record.noticeId, - ), - ); - } - _recordsByScopedId[key] = record; - _revisionsByScopedId[key] = 1; - return RepositoryMutationResult.success(revision: 1); - } -} - -class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { - _UnitOfWorkOperationRepository(this._operationsById); - - final Map _operationsById; - - @override - Future findById(String operationId) async { - return _operationsById[operationId]; - } - - @override - Future findByIdempotencyKey({ - required String ownerId, - required String operationId, - }) async { - final operation = _operationsById[operationId]; - if (operation == null || operation.ownerId != ownerId) { - return null; - } - return operation; - } - - @override - Future save(ApplicationOperationRecord operation) async { - _operationsById[operation.operationId] = operation; - } - - @override - Future insertIfAbsent( - ApplicationOperationRecord operation, - ) async { - if (_operationsById.containsKey(operation.operationId)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateOperation, - entityId: operation.operationId, - ), - ); - } - _operationsById[operation.operationId] = operation; - return RepositoryMutationResult.success(revision: 1); - } -} - -String _noticeAcknowledgementKey({ - required String ownerId, - required String noticeId, -}) { - return '$ownerId::$noticeId'; -} - -RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { - final offset = int.tryParse(request.cursor ?? '') ?? 0; - final safeOffset = offset.clamp(0, sortedItems.length); - final end = (safeOffset + request.limit).clamp(0, sortedItems.length); - final items = sortedItems.sublist(safeOffset, end); - final nextCursor = end < sortedItems.length ? end.toString() : null; - return RepositoryPage( - items: List.unmodifiable(items), - nextCursor: nextCursor, - ); -} - -bool _taskOverlapsWindow(Task task, SchedulingWindow window) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return TimeInterval(start: start, end: end).overlaps(window.interval); -} - -int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); - -int _compareScheduledTasks(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart != null && rightStart != null) { - final startComparison = leftStart.compareTo(rightStart); - if (startComparison != 0) { - return startComparison; - } - } - return left.id.compareTo(right.id); -} - -int _compareBacklogCandidates(Task left, Task right) { - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareProjects(ProjectProfile left, ProjectProfile right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedBlocks(LockedBlock left, LockedBlock right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedOverrides( - LockedBlockOverride left, - LockedBlockOverride right, -) { - final dateComparison = left.date.compareTo(right.date); - if (dateComparison != 0) { - return dateComparison; - } - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareActivities(TaskActivity left, TaskActivity right) { - final occurredComparison = left.occurredAt.compareTo(right.occurredAt); - if (occurredComparison != 0) { - return occurredComparison; - } - return left.id.compareTo(right.id); -} - -int _compareNoticeAcknowledgements( - NoticeAcknowledgementRecord left, - NoticeAcknowledgementRecord right, -) { - final acknowledgedComparison = - left.acknowledgedAt.compareTo(right.acknowledgedAt); - if (acknowledgedComparison != 0) { - return acknowledgedComparison; - } - return left.noticeId.compareTo(right.noticeId); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} diff --git a/packages/scheduler_core/lib/src/child_tasks.dart b/packages/scheduler_core/lib/src/child_tasks.dart deleted file mode 100644 index 8f23fb8..0000000 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ /dev/null @@ -1,755 +0,0 @@ -/// Implements Child Tasks behavior for the Scheduler Core package. -library; - -// Parent/child task ownership helpers. -// -// Child tasks are owned by one parent through `Task.parentTaskId`. This file -// keeps that relationship queryable without adding dependency scheduling, DAG -// traversal, or UI-specific state. - -import 'models.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -/// Row-style input for creating one child task. -/// -/// Priority is nullable on purpose. A null priority means the user did not set -/// one, so callers should preserve row insertion order instead of treating the -/// child as medium priority. -class ChildTaskEntry { - const ChildTaskEntry({ - required this.id, - required this.title, - required this.createdAt, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.durationMinutes, - this.projectId, - this.updatedAt, - }); - - /// Caller-generated child id. - final String id; - - /// User-entered child title. - final String title; - - /// Creation timestamp supplied by the caller. - final DateTime createdAt; - - /// Optional explicit child priority. - final PriorityLevel? priority; - - /// Optional explicit child reward. Missing reward stays `notSet`. - final RewardLevel reward; - - /// Optional explicit child difficulty. - final DifficultyLevel difficulty; - - /// Optional child duration estimate. - final int? durationMinutes; - - /// Optional project override. Null means inherit the parent project. - final String? projectId; - - /// Optional update timestamp. - final DateTime? updatedAt; - - /// Convert this entry into a child [Task] owned by [parent]. - Task toTask({ - required Task parent, - }) { - final trimmedTitle = title.trim(); - if (trimmedTitle.isEmpty) { - throw ArgumentError.value(title, 'title', 'Title is required.'); - } - - return Task( - id: id, - title: trimmedTitle, - projectId: projectId ?? parent.projectId, - type: TaskType.flexible, - status: TaskStatus.backlog, - priority: priority, - reward: reward, - difficulty: difficulty, - durationMinutes: durationMinutes, - parentTaskId: parent.id, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } -} - -/// Command input for breaking one parent task into direct children. -class ChildTaskBreakUpRequest { - ChildTaskBreakUpRequest({ - required String parentTaskId, - required List entries, - required String operationId, - }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), - entries = List.unmodifiable(entries), - operationId = _requiredTrimmed(operationId, 'operationId'); - - /// Parent task that will own every created child. - final String parentTaskId; - - /// Ordered child rows supplied by the caller. - final List entries; - - /// Idempotency key for the application command that creates the children. - final String operationId; -} - -/// Result from breaking a parent task into direct children. -class ChildTaskBreakUpResult { - ChildTaskBreakUpResult({ - required List tasks, - required this.parentTask, - required List childTasks, - required this.operationId, - List createdChildTaskIds = const [], - List activities = const [], - }) : tasks = List.unmodifiable(tasks), - childTasks = List.unmodifiable(childTasks), - createdChildTaskIds = List.unmodifiable(createdChildTaskIds), - activities = List.unmodifiable(activities); - - /// Replacement task list including newly created children. - final List tasks; - - /// Parent that owns the created children. - final Task parentTask; - - /// Created children in request order. - final List childTasks; - - /// Idempotency key for the command. - final String operationId; - - /// Child ids created by this command. - final List createdChildTaskIds; - - /// Activity records produced by this command. - /// - /// V1 child creation is represented by task inserts, so this is currently - /// empty while still giving application use cases one atomic mutation shape. - final List activities; -} - -/// Creates direct child tasks from ordered child-entry rows. -class ChildTaskBreakUpService { - const ChildTaskBreakUpService(); - - /// Break `request.parentTaskId` into direct child tasks. - /// - /// This service validates the full requested set before returning any - /// mutations. Persistence and transaction wiring are handled by later blocks. - ChildTaskBreakUpResult breakUp({ - required List tasks, - required ChildTaskBreakUpRequest request, - }) { - final parent = _taskById(tasks, request.parentTaskId); - if (parent == null) { - throw ArgumentError.value( - request.parentTaskId, - 'parentTaskId', - 'Parent not found.', - ); - } - if (request.entries.isEmpty) { - throw ArgumentError.value( - request.entries, - 'entries', - 'At least one child task is required.', - ); - } - - final existingIds = tasks.map((task) => task.id).toSet(); - final requestedIds = {}; - for (final entry in request.entries) { - final childId = entry.id.trim(); - if (childId.isEmpty) { - throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); - } - if (childId == parent.id) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child id cannot match the parent id.', - ); - } - if (!requestedIds.add(childId)) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child ids must be unique within one break-up command.', - ); - } - if (existingIds.contains(childId)) { - throw ArgumentError.value( - entry.id, - 'id', - 'Child id is already used by another task.', - ); - } - } - - final childTasks = request.entries - .map((entry) => entry.toTask(parent: parent)) - .toList(growable: false); - - return ChildTaskBreakUpResult( - tasks: List.unmodifiable([...tasks, ...childTasks]), - parentTask: parent, - childTasks: childTasks, - operationId: request.operationId, - createdChildTaskIds: - childTasks.map((child) => child.id).toList(growable: false), - ); - } -} - -/// Read-only parent/child projection over a task list. -/// -/// This is intentionally not a scheduler. It answers ownership questions such -/// as "which tasks belong to this parent?" while leaving task placement and -/// completion rules to other domain services. -class ChildTaskView { - ChildTaskView({ - required List tasks, - }) : tasks = List.unmodifiable(tasks); - - /// Source task list supplied by the caller. - final List tasks; - - /// Direct children owned by [parent], preserving source-list order. - List childrenOf(Task parent) { - return List.unmodifiable( - tasks.where((task) => task.parentTaskId == parent.id), - ); - } - - /// Direct children sorted by explicit priority while preserving row order ties. - /// - /// Children without priority sort after explicit priorities. Within each - /// priority group, including the no-priority group, source-list order is kept. - List childrenOfSortedByPriority(Task parent) { - final indexedChildren = childrenOf(parent) - .asMap() - .entries - .map( - (entry) => _IndexedChild( - task: entry.value, - originalIndex: entry.key, - ), - ) - .toList(growable: false); - - indexedChildren.sort((a, b) { - final priorityComparison = _priorityRank(b.task.priority) - .compareTo(_priorityRank(a.task.priority)); - if (priorityComparison != 0) { - return priorityComparison; - } - - return a.originalIndex.compareTo(b.originalIndex); - }); - - return List.unmodifiable( - indexedChildren.map((child) => child.task), - ); - } - - /// Parent task for [child], or null when the parent is not in [tasks]. - Task? parentOf(Task child) { - final parentId = child.parentTaskId; - if (parentId == null) { - return null; - } - - for (final task in tasks) { - if (task.id == parentId) { - return task; - } - } - - return null; - } - - /// Whether [child] is directly owned by [parent]. - bool isChildOf({ - required Task child, - required Task parent, - }) { - return child.parentTaskId == parent.id; - } - - /// Aggregate direct child status counts for [parent]. - ChildTaskSummary summaryFor(Task parent) { - return ChildTaskSummary.fromChildren(childrenOf(parent)); - } - - /// Whether this helper would auto-complete [parent]. - bool parentShouldAutoComplete(Task parent) { - return summaryFor(parent).allChildrenCompleted; - } -} - -/// Result from child/parent completion propagation. -class ChildTaskCompletionResult { - ChildTaskCompletionResult({ - required List tasks, - required List changedTaskIds, - this.completedChildId, - this.completedParentId, - List forceCompletedChildIds = const [], - List activities = const [], - List transitionOutcomeCodes = - const [], - this.autoCompletedParent = false, - this.canCompleteParentExplicitly = false, - }) : tasks = List.unmodifiable(tasks), - changedTaskIds = List.unmodifiable(changedTaskIds), - forceCompletedChildIds = - List.unmodifiable(forceCompletedChildIds), - activities = List.unmodifiable(activities), - transitionOutcomeCodes = List.unmodifiable( - transitionOutcomeCodes); - - /// Replacement task list after the completion action. - final List tasks; - - /// Task ids whose completion state changed. - final List changedTaskIds; - - /// Child id completed by the initial child-complete action, when applicable. - final String? completedChildId; - - /// Parent id completed by the action, when applicable. - final String? completedParentId; - - /// Direct child ids completed by explicit parent-complete behavior. - final List forceCompletedChildIds; - - /// Internal activities produced by parent/child completion propagation. - final List activities; - - /// Transition outcomes returned while applying completion propagation. - final List transitionOutcomeCodes; - - /// Whether the parent was completed because all direct children are complete. - final bool autoCompletedParent; - - /// Whether callers may offer an explicit parent-complete action. - final bool canCompleteParentExplicitly; -} - -/// Applies direct parent/child completion rules. -/// -/// This service intentionally handles only one ownership level. It does not walk -/// dependency graphs, and it only completes sibling children when the caller -/// explicitly completes the parent. -class ChildTaskCompletionService { - const ChildTaskCompletionService({ - this.clock = const SystemClock(), - this.transitionService = const TaskTransitionService(), - }); - - /// Clock boundary used when a completion timestamp is not supplied. - final Clock clock; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Complete one child and auto-complete its parent only when all children are complete. - ChildTaskCompletionResult completeChild({ - required List tasks, - required String childTaskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final now = updatedAt ?? clock.now(); - final child = _taskById(tasks, childTaskId); - if (child == null) { - throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); - } - - final parentId = child.parentTaskId; - if (parentId == null) { - throw ArgumentError.value( - childTaskId, 'childTaskId', 'Task is not a child.'); - } - - final parent = _taskById(tasks, parentId); - if (parent == null) { - throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); - } - - final opId = - operationId ?? _defaultOperationId('complete-child', child.id, now); - final childTransition = _completeWithTransition( - task: child, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': 'child', - 'parentTaskId': parent.id, - }, - ); - final withChildCompleted = _replaceTask(tasks, childTransition.task); - final view = ChildTaskView(tasks: withChildCompleted); - final shouldCompleteParent = parent.status != TaskStatus.completed && - view.parentShouldAutoComplete(parent); - final activities = [...childTransition.activities]; - final outcomes = [childTransition.outcomeCode]; - - if (!shouldCompleteParent) { - return ChildTaskCompletionResult( - tasks: List.unmodifiable(withChildCompleted), - changedTaskIds: childTransition.applied ? [child.id] : const [], - completedChildId: child.id, - canCompleteParentExplicitly: parent.status != TaskStatus.completed, - activities: activities, - transitionOutcomeCodes: outcomes, - ); - } - - final parentTransition = _completeWithTransition( - task: parent, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': 'autoParentAfterLastChild', - 'completedChildTaskId': child.id, - }, - ); - activities.addAll(parentTransition.activities); - outcomes.add(parentTransition.outcomeCode); - - final updatedParent = parentTransition.task; - final completedTasks = _replaceTask(withChildCompleted, updatedParent); - final changedTaskIds = [ - if (childTransition.applied) child.id, - if (parentTransition.applied) parent.id, - ]; - - return ChildTaskCompletionResult( - tasks: List.unmodifiable(completedTasks), - changedTaskIds: List.unmodifiable(changedTaskIds), - completedChildId: child.id, - completedParentId: parent.id, - autoCompletedParent: true, - activities: activities, - transitionOutcomeCodes: outcomes, - ); - } - - /// Complete a parent from one of its direct children and force-complete siblings. - ChildTaskCompletionResult completeParentFromChild({ - required List tasks, - required String childTaskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final child = _taskById(tasks, childTaskId); - if (child == null) { - throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); - } - final parentId = child.parentTaskId; - if (parentId == null) { - throw ArgumentError.value( - childTaskId, - 'childTaskId', - 'Task is not a child.', - ); - } - - return completeParent( - tasks: tasks, - parentTaskId: parentId, - updatedAt: updatedAt, - operationId: operationId, - initiatingChildTaskId: child.id, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - ); - } - - /// Explicitly complete a parent and any direct children not already completed. - ChildTaskCompletionResult completeParent({ - required List tasks, - required String parentTaskId, - DateTime? updatedAt, - String? operationId, - String? initiatingChildTaskId, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - }) { - final now = updatedAt ?? clock.now(); - final parent = _taskById(tasks, parentTaskId); - if (parent == null) { - throw ArgumentError.value( - parentTaskId, 'parentTaskId', 'Parent not found.'); - } - - final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); - if (initiatingChildTaskId != null && - !directChildren.any((child) => child.id == initiatingChildTaskId)) { - throw ArgumentError.value( - initiatingChildTaskId, - 'initiatingChildTaskId', - 'Initiating task must be a direct child of the parent.', - ); - } - - final opId = - operationId ?? _defaultOperationId('complete-parent', parent.id, now); - final forceCompletedChildIds = []; - final changedTaskIds = []; - final activities = []; - final outcomes = []; - final updatedTasks = []; - - for (final task in tasks) { - if (task.id == parent.id) { - final transition = _completeWithTransition( - task: task, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': - initiatingChildTaskId == null ? 'parent' : 'parentFromChild', - if (initiatingChildTaskId != null) - 'initiatingChildTaskId': initiatingChildTaskId, - }, - ); - final completedParent = transition.task; - updatedTasks.add(completedParent); - activities.addAll(transition.activities); - outcomes.add(transition.outcomeCode); - if (transition.applied) { - changedTaskIds.add(task.id); - } - continue; - } - - final isDirectChild = directChildren.any((child) => child.id == task.id); - if (isDirectChild && task.status != TaskStatus.completed) { - final transition = _completeWithTransition( - task: task, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: { - 'completionSource': initiatingChildTaskId == null - ? 'parentForce' - : 'childForceParent', - 'parentTaskId': parent.id, - if (initiatingChildTaskId != null) - 'initiatingChildTaskId': initiatingChildTaskId, - }, - ); - updatedTasks.add(transition.task); - activities.addAll(transition.activities); - outcomes.add(transition.outcomeCode); - if (transition.applied) { - forceCompletedChildIds.add(task.id); - changedTaskIds.add(task.id); - } - continue; - } - - updatedTasks.add(task); - } - - return ChildTaskCompletionResult( - tasks: List.unmodifiable(updatedTasks), - changedTaskIds: List.unmodifiable(changedTaskIds), - completedParentId: parent.id, - forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), - activities: List.unmodifiable(activities), - transitionOutcomeCodes: List.unmodifiable( - outcomes, - ), - ); - } - - TaskTransitionResult _completeWithTransition({ - required Task task, - required String operationId, - required DateTime occurredAt, - required Iterable existingActivities, - required Iterable appliedActivityIds, - required Map metadata, - }) { - return transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.complete, - operationId: operationId, - activityId: _activityId( - operationId, - task.id, - TaskActivityCode.completed, - ), - occurredAt: occurredAt, - existingActivities: existingActivities, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ); - } -} - -/// Status counts for a parent's direct children. -class ChildTaskSummary { - const ChildTaskSummary({ - required this.totalCount, - required this.plannedCount, - required this.activeCount, - required this.completedCount, - required this.missedCount, - required this.cancelledCount, - required this.noLongerRelevantCount, - required this.backlogCount, - }); - - /// Build counts from [children]. - factory ChildTaskSummary.fromChildren(List children) { - var plannedCount = 0; - var activeCount = 0; - var completedCount = 0; - var missedCount = 0; - var cancelledCount = 0; - var noLongerRelevantCount = 0; - var backlogCount = 0; - - for (final child in children) { - switch (child.status) { - case TaskStatus.planned: - plannedCount += 1; - case TaskStatus.active: - activeCount += 1; - case TaskStatus.completed: - completedCount += 1; - case TaskStatus.missed: - missedCount += 1; - case TaskStatus.cancelled: - cancelledCount += 1; - case TaskStatus.noLongerRelevant: - noLongerRelevantCount += 1; - case TaskStatus.backlog: - backlogCount += 1; - } - } - - return ChildTaskSummary( - totalCount: children.length, - plannedCount: plannedCount, - activeCount: activeCount, - completedCount: completedCount, - missedCount: missedCount, - cancelledCount: cancelledCount, - noLongerRelevantCount: noLongerRelevantCount, - backlogCount: backlogCount, - ); - } - - /// Total direct children counted. - final int totalCount; - - /// Children in planned status. - final int plannedCount; - - /// Children in active status. - final int activeCount; - - /// Children in completed status. - final int completedCount; - - /// Children in missed status. - final int missedCount; - - /// Children in cancelled status. - final int cancelledCount; - - /// Children marked no longer relevant. - final int noLongerRelevantCount; - - /// Children currently in backlog. - final int backlogCount; - - /// Whether at least one direct child exists. - bool get hasChildren => totalCount > 0; - - /// Whether every direct child is completed. - bool get allChildrenCompleted => hasChildren && completedCount == totalCount; -} - -class _IndexedChild { - const _IndexedChild({ - required this.task, - required this.originalIndex, - }); - - final Task task; - final int originalIndex; -} - -int _priorityRank(PriorityLevel? priority) { - return switch (priority) { - PriorityLevel.veryLow => 0, - PriorityLevel.low => 1, - PriorityLevel.medium => 2, - PriorityLevel.high => 3, - PriorityLevel.veryHigh => 4, - null => -1, - }; -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -List _replaceTask(List tasks, Task replacement) { - return tasks - .map((task) => task.id == replacement.id ? replacement : task) - .toList(growable: false); -} - -String _defaultOperationId(String actionName, String taskId, DateTime at) { - return '$actionName:$taskId:${at.toIso8601String()}'; -} - -String _activityId( - String operationId, - String taskId, - TaskActivityCode activityCode, -) { - return '$operationId:$taskId:${activityCode.name}'; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} diff --git a/packages/scheduler_core/lib/src/document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping.dart deleted file mode 100644 index b084b1e..0000000 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ /dev/null @@ -1,2139 +0,0 @@ -/// Implements Document Mapping behavior for the Scheduler Core package. -library; - -// Adapter-neutral V1 document mapping helpers. -// -// These helpers use plain Dart map shapes only. They do not import database -// APIs, create clients, or perform database I/O. - -import 'application_layer.dart'; -import 'backlog.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'persistence_contract.dart'; -import 'project_statistics.dart'; -import 'repositories.dart'; -import 'scheduling_engine.dart'; -import 'task_lifecycle.dart'; -import 'task_statistics.dart'; -import 'time_contracts.dart'; - -/// Stable document mapping failure categories. -enum DocumentMappingFailureCode { - missingField, - wrongType, - unknownCode, - invalidSchemaVersion, - invalidRevision, - invalidValue, -} - -/// Typed mapping failure for malformed V1 documents. -class DocumentMappingException implements Exception { - const DocumentMappingException({ - required this.code, - this.fieldName, - this.detailCode, - }); - - /// Stable category for caller branching. - final DocumentMappingFailureCode code; - - /// Field related to the failure, when known. - final String? fieldName; - - /// Optional narrower reason. - final String? detailCode; - - @override - String toString() { - return 'DocumentMappingException($code, field: $fieldName, detail: ' - '$detailCode)'; - } -} - -/// Common metadata decoded from a V1 top-level document. -class DocumentMetadata { - const DocumentMetadata({ - required this.id, - required this.ownerId, - required this.revision, - required this.createdAt, - required this.updatedAt, - }); - - final String id; - final String ownerId; - final int revision; - final DateTime createdAt; - final DateTime updatedAt; -} - -/// Stable enum encode/decode helpers for V1 document maps. -abstract final class PersistenceEnumMapping { - static String? encodeNullable(Enum? value) { - return value == null ? null : encode(value); - } - - static String encode(Enum value) { - if (value is TaskType) return encodeTaskType(value); - if (value is TaskStatus) return encodeTaskStatus(value); - if (value is PriorityLevel) return encodePriority(value); - if (value is RewardLevel) return encodeReward(value); - if (value is DifficultyLevel) return encodeDifficulty(value); - if (value is ReminderProfile) return encodeReminderProfile(value); - if (value is BacklogTag) return encodeBacklogTag(value); - if (value is LockedWeekday) return encodeLockedWeekday(value); - if (value is LockedBlockOverrideType) { - return encodeLockedBlockOverrideType(value); - } - if (value is TaskActivityCode) return encodeTaskActivityCode(value); - if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value); - if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value); - if (value is SchedulingMovementCode) { - return encodeSchedulingMovementCode(value); - } - if (value is SchedulingConflictCode) { - return encodeSchedulingConflictCode(value); - } - if (value is ProjectCompletionTimeBucket) { - return encodeProjectCompletionTimeBucket(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - detailCode: value.runtimeType.toString(), - ); - } - - static String encodeTaskType(TaskType value) => switch (value) { - TaskType.flexible => 'flexible', - TaskType.inflexible => 'inflexible', - TaskType.critical => 'critical', - TaskType.locked => 'locked', - TaskType.surprise => 'surprise', - TaskType.freeSlot => 'free_slot', - }; - - static TaskType decodeTaskType(String value) { - return _decodeCode(_taskTypeByCode, value, 'TaskType'); - } - - static String encodeTaskStatus(TaskStatus value) => switch (value) { - TaskStatus.planned => 'planned', - TaskStatus.active => 'active', - TaskStatus.completed => 'completed', - TaskStatus.missed => 'missed', - TaskStatus.cancelled => 'cancelled', - TaskStatus.noLongerRelevant => 'no_longer_relevant', - TaskStatus.backlog => 'backlog', - }; - - static TaskStatus decodeTaskStatus(String value) { - return _decodeCode(_taskStatusByCode, value, 'TaskStatus'); - } - - static String encodePriority(PriorityLevel value) => switch (value) { - PriorityLevel.veryLow => 'very_low', - PriorityLevel.low => 'low', - PriorityLevel.medium => 'medium', - PriorityLevel.high => 'high', - PriorityLevel.veryHigh => 'very_high', - }; - - static PriorityLevel? decodeNullablePriority(String? value) { - return value == null - ? null - : _decodeCode(_priorityByCode, value, 'PriorityLevel'); - } - - static String encodeReward(RewardLevel value) => switch (value) { - RewardLevel.notSet => 'not_set', - RewardLevel.veryLow => 'very_low', - RewardLevel.low => 'low', - RewardLevel.medium => 'medium', - RewardLevel.high => 'high', - RewardLevel.veryHigh => 'very_high', - }; - - static RewardLevel decodeReward(String value) { - return _decodeCode(_rewardByCode, value, 'RewardLevel'); - } - - static String encodeDifficulty(DifficultyLevel value) => switch (value) { - DifficultyLevel.notSet => 'not_set', - DifficultyLevel.veryEasy => 'very_easy', - DifficultyLevel.easy => 'easy', - DifficultyLevel.medium => 'medium', - DifficultyLevel.hard => 'hard', - DifficultyLevel.veryHard => 'very_hard', - }; - - static DifficultyLevel decodeDifficulty(String value) { - return _decodeCode(_difficultyByCode, value, 'DifficultyLevel'); - } - - static String encodeReminderProfile(ReminderProfile value) => switch (value) { - ReminderProfile.silent => 'silent', - ReminderProfile.gentle => 'gentle', - ReminderProfile.persistent => 'persistent', - ReminderProfile.strict => 'strict', - }; - - static ReminderProfile? decodeNullableReminderProfile(String? value) { - return value == null - ? null - : _decodeCode(_reminderProfileByCode, value, 'ReminderProfile'); - } - - static String encodeBacklogTag(BacklogTag value) => switch (value) { - BacklogTag.wishlist => 'wishlist', - }; - - static BacklogTag decodeBacklogTag(String value) { - return _decodeCode(_backlogTagByCode, value, 'BacklogTag'); - } - - static String encodeLockedWeekday(LockedWeekday value) => switch (value) { - LockedWeekday.monday => 'monday', - LockedWeekday.tuesday => 'tuesday', - LockedWeekday.wednesday => 'wednesday', - LockedWeekday.thursday => 'thursday', - LockedWeekday.friday => 'friday', - LockedWeekday.saturday => 'saturday', - LockedWeekday.sunday => 'sunday', - }; - - static LockedWeekday decodeLockedWeekday(String value) { - return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday'); - } - - static String encodeLockedBlockOverrideType( - LockedBlockOverrideType value, - ) => - switch (value) { - LockedBlockOverrideType.remove => 'remove', - LockedBlockOverrideType.replace => 'replace', - LockedBlockOverrideType.add => 'add', - }; - - static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) { - return _decodeCode( - _lockedBlockOverrideTypeByCode, - value, - 'LockedBlockOverrideType', - ); - } - - static String encodeTaskActivityCode(TaskActivityCode value) => - switch (value) { - TaskActivityCode.completed => 'completed', - TaskActivityCode.missed => 'missed', - TaskActivityCode.cancelled => 'cancelled', - TaskActivityCode.noLongerRelevant => 'no_longer_relevant', - TaskActivityCode.manuallyPushed => 'manually_pushed', - TaskActivityCode.automaticallyPushed => 'automatically_pushed', - TaskActivityCode.movedToBacklog => 'moved_to_backlog', - TaskActivityCode.restoredFromBacklog => 'restored_from_backlog', - TaskActivityCode.activated => 'activated', - }; - - static TaskActivityCode decodeTaskActivityCode(String value) { - return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode'); - } - - static String encodeSchedulingNoticeType(SchedulingNoticeType value) => - switch (value) { - SchedulingNoticeType.info => 'info', - SchedulingNoticeType.moved => 'moved', - SchedulingNoticeType.overlap => 'overlap', - SchedulingNoticeType.noFit => 'no_fit', - SchedulingNoticeType.overflow => 'overflow', - }; - - static SchedulingNoticeType decodeSchedulingNoticeType(String value) { - return _decodeCode( - _schedulingNoticeTypeByCode, - value, - 'SchedulingNoticeType', - ); - } - - static String encodeSchedulingIssueCode(SchedulingIssueCode value) => - switch (value) { - SchedulingIssueCode.taskNotFound => 'task_not_found', - SchedulingIssueCode.invalidTaskState => 'invalid_task_state', - SchedulingIssueCode.missingDuration => 'missing_duration', - SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', - SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', - SchedulingIssueCode.noAvailableSlot => 'no_available_slot', - SchedulingIssueCode.unfinishedTasksCouldNotFit => - 'unfinished_tasks_could_not_fit', - SchedulingIssueCode.noUnfinishedFlexibleTasks => - 'no_unfinished_flexible_tasks', - SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log', - }; - - static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) { - return value == null - ? null - : _decodeCode( - _schedulingIssueCodeByCode, - value, - 'SchedulingIssueCode', - ); - } - - static String encodeSchedulingMovementCode(SchedulingMovementCode value) => - switch (value) { - SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted', - SchedulingMovementCode.flexibleTaskMovedToMakeRoom => - 'flexible_task_moved_to_make_room', - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot => - 'flexible_task_pushed_to_next_available_slot', - SchedulingMovementCode.flexibleTaskMovedToTomorrow => - 'flexible_task_moved_to_tomorrow', - SchedulingMovementCode.unfinishedFlexibleTasksRolledOver => - 'unfinished_flexible_tasks_rolled_over', - SchedulingMovementCode.flexibleTaskMovedToBacklog => - 'flexible_task_moved_to_backlog', - SchedulingMovementCode.requiredCommitmentScheduled => - 'required_commitment_scheduled', - }; - - static SchedulingMovementCode? decodeNullableSchedulingMovementCode( - String? value, - ) { - return value == null - ? null - : _decodeCode( - _schedulingMovementCodeByCode, - value, - 'SchedulingMovementCode', - ); - } - - static String encodeSchedulingConflictCode(SchedulingConflictCode value) => - switch (value) { - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime => - 'flexible_task_overlaps_blocked_time', - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime => - 'surprise_task_overlaps_required_visible_time', - SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot => - 'required_commitment_overlaps_protected_free_slot', - }; - - static SchedulingConflictCode? decodeNullableSchedulingConflictCode( - String? value, - ) { - return value == null - ? null - : _decodeCode( - _schedulingConflictCodeByCode, - value, - 'SchedulingConflictCode', - ); - } - - static String encodeProjectCompletionTimeBucket( - ProjectCompletionTimeBucket value, - ) => - switch (value) { - ProjectCompletionTimeBucket.overnight => 'overnight', - ProjectCompletionTimeBucket.morning => 'morning', - ProjectCompletionTimeBucket.afternoon => 'afternoon', - ProjectCompletionTimeBucket.evening => 'evening', - ProjectCompletionTimeBucket.night => 'night', - }; - - static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( - String value, - ) { - return _decodeCode( - _projectCompletionTimeBucketByCode, - value, - 'ProjectCompletionTimeBucket', - ); - } -} - -/// Document mapping for [Task]. -abstract final class TaskDocumentMapping { - static Map toDocument( - Task task, { - required String ownerId, - int revision = 1, - bool clearSchedule = false, - DateTime? backlogEnteredAt, - String? backlogEnteredAtProvenance, - }) { - final scheduledStart = clearSchedule ? null : task.scheduledStart; - final scheduledEnd = clearSchedule ? null : task.scheduledEnd; - - return { - ..._commonFields( - id: task.id, - ownerId: ownerId, - revision: revision, - createdAt: task.createdAt, - updatedAt: task.updatedAt, - ), - TaskDocumentFields.title: task.title, - TaskDocumentFields.projectId: task.projectId, - TaskDocumentFields.type: PersistenceEnumMapping.encodeTaskType(task.type), - TaskDocumentFields.status: - PersistenceEnumMapping.encodeTaskStatus(task.status), - TaskDocumentFields.priority: task.priority == null - ? null - : PersistenceEnumMapping.encodePriority(task.priority!), - TaskDocumentFields.reward: - PersistenceEnumMapping.encodeReward(task.reward), - TaskDocumentFields.difficulty: - PersistenceEnumMapping.encodeDifficulty(task.difficulty), - TaskDocumentFields.durationMinutes: task.durationMinutes, - TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), - TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), - TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), - TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), - TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), - TaskDocumentFields.parentTaskId: task.parentTaskId, - TaskDocumentFields.backlogTags: task.backlogTags - .map(PersistenceEnumMapping.encodeBacklogTag) - .toList(), - TaskDocumentFields.reminderOverride: task.reminderOverride == null - ? null - : PersistenceEnumMapping.encodeReminderProfile( - task.reminderOverride!), - TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( - task.stats, - ), - TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), - TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, - }; - } - - static Task fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return Task( - id: _requiredString(document, TaskDocumentFields.id), - title: _requiredString(document, TaskDocumentFields.title), - projectId: _requiredString(document, TaskDocumentFields.projectId), - type: PersistenceEnumMapping.decodeTaskType( - _requiredString(document, TaskDocumentFields.type), - ), - status: PersistenceEnumMapping.decodeTaskStatus( - _requiredString(document, TaskDocumentFields.status), - ), - priority: PersistenceEnumMapping.decodeNullablePriority( - _requiredNullableString(document, TaskDocumentFields.priority), - ), - reward: PersistenceEnumMapping.decodeReward( - _requiredString(document, TaskDocumentFields.reward), - ), - difficulty: PersistenceEnumMapping.decodeDifficulty( - _requiredString(document, TaskDocumentFields.difficulty), - ), - durationMinutes: - _requiredNullableInt(document, TaskDocumentFields.durationMinutes), - scheduledStart: _requiredNullableDateTime( - document, - TaskDocumentFields.scheduledStart, - ), - scheduledEnd: _requiredNullableDateTime( - document, - TaskDocumentFields.scheduledEnd, - ), - actualStart: _requiredNullableDateTime( - document, - TaskDocumentFields.actualStart, - ), - actualEnd: _requiredNullableDateTime( - document, - TaskDocumentFields.actualEnd, - ), - completedAt: _requiredNullableDateTime( - document, - TaskDocumentFields.completedAt, - ), - parentTaskId: _requiredNullableString( - document, - TaskDocumentFields.parentTaskId, - ), - backlogTags: _backlogTagsFromDocument(document), - reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( - _requiredNullableString( - document, - TaskDocumentFields.reminderOverride, - ), - ), - createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), - updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), - stats: TaskStatisticsDocumentMapping.fromDocument( - _requiredMap(document, TaskDocumentFields.stats), - ), - ).._validateTaskDocumentMetadata(document); - }); - } - - static Set _backlogTagsFromDocument( - Map document, - ) { - final rawTags = _requiredList(document, TaskDocumentFields.backlogTags); - return rawTags.map((tag) { - if (tag is! String) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: TaskDocumentFields.backlogTags, - ); - } - return PersistenceEnumMapping.decodeBacklogTag(tag); - }).toSet(); - } -} - -extension on Task { - void _validateTaskDocumentMetadata(Map document) { - _requiredNullableDateTime(document, TaskDocumentFields.backlogEnteredAt); - _requiredNullableString( - document, - TaskDocumentFields.backlogEnteredAtProvenance, - ); - } -} - -/// Document mapping for [TaskStatistics]. -abstract final class TaskStatisticsDocumentMapping { - static Map toDocument(TaskStatistics stats) { - return { - TaskStatisticsDocumentFields.skippedDuringBurnoutCount: - stats.skippedDuringBurnoutCount, - TaskStatisticsDocumentFields.manuallyPushedCount: - stats.manuallyPushedCount, - TaskStatisticsDocumentFields.autoPushedCount: stats.autoPushedCount, - TaskStatisticsDocumentFields.movedToBacklogCount: - stats.movedToBacklogCount, - TaskStatisticsDocumentFields.restoredFromBacklogCount: - stats.restoredFromBacklogCount, - TaskStatisticsDocumentFields.missedCount: stats.missedCount, - TaskStatisticsDocumentFields.cancelledCount: stats.cancelledCount, - TaskStatisticsDocumentFields.completedLateCount: stats.completedLateCount, - TaskStatisticsDocumentFields.completedDuringLockedHoursCount: - stats.completedDuringLockedHoursCount, - TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: - stats.completedDuringLockedHoursMinutes, - TaskStatisticsDocumentFields.completedAfterShieldCount: - stats.completedAfterShieldCount, - TaskStatisticsDocumentFields.completedAfterPushCount: - stats.completedAfterPushCount, - TaskStatisticsDocumentFields.totalPushesBeforeCompletion: - stats.totalPushesBeforeCompletion, - }; - } - - static TaskStatistics fromDocument(Map document) { - return _mapInvalidValues(() { - return TaskStatistics( - skippedDuringBurnoutCount: _requiredInt( - document, - TaskStatisticsDocumentFields.skippedDuringBurnoutCount, - ), - manuallyPushedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.manuallyPushedCount, - ), - autoPushedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.autoPushedCount, - ), - movedToBacklogCount: _requiredInt( - document, - TaskStatisticsDocumentFields.movedToBacklogCount, - ), - restoredFromBacklogCount: _requiredInt( - document, - TaskStatisticsDocumentFields.restoredFromBacklogCount, - ), - missedCount: _requiredInt( - document, - TaskStatisticsDocumentFields.missedCount, - ), - cancelledCount: _requiredInt( - document, - TaskStatisticsDocumentFields.cancelledCount, - ), - completedLateCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedLateCount, - ), - completedDuringLockedHoursCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursCount, - ), - completedDuringLockedHoursMinutes: _requiredInt( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, - ), - completedAfterShieldCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedAfterShieldCount, - ), - completedAfterPushCount: _requiredInt( - document, - TaskStatisticsDocumentFields.completedAfterPushCount, - ), - totalPushesBeforeCompletion: _requiredInt( - document, - TaskStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - ); - }); - } -} - -/// Document mapping for [ProjectProfile]. -abstract final class ProjectDocumentMapping { - static Map toDocument( - ProjectProfile project, { - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - }) { - return { - ..._commonFields( - id: project.id, - ownerId: ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - ProjectDocumentFields.name: project.name, - ProjectDocumentFields.colorKey: project.colorKey, - ProjectDocumentFields.defaultPriority: - PersistenceEnumMapping.encodePriority(project.defaultPriority), - ProjectDocumentFields.defaultReward: - PersistenceEnumMapping.encodeReward(project.defaultReward), - ProjectDocumentFields.defaultDifficulty: - PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty), - ProjectDocumentFields.defaultReminderProfile: - PersistenceEnumMapping.encodeReminderProfile( - project.defaultReminderProfile, - ), - ProjectDocumentFields.defaultDurationMinutes: - project.defaultDurationMinutes, - ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt), - }; - } - - static ProjectProfile fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return ProjectProfile( - id: _requiredString(document, ProjectDocumentFields.id), - name: _requiredString(document, ProjectDocumentFields.name), - colorKey: _requiredString(document, ProjectDocumentFields.colorKey), - defaultPriority: PersistenceEnumMapping.decodeNullablePriority( - _requiredString(document, ProjectDocumentFields.defaultPriority), - )!, - defaultReward: PersistenceEnumMapping.decodeReward( - _requiredString(document, ProjectDocumentFields.defaultReward), - ), - defaultDifficulty: PersistenceEnumMapping.decodeDifficulty( - _requiredString(document, ProjectDocumentFields.defaultDifficulty), - ), - defaultReminderProfile: - PersistenceEnumMapping.decodeNullableReminderProfile( - _requiredString( - document, - ProjectDocumentFields.defaultReminderProfile, - ), - )!, - defaultDurationMinutes: _requiredNullableInt( - document, - ProjectDocumentFields.defaultDurationMinutes, - ), - archivedAt: _requiredNullableDateTime( - document, - ProjectDocumentFields.archivedAt, - ), - ); - }); - } -} - -/// Document mapping for [ProjectStatistics]. -abstract final class ProjectStatisticsDocumentMapping { - static Map toDocument( - ProjectStatistics statistics, { - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - Iterable appliedActivityIds = const [], - }) { - return { - ..._commonFields( - id: statistics.projectId, - ownerId: ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - ProjectStatisticsDocumentFields.projectId: statistics.projectId, - ProjectStatisticsDocumentFields.completedTaskCount: - statistics.completedTaskCount, - ProjectStatisticsDocumentFields.durationMinuteCounts: - _intKeyCountMapToDocument(statistics.durationMinuteCounts), - ProjectStatisticsDocumentFields.completionTimeBucketCounts: - _enumCountMapToDocument( - statistics.completionTimeBucketCounts, - PersistenceEnumMapping.encodeProjectCompletionTimeBucket, - ), - ProjectStatisticsDocumentFields.totalPushesBeforeCompletion: - statistics.totalPushesBeforeCompletion, - ProjectStatisticsDocumentFields.completedAfterPushCount: - statistics.completedAfterPushCount, - ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument( - statistics.rewardCounts, - PersistenceEnumMapping.encodeReward, - ), - ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument( - statistics.difficultyCounts, - PersistenceEnumMapping.encodeDifficulty, - ), - ProjectStatisticsDocumentFields.reminderProfileCounts: - _enumCountMapToDocument( - statistics.reminderProfileCounts, - PersistenceEnumMapping.encodeReminderProfile, - ), - ProjectStatisticsDocumentFields.appliedActivityIds: - appliedActivityIds.toList(growable: false), - }; - } - - static ProjectStatistics fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return ProjectStatistics( - projectId: _requiredString( - document, - ProjectStatisticsDocumentFields.projectId, - ), - completedTaskCount: _requiredInt( - document, - ProjectStatisticsDocumentFields.completedTaskCount, - ), - durationMinuteCounts: _intKeyCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.durationMinuteCounts, - ), - completionTimeBucketCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.completionTimeBucketCounts, - PersistenceEnumMapping.decodeProjectCompletionTimeBucket, - ), - totalPushesBeforeCompletion: _requiredInt( - document, - ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - completedAfterPushCount: _requiredInt( - document, - ProjectStatisticsDocumentFields.completedAfterPushCount, - ), - rewardCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.rewardCounts, - PersistenceEnumMapping.decodeReward, - ), - difficultyCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.difficultyCounts, - PersistenceEnumMapping.decodeDifficulty, - ), - reminderProfileCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.reminderProfileCounts, - (value) => - PersistenceEnumMapping.decodeNullableReminderProfile(value)!, - ), - ).._validateProjectStatisticsDocumentMetadata(document); - }); - } -} - -extension on ProjectStatistics { - void _validateProjectStatisticsDocumentMetadata( - Map document, - ) { - for (final value in _requiredList( - document, - ProjectStatisticsDocumentFields.appliedActivityIds, - )) { - if (value is! String) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: ProjectStatisticsDocumentFields.appliedActivityIds, - ); - } - } - } -} - -/// Document mapping for [LockedBlockRecurrence]. -abstract final class LockedBlockRecurrenceDocumentMapping { - static Map toDocument(LockedBlockRecurrence recurrence) { - return { - LockedBlockRecurrenceDocumentFields.type: 'weekly', - LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays - .map(PersistenceEnumMapping.encodeLockedWeekday) - .toList(growable: false), - }; - } - - static LockedBlockRecurrence fromDocument(Map document) { - return _mapInvalidValues(() { - final type = _requiredString( - document, - LockedBlockRecurrenceDocumentFields.type, - ); - if (type != 'weekly') { - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - fieldName: LockedBlockRecurrenceDocumentFields.type, - detailCode: type, - ); - } - final weekdays = _requiredList( - document, - LockedBlockRecurrenceDocumentFields.weekdays, - ).map((value) { - if (value is! String) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - ); - } - return PersistenceEnumMapping.decodeLockedWeekday(value); - }).toSet(); - return LockedBlockRecurrence.weekly(weekdays: weekdays); - }); - } -} - -/// Document mapping for [LockedBlock]. -abstract final class LockedBlockDocumentMapping { - static Map toDocument( - LockedBlock block, { - required String ownerId, - int revision = 1, - }) { - return { - ..._commonFields( - id: block.id, - ownerId: ownerId, - revision: revision, - createdAt: block.createdAt, - updatedAt: block.updatedAt, - ), - LockedBlockDocumentFields.name: block.name, - LockedBlockDocumentFields.startTime: - PersistenceWallTimeConvention.toStoredString(block.startTime), - LockedBlockDocumentFields.endTime: - PersistenceWallTimeConvention.toStoredString(block.endTime), - LockedBlockDocumentFields.date: _civilDateToStored(block.date), - LockedBlockDocumentFields.recurrence: block.recurrence == null - ? null - : LockedBlockRecurrenceDocumentMapping.toDocument( - block.recurrence!, - ), - LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault, - LockedBlockDocumentFields.projectId: block.projectId, - LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt), - }; - } - - static LockedBlock fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - final recurrenceDocument = _requiredNullableMap( - document, - LockedBlockDocumentFields.recurrence, - ); - return LockedBlock( - id: _requiredString(document, LockedBlockDocumentFields.id), - name: _requiredString(document, LockedBlockDocumentFields.name), - startTime: _requiredWallTime( - document, - LockedBlockDocumentFields.startTime, - ), - endTime: _requiredWallTime( - document, - LockedBlockDocumentFields.endTime, - ), - date: _requiredNullableCivilDate( - document, - LockedBlockDocumentFields.date, - ), - recurrence: recurrenceDocument == null - ? null - : LockedBlockRecurrenceDocumentMapping.fromDocument( - recurrenceDocument, - ), - hiddenByDefault: _requiredBool( - document, - LockedBlockDocumentFields.hiddenByDefault, - ), - projectId: _requiredNullableString( - document, - LockedBlockDocumentFields.projectId, - ), - createdAt: - _requiredDateTime(document, LockedBlockDocumentFields.createdAt), - updatedAt: - _requiredDateTime(document, LockedBlockDocumentFields.updatedAt), - archivedAt: _requiredNullableDateTime( - document, - LockedBlockDocumentFields.archivedAt, - ), - ); - }); - } -} - -/// Document mapping for [LockedBlockOverride]. -abstract final class LockedBlockOverrideDocumentMapping { - static Map toDocument( - LockedBlockOverride override, { - required String ownerId, - int revision = 1, - }) { - return { - ..._commonFields( - id: override.id, - ownerId: ownerId, - revision: revision, - createdAt: override.createdAt, - updatedAt: override.updatedAt, - ), - LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId, - LockedBlockOverrideDocumentFields.date: - PersistenceCivilDateConvention.toStoredString(override.date), - LockedBlockOverrideDocumentFields.type: - PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type), - LockedBlockOverrideDocumentFields.name: override.name, - LockedBlockOverrideDocumentFields.startTime: - _wallTimeToStored(override.startTime), - LockedBlockOverrideDocumentFields.endTime: - _wallTimeToStored(override.endTime), - LockedBlockOverrideDocumentFields.hiddenByDefault: - override.hiddenByDefault, - LockedBlockOverrideDocumentFields.projectId: override.projectId, - }; - } - - static LockedBlockOverride fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return LockedBlockOverride( - id: _requiredString(document, LockedBlockOverrideDocumentFields.id), - date: _requiredCivilDate( - document, - LockedBlockOverrideDocumentFields.date, - ), - type: PersistenceEnumMapping.decodeLockedBlockOverrideType( - _requiredString(document, LockedBlockOverrideDocumentFields.type), - ), - createdAt: _requiredDateTime( - document, - LockedBlockOverrideDocumentFields.createdAt, - ), - updatedAt: _requiredDateTime( - document, - LockedBlockOverrideDocumentFields.updatedAt, - ), - lockedBlockId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.lockedBlockId, - ), - name: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.name, - ), - startTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.startTime, - ), - endTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.endTime, - ), - hiddenByDefault: _requiredBool( - document, - LockedBlockOverrideDocumentFields.hiddenByDefault, - ), - projectId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.projectId, - ), - ); - }); - } -} - -/// Document mapping for [TaskActivity]. -abstract final class TaskActivityDocumentMapping { - static Map toDocument( - TaskActivity activity, { - required String ownerId, - int revision = 1, - DateTime? createdAt, - DateTime? updatedAt, - }) { - final created = createdAt ?? activity.occurredAt; - final updated = updatedAt ?? created; - return { - ..._commonFields( - id: activity.id, - ownerId: ownerId, - revision: revision, - createdAt: created, - updatedAt: updated, - ), - TaskActivityDocumentFields.operationId: activity.operationId, - TaskActivityDocumentFields.code: - PersistenceEnumMapping.encodeTaskActivityCode(activity.code), - TaskActivityDocumentFields.taskId: activity.taskId, - TaskActivityDocumentFields.projectId: activity.projectId, - TaskActivityDocumentFields.occurredAt: - PersistenceDateTimeConvention.toStoredString(activity.occurredAt), - TaskActivityDocumentFields.metadata: _plainDocument(activity.metadata), - }; - } - - static TaskActivity fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return TaskActivity( - id: _requiredString(document, TaskActivityDocumentFields.id), - operationId: _requiredString( - document, - TaskActivityDocumentFields.operationId, - ), - code: PersistenceEnumMapping.decodeTaskActivityCode( - _requiredString(document, TaskActivityDocumentFields.code), - ), - taskId: _requiredString(document, TaskActivityDocumentFields.taskId), - projectId: - _requiredString(document, TaskActivityDocumentFields.projectId), - occurredAt: _requiredDateTime( - document, - TaskActivityDocumentFields.occurredAt, - ), - metadata: _requiredMap(document, TaskActivityDocumentFields.metadata), - ); - }); - } -} - -/// Document mapping for [OwnerSettings]. -abstract final class OwnerSettingsDocumentMapping { - static Map toDocument( - OwnerSettings settings, { - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - }) { - return { - ..._commonFields( - id: settings.ownerId, - ownerId: settings.ownerId, - revision: revision, - createdAt: createdAt, - updatedAt: updatedAt, - ), - OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId, - OwnerSettingsDocumentFields.dayStart: - PersistenceWallTimeConvention.toStoredString(settings.dayStart), - OwnerSettingsDocumentFields.dayEnd: - PersistenceWallTimeConvention.toStoredString(settings.dayEnd), - OwnerSettingsDocumentFields.compactModeEnabled: - settings.compactModeEnabled, - OwnerSettingsDocumentFields.backlogStaleness: - BacklogStalenessDocumentMapping.toDocument( - settings.backlogStalenessSettings, - ), - }; - } - - static OwnerSettings fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return OwnerSettings( - ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId), - timeZoneId: _requiredString( - document, - OwnerSettingsDocumentFields.timeZoneId, - ), - dayStart: _requiredWallTime( - document, - OwnerSettingsDocumentFields.dayStart, - ), - dayEnd: _requiredWallTime( - document, - OwnerSettingsDocumentFields.dayEnd, - ), - compactModeEnabled: _requiredBool( - document, - OwnerSettingsDocumentFields.compactModeEnabled, - ), - backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument( - _requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness), - ), - ); - }); - } -} - -/// Document mapping for [BacklogStalenessSettings]. -abstract final class BacklogStalenessDocumentMapping { - static Map toDocument(BacklogStalenessSettings settings) { - return { - BacklogStalenessDocumentFields.greenMaxAgeDays: - settings.greenMaxAge.inDays, - BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays, - }; - } - - static BacklogStalenessSettings fromDocument(Map document) { - return _mapInvalidValues(() { - final greenDays = _requiredInt( - document, - BacklogStalenessDocumentFields.greenMaxAgeDays, - ); - final blueDays = _requiredInt( - document, - BacklogStalenessDocumentFields.blueMaxAgeDays, - ); - if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: OwnerSettingsDocumentFields.backlogStaleness, - detailCode: 'invalidBacklogStalenessThresholds', - ); - } - return BacklogStalenessSettings( - greenMaxAge: Duration(days: greenDays), - blueMaxAge: Duration(days: blueDays), - ); - }); - } -} - -/// Document mapping for [NoticeAcknowledgementRecord]. -abstract final class NoticeAcknowledgementDocumentMapping { - static Map toDocument( - NoticeAcknowledgementRecord record, { - int revision = 1, - }) { - return { - ..._commonFields( - id: record.noticeId, - ownerId: record.ownerId, - revision: revision, - createdAt: record.acknowledgedAt, - updatedAt: record.acknowledgedAt, - ), - NoticeAcknowledgementDocumentFields.noticeId: record.noticeId, - NoticeAcknowledgementDocumentFields.acknowledgedAt: - PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt), - }; - } - - static NoticeAcknowledgementRecord fromDocument( - Map document, - ) { - return _mapInvalidValues(() { - _readCommon(document); - return NoticeAcknowledgementRecord( - noticeId: _requiredString( - document, - NoticeAcknowledgementDocumentFields.noticeId, - ), - ownerId: _requiredString( - document, - NoticeAcknowledgementDocumentFields.ownerId, - ), - acknowledgedAt: _requiredDateTime( - document, - NoticeAcknowledgementDocumentFields.acknowledgedAt, - ), - ); - }); - } -} - -/// Document mapping for [ApplicationOperationRecord]. -abstract final class ApplicationOperationDocumentMapping { - static Map toDocument( - ApplicationOperationRecord record, { - int revision = 1, - }) { - return { - ..._commonFields( - id: record.operationId, - ownerId: record.ownerId, - revision: revision, - createdAt: record.committedAt, - updatedAt: record.committedAt, - ), - ApplicationOperationDocumentFields.operationId: record.operationId, - ApplicationOperationDocumentFields.operationName: record.operationName, - ApplicationOperationDocumentFields.committedAt: - PersistenceDateTimeConvention.toStoredString(record.committedAt), - }; - } - - static ApplicationOperationRecord fromDocument( - Map document, - ) { - return _mapInvalidValues(() { - _readCommon(document); - return ApplicationOperationRecord( - operationId: _requiredString( - document, - ApplicationOperationDocumentFields.operationId, - ), - ownerId: _requiredString( - document, - ApplicationOperationDocumentFields.ownerId, - ), - operationName: _requiredString( - document, - ApplicationOperationDocumentFields.operationName, - ), - committedAt: _requiredDateTime( - document, - ApplicationOperationDocumentFields.committedAt, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingStateSnapshot]. -abstract final class SchedulingSnapshotDocumentMapping { - static Map toDocument( - SchedulingStateSnapshot snapshot, { - required String ownerId, - int revision = 1, - CivilDate? sourceDate, - CivilDate? targetDate, - DateTime? retentionExpiresAt, - bool truncated = false, - }) { - return { - ..._commonFields( - id: snapshot.id, - ownerId: ownerId, - revision: revision, - createdAt: snapshot.capturedAt, - updatedAt: snapshot.capturedAt, - ), - SchedulingSnapshotDocumentFields.capturedAt: - PersistenceDateTimeConvention.toStoredString(snapshot.capturedAt), - SchedulingSnapshotDocumentFields.sourceDate: - _civilDateToStored(sourceDate), - SchedulingSnapshotDocumentFields.targetDate: - _civilDateToStored(targetDate), - SchedulingSnapshotDocumentFields.window: - SchedulingWindowDocumentMapping.toDocument(snapshot.window), - SchedulingSnapshotDocumentFields.tasks: snapshot.tasks - .map( - (task) => TaskDocumentMapping.toDocument( - task, - ownerId: ownerId, - ), - ) - .toList(growable: false), - SchedulingSnapshotDocumentFields.lockedIntervals: snapshot.lockedIntervals - .map(TimeIntervalDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.requiredVisibleIntervals: snapshot - .requiredVisibleIntervals - .map(TimeIntervalDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.notices: snapshot.notices - .map(SchedulingNoticeDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.changes: snapshot.changes - .map(SchedulingChangeDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.overlaps: snapshot.overlaps - .map(SchedulingOverlapDocumentMapping.toDocument) - .toList(growable: false), - SchedulingSnapshotDocumentFields.operationName: snapshot.operationName, - SchedulingSnapshotDocumentFields.retentionExpiresAt: - _dateTimeToStored(retentionExpiresAt), - SchedulingSnapshotDocumentFields.truncated: truncated, - }; - } - - static SchedulingStateSnapshot fromDocument(Map document) { - return _mapInvalidValues(() { - _readCommon(document); - return SchedulingStateSnapshot( - id: _requiredString(document, SchedulingSnapshotDocumentFields.id), - capturedAt: _requiredDateTime( - document, - SchedulingSnapshotDocumentFields.capturedAt, - ), - window: SchedulingWindowDocumentMapping.fromDocument( - _requiredMap(document, SchedulingSnapshotDocumentFields.window), - ), - tasks: _mappedList( - document, - SchedulingSnapshotDocumentFields.tasks, - TaskDocumentMapping.fromDocument, - ), - lockedIntervals: _mappedList( - document, - SchedulingSnapshotDocumentFields.lockedIntervals, - TimeIntervalDocumentMapping.fromDocument, - ), - requiredVisibleIntervals: _mappedList( - document, - SchedulingSnapshotDocumentFields.requiredVisibleIntervals, - TimeIntervalDocumentMapping.fromDocument, - ), - notices: _mappedList( - document, - SchedulingSnapshotDocumentFields.notices, - SchedulingNoticeDocumentMapping.fromDocument, - ), - changes: _mappedList( - document, - SchedulingSnapshotDocumentFields.changes, - SchedulingChangeDocumentMapping.fromDocument, - ), - overlaps: _mappedList( - document, - SchedulingSnapshotDocumentFields.overlaps, - SchedulingOverlapDocumentMapping.fromDocument, - ), - operationName: _requiredNullableString( - document, - SchedulingSnapshotDocumentFields.operationName, - ), - ).._validateSchedulingSnapshotDocumentMetadata(document); - }); - } -} - -extension on SchedulingStateSnapshot { - void _validateSchedulingSnapshotDocumentMetadata( - Map document, - ) { - _requiredNullableCivilDate( - document, - SchedulingSnapshotDocumentFields.sourceDate, - ); - _requiredNullableCivilDate( - document, - SchedulingSnapshotDocumentFields.targetDate, - ); - _requiredNullableDateTime( - document, - SchedulingSnapshotDocumentFields.retentionExpiresAt, - ); - _requiredBool(document, SchedulingSnapshotDocumentFields.truncated); - } -} - -/// Document mapping for [SchedulingWindow]. -abstract final class SchedulingWindowDocumentMapping { - static Map toDocument(SchedulingWindow window) { - return { - SchedulingWindowDocumentFields.start: - PersistenceDateTimeConvention.toStoredString(window.start), - SchedulingWindowDocumentFields.end: - PersistenceDateTimeConvention.toStoredString(window.end), - }; - } - - static SchedulingWindow fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingWindow( - start: - _requiredDateTime(document, SchedulingWindowDocumentFields.start), - end: _requiredDateTime(document, SchedulingWindowDocumentFields.end), - ); - }); - } -} - -/// Document mapping for [TimeInterval]. -abstract final class TimeIntervalDocumentMapping { - static Map toDocument(TimeInterval interval) { - return { - TimeIntervalDocumentFields.start: - PersistenceDateTimeConvention.toStoredString(interval.start), - TimeIntervalDocumentFields.end: - PersistenceDateTimeConvention.toStoredString(interval.end), - TimeIntervalDocumentFields.label: interval.label, - }; - } - - static TimeInterval fromDocument(Map document) { - return _mapInvalidValues(() { - return TimeInterval( - start: _requiredDateTime(document, TimeIntervalDocumentFields.start), - end: _requiredDateTime(document, TimeIntervalDocumentFields.end), - label: _requiredNullableString( - document, - TimeIntervalDocumentFields.label, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingNotice]. -abstract final class SchedulingNoticeDocumentMapping { - static Map toDocument(SchedulingNotice notice) { - return { - SchedulingNoticeDocumentFields.message: notice.message, - SchedulingNoticeDocumentFields.type: - PersistenceEnumMapping.encodeSchedulingNoticeType(notice.type), - SchedulingNoticeDocumentFields.taskId: notice.taskId, - SchedulingNoticeDocumentFields.issueCode: notice.issueCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingIssueCode(notice.issueCode!), - SchedulingNoticeDocumentFields.movementCode: notice.movementCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingMovementCode( - notice.movementCode!, - ), - SchedulingNoticeDocumentFields.conflictCode: notice.conflictCode == null - ? null - : PersistenceEnumMapping.encodeSchedulingConflictCode( - notice.conflictCode!, - ), - SchedulingNoticeDocumentFields.parameters: - _plainDocument(notice.parameters), - }; - } - - static SchedulingNotice fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingNotice( - _requiredString(document, SchedulingNoticeDocumentFields.message), - type: PersistenceEnumMapping.decodeSchedulingNoticeType( - _requiredString(document, SchedulingNoticeDocumentFields.type), - ), - taskId: _requiredNullableString( - document, - SchedulingNoticeDocumentFields.taskId, - ), - issueCode: PersistenceEnumMapping.decodeNullableSchedulingIssueCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.issueCode, - ), - ), - movementCode: - PersistenceEnumMapping.decodeNullableSchedulingMovementCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.movementCode, - ), - ), - conflictCode: - PersistenceEnumMapping.decodeNullableSchedulingConflictCode( - _requiredNullableString( - document, - SchedulingNoticeDocumentFields.conflictCode, - ), - ), - parameters: _requiredMap( - document, - SchedulingNoticeDocumentFields.parameters, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingChange]. -abstract final class SchedulingChangeDocumentMapping { - static Map toDocument(SchedulingChange change) { - return { - SchedulingChangeDocumentFields.taskId: change.taskId, - SchedulingChangeDocumentFields.previousStart: - _dateTimeToStored(change.previousStart), - SchedulingChangeDocumentFields.previousEnd: - _dateTimeToStored(change.previousEnd), - SchedulingChangeDocumentFields.nextStart: - _dateTimeToStored(change.nextStart), - SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd), - }; - } - - static SchedulingChange fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingChange( - taskId: - _requiredString(document, SchedulingChangeDocumentFields.taskId), - previousStart: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.previousStart, - ), - previousEnd: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.previousEnd, - ), - nextStart: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.nextStart, - ), - nextEnd: _requiredNullableDateTime( - document, - SchedulingChangeDocumentFields.nextEnd, - ), - ); - }); - } -} - -/// Document mapping for [SchedulingOverlap]. -abstract final class SchedulingOverlapDocumentMapping { - static Map toDocument(SchedulingOverlap overlap) { - return { - SchedulingOverlapDocumentFields.taskId: overlap.taskId, - SchedulingOverlapDocumentFields.taskInterval: - TimeIntervalDocumentMapping.toDocument(overlap.taskInterval), - SchedulingOverlapDocumentFields.blockedInterval: - TimeIntervalDocumentMapping.toDocument(overlap.blockedInterval), - }; - } - - static SchedulingOverlap fromDocument(Map document) { - return _mapInvalidValues(() { - return SchedulingOverlap( - taskId: - _requiredString(document, SchedulingOverlapDocumentFields.taskId), - taskInterval: TimeIntervalDocumentMapping.fromDocument( - _requiredMap(document, SchedulingOverlapDocumentFields.taskInterval), - ), - blockedInterval: TimeIntervalDocumentMapping.fromDocument( - _requiredMap( - document, - SchedulingOverlapDocumentFields.blockedInterval, - ), - ), - ); - }); - } -} - -/// Convenience extension for converting a [Task] into a V1 document map. -extension TaskDocumentExtension on Task { - Map toDocument({ - required String ownerId, - int revision = 1, - bool clearSchedule = false, - DateTime? backlogEnteredAt, - String? backlogEnteredAtProvenance, - }) { - return TaskDocumentMapping.toDocument( - this, - ownerId: ownerId, - revision: revision, - clearSchedule: clearSchedule, - backlogEnteredAt: backlogEnteredAt, - backlogEnteredAtProvenance: backlogEnteredAtProvenance, - ); - } -} - -/// Convenience extension for converting [TaskStatistics] into an embedded map. -extension TaskStatisticsDocumentExtension on TaskStatistics { - Map toDocument() { - return TaskStatisticsDocumentMapping.toDocument(this); - } -} - -/// Convenience extension for converting [ProjectStatistics] into a document. -extension ProjectStatisticsDocumentExtension on ProjectStatistics { - Map toDocument({ - required String ownerId, - required DateTime createdAt, - required DateTime updatedAt, - int revision = 1, - Iterable appliedActivityIds = const [], - }) { - return ProjectStatisticsDocumentMapping.toDocument( - this, - ownerId: ownerId, - createdAt: createdAt, - updatedAt: updatedAt, - revision: revision, - appliedActivityIds: appliedActivityIds, - ); - } -} - -const _taskTypeByCode = { - 'flexible': TaskType.flexible, - 'inflexible': TaskType.inflexible, - 'critical': TaskType.critical, - 'locked': TaskType.locked, - 'surprise': TaskType.surprise, - 'free_slot': TaskType.freeSlot, -}; - -const _taskStatusByCode = { - 'planned': TaskStatus.planned, - 'active': TaskStatus.active, - 'completed': TaskStatus.completed, - 'missed': TaskStatus.missed, - 'cancelled': TaskStatus.cancelled, - 'no_longer_relevant': TaskStatus.noLongerRelevant, - 'backlog': TaskStatus.backlog, -}; - -const _priorityByCode = { - 'very_low': PriorityLevel.veryLow, - 'low': PriorityLevel.low, - 'medium': PriorityLevel.medium, - 'high': PriorityLevel.high, - 'very_high': PriorityLevel.veryHigh, -}; - -const _rewardByCode = { - 'not_set': RewardLevel.notSet, - 'very_low': RewardLevel.veryLow, - 'low': RewardLevel.low, - 'medium': RewardLevel.medium, - 'high': RewardLevel.high, - 'very_high': RewardLevel.veryHigh, -}; - -const _difficultyByCode = { - 'not_set': DifficultyLevel.notSet, - 'very_easy': DifficultyLevel.veryEasy, - 'easy': DifficultyLevel.easy, - 'medium': DifficultyLevel.medium, - 'hard': DifficultyLevel.hard, - 'very_hard': DifficultyLevel.veryHard, -}; - -const _reminderProfileByCode = { - 'silent': ReminderProfile.silent, - 'gentle': ReminderProfile.gentle, - 'persistent': ReminderProfile.persistent, - 'strict': ReminderProfile.strict, -}; - -const _backlogTagByCode = { - 'wishlist': BacklogTag.wishlist, -}; - -const _lockedWeekdayByCode = { - 'monday': LockedWeekday.monday, - 'tuesday': LockedWeekday.tuesday, - 'wednesday': LockedWeekday.wednesday, - 'thursday': LockedWeekday.thursday, - 'friday': LockedWeekday.friday, - 'saturday': LockedWeekday.saturday, - 'sunday': LockedWeekday.sunday, -}; - -const _lockedBlockOverrideTypeByCode = { - 'remove': LockedBlockOverrideType.remove, - 'replace': LockedBlockOverrideType.replace, - 'add': LockedBlockOverrideType.add, -}; - -const _taskActivityCodeByCode = { - 'completed': TaskActivityCode.completed, - 'missed': TaskActivityCode.missed, - 'cancelled': TaskActivityCode.cancelled, - 'no_longer_relevant': TaskActivityCode.noLongerRelevant, - 'manually_pushed': TaskActivityCode.manuallyPushed, - 'automatically_pushed': TaskActivityCode.automaticallyPushed, - 'moved_to_backlog': TaskActivityCode.movedToBacklog, - 'restored_from_backlog': TaskActivityCode.restoredFromBacklog, - 'activated': TaskActivityCode.activated, -}; - -const _schedulingNoticeTypeByCode = { - 'info': SchedulingNoticeType.info, - 'moved': SchedulingNoticeType.moved, - 'overlap': SchedulingNoticeType.overlap, - 'no_fit': SchedulingNoticeType.noFit, - 'overflow': SchedulingNoticeType.overflow, -}; - -const _schedulingIssueCodeByCode = { - 'task_not_found': SchedulingIssueCode.taskNotFound, - 'invalid_task_state': SchedulingIssueCode.invalidTaskState, - 'missing_duration': SchedulingIssueCode.missingDuration, - 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, - 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, - 'no_available_slot': SchedulingIssueCode.noAvailableSlot, - 'unfinished_tasks_could_not_fit': - SchedulingIssueCode.unfinishedTasksCouldNotFit, - 'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks, - 'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog, -}; - -const _schedulingMovementCodeByCode = { - 'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted, - 'flexible_task_moved_to_make_room': - SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - 'flexible_task_pushed_to_next_available_slot': - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, - 'flexible_task_moved_to_tomorrow': - SchedulingMovementCode.flexibleTaskMovedToTomorrow, - 'unfinished_flexible_tasks_rolled_over': - SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - 'flexible_task_moved_to_backlog': - SchedulingMovementCode.flexibleTaskMovedToBacklog, - 'required_commitment_scheduled': - SchedulingMovementCode.requiredCommitmentScheduled, -}; - -const _schedulingConflictCodeByCode = { - 'flexible_task_overlaps_blocked_time': - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, - 'surprise_task_overlaps_required_visible_time': - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, - 'required_commitment_overlaps_protected_free_slot': - SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot, -}; - -const _projectCompletionTimeBucketByCode = - { - 'overnight': ProjectCompletionTimeBucket.overnight, - 'morning': ProjectCompletionTimeBucket.morning, - 'afternoon': ProjectCompletionTimeBucket.afternoon, - 'evening': ProjectCompletionTimeBucket.evening, - 'night': ProjectCompletionTimeBucket.night, -}; - -T _decodeCode(Map valuesByCode, String code, String fieldName) { - final value = valuesByCode[code]; - if (value == null) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.unknownCode, - fieldName: fieldName, - detailCode: code, - ); - } - return value; -} - -Map _commonFields({ - required String id, - required String ownerId, - required int revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - if (revision <= 0) { - throw const DocumentMappingException( - code: DocumentMappingFailureCode.invalidRevision, - fieldName: DocumentFields.revision, - ); - } - return { - DocumentFields.schemaVersion: v1SchemaVersion, - DocumentFields.id: id, - DocumentFields.ownerId: ownerId, - DocumentFields.revision: revision, - DocumentFields.createdAt: - PersistenceDateTimeConvention.toStoredString(createdAt), - DocumentFields.updatedAt: - PersistenceDateTimeConvention.toStoredString(updatedAt), - }; -} - -DocumentMetadata _readCommon(Map document) { - final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion); - if (schemaVersion != v1SchemaVersion) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: schemaVersion.toString(), - ); - } - final revision = _requiredInt(document, DocumentFields.revision); - if (revision <= 0) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidRevision, - fieldName: DocumentFields.revision, - detailCode: revision.toString(), - ); - } - return DocumentMetadata( - id: _requiredString(document, DocumentFields.id), - ownerId: _requiredString(document, DocumentFields.ownerId), - revision: revision, - createdAt: _requiredDateTime(document, DocumentFields.createdAt), - updatedAt: _requiredDateTime(document, DocumentFields.updatedAt), - ); -} - -T _mapInvalidValues(T Function() read) { - try { - return read(); - } on DocumentMappingException { - rethrow; - } on DomainValidationException catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: error.name, - detailCode: error.code.name, - ); - } on FormatException catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - detailCode: error.message, - ); - } on ArgumentError catch (error) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.invalidValue, - fieldName: error.name, - ); - } -} - -String? _dateTimeToStored(DateTime? value) { - return value == null - ? null - : PersistenceDateTimeConvention.toStoredString(value); -} - -String? _civilDateToStored(CivilDate? value) { - return value == null - ? null - : PersistenceCivilDateConvention.toStoredString(value); -} - -String? _wallTimeToStored(WallTime? value) { - return value == null - ? null - : PersistenceWallTimeConvention.toStoredString(value); -} - -String _requiredString(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is String) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -String? _requiredNullableString( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null || value is String) { - return value as String?; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -int _requiredInt(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is int) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -int? _requiredNullableInt( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null || value is int) { - return value as int?; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -bool _requiredBool(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is bool) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -DateTime _requiredDateTime(Map document, String fieldName) { - return PersistenceDateTimeConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -DateTime? _requiredNullableDateTime( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceDateTimeConvention.fromStoredString(value); -} - -CivilDate _requiredCivilDate(Map document, String fieldName) { - return PersistenceCivilDateConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -CivilDate? _requiredNullableCivilDate( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceCivilDateConvention.fromStoredString(value); -} - -WallTime _requiredWallTime(Map document, String fieldName) { - return PersistenceWallTimeConvention.fromStoredString( - _requiredString(document, fieldName), - ); -} - -WallTime? _requiredNullableWallTime( - Map document, - String fieldName, -) { - final value = _requiredNullableString(document, fieldName); - return value == null - ? null - : PersistenceWallTimeConvention.fromStoredString(value); -} - -Map _requiredMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is Map) { - return value; - } - if (value is Map) { - return Map.from(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -Map? _requiredNullableMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value == null) { - return null; - } - if (value is Map) { - return value; - } - if (value is Map) { - return Map.from(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -List _requiredList(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw DocumentMappingException( - code: DocumentMappingFailureCode.missingField, - fieldName: fieldName, - ); - } - final value = document[fieldName]; - if (value is List) { - return List.unmodifiable(value); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -List _mappedList( - Map document, - String fieldName, - T Function(Map document) decode, -) { - return _requiredList(document, fieldName).map((value) { - if (value is Map) { - return decode(value); - } - if (value is Map) { - return decode(Map.from(value)); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); - }).toList(growable: false); -} - -Map _intKeyCountMapToDocument(Map counts) { - return { - for (final entry in counts.entries) entry.key.toString(): entry.value, - }; -} - -Map _enumCountMapToDocument( - Map counts, - String Function(T value) encode, -) { - return { - for (final entry in counts.entries) encode(entry.key): entry.value, - }; -} - -Map _intKeyCountMapFromDocument( - Map document, - String fieldName, -) { - final raw = _requiredMap(document, fieldName); - return { - for (final entry in raw.entries) - int.parse(entry.key): _countValue(entry.value, fieldName), - }; -} - -Map _enumCountMapFromDocument( - Map document, - String fieldName, - T Function(String value) decode, -) { - final raw = _requiredMap(document, fieldName); - return { - for (final entry in raw.entries) - decode(entry.key): _countValue(entry.value, fieldName), - }; -} - -int _countValue(Object? value, String fieldName) { - if (value is int) { - return value; - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - fieldName: fieldName, - ); -} - -Map _plainDocument(Map value) { - return Map.unmodifiable( - value.map((key, value) => MapEntry(key, _plainDocumentValue(value))), - ); -} - -Object? _plainDocumentValue(Object? value) { - if (value == null || - value is String || - value is num || - value is bool || - value is DateTime) { - return value is DateTime - ? PersistenceDateTimeConvention.toStoredString(value) - : value; - } - if (value is CivilDate) { - return PersistenceCivilDateConvention.toStoredString(value); - } - if (value is WallTime) { - return PersistenceWallTimeConvention.toStoredString(value); - } - if (value is Enum) { - return PersistenceEnumMapping.encode(value); - } - if (value is List) { - return value.map(_plainDocumentValue).toList(growable: false); - } - if (value is Map) { - return _plainDocument(value); - } - if (value is Map) { - return _plainDocument(Map.from(value)); - } - throw DocumentMappingException( - code: DocumentMappingFailureCode.wrongType, - detailCode: value.runtimeType.toString(), - ); -} diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/document_migration.dart deleted file mode 100644 index 8dcc6fa..0000000 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ /dev/null @@ -1,1141 +0,0 @@ -/// Implements Document Migration behavior for the Scheduler Core package. -library; - -// Legacy document migration helpers for the V1 persistence contract. -// -// The migration layer is intentionally plain Dart and side-effect free. Future -// adapters can use these results for dry-runs before deciding whether to write -// the migrated document back to storage. - -import 'document_mapping.dart'; -import 'persistence_contract.dart'; -import 'time_contracts.dart'; - -/// Entity shape being migrated. -enum DocumentMigrationEntity { - task, - project, - lockedBlock, - lockedBlockOverride, -} - -/// Result state for a migration attempt. -enum DocumentMigrationStatus { - migrated, - alreadyCurrent, - failed, -} - -/// Typed categories for migration failures. -enum DocumentMigrationFailureCode { - invalidSchemaVersion, - unsupportedSchemaVersion, - legacyDocumentFailure, - currentDocumentFailure, -} - -/// Non-fatal migration notes surfaced to callers during dry-runs. -enum DocumentMigrationNoteCode { - approximatedBacklogEnteredAt, - synthesizedMetadataTimestamp, -} - -/// Provenance labels written by migrations. -abstract final class DocumentMigrationProvenance { - static const approximatedFromCreatedAt = 'approximated_from_created_at'; -} - -/// A non-fatal detail about a migrated document. -class DocumentMigrationNote { - const DocumentMigrationNote({ - required this.code, - this.fieldName, - this.detailCode, - }); - - final DocumentMigrationNoteCode code; - final String? fieldName; - final String? detailCode; -} - -/// Typed dry-run report for one document migration attempt. -class DocumentMigrationReport { - const DocumentMigrationReport({ - required this.entity, - required this.status, - required this.documentId, - required this.fromSchemaVersion, - this.targetSchemaVersion = v1SchemaVersion, - this.failureCode, - this.mappingFailureCode, - this.fieldName, - this.detailCode, - this.notes = const [], - }); - - final DocumentMigrationEntity entity; - final DocumentMigrationStatus status; - final String? documentId; - final int? fromSchemaVersion; - final int targetSchemaVersion; - final DocumentMigrationFailureCode? failureCode; - final DocumentMappingFailureCode? mappingFailureCode; - final String? fieldName; - final String? detailCode; - final List notes; - - bool get canWrite => status != DocumentMigrationStatus.failed; -} - -/// Migration output plus report. -class DocumentMigrationResult { - const DocumentMigrationResult({ - required this.report, - required this.document, - }); - - /// Migrated/current document. Null means the source must not be overwritten. - final Map? document; - - final DocumentMigrationReport report; - - bool get isSuccess => report.status != DocumentMigrationStatus.failed; - - /// True only when the caller should persist a transformed replacement. - bool get shouldWrite => report.status == DocumentMigrationStatus.migrated; -} - -/// Pure V0-to-V1 migration service. -/// -/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner -/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry -/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future -/// versions fail closed. -class V1DocumentMigrationService { - const V1DocumentMigrationService({ - required this.ownerId, - required this.migratedAt, - }); - - final String ownerId; - final DateTime migratedAt; - - DocumentMigrationResult migrateTask(Map document) { - return _migrate( - entity: DocumentMigrationEntity.task, - document: document, - migrateV0: _taskV0ToV1, - validateCurrent: TaskDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateProject(Map document) { - return _migrate( - entity: DocumentMigrationEntity.project, - document: document, - migrateV0: _projectV0ToV1, - validateCurrent: ProjectDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateLockedBlock(Map document) { - return _migrate( - entity: DocumentMigrationEntity.lockedBlock, - document: document, - migrateV0: _lockedBlockV0ToV1, - validateCurrent: LockedBlockDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult migrateLockedBlockOverride( - Map document, - ) { - return _migrate( - entity: DocumentMigrationEntity.lockedBlockOverride, - document: document, - migrateV0: _lockedBlockOverrideV0ToV1, - validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument, - ); - } - - DocumentMigrationResult _migrate({ - required DocumentMigrationEntity entity, - required Map document, - required _V0Migrator migrateV0, - required void Function(Map document) validateCurrent, - }) { - final documentId = _documentId(document); - final schemaRead = _readSchemaVersion(document); - if (schemaRead.failure != null) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: null, - failureCode: DocumentMigrationFailureCode.invalidSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: schemaRead.failure, - ); - } - - final fromVersion = schemaRead.version; - if (fromVersion == v1SchemaVersion) { - try { - final copy = _deepCopyDocument(document); - validateCurrent(copy); - return DocumentMigrationResult( - document: copy, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.alreadyCurrent, - documentId: documentId, - fromSchemaVersion: fromVersion, - ), - ); - } on DocumentMappingException catch (error) { - return _failedFromMapping( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.currentDocumentFailure, - error: error, - ); - } - } - - if (fromVersion > v1SchemaVersion || fromVersion < 0) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: fromVersion.toString(), - ); - } - - if (fromVersion != 0) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, - fieldName: DocumentFields.schemaVersion, - detailCode: fromVersion.toString(), - ); - } - - final notes = []; - late final Map migrated; - try { - migrated = migrateV0(document, notes); - } on _LegacyDocumentException catch (error) { - return _failed( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.legacyDocumentFailure, - fieldName: error.fieldName, - detailCode: error.detailCode, - ); - } - - try { - validateCurrent(migrated); - } on DocumentMappingException catch (error) { - return _failedFromMapping( - entity: entity, - documentId: documentId, - fromSchemaVersion: fromVersion, - failureCode: DocumentMigrationFailureCode.currentDocumentFailure, - error: error, - ); - } - - return DocumentMigrationResult( - document: migrated, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.migrated, - documentId: documentId, - fromSchemaVersion: fromVersion, - notes: List.unmodifiable(notes), - ), - ); - } - - Map _taskV0ToV1( - Map document, - List notes, - ) { - final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt); - final status = _requiredLegacyCode( - document, - TaskDocumentFields.status, - _taskStatusCodes, - ); - final isBacklog = status == 'backlog'; - if (isBacklog) { - notes.add( - const DocumentMigrationNote( - code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt, - fieldName: TaskDocumentFields.backlogEnteredAt, - detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt, - ), - ); - } - - return { - ..._commonV1Fields( - id: _requiredString(document, TaskDocumentFields.id), - createdAt: createdAt, - updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt), - ), - TaskDocumentFields.title: - _requiredString(document, TaskDocumentFields.title), - TaskDocumentFields.projectId: - _requiredString(document, TaskDocumentFields.projectId), - TaskDocumentFields.type: _requiredLegacyCode( - document, - TaskDocumentFields.type, - _taskTypeCodes, - ), - TaskDocumentFields.status: status, - TaskDocumentFields.priority: _nullableLegacyCode( - document, - TaskDocumentFields.priority, - _priorityCodes, - ), - TaskDocumentFields.reward: _requiredLegacyCode( - document, - TaskDocumentFields.reward, - _rewardCodes, - ), - TaskDocumentFields.difficulty: _requiredLegacyCode( - document, - TaskDocumentFields.difficulty, - _difficultyCodes, - ), - TaskDocumentFields.durationMinutes: - _requiredNullableInt(document, TaskDocumentFields.durationMinutes), - TaskDocumentFields.scheduledStart: _nullableInstant( - document, - TaskDocumentFields.scheduledStart, - ), - TaskDocumentFields.scheduledEnd: _nullableInstant( - document, - TaskDocumentFields.scheduledEnd, - ), - TaskDocumentFields.actualStart: - _nullableInstant(document, TaskDocumentFields.actualStart), - TaskDocumentFields.actualEnd: - _nullableInstant(document, TaskDocumentFields.actualEnd), - TaskDocumentFields.completedAt: - _nullableInstant(document, TaskDocumentFields.completedAt), - TaskDocumentFields.parentTaskId: - _requiredNullableString(document, TaskDocumentFields.parentTaskId), - TaskDocumentFields.backlogTags: _requiredLegacyCodeList( - document, - TaskDocumentFields.backlogTags, - _backlogTagCodes, - ), - TaskDocumentFields.reminderOverride: _nullableLegacyCode( - document, - TaskDocumentFields.reminderOverride, - _reminderProfileCodes, - ), - TaskDocumentFields.stats: - _requiredMap(document, TaskDocumentFields.stats), - TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null, - TaskDocumentFields.backlogEnteredAtProvenance: isBacklog - ? DocumentMigrationProvenance.approximatedFromCreatedAt - : null, - }; - } - - Map _projectV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, ProjectDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - ProjectDocumentFields.name: - _requiredString(document, ProjectDocumentFields.name), - ProjectDocumentFields.colorKey: - _requiredString(document, ProjectDocumentFields.colorKey), - ProjectDocumentFields.defaultPriority: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultPriority, - _priorityCodes, - ), - ProjectDocumentFields.defaultReward: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultReward, - _rewardCodes, - ), - ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultDifficulty, - _difficultyCodes, - ), - ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode( - document, - ProjectDocumentFields.defaultReminderProfile, - _reminderProfileCodes, - ), - ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt( - document, - ProjectDocumentFields.defaultDurationMinutes, - ), - ProjectDocumentFields.archivedAt: - _optionalNullableInstant(document, ProjectDocumentFields.archivedAt), - }; - } - - Map _lockedBlockV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, LockedBlockDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - LockedBlockDocumentFields.name: - _requiredString(document, LockedBlockDocumentFields.name), - LockedBlockDocumentFields.startTime: - _requiredWallTime(document, LockedBlockDocumentFields.startTime), - LockedBlockDocumentFields.endTime: - _requiredWallTime(document, LockedBlockDocumentFields.endTime), - LockedBlockDocumentFields.date: - _requiredNullableCivilDate(document, LockedBlockDocumentFields.date), - LockedBlockDocumentFields.recurrence: - _nullableRecurrence(document, LockedBlockDocumentFields.recurrence), - LockedBlockDocumentFields.hiddenByDefault: - _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault), - LockedBlockDocumentFields.projectId: _requiredNullableString( - document, - LockedBlockDocumentFields.projectId, - ), - LockedBlockDocumentFields.archivedAt: _optionalNullableInstant( - document, - LockedBlockDocumentFields.archivedAt, - ), - }; - } - - Map _lockedBlockOverrideV0ToV1( - Map document, - List notes, - ) { - final metadata = _metadataInstants(document, notes); - return { - ..._commonV1Fields( - id: _requiredString(document, LockedBlockOverrideDocumentFields.id), - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - ), - LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.lockedBlockId, - ), - LockedBlockOverrideDocumentFields.date: _requiredCivilDate( - document, - LockedBlockOverrideDocumentFields.date, - ), - LockedBlockOverrideDocumentFields.type: _requiredLegacyCode( - document, - LockedBlockOverrideDocumentFields.type, - _lockedOverrideTypeCodes, - ), - LockedBlockOverrideDocumentFields.name: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.name, - ), - LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.startTime, - ), - LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime( - document, - LockedBlockOverrideDocumentFields.endTime, - ), - LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool( - document, - LockedBlockOverrideDocumentFields.hiddenByDefault, - ), - LockedBlockOverrideDocumentFields.projectId: _requiredNullableString( - document, - LockedBlockOverrideDocumentFields.projectId, - ), - }; - } - - Map _commonV1Fields({ - required String id, - required String createdAt, - required String updatedAt, - }) { - return { - DocumentFields.schemaVersion: v1SchemaVersion, - DocumentFields.id: id, - DocumentFields.ownerId: ownerId, - DocumentFields.revision: 1, - DocumentFields.createdAt: createdAt, - DocumentFields.updatedAt: updatedAt, - }; - } - - _LegacyMetadataInstants _metadataInstants( - Map document, - List notes, - ) { - final createdAt = - _optionalNullableInstant(document, DocumentFields.createdAt); - final updatedAt = - _optionalNullableInstant(document, DocumentFields.updatedAt); - if (createdAt == null || updatedAt == null) { - notes.add( - const DocumentMigrationNote( - code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp, - detailCode: 'migratedAt', - ), - ); - } - final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt); - return _LegacyMetadataInstants( - createdAt: createdAt ?? fallback, - updatedAt: updatedAt ?? createdAt ?? fallback, - ); - } -} - -typedef _V0Migrator = Map Function( - Map document, - List notes, -); - -class _SchemaVersionRead { - const _SchemaVersionRead.version(this.version) : failure = null; - const _SchemaVersionRead.failure(this.failure) : version = 0; - - final int version; - final String? failure; -} - -class _LegacyDocumentException implements Exception { - const _LegacyDocumentException({ - required this.fieldName, - required this.detailCode, - }); - - final String fieldName; - final String detailCode; -} - -class _LegacyMetadataInstants { - const _LegacyMetadataInstants({ - required this.createdAt, - required this.updatedAt, - }); - - final String createdAt; - final String updatedAt; -} - -const _taskTypeCodes = { - 'flexible': 'flexible', - 'inflexible': 'inflexible', - 'critical': 'critical', - 'locked': 'locked', - 'surprise': 'surprise', - 'freeSlot': 'free_slot', - 'free_slot': 'free_slot', -}; - -const _taskStatusCodes = { - 'planned': 'planned', - 'active': 'active', - 'completed': 'completed', - 'missed': 'missed', - 'cancelled': 'cancelled', - 'noLongerRelevant': 'no_longer_relevant', - 'no_longer_relevant': 'no_longer_relevant', - 'backlog': 'backlog', -}; - -const _priorityCodes = { - 'veryLow': 'very_low', - 'very_low': 'very_low', - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'veryHigh': 'very_high', - 'very_high': 'very_high', -}; - -const _rewardCodes = { - 'notSet': 'not_set', - 'not_set': 'not_set', - 'veryLow': 'very_low', - 'very_low': 'very_low', - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'veryHigh': 'very_high', - 'very_high': 'very_high', -}; - -const _difficultyCodes = { - 'notSet': 'not_set', - 'not_set': 'not_set', - 'veryEasy': 'very_easy', - 'very_easy': 'very_easy', - 'easy': 'easy', - 'medium': 'medium', - 'hard': 'hard', - 'veryHard': 'very_hard', - 'very_hard': 'very_hard', -}; - -const _reminderProfileCodes = { - 'silent': 'silent', - 'gentle': 'gentle', - 'persistent': 'persistent', - 'strict': 'strict', -}; - -const _backlogTagCodes = { - 'wishlist': 'wishlist', -}; - -const _lockedWeekdayCodes = { - 'monday': 'monday', - 'tuesday': 'tuesday', - 'wednesday': 'wednesday', - 'thursday': 'thursday', - 'friday': 'friday', - 'saturday': 'saturday', - 'sunday': 'sunday', -}; - -const _lockedOverrideTypeCodes = { - 'remove': 'remove', - 'replace': 'replace', - 'add': 'add', -}; - -final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); - -_SchemaVersionRead _readSchemaVersion(Map document) { - if (!document.containsKey(DocumentFields.schemaVersion)) { - return const _SchemaVersionRead.version(0); - } - final value = document[DocumentFields.schemaVersion]; - if (value is int) { - return _SchemaVersionRead.version(value); - } - return const _SchemaVersionRead.failure('wrongType'); -} - -String? _documentId(Map document) { - final value = document[DocumentFields.id]; - return value is String ? value : null; -} - -DocumentMigrationResult _failed({ - required DocumentMigrationEntity entity, - required String? documentId, - required int? fromSchemaVersion, - required DocumentMigrationFailureCode failureCode, - String? fieldName, - String? detailCode, -}) { - return DocumentMigrationResult( - document: null, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.failed, - documentId: documentId, - fromSchemaVersion: fromSchemaVersion, - failureCode: failureCode, - fieldName: fieldName, - detailCode: detailCode, - ), - ); -} - -DocumentMigrationResult _failedFromMapping({ - required DocumentMigrationEntity entity, - required String? documentId, - required int fromSchemaVersion, - required DocumentMigrationFailureCode failureCode, - required DocumentMappingException error, -}) { - return DocumentMigrationResult( - document: null, - report: DocumentMigrationReport( - entity: entity, - status: DocumentMigrationStatus.failed, - documentId: documentId, - fromSchemaVersion: fromSchemaVersion, - failureCode: failureCode, - mappingFailureCode: error.code, - fieldName: error.fieldName, - detailCode: error.detailCode, - ), - ); -} - -String _requiredString(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is String) { - return value; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String? _requiredNullableString( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null || value is String) { - return value as String?; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -int? _requiredNullableInt(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null || value is int) { - return value as int?; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -bool _requiredBool(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is bool) { - return value; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -Map _requiredMap( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is Map) { - return _deepCopyMap(value, fieldName); - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredLegacyCode( - Map document, - String fieldName, - Map codes, -) { - final raw = _requiredString(document, fieldName); - final code = codes[raw]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$raw', - ); - } - return code; -} - -String? _nullableLegacyCode( - Map document, - String fieldName, - Map codes, -) { - final raw = _requiredNullableString(document, fieldName); - if (raw == null) { - return null; - } - final code = codes[raw]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$raw', - ); - } - return code; -} - -List _requiredLegacyCodeList( - Map document, - String fieldName, - Map codes, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value is! List) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value.map((entry) { - if (entry is! String) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - final code = codes[entry]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'unknownCode:$entry', - ); - } - return code; - }).toList(growable: false); -} - -String _requiredInstant(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - final stored = _instantToStored(value, fieldName); - if (stored == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return stored; -} - -String? _nullableInstant(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _instantToStored(document[fieldName], fieldName); -} - -String? _optionalNullableInstant( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - return null; - } - return _instantToStored(document[fieldName], fieldName); -} - -String? _instantToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is DateTime) { - if (!value.isUtc) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousDateTime', - ); - } - return PersistenceDateTimeConvention.toStoredString(value); - } - if (value is String) { - if (!_explicitOffsetPattern.hasMatch(value)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousDateTime', - ); - } - try { - return PersistenceDateTimeConvention.toStoredString( - DateTime.parse(value), - ); - } on FormatException { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidDateTime', - ); - } - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredCivilDate(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = _civilDateToStored(document[fieldName], fieldName); - if (value == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value; -} - -String? _requiredNullableCivilDate( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _civilDateToStored(document[fieldName], fieldName); -} - -String? _civilDateToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is String) { - try { - return PersistenceCivilDateConvention.toStoredString( - PersistenceCivilDateConvention.fromStoredString(value), - ); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidCivilDate', - ); - } - } - if (value is DateTime) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'ambiguousCivilDateTime', - ); - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -String _requiredWallTime(Map document, String fieldName) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = _wallTimeToStored(document[fieldName], fieldName); - if (value == null) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - return value; -} - -String? _requiredNullableWallTime( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - return _wallTimeToStored(document[fieldName], fieldName); -} - -String? _wallTimeToStored(Object? value, String fieldName) { - if (value == null) { - return null; - } - if (value is String) { - try { - return PersistenceWallTimeConvention.toStoredString( - PersistenceWallTimeConvention.fromStoredString(value), - ); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidWallTime', - ); - } - } - if (value is Map) { - final map = _deepCopyMap(value, fieldName); - final legacyValue = map[ClockTimeDocumentFields.value]; - if (legacyValue is String) { - return _wallTimeToStored(legacyValue, fieldName); - } - final hour = map['hour']; - final minute = map['minute']; - if (hour is int && minute is int) { - try { - return WallTime(hour: hour, minute: minute).toIsoString(); - } on Object { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'invalidWallTime', - ); - } - } - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); -} - -Map? _nullableRecurrence( - Map document, - String fieldName, -) { - if (!document.containsKey(fieldName)) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'missingField', - ); - } - final value = document[fieldName]; - if (value == null) { - return null; - } - if (value is! Map) { - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'wrongType', - ); - } - final recurrence = _deepCopyMap(value, fieldName); - final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays]; - if (weekdays is! List) { - throw _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'wrongType', - ); - } - return { - LockedBlockRecurrenceDocumentFields.type: - recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly', - LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) { - if (entry is! String) { - throw const _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'wrongType', - ); - } - final code = _lockedWeekdayCodes[entry]; - if (code == null) { - throw _LegacyDocumentException( - fieldName: LockedBlockRecurrenceDocumentFields.weekdays, - detailCode: 'unknownCode:$entry', - ); - } - return code; - }).toList(growable: false), - }; -} - -Map _deepCopyDocument(Map document) { - return _deepCopyMap(document, 'document'); -} - -Map _deepCopyMap(Map map, String fieldName) { - return { - for (final entry in map.entries) - _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName), - }; -} - -String _stringKey(Object? key, String fieldName) { - if (key is String) { - return key; - } - throw _LegacyDocumentException( - fieldName: fieldName, - detailCode: 'nonStringMapKey', - ); -} - -Object? _deepCopyValue(Object? value, String fieldName) { - if (value is Map) { - return _deepCopyMap(value, fieldName); - } - if (value is List) { - return value.map((entry) => _deepCopyValue(entry, fieldName)).toList(); - } - return value; -} diff --git a/packages/scheduler_core/lib/src/domain/locked_time.dart b/packages/scheduler_core/lib/src/domain/locked_time.dart new file mode 100644 index 0000000..9003496 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Locked Time behavior for the Scheduler Core package. +library; + +// Locked-time modeling and expansion. +// +// Locked time is anything the flexible scheduler should treat as unavailable: +// work, appointments, streams, sleep boundaries, relationship blocks, or one-off +// interruptions. This file converts human-friendly locked block definitions into +// concrete `TimeInterval`s that the scheduler can avoid. + +import 'models.dart'; +import 'time_contracts.dart'; +part 'locked_time/enums/locked_weekday.dart'; +part 'locked_time/aliases/clock_time.dart'; +part 'locked_time/recurrence/locked_block_recurrence.dart'; +part 'locked_time/blocks/locked_block.dart'; +part 'locked_time/blocks/locked_block_occurrence.dart'; +part 'locked_time/enums/locked_block_override_type.dart'; +part 'locked_time/overrides/locked_block_override.dart'; +part 'locked_time/expansion/locked_schedule_expansion.dart'; diff --git a/packages/scheduler_core/lib/src/domain/locked_time/aliases/clock_time.dart b/packages/scheduler_core/lib/src/domain/locked_time/aliases/clock_time.dart new file mode 100644 index 0000000..ab73afb --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/aliases/clock_time.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Backwards-compatible name for explicit wall-clock time. +typedef ClockTime = WallTime; diff --git a/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block.dart b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block.dart new file mode 100644 index 0000000..bb5d63b --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block.dart @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Scheduling constraint that reserves time without becoming a task card. +/// +/// Locked blocks represent time the flexible scheduler should avoid: work hours, +/// appointments, sleep boundaries, streams, relationship blocks, or any other +/// commitment that should not be automatically rearranged. They are modeled +/// separately from normal tasks because the UI may show them as subtle overlays +/// rather than actionable task cards. +/// +/// A block is either: +/// - one-off, with [date] set and [recurrence] null; or +/// - recurring, with [recurrence] set and [date] usually null. +class LockedBlock { + /// Creates a `LockedBlock` 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. + LockedBlock({ + required String id, + required String name, + required this.startTime, + required this.endTime, + required this.createdAt, + required this.updatedAt, + this.date, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + this.archivedAt, + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + name = _requiredLockedString( + name, 'name', DomainValidationCode.blankName) { + if (recurrence == null && date == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: null, + name: 'date', + message: 'One-off locked blocks need a date.', + ); + } + if (!_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedBlock, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Locked block end time must be after start time.', + ); + } + } + + /// Stable id for persistence and one-day overrides. + final String id; + + /// User-facing label, such as `Work`, `Stream`, or `Relationship block`. + final String name; + + /// Start time-of-day. Combined with a date during expansion. + final ClockTime startTime; + + /// End time-of-day. Combined with a date during expansion. + final ClockTime endTime; + + /// Calendar date for one-off locked blocks. Recurring blocks leave this null. + final CivilDate? date; + + /// Optional weekly recurrence. Null means this is a one-off block. + final LockedBlockRecurrence? recurrence; + + /// Whether UI should keep this block visually quiet by default. Scheduling still + /// treats hidden blocks as blocked time. + final bool hiddenByDefault; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Creation timestamp for persistence/auditing. + final DateTime createdAt; + + /// Last update timestamp for persistence/auditing. + final DateTime updatedAt; + + /// When present, this block no longer creates future occurrences. + /// + /// One-day overrides are intentionally stored separately and are not deleted + /// when a block is archived. + final DateTime? archivedAt; + + /// Convenience check for whether this block expands through recurrence. + bool get isRecurring => recurrence != null; + + /// Whether this block has been archived. + bool get isArchived => archivedAt != null; + + /// Return a copy with selected locked-block details changed. + LockedBlock copyWith({ + String? id, + String? name, + ClockTime? startTime, + ClockTime? endTime, + CivilDate? date, + LockedBlockRecurrence? recurrence, + bool? hiddenByDefault, + String? projectId, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? archivedAt, + bool clearArchivedAt = false, + }) { + return LockedBlock( + id: id ?? this.id, + name: name ?? this.name, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + date: date ?? this.date, + recurrence: recurrence ?? this.recurrence, + hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault, + projectId: projectId ?? this.projectId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block_occurrence.dart new file mode 100644 index 0000000..8fe48b0 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block_occurrence.dart @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Concrete locked-time occurrence for one calendar day. +/// +/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a +/// specific date. The scheduler only needs occurrences/time intervals, while UI +/// can use ids and visibility flags to explain where the blocked time came from. +class LockedBlockOccurrence { + /// Creates a `LockedBlockOccurrence` 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 LockedBlockOccurrence({ + required this.name, + required this.interval, + required this.hiddenByDefault, + this.lockedBlockId, + this.overrideId, + this.projectId, + }); + + /// Display/debug label for this occurrence. + final String name; + + /// Concrete start/end on one calendar date. + final TimeInterval interval; + + /// Visibility hint for UI; does not affect scheduling. + final bool hiddenByDefault; + + /// Source recurring/one-off block id, when this came from a base block. + final String? lockedBlockId; + + /// Source override id, when this was replaced or added for one date. + final String? overrideId; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Scheduler-facing interval. Visibility only affects future UI overlays. + /// + /// A fresh [TimeInterval] is returned with [name] as the label so scheduling + /// notices/debugging can identify the blocked source without depending on the + /// richer occurrence object. + TimeInterval get schedulingInterval { + return TimeInterval( + start: interval.start, + end: interval.end, + label: name, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_block_override_type.dart b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_block_override_type.dart new file mode 100644 index 0000000..441ea8a --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_block_override_type.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Type of one-day override applied to locked time. +/// +/// Overrides let the app handle holidays, one-off appointments, or changed work +/// hours without editing the base recurring rule. +enum LockedBlockOverrideType { + /// Suppress one occurrence of a recurring locked block. + remove, + + /// Replace one occurrence with different details. + replace, + + /// Add a one-off locked occurrence that has no base recurring block. + add, +} diff --git a/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_weekday.dart b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_weekday.dart new file mode 100644 index 0000000..3c9752b --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_weekday.dart @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Weekday value using DateTime's Monday-first convention. +/// +/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`. +/// This enum wraps those integers so the rest of the code can talk in readable +/// names while still comparing directly to [DateTime.weekday]. +enum LockedWeekday { + /// Selects the `monday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + monday(DateTime.monday), + + /// Selects the `tuesday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + tuesday(DateTime.tuesday), + + /// Selects the `wednesday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + wednesday(DateTime.wednesday), + + /// Selects the `thursday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + thursday(DateTime.thursday), + + /// Selects the `friday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + friday(DateTime.friday), + + /// Selects the `saturday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + saturday(DateTime.saturday), + + /// Selects the `sunday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + sunday(DateTime.sunday); + + /// Creates a `LockedWeekday` 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 LockedWeekday(this.dateTimeValue); + + /// Matching [DateTime.weekday] integer value. + final int dateTimeValue; +} diff --git a/packages/scheduler_core/lib/src/locked_time.dart b/packages/scheduler_core/lib/src/domain/locked_time/expansion/locked_schedule_expansion.dart similarity index 52% rename from packages/scheduler_core/lib/src/locked_time.dart rename to packages/scheduler_core/lib/src/domain/locked_time/expansion/locked_schedule_expansion.dart index e31a3d8..8d2d3bb 100644 --- a/packages/scheduler_core/lib/src/locked_time.dart +++ b/packages/scheduler_core/lib/src/domain/locked_time/expansion/locked_schedule_expansion.dart @@ -1,437 +1,7 @@ -/// Implements Locked Time behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Locked-time modeling and expansion. -// -// Locked time is anything the flexible scheduler should treat as unavailable: -// work, appointments, streams, sleep boundaries, relationship blocks, or one-off -// interruptions. This file converts human-friendly locked block definitions into -// concrete `TimeInterval`s that the scheduler can avoid. - -import 'models.dart'; -import 'time_contracts.dart'; - -/// Weekday value using DateTime's Monday-first convention. -/// -/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`. -/// This enum wraps those integers so the rest of the code can talk in readable -/// names while still comparing directly to [DateTime.weekday]. -enum LockedWeekday { - monday(DateTime.monday), - tuesday(DateTime.tuesday), - wednesday(DateTime.wednesday), - thursday(DateTime.thursday), - friday(DateTime.friday), - saturday(DateTime.saturday), - sunday(DateTime.sunday); - - const LockedWeekday(this.dateTimeValue); - - /// Matching [DateTime.weekday] integer value. - final int dateTimeValue; -} - -/// Backwards-compatible name for explicit wall-clock time. -typedef ClockTime = WallTime; - -/// Recurrence rule for locked time. -/// -/// The current starter implementation only supports weekly recurrence because -/// that covers the product's fixed work/stream/relationship blocks. This object -/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence -/// rules can be added later without changing the rest of the locked-time model. -class LockedBlockRecurrence { - LockedBlockRecurrence.weekly({ - required Set weekdays, - }) : weekdays = Set.unmodifiable(weekdays) { - if (weekdays.isEmpty) { - throw DomainValidationException( - code: DomainValidationCode.emptyRecurrence, - invalidValue: weekdays, - name: 'weekdays', - message: 'Recurring locked blocks need at least one weekday.', - ); - } - } - - /// Weekdays when this recurrence should produce an occurrence. - final Set weekdays; - - /// Whether this recurrence applies to [date]. - bool occursOn(CivilDate date) { - return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); - } -} - -/// Scheduling constraint that reserves time without becoming a task card. -/// -/// Locked blocks represent time the flexible scheduler should avoid: work hours, -/// appointments, sleep boundaries, streams, relationship blocks, or any other -/// commitment that should not be automatically rearranged. They are modeled -/// separately from normal tasks because the UI may show them as subtle overlays -/// rather than actionable task cards. -/// -/// A block is either: -/// - one-off, with [date] set and [recurrence] null; or -/// - recurring, with [recurrence] set and [date] usually null. -class LockedBlock { - LockedBlock({ - required String id, - required String name, - required this.startTime, - required this.endTime, - required this.createdAt, - required this.updatedAt, - this.date, - this.recurrence, - this.hiddenByDefault = true, - this.projectId, - this.archivedAt, - }) : id = - _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), - name = _requiredLockedString( - name, 'name', DomainValidationCode.blankName) { - if (recurrence == null && date == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedBlock, - invalidValue: null, - name: 'date', - message: 'One-off locked blocks need a date.', - ); - } - if (!_clockTimeIsBefore(startTime, endTime)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedBlock, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Locked block end time must be after start time.', - ); - } - } - - /// Stable id for persistence and one-day overrides. - final String id; - - /// User-facing label, such as `Work`, `Stream`, or `Relationship block`. - final String name; - - /// Start time-of-day. Combined with a date during expansion. - final ClockTime startTime; - - /// End time-of-day. Combined with a date during expansion. - final ClockTime endTime; - - /// Calendar date for one-off locked blocks. Recurring blocks leave this null. - final CivilDate? date; - - /// Optional weekly recurrence. Null means this is a one-off block. - final LockedBlockRecurrence? recurrence; - - /// Whether UI should keep this block visually quiet by default. Scheduling still - /// treats hidden blocks as blocked time. - final bool hiddenByDefault; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Creation timestamp for persistence/auditing. - final DateTime createdAt; - - /// Last update timestamp for persistence/auditing. - final DateTime updatedAt; - - /// When present, this block no longer creates future occurrences. - /// - /// One-day overrides are intentionally stored separately and are not deleted - /// when a block is archived. - final DateTime? archivedAt; - - /// Convenience check for whether this block expands through recurrence. - bool get isRecurring => recurrence != null; - - /// Whether this block has been archived. - bool get isArchived => archivedAt != null; - - /// Return a copy with selected locked-block details changed. - LockedBlock copyWith({ - String? id, - String? name, - ClockTime? startTime, - ClockTime? endTime, - CivilDate? date, - LockedBlockRecurrence? recurrence, - bool? hiddenByDefault, - String? projectId, - DateTime? createdAt, - DateTime? updatedAt, - DateTime? archivedAt, - bool clearArchivedAt = false, - }) { - return LockedBlock( - id: id ?? this.id, - name: name ?? this.name, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - date: date ?? this.date, - recurrence: recurrence ?? this.recurrence, - hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault, - projectId: projectId ?? this.projectId, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), - ); - } -} - -/// Concrete locked-time occurrence for one calendar day. -/// -/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a -/// specific date. The scheduler only needs occurrences/time intervals, while UI -/// can use ids and visibility flags to explain where the blocked time came from. -class LockedBlockOccurrence { - const LockedBlockOccurrence({ - required this.name, - required this.interval, - required this.hiddenByDefault, - this.lockedBlockId, - this.overrideId, - this.projectId, - }); - - /// Display/debug label for this occurrence. - final String name; - - /// Concrete start/end on one calendar date. - final TimeInterval interval; - - /// Visibility hint for UI; does not affect scheduling. - final bool hiddenByDefault; - - /// Source recurring/one-off block id, when this came from a base block. - final String? lockedBlockId; - - /// Source override id, when this was replaced or added for one date. - final String? overrideId; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Scheduler-facing interval. Visibility only affects future UI overlays. - /// - /// A fresh [TimeInterval] is returned with [name] as the label so scheduling - /// notices/debugging can identify the blocked source without depending on the - /// richer occurrence object. - TimeInterval get schedulingInterval { - return TimeInterval( - start: interval.start, - end: interval.end, - label: name, - ); - } -} - -/// Type of one-day override applied to locked time. -/// -/// Overrides let the app handle holidays, one-off appointments, or changed work -/// hours without editing the base recurring rule. -enum LockedBlockOverrideType { - /// Suppress one occurrence of a recurring locked block. - remove, - - /// Replace one occurrence with different details. - replace, - - /// Add a one-off locked occurrence that has no base recurring block. - add, -} - -/// One-day change to locked time that leaves the base recurrence unchanged. -/// -/// Overrides are applied during expansion for a single date only. This is safer -/// than modifying the recurring block because the normal schedule remains intact -/// for every other day. -/// -/// Use the named factories to create valid override shapes: -/// - [remove] references a base block and suppresses that occurrence. -/// - [replace] references a base block and swaps its details for one day. -/// - [add] creates an extra locked occurrence that has no base block. -class LockedBlockOverride { - LockedBlockOverride({ - required String id, - required this.date, - required this.type, - required this.createdAt, - required this.updatedAt, - String? lockedBlockId, - String? name, - this.startTime, - this.endTime, - this.hiddenByDefault = true, - this.projectId, - }) : id = - _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), - lockedBlockId = _nullableLockedString( - lockedBlockId, - 'lockedBlockId', - DomainValidationCode.blankStableId, - ), - name = _nullableLockedString( - name, - 'name', - DomainValidationCode.blankName, - ) { - _validateOverrideShape( - type: type, - lockedBlockId: this.lockedBlockId, - name: this.name, - startTime: startTime, - endTime: endTime, - ); - } - - /// Removes a recurring occurrence for one date. - factory LockedBlockOverride.remove({ - required String id, - required String lockedBlockId, - required CivilDate date, - required DateTime createdAt, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - lockedBlockId: lockedBlockId, - date: date, - type: LockedBlockOverrideType.remove, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Replaces a recurring occurrence with different times for one date. - factory LockedBlockOverride.replace({ - required String id, - required String lockedBlockId, - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - required DateTime createdAt, - String? name, - bool hiddenByDefault = true, - String? projectId, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - lockedBlockId: lockedBlockId, - date: date, - type: LockedBlockOverrideType.replace, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Adds surprise locked time for one date. - factory LockedBlockOverride.add({ - required String id, - required CivilDate date, - required String name, - required ClockTime startTime, - required ClockTime endTime, - required DateTime createdAt, - bool hiddenByDefault = true, - String? projectId, - DateTime? updatedAt, - }) { - return LockedBlockOverride( - id: id, - date: date, - type: LockedBlockOverrideType.add, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Stable id for persistence and debugging. - final String id; - - /// Base block id affected by remove/replace overrides. Null for add overrides. - final String? lockedBlockId; - - /// Date the override applies to. - final CivilDate date; - - /// Kind of override operation. - final LockedBlockOverrideType type; - - /// Optional replacement/addition name. - final String? name; - - /// Optional replacement/addition start time. - final ClockTime? startTime; - - /// Optional replacement/addition end time. - final ClockTime? endTime; - - /// Visibility hint for UI; scheduling still blocks this time. - final bool hiddenByDefault; - - /// Optional project/category association for UI colors or reports. - final String? projectId; - - /// Creation timestamp for persistence/auditing. - final DateTime createdAt; - - /// Last update timestamp for persistence/auditing. - final DateTime updatedAt; - - /// Whether this override targets [blockId] on [occurrenceDate]. - bool appliesTo({ - required String blockId, - required CivilDate occurrenceDate, - }) { - return lockedBlockId == blockId && _sameDate(date, occurrenceDate); - } - - /// Build a concrete interval for [targetDate] when this override has enough - /// time data and applies to that date. - /// - /// Remove overrides intentionally return null because they do not create a new - /// interval. Replacement/add overrides need both [startTime] and [endTime]. - TimeInterval? intervalForDate({ - required CivilDate targetDate, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), - }) { - final start = startTime; - final end = endTime; - - if (start == null || end == null || !_sameDate(date, targetDate)) { - return null; - } - - return _resolvedInterval( - date: targetDate, - startTime: start, - endTime: end, - label: name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - } -} +part of '../../locked_time.dart'; /// Expands locked blocks and one-day overrides into concrete intervals. /// @@ -439,6 +9,8 @@ class LockedBlockOverride { /// and scheduler-friendly intervals. It retains the occurrence details for UI /// while exposing [schedulingIntervals] for placement algorithms. class LockedScheduleExpansion { + /// Creates a `LockedScheduleExpansion` 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 LockedScheduleExpansion({ required this.date, required this.occurrences, @@ -810,6 +382,8 @@ DateTime _latest(DateTime first, DateTime second) { return first.isAfter(second) ? first : second; } +/// Top-level helper that performs the `_resolvedInterval` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TimeInterval _resolvedInterval({ required CivilDate date, required ClockTime startTime, @@ -839,8 +413,12 @@ TimeInterval _resolvedInterval({ ); } +/// Top-level helper that performs the `_sameDate` 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 _sameDate(CivilDate first, CivilDate second) => first == second; +/// Top-level helper that performs the `_requiredLockedString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _requiredLockedString( String value, String name, @@ -858,6 +436,8 @@ String _requiredLockedString( return trimmed; } +/// Top-level helper that performs the `_nullableLockedString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _nullableLockedString( String? value, String name, @@ -878,6 +458,8 @@ String? _nullableLockedString( return trimmed; } +/// Top-level helper that performs the `_clockTimeIsBefore` 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 _clockTimeIsBefore(ClockTime start, ClockTime end) { if (start.hour != end.hour) { return start.hour < end.hour; @@ -885,6 +467,8 @@ bool _clockTimeIsBefore(ClockTime start, ClockTime end) { return start.minute < end.minute; } +/// Top-level helper that performs the `_validateOverrideShape` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. void _validateOverrideShape({ required LockedBlockOverrideType type, required String? lockedBlockId, @@ -937,6 +521,8 @@ void _validateOverrideShape({ } } +/// Top-level helper that performs the `_validateOverrideInterval` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. void _validateOverrideInterval({ required ClockTime? startTime, required ClockTime? endTime, diff --git a/packages/scheduler_core/lib/src/domain/locked_time/overrides/locked_block_override.dart b/packages/scheduler_core/lib/src/domain/locked_time/overrides/locked_block_override.dart new file mode 100644 index 0000000..af622cc --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/overrides/locked_block_override.dart @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// One-day change to locked time that leaves the base recurrence unchanged. +/// +/// Overrides are applied during expansion for a single date only. This is safer +/// than modifying the recurring block because the normal schedule remains intact +/// for every other day. +/// +/// Use the named factories to create valid override shapes: +/// - [remove] references a base block and suppresses that occurrence. +/// - [replace] references a base block and swaps its details for one day. +/// - [add] creates an extra locked occurrence that has no base block. +class LockedBlockOverride { + /// Creates a `LockedBlockOverride` 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. + LockedBlockOverride({ + required String id, + required this.date, + required this.type, + required this.createdAt, + required this.updatedAt, + String? lockedBlockId, + String? name, + this.startTime, + this.endTime, + this.hiddenByDefault = true, + this.projectId, + }) : id = + _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), + lockedBlockId = _nullableLockedString( + lockedBlockId, + 'lockedBlockId', + DomainValidationCode.blankStableId, + ), + name = _nullableLockedString( + name, + 'name', + DomainValidationCode.blankName, + ) { + _validateOverrideShape( + type: type, + lockedBlockId: this.lockedBlockId, + name: this.name, + startTime: startTime, + endTime: endTime, + ); + } + + /// Removes a recurring occurrence for one date. + factory LockedBlockOverride.remove({ + required String id, + required String lockedBlockId, + required CivilDate date, + required DateTime createdAt, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + lockedBlockId: lockedBlockId, + date: date, + type: LockedBlockOverrideType.remove, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Replaces a recurring occurrence with different times for one date. + factory LockedBlockOverride.replace({ + required String id, + required String lockedBlockId, + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + required DateTime createdAt, + String? name, + bool hiddenByDefault = true, + String? projectId, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + lockedBlockId: lockedBlockId, + date: date, + type: LockedBlockOverrideType.replace, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Adds surprise locked time for one date. + factory LockedBlockOverride.add({ + required String id, + required CivilDate date, + required String name, + required ClockTime startTime, + required ClockTime endTime, + required DateTime createdAt, + bool hiddenByDefault = true, + String? projectId, + DateTime? updatedAt, + }) { + return LockedBlockOverride( + id: id, + date: date, + type: LockedBlockOverrideType.add, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Stable id for persistence and debugging. + final String id; + + /// Base block id affected by remove/replace overrides. Null for add overrides. + final String? lockedBlockId; + + /// Date the override applies to. + final CivilDate date; + + /// Kind of override operation. + final LockedBlockOverrideType type; + + /// Optional replacement/addition name. + final String? name; + + /// Optional replacement/addition start time. + final ClockTime? startTime; + + /// Optional replacement/addition end time. + final ClockTime? endTime; + + /// Visibility hint for UI; scheduling still blocks this time. + final bool hiddenByDefault; + + /// Optional project/category association for UI colors or reports. + final String? projectId; + + /// Creation timestamp for persistence/auditing. + final DateTime createdAt; + + /// Last update timestamp for persistence/auditing. + final DateTime updatedAt; + + /// Whether this override targets [blockId] on [occurrenceDate]. + bool appliesTo({ + required String blockId, + required CivilDate occurrenceDate, + }) { + return lockedBlockId == blockId && _sameDate(date, occurrenceDate); + } + + /// Build a concrete interval for [targetDate] when this override has enough + /// time data and applies to that date. + /// + /// Remove overrides intentionally return null because they do not create a new + /// interval. Replacement/add overrides need both [startTime] and [endTime]. + TimeInterval? intervalForDate({ + required CivilDate targetDate, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), + }) { + final start = startTime; + final end = endTime; + + if (start == null || end == null || !_sameDate(date, targetDate)) { + return null; + } + + return _resolvedInterval( + date: targetDate, + startTime: start, + endTime: end, + label: name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/locked_time/recurrence/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/domain/locked_time/recurrence/locked_block_recurrence.dart new file mode 100644 index 0000000..68a3bf8 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/locked_time/recurrence/locked_block_recurrence.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../locked_time.dart'; + +/// Recurrence rule for locked time. +/// +/// The current starter implementation only supports weekly recurrence because +/// that covers the product's fixed work/stream/relationship blocks. This object +/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence +/// rules can be added later without changing the rest of the locked-time model. +class LockedBlockRecurrence { + /// Creates a `LockedBlockRecurrence.weekly` 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. + LockedBlockRecurrence.weekly({ + required Set weekdays, + }) : weekdays = Set.unmodifiable(weekdays) { + if (weekdays.isEmpty) { + throw DomainValidationException( + code: DomainValidationCode.emptyRecurrence, + invalidValue: weekdays, + name: 'weekdays', + message: 'Recurring locked blocks need at least one weekday.', + ); + } + } + + /// Weekdays when this recurrence should produce an occurrence. + final Set weekdays; + + /// Whether this recurrence applies to [date]. + bool occursOn(CivilDate date) { + return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); + } +} diff --git a/packages/scheduler_core/lib/src/domain/models.dart b/packages/scheduler_core/lib/src/domain/models.dart new file mode 100644 index 0000000..21ed47c --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Models behavior for the Scheduler Core package. +library; + +// Core domain model for the ADHD scheduling starter project. +// +// This file intentionally contains small immutable value objects and enums. It +// should stay free of UI, persistence, notification, and platform code. Keeping +// this layer plain makes the scheduler easy to test and easy to move between a +// command-line prototype, Flutter UI, or future backend service. +// +// Reading order for humans: +// 1. `TaskType` and `TaskStatus` explain the two main axes of a task. +// 2. `Task` shows the actual data carried through the scheduling engine. +// 3. `ProjectProfile` explains how project defaults create tasks. +// 4. `TimeInterval` is the shared time-span helper used by scheduling logic. + +import 'task_statistics.dart'; +part 'models/validation/domain_validation_code.dart'; +part 'models/validation/domain_validation_exception.dart'; +part 'models/enums/tasks/task_type.dart'; +part 'models/enums/tasks/task_status.dart'; +part 'models/enums/planning/priority_level.dart'; +part 'models/enums/effort/reward_level.dart'; +part 'models/enums/effort/difficulty_level.dart'; +part 'models/enums/planning/reminder_profile.dart'; +part 'models/enums/planning/backlog_tag.dart'; +part 'models/entities/task.dart'; +part 'models/entities/project_profile.dart'; +part 'models/entities/time_interval.dart'; diff --git a/packages/scheduler_core/lib/src/domain/models/entities/project_profile.dart b/packages/scheduler_core/lib/src/domain/models/entities/project_profile.dart new file mode 100644 index 0000000..66db66c --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/entities/project_profile.dart @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../models.dart'; + +/// Starter project defaults used when creating or scheduling tasks. +/// +/// A project profile represents reusable defaults for a group of tasks. UI code +/// can let the user pick a project, then call [createTask] so new tasks inherit +/// a color, default priority, reward, difficulty, reminder profile, and duration. +/// +/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping +/// project defaults separate prevents every scheduling function from needing to +/// know project configuration details. +class ProjectProfile { + /// Creates a `ProjectProfile` 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. + ProjectProfile({ + required String id, + required String name, + required String colorKey, + this.defaultPriority = PriorityLevel.medium, + this.defaultReward = RewardLevel.notSet, + this.defaultDifficulty = DifficultyLevel.notSet, + this.defaultReminderProfile = ReminderProfile.gentle, + int? defaultDurationMinutes, + this.archivedAt, + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + name = _requiredTrimmed( + name, + 'name', + DomainValidationCode.blankName, + 'Project name is required.', + ), + colorKey = _requiredTrimmed( + colorKey, + 'colorKey', + DomainValidationCode.blankColorKey, + 'Project color key is required.', + ), + defaultDurationMinutes = defaultDurationMinutes { + _validatePositiveDuration( + defaultDurationMinutes, + 'defaultDurationMinutes', + ); + } + + /// Stable project id stored on tasks. + final String id; + + /// User-facing project name. + final String name; + + /// Theme/color token for UI rendering. This is a key, not a raw color value. + final String colorKey; + + /// Default importance assigned when a task does not override priority. + final PriorityLevel defaultPriority; + + /// Default motivational payoff assigned when a task does not override reward. + final RewardLevel defaultReward; + + /// Default activation difficulty assigned when a task does not override effort. + final DifficultyLevel defaultDifficulty; + + /// Default reminder behavior for future notification/UI layers. + final ReminderProfile defaultReminderProfile; + + /// Optional duration estimate used for newly created tasks. + final int? defaultDurationMinutes; + + /// When present, this project is hidden from normal pickers but tasks keep + /// their project ids for history and filtering. + final DateTime? archivedAt; + + /// Whether the project has been archived. + bool get isArchived => archivedAt != null; + + /// Create a task using project defaults while allowing explicit overrides. + /// + /// This keeps capture and project-default behavior in one place. Callers can + /// pass only the fields the user explicitly set; everything else falls back to + /// this profile. The created task receives this profile's [id] as [Task.projectId]. + Task createTask({ + required String id, + required String title, + required DateTime createdAt, + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.backlog, + PriorityLevel? priority, + RewardLevel? reward, + DifficultyLevel? difficulty, + int? durationMinutes, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, + String? parentTaskId, + Set backlogTags = const {}, + ReminderProfile? reminderOverride, + DateTime? updatedAt, + }) { + return Task( + id: id, + title: title, + projectId: this.id, + type: type, + status: status, + priority: priority ?? defaultPriority, + reward: reward ?? defaultReward, + difficulty: difficulty ?? defaultDifficulty, + durationMinutes: durationMinutes ?? defaultDurationMinutes, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, + completedAt: completedAt, + parentTaskId: parentTaskId, + backlogTags: backlogTags, + reminderOverride: reminderOverride, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Return a copy with selected project defaults changed. + ProjectProfile copyWith({ + String? id, + String? name, + String? colorKey, + PriorityLevel? defaultPriority, + RewardLevel? defaultReward, + DifficultyLevel? defaultDifficulty, + ReminderProfile? defaultReminderProfile, + int? defaultDurationMinutes, + bool clearDefaultDuration = false, + DateTime? archivedAt, + bool clearArchivedAt = false, + }) { + return ProjectProfile( + id: id ?? this.id, + name: name ?? this.name, + colorKey: colorKey ?? this.colorKey, + defaultPriority: defaultPriority ?? this.defaultPriority, + defaultReward: defaultReward ?? this.defaultReward, + defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, + defaultReminderProfile: + defaultReminderProfile ?? this.defaultReminderProfile, + defaultDurationMinutes: clearDefaultDuration + ? null + : (defaultDurationMinutes ?? this.defaultDurationMinutes), + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/models/entities/task.dart b/packages/scheduler_core/lib/src/domain/models/entities/task.dart new file mode 100644 index 0000000..46b9b8d --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/entities/task.dart @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../models.dart'; + +/// Starter task model for the scheduling core. +/// +/// [Task] is the main domain object passed through the scheduler. It is written +/// as an immutable value object: operations do not mutate an existing task, they +/// return a copied task with changed fields. That approach makes scheduling +/// actions easier to test because every function receives an input list and +/// returns a new output list. +/// +/// Important modeling choices: +/// - [type] controls scheduling behavior: flexible, critical, locked, etc. +/// - [status] controls lifecycle state: planned, completed, backlog, etc. +/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and +/// unscheduled captures do not have timeline placement yet. +/// - [stats] records quiet metadata for future reports. It should not clutter +/// the everyday UI unless a report or filter specifically needs it. +/// - [parentTaskId] links child tasks to a larger parent task without requiring +/// a nested object graph. That keeps persistence simple and avoids recursive +/// scheduling structures. +/// +/// This is still a starter V1 model, so behavior changes should be explicit and +/// backed by tests as the product rules settle. +class Task { + /// Creates a `Task` 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. + Task({ + required String id, + required String title, + required String projectId, + required this.type, + required this.status, + required this.createdAt, + required this.updatedAt, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + int? durationMinutes, + this.scheduledStart, + this.scheduledEnd, + this.actualStart, + this.actualEnd, + this.completedAt, + String? parentTaskId, + Set backlogTags = const {}, + this.reminderOverride, + this.stats = const TaskStatistics(), + }) : id = _requiredTrimmed( + id, + 'id', + DomainValidationCode.blankStableId, + 'Stable id is required.', + ), + title = _requiredTrimmed( + title, + 'title', + DomainValidationCode.blankTitle, + 'Title is required.', + ), + projectId = _requiredTrimmed( + projectId, + 'projectId', + DomainValidationCode.blankProjectId, + 'Project id is required.', + ), + durationMinutes = durationMinutes, + parentTaskId = _nullableTrimmed( + parentTaskId, + 'parentTaskId', + DomainValidationCode.blankParentTaskId, + 'Parent task id cannot be blank.', + ), + backlogTags = Set.unmodifiable(backlogTags) { + _validatePositiveDuration(durationMinutes, 'durationMinutes'); + _validateOptionalInterval( + start: scheduledStart, + end: scheduledEnd, + partialCode: DomainValidationCode.partialScheduledInterval, + invalidCode: DomainValidationCode.invalidScheduledInterval, + name: 'scheduledStart/scheduledEnd', + partialMessage: + 'Scheduled start and scheduled end must both be present or absent.', + invalidMessage: 'Scheduled end must be after scheduled start.', + ); + _validateOptionalInterval( + start: actualStart, + end: actualEnd, + partialCode: DomainValidationCode.partialActualInterval, + invalidCode: DomainValidationCode.invalidActualInterval, + name: 'actualStart/actualEnd', + partialMessage: + 'Actual start and actual end must both be present or absent.', + invalidMessage: 'Actual end must be after actual start.', + ); + if (this.parentTaskId == this.id) { + throw DomainValidationException( + code: DomainValidationCode.selfParent, + invalidValue: parentTaskId, + name: 'parentTaskId', + message: 'A task cannot be its own parent.', + ); + } + } + + /// Create a minimal captured task without requiring planning details. + /// + /// Quick capture is intentionally forgiving: the user can enter only a title + /// and the system can still create a valid backlog item. Defaults route the + /// item into the inbox project as a medium-priority flexible backlog task. + /// + /// The only hard validation here is that [title] must contain non-whitespace + /// text. Scheduling validation, such as requiring a positive duration, happens + /// in `quick_capture.dart` because that depends on the requested capture flow. + factory Task.quickCapture({ + required String id, + required String title, + required DateTime createdAt, + String projectId = 'inbox', + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.backlog, + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + Set backlogTags = const {}, + DateTime? updatedAt, + }) { + return Task( + id: id, + title: title.trim(), + projectId: projectId, + type: type, + status: status, + priority: priority, + reward: reward, + difficulty: difficulty, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + backlogTags: backlogTags, + ); + } + + /// Stable identifier used by persistence, UI selection, and scheduler changes. + final String id; + + /// User-facing task title. The model expects this to already be trimmed. + final String title; + + /// Owning project/profile id. `inbox` is used for uncategorized captures. + final String projectId; + + /// Scheduling behavior category. See [TaskType] for rule-level meaning. + final TaskType type; + + /// Current lifecycle state. See [TaskStatus] for state-level meaning. + final TaskStatus status; + + /// Optional importance. Most creation paths default this to medium, but it is + /// nullable to leave room for imports or legacy data that have not set it yet. + final PriorityLevel? priority; + + /// Motivational payoff used by backlog sorting and future planning hints. + final RewardLevel reward; + + /// Activation/effort estimate used by backlog sorting and future planning hints. + final DifficultyLevel difficulty; + + /// Estimated task length. Required for most scheduling operations, optional for + /// backlog capture because not every captured thought has an estimate yet. + final int? durationMinutes; + + /// Inclusive scheduled start time. Null means the task is not currently placed. + final DateTime? scheduledStart; + + /// Exclusive scheduled end time. Null means the task is not currently placed. + final DateTime? scheduledEnd; + + /// Actual work start time, when known after the fact. + final DateTime? actualStart; + + /// Actual work end time, when known after the fact. + final DateTime? actualEnd; + + /// Explicit completion timestamp, distinct from the generic update timestamp. + final DateTime? completedAt; + + /// Parent task id when this task is a child/subtask. Null means top-level task. + final String? parentTaskId; + + /// Backlog-specific flags, such as wishlist/someday behavior. + final Set backlogTags; + + /// Optional task-level reminder override. + final ReminderProfile? reminderOverride; + + /// Creation timestamp used for age/staleness sorting. + final DateTime createdAt; + + /// Last domain-level update timestamp. Scheduling actions set this when moving + /// or changing tasks so persistence and reports can detect recent activity. + final DateTime updatedAt; + + /// Quiet counters for reporting and later heuristics. + final TaskStatistics stats; + + /// Convenience predicate for the task type most scheduler movement operates on. + bool get isFlexible => type == TaskType.flexible; + + /// Critical and inflexible tasks are both visible to the user and treated as + /// blocked time by flexible scheduling. + bool get isRequiredVisible => + type == TaskType.critical || type == TaskType.inflexible; + + /// Locked tasks behave as timeline constraints, not normal interactive cards. + bool get isLocked => type == TaskType.locked; + + /// Backlog status means the task is stored for later and has no active slot. + bool get isBacklog => status == TaskStatus.backlog; + + /// Return a copy with selected fields changed. + /// + /// The core uses this instead of mutation. That matters because scheduling + /// operations often need to produce an auditable before/after result, including + /// [SchedulingChange]-style records elsewhere. + /// + /// [clearSchedule] is a deliberate escape hatch for nullable schedule fields. + /// Without it, passing null would be ambiguous: it could mean "do not change" + /// or "clear this value." When [clearSchedule] is true, both schedule fields + /// are removed even if [scheduledStart] or [scheduledEnd] are omitted. + /// + /// The other `clear*` flags provide the same explicit semantics for nullable + /// fields that the product can remove. + Task copyWith({ + String? id, + String? title, + String? projectId, + TaskType? type, + TaskStatus? status, + PriorityLevel? priority, + RewardLevel? reward, + DifficultyLevel? difficulty, + int? durationMinutes, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, + String? parentTaskId, + Set? backlogTags, + ReminderProfile? reminderOverride, + DateTime? createdAt, + DateTime? updatedAt, + TaskStatistics? stats, + bool clearPriority = false, + bool clearDuration = false, + bool clearSchedule = false, + bool clearActualInterval = false, + bool clearCompletion = false, + bool clearParentTask = false, + bool clearReminderOverride = false, + }) { + return Task( + id: id ?? this.id, + title: title ?? this.title, + projectId: projectId ?? this.projectId, + type: type ?? this.type, + status: status ?? this.status, + priority: clearPriority ? null : (priority ?? this.priority), + reward: reward ?? this.reward, + difficulty: difficulty ?? this.difficulty, + durationMinutes: + clearDuration ? null : (durationMinutes ?? this.durationMinutes), + scheduledStart: + clearSchedule ? null : (scheduledStart ?? this.scheduledStart), + scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), + actualStart: + clearActualInterval ? null : (actualStart ?? this.actualStart), + actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), + completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), + parentTaskId: + clearParentTask ? null : (parentTaskId ?? this.parentTaskId), + backlogTags: backlogTags ?? this.backlogTags, + reminderOverride: clearReminderOverride + ? null + : (reminderOverride ?? this.reminderOverride), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + stats: stats ?? this.stats, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/models/entities/time_interval.dart b/packages/scheduler_core/lib/src/domain/models/entities/time_interval.dart new file mode 100644 index 0000000..8e5e493 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/entities/time_interval.dart @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../models.dart'; + +/// Starter time range value used by scheduling helpers. +/// +/// [TimeInterval] is the scheduler's neutral representation of a time span. It +/// is used for scheduled task slots, locked blocks, required visible blocks, and +/// candidate placements. The interval convention is start-inclusive and +/// end-exclusive, which avoids treating two back-to-back blocks as overlapping. +class TimeInterval { + /// Creates a `TimeInterval` 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. + TimeInterval({ + required this.start, + required this.end, + this.label, + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidTimeInterval, + invalidValue: {'start': start, 'end': end}, + name: 'TimeInterval', + message: 'Interval end must be after interval start.', + ); + } + } + + /// Inclusive beginning of the interval. + final DateTime start; + + /// Exclusive ending of the interval. + final DateTime end; + + /// Optional debug/UI label, often a task id or locked block name. + final String? label; + + /// Raw duration between [start] and [end]. Callers are responsible for only + /// constructing meaningful positive intervals when required by a rule. + Duration get duration => end.difference(start); + + /// Whether this interval shares any actual time with [other]. + /// + /// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe + /// to place back-to-back because the first interval's end is the second + /// interval's start. + bool overlaps(TimeInterval other) { + return start.isBefore(other.end) && end.isAfter(other.start); + } +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed( + String value, + String name, + DomainValidationCode code, + String message, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +/// Top-level helper that performs the `_nullableTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _nullableTrimmed( + String? value, + String name, + DomainValidationCode code, + String message, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: message, + ); + } + return trimmed; +} + +/// Top-level helper that performs the `_validatePositiveDuration` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +void _validatePositiveDuration(int? durationMinutes, String name) { + if (durationMinutes == null) { + return; + } + if (durationMinutes <= 0) { + throw DomainValidationException( + code: DomainValidationCode.nonPositiveDuration, + invalidValue: durationMinutes, + name: name, + message: 'Duration must be positive when present.', + ); + } +} + +/// Top-level helper that performs the `_validateOptionalInterval` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +void _validateOptionalInterval({ + required DateTime? start, + required DateTime? end, + required DomainValidationCode partialCode, + required DomainValidationCode invalidCode, + required String name, + required String partialMessage, + required String invalidMessage, +}) { + if ((start == null) != (end == null)) { + throw DomainValidationException( + code: partialCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: partialMessage, + ); + } + if (start != null && end != null && !start.isBefore(end)) { + throw DomainValidationException( + code: invalidCode, + invalidValue: {'start': start, 'end': end}, + name: name, + message: invalidMessage, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/effort/difficulty_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/effort/difficulty_level.dart new file mode 100644 index 0000000..7dc5035 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/effort/difficulty_level.dart @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Expected effort or activation difficulty for a task. +/// +/// Difficulty is not the same as duration. A five-minute phone call might be +/// very hard to start, while an hour of familiar maintenance may be easy. The +/// backlog view uses this with [RewardLevel] to expose a simple +/// reward-versus-effort sort. +enum DifficultyLevel { + /// No difficulty has been captured yet. + notSet, + + /// Very easy to start or complete. + veryEasy, + + /// Easy to start or complete. + easy, + + /// Medium effort. + medium, + + /// Hard to start or complete. + hard, + + /// Very hard to start or complete. + veryHard, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/effort/reward_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/effort/reward_level.dart new file mode 100644 index 0000000..8856091 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/effort/reward_level.dart @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Expected reward or payoff from completing a task. +/// +/// Reward is meant to capture motivational payoff, not objective value. In this +/// app design it supports ADHD-friendly planning: a small, easy, high-reward task +/// may be a better momentum starter than a large low-reward task. +enum RewardLevel { + /// No reward level has been captured; this is not equivalent to low reward. + notSet, + + /// Very low expected reward. + veryLow, + + /// Low expected reward. + low, + + /// Medium expected reward. + medium, + + /// High expected reward. + high, + + /// Very high expected reward. + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/planning/backlog_tag.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/backlog_tag.dart new file mode 100644 index 0000000..f6e4242 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/planning/backlog_tag.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Lightweight backlog-only metadata. +/// +/// Tags here are deliberately narrow. They are not meant to replace a general +/// tagging system. They identify special backlog behavior that the planner needs +/// to understand, such as "wishlist/someday" items. +enum BacklogTag { + /// Task is intentionally saved as a someday/wishlist item. + wishlist, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/planning/priority_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/priority_level.dart new file mode 100644 index 0000000..bb03c2c --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/planning/priority_level.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// User-facing importance level. +/// +/// Priority is a relative ordering hint. It should help decide what rises to the +/// top of a queue, but it should not be treated as an absolute promise that the +/// task must happen at a specific time. The scheduling engine currently ranks +/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics +/// can build on the same enum later. +enum PriorityLevel { + /// Lowest priority. + veryLow, + + /// Low priority. + low, + + /// Default middle priority. + medium, + + /// High priority. + high, + + /// Highest priority. + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/planning/reminder_profile.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/reminder_profile.dart new file mode 100644 index 0000000..b53a079 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/planning/reminder_profile.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Reminder intensity preference. +/// +/// This is currently stored as project metadata rather than enforced by the core +/// scheduler. Future notification/UI layers can use it to decide how aggressive +/// reminders should be without adding reminder-specific logic to [Task]. +enum ReminderProfile { + /// No reminder nudges. + silent, + + /// Low-friction reminder nudges. + gentle, + + /// Repeated reminder nudges. + persistent, + + /// Strong reminder behavior for required items. + strict, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_status.dart b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_status.dart new file mode 100644 index 0000000..72b3b50 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_status.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Current lifecycle state of a task. +/// +/// Status is intentionally data-oriented: it describes what happened to a task, +/// not how important it is or how the scheduler should move it. Scheduler rules +/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task +/// can be pushed, while a planned critical task should remain visible and become +/// backlog if missed. +enum TaskStatus { + /// Scheduled or queued work that has not started. + planned, + + /// Work currently in progress. + active, + + /// Work finished by the user. + completed, + + /// Work that was not completed in its intended time. + missed, + + /// Work intentionally removed from the plan. + cancelled, + + /// Work intentionally dismissed because it no longer applies. + noLongerRelevant, + + /// Unscheduled work kept for later planning. + backlog, +} diff --git a/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_type.dart b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_type.dart new file mode 100644 index 0000000..ed2663e --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_type.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../models.dart'; + +/// Scheduling behavior category. +/// +/// This enum is one of the central concepts in the planner. The type answers +/// the question: "how should the scheduler treat this item?" It is separate +/// from [TaskStatus], which answers "where is this item in its lifecycle?" +/// +/// For example, a flexible task can be planned, completed, missed, or moved to +/// backlog. A locked item, by contrast, acts more like a calendar constraint +/// than a normal task card. Keeping behavior and lifecycle separate makes later +/// UI and persistence logic easier to reason about. +enum TaskType { + /// Movable planned work. + flexible, + + /// Required visible block that should not be moved automatically. + inflexible, + + /// Required visible task that remains actionable if missed. + critical, + + /// Hidden scheduling constraint, not a task card. + locked, + + /// Unplanned completed/logged task. + surprise, + + /// Intentional rest time. + freeSlot, +} diff --git a/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_code.dart b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_code.dart new file mode 100644 index 0000000..b912cb2 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_code.dart @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../models.dart'; + +/// Stable validation failure categories for domain model boundaries. +/// +/// Callers should branch on these codes instead of parsing exception text. The +/// explanatory messages may change, but these enum values are part of the V1 +/// backend contract. +enum DomainValidationCode { + /// Selects the `blankStableId` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankStableId, + + /// Selects the `blankTitle` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankTitle, + + /// Selects the `blankProjectId` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankProjectId, + + /// Selects the `blankName` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankName, + + /// Selects the `blankColorKey` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankColorKey, + + /// Selects the `blankParentTaskId` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankParentTaskId, + + /// Selects the `selfParent` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + selfParent, + + /// Selects the `nonPositiveDuration` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + nonPositiveDuration, + + /// Selects the `partialScheduledInterval` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + partialScheduledInterval, + + /// Selects the `invalidScheduledInterval` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidScheduledInterval, + + /// Selects the `partialActualInterval` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + partialActualInterval, + + /// Selects the `invalidActualInterval` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidActualInterval, + + /// Selects the `invalidTimeInterval` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidTimeInterval, + + /// Selects the `invalidSchedulingWindow` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidSchedulingWindow, + + /// Selects the `invalidCivilDate` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidCivilDate, + + /// Selects the `invalidClockTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidClockTime, + + /// Selects the `blankTimeZoneId` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + blankTimeZoneId, + + /// Selects the `nonexistentLocalTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + nonexistentLocalTime, + + /// Selects the `ambiguousLocalTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + ambiguousLocalTime, + + /// Selects the `emptyRecurrence` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + emptyRecurrence, + + /// Selects the `invalidLockedBlock` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidLockedBlock, + + /// Selects the `invalidLockedOverride` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidLockedOverride, +} diff --git a/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_exception.dart b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_exception.dart new file mode 100644 index 0000000..f710461 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_exception.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../models.dart'; + +/// Typed validation error thrown by domain model constructors. +class DomainValidationException extends ArgumentError { + /// Creates a `DomainValidationException` 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. + DomainValidationException({ + required this.code, + required Object? invalidValue, + required String name, + required String message, + }) : super.value(invalidValue, name, message); + + /// Stable category for application and persistence layers. + final DomainValidationCode code; +} diff --git a/packages/scheduler_core/lib/src/domain/occupancy_policy.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy.dart new file mode 100644 index 0000000..4779f97 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/occupancy_policy.dart @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Occupancy Policy behavior for the Scheduler Core package. +library; + +// Central occupancy policy for V1 scheduling. +// +// This file keeps scheduler movement rules separate from UI timeline +// categories. A task can be hidden, visible, movable, historical, or +// non-occupying for scheduling purposes independent of how a view renders it. + +import 'models.dart'; +part 'occupancy_policy/occupancy_category.dart'; +part 'occupancy_policy/occupancy_source.dart'; +part 'occupancy_policy/occupancy_entry.dart'; +part 'occupancy_policy/occupancy_policy.dart'; diff --git a/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_category.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_category.dart new file mode 100644 index 0000000..ec871dc --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_category.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../occupancy_policy.dart'; + +/// Scheduling-facing occupancy category for one task or external interval. +enum OccupancyCategory { + /// Hidden locked time that constrains scheduling. + hiddenLockedConstraint, + + /// Visible critical or inflexible commitment. + requiredVisibleCommitment, + + /// Intentional protected rest. + protectedFreeSlot, + + /// Planned flexible work that automatic scheduling may move. + movablePlannedFlexible, + + /// Work currently in progress. + activeWork, + + /// Known actual historical occupancy from completed work. + completedActualOccupancy, + + /// Historical missed placement retained as context. + retainedMissedHistoricalInterval, + + /// Record that should not occupy the scheduling window. + nonOccupyingRecord, +} diff --git a/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_entry.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_entry.dart new file mode 100644 index 0000000..9bded27 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_entry.dart @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../occupancy_policy.dart'; + +/// Policy classification for one task or external interval. +class OccupancyEntry { + /// Creates a `OccupancyEntry` 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 OccupancyEntry({ + required this.source, + required this.category, + required this.interval, + required this.task, + required this.isImmovable, + required this.blocksAutomaticFlexiblePlacement, + required this.mayBeExplicitlyOverlappedByRequiredCommitment, + required this.reportsConflict, + required this.hiddenByDefault, + }); + + /// Origin of the entry. + final OccupancySource source; + + /// Scheduling-facing category. + final OccupancyCategory category; + + /// Interval used by scheduling, or null when the record is unplaced. + final TimeInterval? interval; + + /// Source task when this entry came from a task record. + final Task? task; + + /// Stable task id when present. + String? get taskId => task?.id; + + /// Whether automatic scheduling must leave this entry's placement unchanged. + final bool isImmovable; + + /// Whether flexible placement must avoid [interval]. + final bool blocksAutomaticFlexiblePlacement; + + /// Whether an explicit critical/inflexible commitment may overlap this entry. + final bool mayBeExplicitlyOverlappedByRequiredCommitment; + + /// Whether overlaps with this entry should be returned as conflicts. + final bool reportsConflict; + + /// Whether entry details should be hidden by default in user-facing output. + final bool hiddenByDefault; + + /// Whether the entry is planned flexible work movable by automatic scheduling. + bool get isMovablePlannedFlexible { + return category == OccupancyCategory.movablePlannedFlexible; + } +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_policy.dart similarity index 71% rename from packages/scheduler_core/lib/src/occupancy_policy.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_policy.dart index 4cf99ec..1b5975e 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy.dart +++ b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_policy.dart @@ -1,105 +1,12 @@ -/// Implements Occupancy Policy behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Central occupancy policy for V1 scheduling. -// -// This file keeps scheduler movement rules separate from UI timeline -// categories. A task can be hidden, visible, movable, historical, or -// non-occupying for scheduling purposes independent of how a view renders it. - -import 'models.dart'; - -/// Scheduling-facing occupancy category for one task or external interval. -enum OccupancyCategory { - /// Hidden locked time that constrains scheduling. - hiddenLockedConstraint, - - /// Visible critical or inflexible commitment. - requiredVisibleCommitment, - - /// Intentional protected rest. - protectedFreeSlot, - - /// Planned flexible work that automatic scheduling may move. - movablePlannedFlexible, - - /// Work currently in progress. - activeWork, - - /// Known actual historical occupancy from completed work. - completedActualOccupancy, - - /// Historical missed placement retained as context. - retainedMissedHistoricalInterval, - - /// Record that should not occupy the scheduling window. - nonOccupyingRecord, -} - -/// Where an occupancy entry came from. -enum OccupancySource { - /// A [Task] record. - task, - - /// A hidden locked interval supplied outside the task list. - lockedInterval, - - /// A visible required interval supplied outside the task list. - requiredVisibleInterval, -} - -/// Policy classification for one task or external interval. -class OccupancyEntry { - const OccupancyEntry({ - required this.source, - required this.category, - required this.interval, - required this.task, - required this.isImmovable, - required this.blocksAutomaticFlexiblePlacement, - required this.mayBeExplicitlyOverlappedByRequiredCommitment, - required this.reportsConflict, - required this.hiddenByDefault, - }); - - /// Origin of the entry. - final OccupancySource source; - - /// Scheduling-facing category. - final OccupancyCategory category; - - /// Interval used by scheduling, or null when the record is unplaced. - final TimeInterval? interval; - - /// Source task when this entry came from a task record. - final Task? task; - - /// Stable task id when present. - String? get taskId => task?.id; - - /// Whether automatic scheduling must leave this entry's placement unchanged. - final bool isImmovable; - - /// Whether flexible placement must avoid [interval]. - final bool blocksAutomaticFlexiblePlacement; - - /// Whether an explicit critical/inflexible commitment may overlap this entry. - final bool mayBeExplicitlyOverlappedByRequiredCommitment; - - /// Whether overlaps with this entry should be returned as conflicts. - final bool reportsConflict; - - /// Whether entry details should be hidden by default in user-facing output. - final bool hiddenByDefault; - - /// Whether the entry is planned flexible work movable by automatic scheduling. - bool get isMovablePlannedFlexible { - return category == OccupancyCategory.movablePlannedFlexible; - } -} +part of '../occupancy_policy.dart'; /// UI-independent V1 occupancy policy. class OccupancyPolicy { + /// Creates a `OccupancyPolicy` 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 OccupancyPolicy(); /// Classify all task and external interval inputs for a scheduling operation. @@ -233,6 +140,8 @@ class OccupancyPolicy { ); } + /// Runs the `_taskEntry` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. OccupancyEntry _taskEntry({ required Task task, required OccupancyCategory category, @@ -246,6 +155,8 @@ class OccupancyPolicy { ); } + /// Runs the `_entry` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. OccupancyEntry _entry({ required OccupancySource source, required OccupancyCategory category, @@ -318,12 +229,16 @@ class OccupancyPolicy { } } +/// Top-level helper that performs the `_isNonOccupyingStatus` 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 _isNonOccupyingStatus(TaskStatus status) { return status == TaskStatus.backlog || status == TaskStatus.cancelled || status == TaskStatus.noLongerRelevant; } +/// Top-level helper that performs the `_scheduledIntervalFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TimeInterval? _scheduledIntervalFor(Task task) { final start = task.scheduledStart; final end = task.scheduledEnd; @@ -334,6 +249,8 @@ TimeInterval? _scheduledIntervalFor(Task task) { return TimeInterval(start: start, end: end, label: task.id); } +/// Top-level helper that performs the `_actualIntervalFor` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TimeInterval? _actualIntervalFor(Task task) { final start = task.actualStart; final end = task.actualEnd; diff --git a/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_source.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_source.dart new file mode 100644 index 0000000..b31ba0c --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_source.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../occupancy_policy.dart'; + +/// Where an occupancy entry came from. +enum OccupancySource { + /// A [Task] record. + task, + + /// A hidden locked interval supplied outside the task list. + lockedInterval, + + /// A visible required interval supplied outside the task list. + requiredVisibleInterval, +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics.dart b/packages/scheduler_core/lib/src/domain/project_statistics.dart new file mode 100644 index 0000000..e4a58fd --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Project Statistics behavior for the Scheduler Core package. +library; + +// Project-level aggregate statistics and deterministic learned suggestions. +// +// These types stay in the pure Dart core. They provide persistence-friendly +// counters and optional suggestions, but they do not overwrite configured +// project defaults or implement reports UI. + +import 'models.dart'; +import '../scheduling/task_lifecycle.dart'; +part 'project_statistics/enums/project_completion_time_bucket.dart'; +part 'project_statistics/enums/project_suggestion_type.dart'; +part 'project_statistics/enums/project_suggestion_confidence.dart'; +part 'project_statistics/models/project_statistics.dart'; +part 'project_statistics/models/project_statistics_application_result.dart'; +part 'project_statistics/services/project_statistics_aggregation_service.dart'; +part 'project_statistics/suggestions/project_suggestion_policy.dart'; +part 'project_statistics/suggestions/project_suggestion.dart'; +part 'project_statistics/suggestions/project_suggestion_set.dart'; +part 'project_statistics/models/project_default_resolution.dart'; +part 'project_statistics/suggestions/project_suggestion_service.dart'; diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_completion_time_bucket.dart new file mode 100644 index 0000000..614b812 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_completion_time_bucket.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Coarse completion-time buckets used for learned project suggestions. +enum ProjectCompletionTimeBucket { + /// Selects the `overnight` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + overnight, + + /// Selects the `morning` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + morning, + + /// Selects the `afternoon` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + afternoon, + + /// Selects the `evening` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + evening, + + /// Selects the `night` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + night, +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_confidence.dart new file mode 100644 index 0000000..38a036b --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_confidence.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// How concentrated the supporting observations are for a suggestion. +enum ProjectSuggestionConfidence { + /// Selects the `low` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + low, + + /// Selects the `medium` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + medium, + + /// Selects the `high` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + high, +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_type.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_type.dart new file mode 100644 index 0000000..5a1f59d --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_type.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Suggestion categories derived from project observations. +enum ProjectSuggestionType { + /// Selects the `durationMinutes` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + durationMinutes, + + /// Selects the `completionTimeBucket` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completionTimeBucket, + + /// Selects the `reward` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + reward, + + /// Selects the `difficulty` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + difficulty, + + /// Selects the `reminderProfile` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + reminderProfile, +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/models/project_default_resolution.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_default_resolution.dart new file mode 100644 index 0000000..3147ebd --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_default_resolution.dart @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Configured project defaults plus optional suggestions kept separate. +class ProjectDefaultResolution { + /// Creates a `ProjectDefaultResolution` 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 ProjectDefaultResolution({ + required this.project, + required this.suggestions, + }); + + /// Authoritative configured project profile. + final ProjectProfile project; + + /// Optional learned suggestions that UI can present explicitly. + final ProjectSuggestionSet suggestions; + + /// Returns the derived `configuredDurationMinutes` 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. + int? get configuredDurationMinutes => project.defaultDurationMinutes; + + /// Returns the derived `configuredReward` 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. + RewardLevel get configuredReward => project.defaultReward; + + /// Returns the derived `configuredDifficulty` 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. + DifficultyLevel get configuredDifficulty => project.defaultDifficulty; + + /// Returns the derived `configuredReminderProfile` 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. + ReminderProfile get configuredReminderProfile => + project.defaultReminderProfile; +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics.dart new file mode 100644 index 0000000..9e955fa --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics.dart @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Immutable project-level observations used for future filtering and hints. +class ProjectStatistics { + /// Creates a `ProjectStatistics` 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. + ProjectStatistics({ + required String projectId, + this.completedTaskCount = 0, + Map durationMinuteCounts = const {}, + Map completionTimeBucketCounts = + const {}, + this.totalPushesBeforeCompletion = 0, + this.completedAfterPushCount = 0, + Map rewardCounts = const {}, + Map difficultyCounts = const {}, + Map reminderProfileCounts = + const {}, + }) : projectId = _requiredTrimmed(projectId, 'projectId'), + durationMinuteCounts = Map.unmodifiable( + _positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'), + ), + completionTimeBucketCounts = + Map.unmodifiable( + _enumCounts( + completionTimeBucketCounts, + 'completionTimeBucketCounts', + ), + ), + rewardCounts = Map.unmodifiable( + _enumCounts(rewardCounts, 'rewardCounts'), + ), + difficultyCounts = Map.unmodifiable( + _enumCounts(difficultyCounts, 'difficultyCounts'), + ), + reminderProfileCounts = Map.unmodifiable( + _enumCounts(reminderProfileCounts, 'reminderProfileCounts'), + ) { + _validateNonNegative(completedTaskCount, 'completedTaskCount'); + _validateNonNegative( + totalPushesBeforeCompletion, + 'totalPushesBeforeCompletion', + ); + _validateNonNegative(completedAfterPushCount, 'completedAfterPushCount'); + } + + /// Project these observations belong to. + final String projectId; + + /// Completion activities applied to this project aggregate. + final int completedTaskCount; + + /// Duration samples stored as minute-to-count buckets. + final Map durationMinuteCounts; + + /// Completion timestamp samples stored as coarse bucket counts. + final Map completionTimeBucketCounts; + + /// Sum of push counts present when tasks were completed. + final int totalPushesBeforeCompletion; + + /// Number of completed tasks that had at least one prior push. + final int completedAfterPushCount; + + /// Reward observations from completed tasks. + final Map rewardCounts; + + /// Difficulty observations from completed tasks. + final Map difficultyCounts; + + /// Reminder-profile observations when explicitly known. + final Map reminderProfileCounts; + + /// Known duration sample count derived from the duration distribution. + int get knownDurationSampleCount => _countTotal(durationMinuteCounts); + + /// Sum of known duration samples in minutes. + int get totalKnownDurationMinutes { + var total = 0; + for (final entry in durationMinuteCounts.entries) { + total += entry.key * entry.value; + } + return total; + } + + /// Numerator for average pushes before completion. + int get averagePushesBeforeCompletionNumerator { + return totalPushesBeforeCompletion; + } + + /// Denominator for average pushes before completion. + int get averagePushesBeforeCompletionDenominator { + return completedTaskCount; + } + + /// Derived average push count, without storing floating-point aggregate state. + double? get averagePushesBeforeCompletion { + if (completedTaskCount == 0) { + return null; + } + return totalPushesBeforeCompletion / completedTaskCount; + } + + /// Return a copy with selected aggregate fields changed. + ProjectStatistics copyWith({ + String? projectId, + int? completedTaskCount, + Map? durationMinuteCounts, + Map? completionTimeBucketCounts, + int? totalPushesBeforeCompletion, + int? completedAfterPushCount, + Map? rewardCounts, + Map? difficultyCounts, + Map? reminderProfileCounts, + }) { + return ProjectStatistics( + projectId: projectId ?? this.projectId, + completedTaskCount: completedTaskCount ?? this.completedTaskCount, + durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts, + completionTimeBucketCounts: + completionTimeBucketCounts ?? this.completionTimeBucketCounts, + totalPushesBeforeCompletion: + totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion, + completedAfterPushCount: + completedAfterPushCount ?? this.completedAfterPushCount, + rewardCounts: rewardCounts ?? this.rewardCounts, + difficultyCounts: difficultyCounts ?? this.difficultyCounts, + reminderProfileCounts: + reminderProfileCounts ?? this.reminderProfileCounts, + ); + } + + /// Record one completed task observation. + ProjectStatistics recordCompletion({ + required DateTime completedAt, + int? durationMinutes, + int pushesBeforeCompletion = 0, + RewardLevel? reward, + DifficultyLevel? difficulty, + ReminderProfile? reminderProfile, + }) { + if (durationMinutes != null && durationMinutes <= 0) { + throw ArgumentError.value( + durationMinutes, + 'durationMinutes', + 'Duration minutes must be positive when present.', + ); + } + if (pushesBeforeCompletion < 0) { + throw ArgumentError.value( + pushesBeforeCompletion, + 'pushesBeforeCompletion', + 'Push count cannot be negative.', + ); + } + + return copyWith( + completedTaskCount: completedTaskCount + 1, + durationMinuteCounts: durationMinutes == null + ? durationMinuteCounts + : _incrementCount(durationMinuteCounts, durationMinutes), + completionTimeBucketCounts: _incrementCount( + completionTimeBucketCounts, + bucketForCompletion(completedAt), + ), + totalPushesBeforeCompletion: + totalPushesBeforeCompletion + pushesBeforeCompletion, + completedAfterPushCount: pushesBeforeCompletion > 0 + ? completedAfterPushCount + 1 + : completedAfterPushCount, + rewardCounts: reward == null + ? rewardCounts + : _incrementCount( + rewardCounts, + reward, + ), + difficultyCounts: difficulty == null + ? difficultyCounts + : _incrementCount( + difficultyCounts, + difficulty, + ), + reminderProfileCounts: reminderProfile == null + ? reminderProfileCounts + : _incrementCount( + reminderProfileCounts, + reminderProfile, + ), + ); + } + + /// Resolve the completion-time bucket for [completedAt]. + static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) { + final hour = completedAt.hour; + if (hour < 5) { + return ProjectCompletionTimeBucket.overnight; + } + if (hour < 12) { + return ProjectCompletionTimeBucket.morning; + } + if (hour < 17) { + return ProjectCompletionTimeBucket.afternoon; + } + if (hour < 21) { + return ProjectCompletionTimeBucket.evening; + } + return ProjectCompletionTimeBucket.night; + } +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics_application_result.dart new file mode 100644 index 0000000..88d7573 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics_application_result.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Result of applying project-statistic effects from activities. +class ProjectStatisticsApplicationResult { + /// Creates a `ProjectStatisticsApplicationResult` 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. + ProjectStatisticsApplicationResult({ + required this.statistics, + List appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Aggregate after newly applied activity effects. + final ProjectStatistics statistics; + + /// Completion activity ids that changed the aggregate. + final List appliedActivityIds; +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/services/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/domain/project_statistics/services/project_statistics_aggregation_service.dart new file mode 100644 index 0000000..5b2920f --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/services/project_statistics_aggregation_service.dart @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Updates project statistics from canonical task activities exactly once. +class ProjectStatisticsAggregationService { + /// Creates a `ProjectStatisticsAggregationService` 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 ProjectStatisticsAggregationService(); + + /// Apply completion [activities] to [statistics]. + /// + /// [alreadyAppliedActivityIds] is supplied by the application/persistence + /// boundary. This service returns the new ids so Block 14 can persist task, + /// activity, and aggregate mutations atomically. + ProjectStatisticsApplicationResult apply({ + required ProjectStatistics statistics, + required Iterable activities, + Iterable tasks = const [], + Iterable alreadyAppliedActivityIds = const [], + }) { + final taskById = { + for (final task in tasks) task.id: task, + }; + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + var current = statistics; + + for (final activity in activities) { + if (activity.projectId != statistics.projectId || + activity.code != TaskActivityCode.completed || + appliedIds.contains(activity.id)) { + continue; + } + + final task = taskById[activity.taskId]; + final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ?? + activity.occurredAt; + final pushesBeforeCompletion = + _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? + _pushesBeforeCompletion(task); + + current = current.recordCompletion( + completedAt: completedAt, + durationMinutes: _durationMinutes(activity, task), + pushesBeforeCompletion: pushesBeforeCompletion, + reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward, + difficulty: _difficultyLevel(activity.metadata['difficulty']) ?? + task?.difficulty, + reminderProfile: + _reminderProfile(activity.metadata['reminderProfile']) ?? + task?.reminderOverride, + ); + appliedIds.add(activity.id); + newlyAppliedIds.add(activity.id); + } + + return ProjectStatisticsApplicationResult( + statistics: current, + appliedActivityIds: newlyAppliedIds, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion.dart new file mode 100644 index 0000000..73c2b69 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// One optional learned suggestion with provenance. +class ProjectSuggestion { + /// Creates a `ProjectSuggestion` 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 ProjectSuggestion({ + required this.type, + required this.value, + required this.sampleSize, + required this.supportingSampleCount, + required this.minimumSampleSize, + required this.confidence, + }); + + /// Suggestion category. + final ProjectSuggestionType type; + + /// Suggested value. + final T value; + + /// Number of meaningful observations considered. + final int sampleSize; + + /// Observations that support [value]. + final int supportingSampleCount; + + /// Minimum sample threshold used before producing the suggestion. + final int minimumSampleSize; + + /// Concentration of supporting observations. + final ProjectSuggestionConfidence confidence; +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_policy.dart new file mode 100644 index 0000000..4286b30 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_policy.dart @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Minimum sample sizes for deterministic project suggestions. +class ProjectSuggestionPolicy { + /// Creates a `ProjectSuggestionPolicy` 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 ProjectSuggestionPolicy({ + this.minimumDurationSamples = 3, + this.minimumCompletionTimeSamples = 3, + this.minimumRewardSamples = 3, + this.minimumDifficultySamples = 3, + this.minimumReminderSamples = 3, + }); + + /// Stores the `minimumDurationSamples` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minimumDurationSamples; + + /// Stores the `minimumCompletionTimeSamples` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minimumCompletionTimeSamples; + + /// Stores the `minimumRewardSamples` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minimumRewardSamples; + + /// Stores the `minimumDifficultySamples` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minimumDifficultySamples; + + /// Stores the `minimumReminderSamples` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minimumReminderSamples; +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_service.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_service.dart new file mode 100644 index 0000000..7561933 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_service.dart @@ -0,0 +1,350 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Derives deterministic project suggestions from aggregate observations. +class ProjectSuggestionService { + /// Creates a `ProjectSuggestionService` 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 ProjectSuggestionService({ + this.policy = const ProjectSuggestionPolicy(), + }); + + /// Stores the `policy` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestionPolicy policy; + + /// Build optional suggestions for [statistics]. + ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) { + return ProjectSuggestionSet( + statistics: statistics, + durationMinutes: _durationSuggestion(statistics), + completionTimeBucket: _modeSuggestion( + type: ProjectSuggestionType.completionTimeBucket, + counts: statistics.completionTimeBucketCounts, + minimumSampleSize: policy.minimumCompletionTimeSamples, + ), + reward: _modeSuggestion( + type: ProjectSuggestionType.reward, + counts: _withoutNotSetReward(statistics.rewardCounts), + minimumSampleSize: policy.minimumRewardSamples, + ), + difficulty: _modeSuggestion( + type: ProjectSuggestionType.difficulty, + counts: _withoutNotSetDifficulty(statistics.difficultyCounts), + minimumSampleSize: policy.minimumDifficultySamples, + ), + reminderProfile: _modeSuggestion( + type: ProjectSuggestionType.reminderProfile, + counts: statistics.reminderProfileCounts, + minimumSampleSize: policy.minimumReminderSamples, + ), + ); + } + + /// Return configured defaults and optional suggestions without merging them. + ProjectDefaultResolution resolveDefaults({ + required ProjectProfile project, + required ProjectStatistics statistics, + }) { + return ProjectDefaultResolution( + project: project, + suggestions: suggestionsFor(statistics), + ); + } + + /// Runs the `_durationSuggestion` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + ProjectSuggestion? _durationSuggestion(ProjectStatistics statistics) { + final sampleSize = statistics.knownDurationSampleCount; + if (sampleSize < policy.minimumDurationSamples) { + return null; + } + + final value = _weightedLowerMedian(statistics.durationMinuteCounts); + if (value == null) { + return null; + } + + return ProjectSuggestion( + type: ProjectSuggestionType.durationMinutes, + value: value, + sampleSize: sampleSize, + supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0, + minimumSampleSize: policy.minimumDurationSamples, + confidence: _confidence( + statistics.durationMinuteCounts[value] ?? 0, + sampleSize, + ), + ); + } + + /// Performs the `Object>` 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. + ProjectSuggestion? _modeSuggestion({ + required ProjectSuggestionType type, + required Map counts, + required int minimumSampleSize, + }) { + final sampleSize = _countTotal(counts); + if (sampleSize < minimumSampleSize) { + return null; + } + + final mode = _uniqueMode(counts); + if (mode == null) { + return null; + } + + final support = counts[mode] ?? 0; + return ProjectSuggestion( + type: type, + value: mode, + sampleSize: sampleSize, + supportingSampleCount: support, + minimumSampleSize: minimumSampleSize, + confidence: _confidence(support, sampleSize), + ); + } +} + +/// Runs the `Object>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +Map _incrementCount(Map counts, K key) { + return { + ...counts, + key: (counts[key] ?? 0) + 1, + }; +} + +/// Top-level helper that performs the `_positiveIntKeyCounts` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _positiveIntKeyCounts(Map counts, String name) { + final normalized = {}; + for (final entry in counts.entries) { + if (entry.key <= 0) { + throw ArgumentError.value(entry.key, name, 'Key must be positive.'); + } + _validateNonNegative(entry.value, name); + if (entry.value > 0) { + normalized[entry.key] = entry.value; + } + } + return normalized; +} + +/// Runs the `Enum>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +Map _enumCounts(Map counts, String name) { + final normalized = {}; + for (final entry in counts.entries) { + _validateNonNegative(entry.value, name); + if (entry.value > 0) { + normalized[entry.key] = entry.value; + } + } + return normalized; +} + +/// Top-level helper that performs the `_validateNonNegative` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +void _validateNonNegative(int value, String name) { + if (value < 0) { + throw ArgumentError.value(value, name, 'Count cannot be negative.'); + } +} + +/// Runs the `Object>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +int _countTotal(Map counts) { + var total = 0; + for (final value in counts.values) { + total += value; + } + return total; +} + +/// Top-level helper that performs the `_weightedLowerMedian` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _weightedLowerMedian(Map counts) { + if (counts.isEmpty) { + return null; + } + final sampleSize = _countTotal(counts); + final target = (sampleSize + 1) ~/ 2; + var seen = 0; + final sortedMinutes = counts.keys.toList()..sort(); + for (final minutes in sortedMinutes) { + seen += counts[minutes] ?? 0; + if (seen >= target) { + return minutes; + } + } + return null; +} + +/// Runs the `Object>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +T? _uniqueMode(Map counts) { + T? bestKey; + var bestCount = 0; + var tied = false; + + for (final entry in counts.entries) { + if (entry.value > bestCount) { + bestKey = entry.key; + bestCount = entry.value; + tied = false; + continue; + } + if (entry.value == bestCount && bestCount > 0) { + tied = true; + } + } + + return tied ? null : bestKey; +} + +/// Top-level helper that performs the `_confidence` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ProjectSuggestionConfidence _confidence(int support, int sampleSize) { + if (sampleSize <= 0) { + return ProjectSuggestionConfidence.low; + } + if (support * 3 >= sampleSize * 2) { + return ProjectSuggestionConfidence.high; + } + if (support * 2 >= sampleSize) { + return ProjectSuggestionConfidence.medium; + } + return ProjectSuggestionConfidence.low; +} + +/// Top-level helper that performs the `_withoutNotSetReward` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _withoutNotSetReward(Map counts) { + return { + for (final entry in counts.entries) + if (entry.key != RewardLevel.notSet) entry.key: entry.value, + }; +} + +/// Top-level helper that performs the `_withoutNotSetDifficulty` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _withoutNotSetDifficulty( + Map counts, +) { + return { + for (final entry in counts.entries) + if (entry.key != DifficultyLevel.notSet) entry.key: entry.value, + }; +} + +/// Top-level helper that performs the `_dateTimeMetadata` 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? _dateTimeMetadata(Object? value) { + if (value is DateTime) { + return value; + } + if (value is String) { + return DateTime.tryParse(value)?.toUtc(); + } + return null; +} + +/// Top-level helper that performs the `_intMetadata` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _intMetadata(Object? value) { + return value is int && value >= 0 ? value : null; +} + +/// Top-level helper that performs the `_durationMinutes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _durationMinutes(TaskActivity activity, Task? task) { + final metadataDuration = _intMetadata(activity.metadata['durationMinutes']); + if (metadataDuration != null && metadataDuration > 0) { + return metadataDuration; + } + + final actualStart = + _dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart; + final actualEnd = + _dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd; + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + final minutes = actualEnd.difference(actualStart).inMinutes; + if (minutes > 0) { + return minutes; + } + } + + return task?.durationMinutes; +} + +/// Top-level helper that performs the `_pushesBeforeCompletion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _pushesBeforeCompletion(Task? task) { + if (task == null) { + return 0; + } + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} + +/// Top-level helper that performs the `_rewardLevel` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +RewardLevel? _rewardLevel(Object? value) { + if (value is RewardLevel) { + return value; + } + if (value is String) { + return _enumByName(RewardLevel.values, value); + } + return null; +} + +/// Top-level helper that performs the `_difficultyLevel` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DifficultyLevel? _difficultyLevel(Object? value) { + if (value is DifficultyLevel) { + return value; + } + if (value is String) { + return _enumByName(DifficultyLevel.values, value); + } + return null; +} + +/// Top-level helper that performs the `_reminderProfile` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ReminderProfile? _reminderProfile(Object? value) { + if (value is ReminderProfile) { + return value; + } + if (value is String) { + return _enumByName(ReminderProfile.values, value); + } + return null; +} + +/// Runs the `Enum>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +T? _enumByName(Iterable values, String name) { + for (final value in values) { + if (value.name == name) { + return value; + } + } + return null; +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_set.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_set.dart new file mode 100644 index 0000000..a4b299b --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_set.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../project_statistics.dart'; + +/// Optional suggestion set derived from one project's observations. +class ProjectSuggestionSet { + /// Creates a `ProjectSuggestionSet` 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 ProjectSuggestionSet({ + required this.statistics, + this.durationMinutes, + this.completionTimeBucket, + this.reward, + this.difficulty, + this.reminderProfile, + }); + + /// Stores the `statistics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectStatistics statistics; + + /// Stores the `durationMinutes` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestion? durationMinutes; + + /// Stores the `completionTimeBucket` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestion? completionTimeBucket; + + /// Stores the `reward` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestion? reward; + + /// Stores the `difficulty` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestion? difficulty; + + /// Stores the `reminderProfile` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final ProjectSuggestion? reminderProfile; +} diff --git a/packages/scheduler_core/lib/src/task_statistics.dart b/packages/scheduler_core/lib/src/domain/task_statistics.dart similarity index 95% rename from packages/scheduler_core/lib/src/task_statistics.dart rename to packages/scheduler_core/lib/src/domain/task_statistics.dart index c48b4bd..3240a07 100644 --- a/packages/scheduler_core/lib/src/task_statistics.dart +++ b/packages/scheduler_core/lib/src/domain/task_statistics.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Task Statistics behavior for the Scheduler Core package. library; @@ -19,6 +22,8 @@ library; /// [TaskStatistics] value so calling code can update a task through `copyWith` /// while retaining predictable before/after behavior. class TaskStatistics { + /// Creates a `TaskStatistics` 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 TaskStatistics({ this.skippedDuringBurnoutCount = 0, this.manuallyPushedCount = 0, diff --git a/packages/scheduler_core/lib/src/domain/time_contracts.dart b/packages/scheduler_core/lib/src/domain/time_contracts.dart new file mode 100644 index 0000000..7e50e7c --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Time Contracts behavior for the Scheduler Core package. +library; + +import 'models.dart'; +part 'time_contracts/clocks/clock.dart'; +part 'time_contracts/clocks/system_clock.dart'; +part 'time_contracts/clocks/fixed_clock.dart'; +part 'time_contracts/ids/id_generator.dart'; +part 'time_contracts/ids/sequential_id_generator.dart'; +part 'time_contracts/civil/civil_date.dart'; +part 'time_contracts/civil/wall_time.dart'; +part 'time_contracts/zones/resolution/local_time_resolution.dart'; +part 'time_contracts/zones/policies/nonexistent_local_time_policy.dart'; +part 'time_contracts/zones/policies/repeated_local_time_policy.dart'; +part 'time_contracts/zones/policies/time_zone_resolution_options.dart'; +part 'time_contracts/zones/resolution/resolved_local_date_time.dart'; +part 'time_contracts/zones/resolvers/time_zone_resolver.dart'; +part 'time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart'; diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/civil/civil_date.dart b/packages/scheduler_core/lib/src/domain/time_contracts/civil/civil_date.dart new file mode 100644 index 0000000..8142bfb --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/civil/civil_date.dart @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Calendar date without a time or timezone. +class CivilDate implements Comparable { + /// Creates a `CivilDate` 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. + CivilDate(this.year, this.month, this.day) { + final normalized = DateTime.utc(year, month, day); + if (normalized.year != year || + normalized.month != month || + normalized.day != day) { + throw DomainValidationException( + code: DomainValidationCode.invalidCivilDate, + invalidValue: {'year': year, 'month': month, 'day': day}, + name: 'CivilDate', + message: 'Civil date must be a valid calendar date.', + ); + } + } + + /// Creates a `CivilDate.fromDateTime` 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. + factory CivilDate.fromDateTime(DateTime value) { + return CivilDate(value.year, value.month, value.day); + } + + /// Creates a `CivilDate.fromIsoString` 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. + factory CivilDate.fromIsoString(String value) { + if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { + throw DomainValidationException( + code: DomainValidationCode.invalidCivilDate, + invalidValue: value, + name: 'CivilDate', + message: 'Civil date must use YYYY-MM-DD format.', + ); + } + final parts = value.split('-'); + return CivilDate( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + } + + /// Stores the `year` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int year; + + /// Stores the `month` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int month; + + /// Stores the `day` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int day; + + /// Returns the derived `weekday` 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. + int get weekday => DateTime.utc(year, month, day).weekday; + + /// Performs the `addDays` 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. + CivilDate addDays(int days) { + final shifted = DateTime.utc(year, month, day).add(Duration(days: days)); + return CivilDate(shifted.year, shifted.month, shifted.day); + } + + /// Converts scheduler data for `toIsoString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + String toIsoString() { + final monthText = month.toString().padLeft(2, '0'); + final dayText = day.toString().padLeft(2, '0'); + return '$year-$monthText-$dayText'; + } + + /// Performs the `compareTo` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + int compareTo(CivilDate other) { + final yearComparison = year.compareTo(other.year); + if (yearComparison != 0) { + return yearComparison; + } + final monthComparison = month.compareTo(other.month); + if (monthComparison != 0) { + return monthComparison; + } + return day.compareTo(other.day); + } + + /// Performs the `operator ==` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool operator ==(Object other) { + return other is CivilDate && + other.year == year && + other.month == month && + other.day == day; + } + + /// Returns the derived `hashCode` 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. + @override + int get hashCode => Object.hash(year, month, day); + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => toIsoString(); +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/civil/wall_time.dart b/packages/scheduler_core/lib/src/domain/time_contracts/civil/wall_time.dart new file mode 100644 index 0000000..37656b5 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/civil/wall_time.dart @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Wall-clock time without a date or timezone. +class WallTime implements Comparable { + /// Creates a `WallTime` 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. + WallTime({ + required this.hour, + required this.minute, + }) { + if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { + throw DomainValidationException( + code: DomainValidationCode.invalidClockTime, + invalidValue: {'hour': hour, 'minute': minute}, + name: 'WallTime', + message: 'Wall time must use hour 0-23 and minute 0-59.', + ); + } + } + + /// Creates a `WallTime.fromIsoString` 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. + factory WallTime.fromIsoString(String value) { + if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) { + throw DomainValidationException( + code: DomainValidationCode.invalidClockTime, + invalidValue: value, + name: 'WallTime', + message: 'Wall time must use HH:MM format.', + ); + } + final parts = value.split(':'); + return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1])); + } + + /// Stores the `hour` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int hour; + + /// Stores the `minute` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int minute; + + /// Returns the derived `minutesSinceMidnight` 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. + int get minutesSinceMidnight => hour * 60 + minute; + + /// Converts scheduler data for `toIsoString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + String toIsoString() { + return '${hour.toString().padLeft(2, '0')}:' + '${minute.toString().padLeft(2, '0')}'; + } + + /// Performs the `compareTo` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + int compareTo(WallTime other) { + return minutesSinceMidnight.compareTo(other.minutesSinceMidnight); + } + + /// Performs the `operator ==` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool operator ==(Object other) { + return other is WallTime && other.hour == hour && other.minute == minute; + } + + /// Returns the derived `hashCode` 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. + @override + int get hashCode => Object.hash(hour, minute); + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => toIsoString(); +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/clocks/clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/clock.dart new file mode 100644 index 0000000..3a6cc52 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/clock.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Deterministic source of the current instant. +abstract interface class Clock { + /// Performs the `now` 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. + DateTime now(); +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/clocks/fixed_clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/fixed_clock.dart new file mode 100644 index 0000000..0a0d9b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/fixed_clock.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Test/application clock with a fixed instant. +class FixedClock implements Clock { + /// Creates a `FixedClock` 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 FixedClock(this.instant); + + /// Stores the `instant` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime instant; + + /// Performs the `now` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + DateTime now() => instant; +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/clocks/system_clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/system_clock.dart new file mode 100644 index 0000000..7fd3314 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/system_clock.dart @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Production clock boundary. Domain services should receive this through +/// constructors rather than reading wall time directly. +class SystemClock implements Clock { + /// Creates a `SystemClock` 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 SystemClock(); + + /// Performs the `now` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + DateTime now() => DateTime.now(); +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/ids/id_generator.dart b/packages/scheduler_core/lib/src/domain/time_contracts/ids/id_generator.dart new file mode 100644 index 0000000..f6ba0ee --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/ids/id_generator.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Deterministic ID source for application/domain convenience entry points. +abstract interface class IdGenerator { + /// Performs the `nextId` 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. + String nextId(); +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/ids/sequential_id_generator.dart b/packages/scheduler_core/lib/src/domain/time_contracts/ids/sequential_id_generator.dart new file mode 100644 index 0000000..6b886fa --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/ids/sequential_id_generator.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../time_contracts.dart'; + +/// Simple deterministic ID generator useful for tests and local wiring. +class SequentialIdGenerator implements IdGenerator { + /// Creates a `SequentialIdGenerator` 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. + SequentialIdGenerator({ + this.prefix = 'id', + int start = 1, + }) : _next = start; + + /// Stores the `prefix` 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 prefix; + + /// Private state stored as `_next` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + int _next; + + /// Performs the `nextId` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + String nextId() { + final id = '$prefix-$_next'; + _next += 1; + return id; + } +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/nonexistent_local_time_policy.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/nonexistent_local_time_policy.dart new file mode 100644 index 0000000..638c336 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/nonexistent_local_time_policy.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Closed set of `NonexistentLocalTimePolicy` choices used by the pure scheduler domain package. +/// Using named enum values keeps scheduling, persistence, and UI branching explicit instead of passing raw strings through the codebase. +enum NonexistentLocalTimePolicy { + /// Move through a daylight-saving gap to the first valid local instant after it. + shiftForward, + + /// Reject local times that do not exist in the requested timezone. + reject, +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/repeated_local_time_policy.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/repeated_local_time_policy.dart new file mode 100644 index 0000000..df15935 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/repeated_local_time_policy.dart @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Closed set of `RepeatedLocalTimePolicy` choices used by the pure scheduler domain package. +/// Using named enum values keeps scheduling, persistence, and UI branching explicit instead of passing raw strings through the codebase. +enum RepeatedLocalTimePolicy { + /// Use the first instant for a repeated local time during a fall-back transition. + earlier, + + /// Use the second instant for a repeated local time during a fall-back transition. + later, + + /// Reject local times that occur more than once in the requested timezone. + reject, +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/time_zone_resolution_options.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/time_zone_resolution_options.dart new file mode 100644 index 0000000..94244a2 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/time_zone_resolution_options.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Represents `TimeZoneResolutionOptions` within the pure scheduler domain package. +/// The type groups related data and behavior behind a named API so readers can understand this part of the scheduler without relying on tribal knowledge about the surrounding files. +class TimeZoneResolutionOptions { + /// Creates a `TimeZoneResolutionOptions` 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 TimeZoneResolutionOptions({ + this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward, + this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier, + }); + + /// Stores the `nonexistentLocalTimePolicy` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final NonexistentLocalTimePolicy nonexistentLocalTimePolicy; + + /// Stores the `repeatedLocalTimePolicy` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RepeatedLocalTimePolicy repeatedLocalTimePolicy; +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/local_time_resolution.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/local_time_resolution.dart new file mode 100644 index 0000000..0d49cfc --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/local_time_resolution.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Closed set of `LocalTimeResolution` choices used by the pure scheduler domain package. +/// Using named enum values keeps scheduling, persistence, and UI branching explicit instead of passing raw strings through the codebase. +enum LocalTimeResolution { + /// Selects the `exact` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + exact, + + /// Selects the `shiftedForward` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + shiftedForward, + + /// Selects the `repeatedEarlier` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + repeatedEarlier, + + /// Selects the `repeatedLater` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + repeatedLater, +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/resolved_local_date_time.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/resolved_local_date_time.dart new file mode 100644 index 0000000..d1d3bd7 --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/resolved_local_date_time.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Represents `ResolvedLocalDateTime` within the pure scheduler domain package. +/// The type groups related data and behavior behind a named API so readers can understand this part of the scheduler without relying on tribal knowledge about the surrounding files. +class ResolvedLocalDateTime { + /// Creates a `ResolvedLocalDateTime` 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 ResolvedLocalDateTime({ + required this.date, + required this.wallTime, + required this.timeZoneId, + required this.instant, + required this.resolution, + required this.utcOffset, + }); + + /// Stores the `date` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final CivilDate date; + + /// Stores the `wallTime` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final WallTime wallTime; + + /// Stores the `timeZoneId` 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 timeZoneId; + + /// Stores the `instant` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime instant; + + /// Stores the `resolution` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final LocalTimeResolution resolution; + + /// Stores the `utcOffset` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Duration utcOffset; +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart new file mode 100644 index 0000000..04f77eb --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Resolver for zones whose offset is already known by the application boundary. +class FixedOffsetTimeZoneResolver implements TimeZoneResolver { + /// Creates a `FixedOffsetTimeZoneResolver.utc` 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 FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero; + + /// Creates a `FixedOffsetTimeZoneResolver` 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 FixedOffsetTimeZoneResolver({required this.offset}); + + /// Stores the `offset` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Duration offset; + + /// Performs the `resolve` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + ResolvedLocalDateTime resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(), + }) { + final trimmedZone = timeZoneId.trim(); + if (trimmedZone.isEmpty) { + throw DomainValidationException( + code: DomainValidationCode.blankTimeZoneId, + invalidValue: timeZoneId, + name: 'timeZoneId', + message: 'Time-zone id is required.', + ); + } + final localAsUtc = DateTime.utc( + date.year, + date.month, + date.day, + wallTime.hour, + wallTime.minute, + ); + + return ResolvedLocalDateTime( + date: date, + wallTime: wallTime, + timeZoneId: trimmedZone, + instant: localAsUtc.subtract(offset), + resolution: LocalTimeResolution.exact, + utcOffset: offset, + ); + } +} diff --git a/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/time_zone_resolver.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/time_zone_resolver.dart new file mode 100644 index 0000000..b948e6a --- /dev/null +++ b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/time_zone_resolver.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../time_contracts.dart'; + +/// Boundary for converting local civil date/time values to instants. +abstract interface class TimeZoneResolver { + /// Performs the `resolve` 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. + ResolvedLocalDateTime resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + TimeZoneResolutionOptions options, + }); +} diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/models.dart deleted file mode 100644 index b21ba4b..0000000 --- a/packages/scheduler_core/lib/src/models.dart +++ /dev/null @@ -1,791 +0,0 @@ -/// Implements Models behavior for the Scheduler Core package. -library; - -// Core domain model for the ADHD scheduling starter project. -// -// This file intentionally contains small immutable value objects and enums. It -// should stay free of UI, persistence, notification, and platform code. Keeping -// this layer plain makes the scheduler easy to test and easy to move between a -// command-line prototype, Flutter UI, or future backend service. -// -// Reading order for humans: -// 1. `TaskType` and `TaskStatus` explain the two main axes of a task. -// 2. `Task` shows the actual data carried through the scheduling engine. -// 3. `ProjectProfile` explains how project defaults create tasks. -// 4. `TimeInterval` is the shared time-span helper used by scheduling logic. - -import 'task_statistics.dart'; - -/// Stable validation failure categories for domain model boundaries. -/// -/// Callers should branch on these codes instead of parsing exception text. The -/// explanatory messages may change, but these enum values are part of the V1 -/// backend contract. -enum DomainValidationCode { - blankStableId, - blankTitle, - blankProjectId, - blankName, - blankColorKey, - blankParentTaskId, - selfParent, - nonPositiveDuration, - partialScheduledInterval, - invalidScheduledInterval, - partialActualInterval, - invalidActualInterval, - invalidTimeInterval, - invalidSchedulingWindow, - invalidCivilDate, - invalidClockTime, - blankTimeZoneId, - nonexistentLocalTime, - ambiguousLocalTime, - emptyRecurrence, - invalidLockedBlock, - invalidLockedOverride, -} - -/// Typed validation error thrown by domain model constructors. -class DomainValidationException extends ArgumentError { - DomainValidationException({ - required this.code, - required Object? invalidValue, - required String name, - required String message, - }) : super.value(invalidValue, name, message); - - /// Stable category for application and persistence layers. - final DomainValidationCode code; -} - -/// Scheduling behavior category. -/// -/// This enum is one of the central concepts in the planner. The type answers -/// the question: "how should the scheduler treat this item?" It is separate -/// from [TaskStatus], which answers "where is this item in its lifecycle?" -/// -/// For example, a flexible task can be planned, completed, missed, or moved to -/// backlog. A locked item, by contrast, acts more like a calendar constraint -/// than a normal task card. Keeping behavior and lifecycle separate makes later -/// UI and persistence logic easier to reason about. -enum TaskType { - /// Movable planned work. - flexible, - - /// Required visible block that should not be moved automatically. - inflexible, - - /// Required visible task that remains actionable if missed. - critical, - - /// Hidden scheduling constraint, not a task card. - locked, - - /// Unplanned completed/logged task. - surprise, - - /// Intentional rest time. - freeSlot, -} - -/// Current lifecycle state of a task. -/// -/// Status is intentionally data-oriented: it describes what happened to a task, -/// not how important it is or how the scheduler should move it. Scheduler rules -/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task -/// can be pushed, while a planned critical task should remain visible and become -/// backlog if missed. -enum TaskStatus { - /// Scheduled or queued work that has not started. - planned, - - /// Work currently in progress. - active, - - /// Work finished by the user. - completed, - - /// Work that was not completed in its intended time. - missed, - - /// Work intentionally removed from the plan. - cancelled, - - /// Work intentionally dismissed because it no longer applies. - noLongerRelevant, - - /// Unscheduled work kept for later planning. - backlog, -} - -/// User-facing importance level. -/// -/// Priority is a relative ordering hint. It should help decide what rises to the -/// top of a queue, but it should not be treated as an absolute promise that the -/// task must happen at a specific time. The scheduling engine currently ranks -/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics -/// can build on the same enum later. -enum PriorityLevel { - /// Lowest priority. - veryLow, - - /// Low priority. - low, - - /// Default middle priority. - medium, - - /// High priority. - high, - - /// Highest priority. - veryHigh, -} - -/// Expected reward or payoff from completing a task. -/// -/// Reward is meant to capture motivational payoff, not objective value. In this -/// app design it supports ADHD-friendly planning: a small, easy, high-reward task -/// may be a better momentum starter than a large low-reward task. -enum RewardLevel { - /// No reward level has been captured; this is not equivalent to low reward. - notSet, - - /// Very low expected reward. - veryLow, - - /// Low expected reward. - low, - - /// Medium expected reward. - medium, - - /// High expected reward. - high, - - /// Very high expected reward. - veryHigh, -} - -/// Expected effort or activation difficulty for a task. -/// -/// Difficulty is not the same as duration. A five-minute phone call might be -/// very hard to start, while an hour of familiar maintenance may be easy. The -/// backlog view uses this with [RewardLevel] to expose a simple -/// reward-versus-effort sort. -enum DifficultyLevel { - /// No difficulty has been captured yet. - notSet, - - /// Very easy to start or complete. - veryEasy, - - /// Easy to start or complete. - easy, - - /// Medium effort. - medium, - - /// Hard to start or complete. - hard, - - /// Very hard to start or complete. - veryHard, -} - -/// Reminder intensity preference. -/// -/// This is currently stored as project metadata rather than enforced by the core -/// scheduler. Future notification/UI layers can use it to decide how aggressive -/// reminders should be without adding reminder-specific logic to [Task]. -enum ReminderProfile { - /// No reminder nudges. - silent, - - /// Low-friction reminder nudges. - gentle, - - /// Repeated reminder nudges. - persistent, - - /// Strong reminder behavior for required items. - strict, -} - -/// Lightweight backlog-only metadata. -/// -/// Tags here are deliberately narrow. They are not meant to replace a general -/// tagging system. They identify special backlog behavior that the planner needs -/// to understand, such as "wishlist/someday" items. -enum BacklogTag { - /// Task is intentionally saved as a someday/wishlist item. - wishlist, -} - -/// Starter task model for the scheduling core. -/// -/// [Task] is the main domain object passed through the scheduler. It is written -/// as an immutable value object: operations do not mutate an existing task, they -/// return a copied task with changed fields. That approach makes scheduling -/// actions easier to test because every function receives an input list and -/// returns a new output list. -/// -/// Important modeling choices: -/// - [type] controls scheduling behavior: flexible, critical, locked, etc. -/// - [status] controls lifecycle state: planned, completed, backlog, etc. -/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and -/// unscheduled captures do not have timeline placement yet. -/// - [stats] records quiet metadata for future reports. It should not clutter -/// the everyday UI unless a report or filter specifically needs it. -/// - [parentTaskId] links child tasks to a larger parent task without requiring -/// a nested object graph. That keeps persistence simple and avoids recursive -/// scheduling structures. -/// -/// This is still a starter V1 model, so behavior changes should be explicit and -/// backed by tests as the product rules settle. -class Task { - Task({ - required String id, - required String title, - required String projectId, - required this.type, - required this.status, - required this.createdAt, - required this.updatedAt, - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - int? durationMinutes, - this.scheduledStart, - this.scheduledEnd, - this.actualStart, - this.actualEnd, - this.completedAt, - String? parentTaskId, - Set backlogTags = const {}, - this.reminderOverride, - this.stats = const TaskStatistics(), - }) : id = _requiredTrimmed( - id, - 'id', - DomainValidationCode.blankStableId, - 'Stable id is required.', - ), - title = _requiredTrimmed( - title, - 'title', - DomainValidationCode.blankTitle, - 'Title is required.', - ), - projectId = _requiredTrimmed( - projectId, - 'projectId', - DomainValidationCode.blankProjectId, - 'Project id is required.', - ), - durationMinutes = durationMinutes, - parentTaskId = _nullableTrimmed( - parentTaskId, - 'parentTaskId', - DomainValidationCode.blankParentTaskId, - 'Parent task id cannot be blank.', - ), - backlogTags = Set.unmodifiable(backlogTags) { - _validatePositiveDuration(durationMinutes, 'durationMinutes'); - _validateOptionalInterval( - start: scheduledStart, - end: scheduledEnd, - partialCode: DomainValidationCode.partialScheduledInterval, - invalidCode: DomainValidationCode.invalidScheduledInterval, - name: 'scheduledStart/scheduledEnd', - partialMessage: - 'Scheduled start and scheduled end must both be present or absent.', - invalidMessage: 'Scheduled end must be after scheduled start.', - ); - _validateOptionalInterval( - start: actualStart, - end: actualEnd, - partialCode: DomainValidationCode.partialActualInterval, - invalidCode: DomainValidationCode.invalidActualInterval, - name: 'actualStart/actualEnd', - partialMessage: - 'Actual start and actual end must both be present or absent.', - invalidMessage: 'Actual end must be after actual start.', - ); - if (this.parentTaskId == this.id) { - throw DomainValidationException( - code: DomainValidationCode.selfParent, - invalidValue: parentTaskId, - name: 'parentTaskId', - message: 'A task cannot be its own parent.', - ); - } - } - - /// Create a minimal captured task without requiring planning details. - /// - /// Quick capture is intentionally forgiving: the user can enter only a title - /// and the system can still create a valid backlog item. Defaults route the - /// item into the inbox project as a medium-priority flexible backlog task. - /// - /// The only hard validation here is that [title] must contain non-whitespace - /// text. Scheduling validation, such as requiring a positive duration, happens - /// in `quick_capture.dart` because that depends on the requested capture flow. - factory Task.quickCapture({ - required String id, - required String title, - required DateTime createdAt, - String projectId = 'inbox', - TaskType type = TaskType.flexible, - TaskStatus status = TaskStatus.backlog, - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - Set backlogTags = const {}, - DateTime? updatedAt, - }) { - return Task( - id: id, - title: title.trim(), - projectId: projectId, - type: type, - status: status, - priority: priority, - reward: reward, - difficulty: difficulty, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - backlogTags: backlogTags, - ); - } - - /// Stable identifier used by persistence, UI selection, and scheduler changes. - final String id; - - /// User-facing task title. The model expects this to already be trimmed. - final String title; - - /// Owning project/profile id. `inbox` is used for uncategorized captures. - final String projectId; - - /// Scheduling behavior category. See [TaskType] for rule-level meaning. - final TaskType type; - - /// Current lifecycle state. See [TaskStatus] for state-level meaning. - final TaskStatus status; - - /// Optional importance. Most creation paths default this to medium, but it is - /// nullable to leave room for imports or legacy data that have not set it yet. - final PriorityLevel? priority; - - /// Motivational payoff used by backlog sorting and future planning hints. - final RewardLevel reward; - - /// Activation/effort estimate used by backlog sorting and future planning hints. - final DifficultyLevel difficulty; - - /// Estimated task length. Required for most scheduling operations, optional for - /// backlog capture because not every captured thought has an estimate yet. - final int? durationMinutes; - - /// Inclusive scheduled start time. Null means the task is not currently placed. - final DateTime? scheduledStart; - - /// Exclusive scheduled end time. Null means the task is not currently placed. - final DateTime? scheduledEnd; - - /// Actual work start time, when known after the fact. - final DateTime? actualStart; - - /// Actual work end time, when known after the fact. - final DateTime? actualEnd; - - /// Explicit completion timestamp, distinct from the generic update timestamp. - final DateTime? completedAt; - - /// Parent task id when this task is a child/subtask. Null means top-level task. - final String? parentTaskId; - - /// Backlog-specific flags, such as wishlist/someday behavior. - final Set backlogTags; - - /// Optional task-level reminder override. - final ReminderProfile? reminderOverride; - - /// Creation timestamp used for age/staleness sorting. - final DateTime createdAt; - - /// Last domain-level update timestamp. Scheduling actions set this when moving - /// or changing tasks so persistence and reports can detect recent activity. - final DateTime updatedAt; - - /// Quiet counters for reporting and later heuristics. - final TaskStatistics stats; - - /// Convenience predicate for the task type most scheduler movement operates on. - bool get isFlexible => type == TaskType.flexible; - - /// Critical and inflexible tasks are both visible to the user and treated as - /// blocked time by flexible scheduling. - bool get isRequiredVisible => - type == TaskType.critical || type == TaskType.inflexible; - - /// Locked tasks behave as timeline constraints, not normal interactive cards. - bool get isLocked => type == TaskType.locked; - - /// Backlog status means the task is stored for later and has no active slot. - bool get isBacklog => status == TaskStatus.backlog; - - /// Return a copy with selected fields changed. - /// - /// The core uses this instead of mutation. That matters because scheduling - /// operations often need to produce an auditable before/after result, including - /// [SchedulingChange]-style records elsewhere. - /// - /// [clearSchedule] is a deliberate escape hatch for nullable schedule fields. - /// Without it, passing null would be ambiguous: it could mean "do not change" - /// or "clear this value." When [clearSchedule] is true, both schedule fields - /// are removed even if [scheduledStart] or [scheduledEnd] are omitted. - /// - /// The other `clear*` flags provide the same explicit semantics for nullable - /// fields that the product can remove. - Task copyWith({ - String? id, - String? title, - String? projectId, - TaskType? type, - TaskStatus? status, - PriorityLevel? priority, - RewardLevel? reward, - DifficultyLevel? difficulty, - int? durationMinutes, - DateTime? scheduledStart, - DateTime? scheduledEnd, - DateTime? actualStart, - DateTime? actualEnd, - DateTime? completedAt, - String? parentTaskId, - Set? backlogTags, - ReminderProfile? reminderOverride, - DateTime? createdAt, - DateTime? updatedAt, - TaskStatistics? stats, - bool clearPriority = false, - bool clearDuration = false, - bool clearSchedule = false, - bool clearActualInterval = false, - bool clearCompletion = false, - bool clearParentTask = false, - bool clearReminderOverride = false, - }) { - return Task( - id: id ?? this.id, - title: title ?? this.title, - projectId: projectId ?? this.projectId, - type: type ?? this.type, - status: status ?? this.status, - priority: clearPriority ? null : (priority ?? this.priority), - reward: reward ?? this.reward, - difficulty: difficulty ?? this.difficulty, - durationMinutes: - clearDuration ? null : (durationMinutes ?? this.durationMinutes), - scheduledStart: - clearSchedule ? null : (scheduledStart ?? this.scheduledStart), - scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), - actualStart: - clearActualInterval ? null : (actualStart ?? this.actualStart), - actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), - completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), - parentTaskId: - clearParentTask ? null : (parentTaskId ?? this.parentTaskId), - backlogTags: backlogTags ?? this.backlogTags, - reminderOverride: clearReminderOverride - ? null - : (reminderOverride ?? this.reminderOverride), - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - stats: stats ?? this.stats, - ); - } -} - -/// Starter project defaults used when creating or scheduling tasks. -/// -/// A project profile represents reusable defaults for a group of tasks. UI code -/// can let the user pick a project, then call [createTask] so new tasks inherit -/// a color, default priority, reward, difficulty, reminder profile, and duration. -/// -/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping -/// project defaults separate prevents every scheduling function from needing to -/// know project configuration details. -class ProjectProfile { - ProjectProfile({ - required String id, - required String name, - required String colorKey, - this.defaultPriority = PriorityLevel.medium, - this.defaultReward = RewardLevel.notSet, - this.defaultDifficulty = DifficultyLevel.notSet, - this.defaultReminderProfile = ReminderProfile.gentle, - int? defaultDurationMinutes, - this.archivedAt, - }) : id = _requiredTrimmed( - id, - 'id', - DomainValidationCode.blankStableId, - 'Stable id is required.', - ), - name = _requiredTrimmed( - name, - 'name', - DomainValidationCode.blankName, - 'Project name is required.', - ), - colorKey = _requiredTrimmed( - colorKey, - 'colorKey', - DomainValidationCode.blankColorKey, - 'Project color key is required.', - ), - defaultDurationMinutes = defaultDurationMinutes { - _validatePositiveDuration( - defaultDurationMinutes, - 'defaultDurationMinutes', - ); - } - - /// Stable project id stored on tasks. - final String id; - - /// User-facing project name. - final String name; - - /// Theme/color token for UI rendering. This is a key, not a raw color value. - final String colorKey; - - /// Default importance assigned when a task does not override priority. - final PriorityLevel defaultPriority; - - /// Default motivational payoff assigned when a task does not override reward. - final RewardLevel defaultReward; - - /// Default activation difficulty assigned when a task does not override effort. - final DifficultyLevel defaultDifficulty; - - /// Default reminder behavior for future notification/UI layers. - final ReminderProfile defaultReminderProfile; - - /// Optional duration estimate used for newly created tasks. - final int? defaultDurationMinutes; - - /// When present, this project is hidden from normal pickers but tasks keep - /// their project ids for history and filtering. - final DateTime? archivedAt; - - /// Whether the project has been archived. - bool get isArchived => archivedAt != null; - - /// Create a task using project defaults while allowing explicit overrides. - /// - /// This keeps capture and project-default behavior in one place. Callers can - /// pass only the fields the user explicitly set; everything else falls back to - /// this profile. The created task receives this profile's [id] as [Task.projectId]. - Task createTask({ - required String id, - required String title, - required DateTime createdAt, - TaskType type = TaskType.flexible, - TaskStatus status = TaskStatus.backlog, - PriorityLevel? priority, - RewardLevel? reward, - DifficultyLevel? difficulty, - int? durationMinutes, - DateTime? scheduledStart, - DateTime? scheduledEnd, - DateTime? actualStart, - DateTime? actualEnd, - DateTime? completedAt, - String? parentTaskId, - Set backlogTags = const {}, - ReminderProfile? reminderOverride, - DateTime? updatedAt, - }) { - return Task( - id: id, - title: title, - projectId: this.id, - type: type, - status: status, - priority: priority ?? defaultPriority, - reward: reward ?? defaultReward, - difficulty: difficulty ?? defaultDifficulty, - durationMinutes: durationMinutes ?? defaultDurationMinutes, - scheduledStart: scheduledStart, - scheduledEnd: scheduledEnd, - actualStart: actualStart, - actualEnd: actualEnd, - completedAt: completedAt, - parentTaskId: parentTaskId, - backlogTags: backlogTags, - reminderOverride: reminderOverride, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Return a copy with selected project defaults changed. - ProjectProfile copyWith({ - String? id, - String? name, - String? colorKey, - PriorityLevel? defaultPriority, - RewardLevel? defaultReward, - DifficultyLevel? defaultDifficulty, - ReminderProfile? defaultReminderProfile, - int? defaultDurationMinutes, - bool clearDefaultDuration = false, - DateTime? archivedAt, - bool clearArchivedAt = false, - }) { - return ProjectProfile( - id: id ?? this.id, - name: name ?? this.name, - colorKey: colorKey ?? this.colorKey, - defaultPriority: defaultPriority ?? this.defaultPriority, - defaultReward: defaultReward ?? this.defaultReward, - defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, - defaultReminderProfile: - defaultReminderProfile ?? this.defaultReminderProfile, - defaultDurationMinutes: clearDefaultDuration - ? null - : (defaultDurationMinutes ?? this.defaultDurationMinutes), - archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), - ); - } -} - -/// Starter time range value used by scheduling helpers. -/// -/// [TimeInterval] is the scheduler's neutral representation of a time span. It -/// is used for scheduled task slots, locked blocks, required visible blocks, and -/// candidate placements. The interval convention is start-inclusive and -/// end-exclusive, which avoids treating two back-to-back blocks as overlapping. -class TimeInterval { - TimeInterval({ - required this.start, - required this.end, - this.label, - }) { - if (!start.isBefore(end)) { - throw DomainValidationException( - code: DomainValidationCode.invalidTimeInterval, - invalidValue: {'start': start, 'end': end}, - name: 'TimeInterval', - message: 'Interval end must be after interval start.', - ); - } - } - - /// Inclusive beginning of the interval. - final DateTime start; - - /// Exclusive ending of the interval. - final DateTime end; - - /// Optional debug/UI label, often a task id or locked block name. - final String? label; - - /// Raw duration between [start] and [end]. Callers are responsible for only - /// constructing meaningful positive intervals when required by a rule. - Duration get duration => end.difference(start); - - /// Whether this interval shares any actual time with [other]. - /// - /// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe - /// to place back-to-back because the first interval's end is the second - /// interval's start. - bool overlaps(TimeInterval other) { - return start.isBefore(other.end) && end.isAfter(other.start); - } -} - -String _requiredTrimmed( - String value, - String name, - DomainValidationCode code, - String message, -) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: message, - ); - } - return trimmed; -} - -String? _nullableTrimmed( - String? value, - String name, - DomainValidationCode code, - String message, -) { - if (value == null) { - return null; - } - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: message, - ); - } - return trimmed; -} - -void _validatePositiveDuration(int? durationMinutes, String name) { - if (durationMinutes == null) { - return; - } - if (durationMinutes <= 0) { - throw DomainValidationException( - code: DomainValidationCode.nonPositiveDuration, - invalidValue: durationMinutes, - name: name, - message: 'Duration must be positive when present.', - ); - } -} - -void _validateOptionalInterval({ - required DateTime? start, - required DateTime? end, - required DomainValidationCode partialCode, - required DomainValidationCode invalidCode, - required String name, - required String partialMessage, - required String invalidMessage, -}) { - if ((start == null) != (end == null)) { - throw DomainValidationException( - code: partialCode, - invalidValue: {'start': start, 'end': end}, - name: name, - message: partialMessage, - ); - } - if (start != null && end != null && !start.isBefore(end)) { - throw DomainValidationException( - code: invalidCode, - invalidValue: {'start': start, 'end': end}, - name: name, - message: invalidMessage, - ); - } -} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping.dart new file mode 100644 index 0000000..2462b22 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping.dart @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Document Mapping behavior for the Scheduler Core package. +library; + +// Adapter-neutral V1 document mapping helpers. +// +// These helpers use plain Dart map shapes only. They do not import database +// APIs, create clients, or perform database I/O. + +import '../application/application_layer.dart'; +import '../scheduling/backlog.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import 'persistence_contract.dart'; +import '../domain/project_statistics.dart'; +import 'repositories.dart'; +import '../scheduling/scheduling_engine.dart'; +import '../scheduling/task_lifecycle.dart'; +import '../domain/task_statistics.dart'; +import '../domain/time_contracts.dart'; +part 'document_mapping/failures/document_mapping_failure_code.dart'; +part 'document_mapping/failures/document_mapping_exception.dart'; +part 'document_mapping/metadata/document_metadata.dart'; +part 'document_mapping/enums/persistence_enum_mapping.dart'; +part 'document_mapping/tasks/task_document_mapping.dart'; +part 'document_mapping/tasks/task_statistics_document_mapping.dart'; +part 'document_mapping/projects/project_document_mapping.dart'; +part 'document_mapping/projects/project_statistics_document_mapping.dart'; +part 'document_mapping/locked_time/locked_block_recurrence_document_mapping.dart'; +part 'document_mapping/locked_time/locked_block_document_mapping.dart'; +part 'document_mapping/locked_time/locked_block_override_document_mapping.dart'; +part 'document_mapping/tasks/task_activity_document_mapping.dart'; +part 'document_mapping/application/owner_settings_document_mapping.dart'; +part 'document_mapping/application/backlog_staleness_document_mapping.dart'; +part 'document_mapping/application/notice_acknowledgement_document_mapping.dart'; +part 'document_mapping/application/application_operation_document_mapping.dart'; +part 'document_mapping/scheduling/scheduling_snapshot_document_mapping.dart'; +part 'document_mapping/scheduling/scheduling_window_document_mapping.dart'; +part 'document_mapping/time/time_interval_document_mapping.dart'; +part 'document_mapping/scheduling/scheduling_notice_document_mapping.dart'; +part 'document_mapping/scheduling/scheduling_change_document_mapping.dart'; +part 'document_mapping/scheduling/scheduling_overlap_document_mapping.dart'; +part 'document_mapping/tasks/task_document_extension.dart'; +part 'document_mapping/tasks/task_statistics_document_extension.dart'; +part 'document_mapping/projects/project_statistics_document_extension.dart'; + +/// Adds focused helper behavior through `on`. +/// The extension keeps conversion or convenience logic near the type it supports while avoiding changes to the original model surface. +extension on Task { + /// Converts scheduler data for `_validateTaskDocumentMetadata` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + void _validateTaskDocumentMetadata(Map document) { + _requiredNullableDateTime(document, TaskDocumentFields.backlogEnteredAt); + _requiredNullableString( + document, + TaskDocumentFields.backlogEnteredAtProvenance, + ); + } +} + +/// Adds focused helper behavior through `on`. +/// The extension keeps conversion or convenience logic near the type it supports while avoiding changes to the original model surface. +extension on ProjectStatistics { + /// Converts scheduler data for `_validateProjectStatisticsDocumentMetadata` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + void _validateProjectStatisticsDocumentMetadata( + Map document, + ) { + for (final value in _requiredList( + document, + ProjectStatisticsDocumentFields.appliedActivityIds, + )) { + if (value is! String) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: ProjectStatisticsDocumentFields.appliedActivityIds, + ); + } + } + } +} + +/// Adds focused helper behavior through `on`. +/// The extension keeps conversion or convenience logic near the type it supports while avoiding changes to the original model surface. +extension on SchedulingStateSnapshot { + /// Converts scheduler data for `_validateSchedulingSnapshotDocumentMetadata` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + void _validateSchedulingSnapshotDocumentMetadata( + Map document, + ) { + _requiredNullableCivilDate( + document, + SchedulingSnapshotDocumentFields.sourceDate, + ); + _requiredNullableCivilDate( + document, + SchedulingSnapshotDocumentFields.targetDate, + ); + _requiredNullableDateTime( + document, + SchedulingSnapshotDocumentFields.retentionExpiresAt, + ); + _requiredBool(document, SchedulingSnapshotDocumentFields.truncated); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/application/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/application_operation_document_mapping.dart new file mode 100644 index 0000000..20bd1ae --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/application/application_operation_document_mapping.dart @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [ApplicationOperationRecord]. +abstract final class ApplicationOperationDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + ApplicationOperationRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.operationId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.committedAt, + updatedAt: record.committedAt, + ), + ApplicationOperationDocumentFields.operationId: record.operationId, + ApplicationOperationDocumentFields.operationName: record.operationName, + ApplicationOperationDocumentFields.committedAt: + PersistenceDateTimeConvention.toStoredString(record.committedAt), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static ApplicationOperationRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return ApplicationOperationRecord( + operationId: _requiredString( + document, + ApplicationOperationDocumentFields.operationId, + ), + ownerId: _requiredString( + document, + ApplicationOperationDocumentFields.ownerId, + ), + operationName: _requiredString( + document, + ApplicationOperationDocumentFields.operationName, + ), + committedAt: _requiredDateTime( + document, + ApplicationOperationDocumentFields.committedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/application/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/backlog_staleness_document_mapping.dart new file mode 100644 index 0000000..46bd537 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/application/backlog_staleness_document_mapping.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [BacklogStalenessSettings]. +abstract final class BacklogStalenessDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(BacklogStalenessSettings settings) { + return { + BacklogStalenessDocumentFields.greenMaxAgeDays: + settings.greenMaxAge.inDays, + BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static BacklogStalenessSettings fromDocument(Map document) { + return _mapInvalidValues(() { + final greenDays = _requiredInt( + document, + BacklogStalenessDocumentFields.greenMaxAgeDays, + ); + final blueDays = _requiredInt( + document, + BacklogStalenessDocumentFields.blueMaxAgeDays, + ); + if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: OwnerSettingsDocumentFields.backlogStaleness, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + return BacklogStalenessSettings( + greenMaxAge: Duration(days: greenDays), + blueMaxAge: Duration(days: blueDays), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/application/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/notice_acknowledgement_document_mapping.dart new file mode 100644 index 0000000..f409bb3 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/application/notice_acknowledgement_document_mapping.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [NoticeAcknowledgementRecord]. +abstract final class NoticeAcknowledgementDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + NoticeAcknowledgementRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.noticeId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.acknowledgedAt, + updatedAt: record.acknowledgedAt, + ), + NoticeAcknowledgementDocumentFields.noticeId: record.noticeId, + NoticeAcknowledgementDocumentFields.acknowledgedAt: + PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static NoticeAcknowledgementRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return NoticeAcknowledgementRecord( + noticeId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.noticeId, + ), + ownerId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.ownerId, + ), + acknowledgedAt: _requiredDateTime( + document, + NoticeAcknowledgementDocumentFields.acknowledgedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/application/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/owner_settings_document_mapping.dart new file mode 100644 index 0000000..efcfa93 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/application/owner_settings_document_mapping.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [OwnerSettings]. +abstract final class OwnerSettingsDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + OwnerSettings settings, { + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: settings.ownerId, + ownerId: settings.ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId, + OwnerSettingsDocumentFields.dayStart: + PersistenceWallTimeConvention.toStoredString(settings.dayStart), + OwnerSettingsDocumentFields.dayEnd: + PersistenceWallTimeConvention.toStoredString(settings.dayEnd), + OwnerSettingsDocumentFields.compactModeEnabled: + settings.compactModeEnabled, + OwnerSettingsDocumentFields.backlogStaleness: + BacklogStalenessDocumentMapping.toDocument( + settings.backlogStalenessSettings, + ), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static OwnerSettings fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return OwnerSettings( + ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId), + timeZoneId: _requiredString( + document, + OwnerSettingsDocumentFields.timeZoneId, + ), + dayStart: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayStart, + ), + dayEnd: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayEnd, + ), + compactModeEnabled: _requiredBool( + document, + OwnerSettingsDocumentFields.compactModeEnabled, + ), + backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument( + _requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart new file mode 100644 index 0000000..12628cc --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Stable enum encode/decode helpers for V1 document maps. +abstract final class PersistenceEnumMapping { + /// Performs the `encodeNullable` 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. + static String? encodeNullable(Enum? value) { + return value == null ? null : encode(value); + } + + /// Performs the `encode` 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. + static String encode(Enum value) { + if (value is TaskType) return encodeTaskType(value); + if (value is TaskStatus) return encodeTaskStatus(value); + if (value is PriorityLevel) return encodePriority(value); + if (value is RewardLevel) return encodeReward(value); + if (value is DifficultyLevel) return encodeDifficulty(value); + if (value is ReminderProfile) return encodeReminderProfile(value); + if (value is BacklogTag) return encodeBacklogTag(value); + if (value is LockedWeekday) return encodeLockedWeekday(value); + if (value is LockedBlockOverrideType) { + return encodeLockedBlockOverrideType(value); + } + if (value is TaskActivityCode) return encodeTaskActivityCode(value); + if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value); + if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value); + if (value is SchedulingMovementCode) { + return encodeSchedulingMovementCode(value); + } + if (value is SchedulingConflictCode) { + return encodeSchedulingConflictCode(value); + } + if (value is ProjectCompletionTimeBucket) { + return encodeProjectCompletionTimeBucket(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + detailCode: value.runtimeType.toString(), + ); + } + + /// Performs the `encodeTaskType` 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. + static String encodeTaskType(TaskType value) => switch (value) { + TaskType.flexible => 'flexible', + TaskType.inflexible => 'inflexible', + TaskType.critical => 'critical', + TaskType.locked => 'locked', + TaskType.surprise => 'surprise', + TaskType.freeSlot => 'free_slot', + }; + + /// Performs the `decodeTaskType` 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. + static TaskType decodeTaskType(String value) { + return _decodeCode(_taskTypeByCode, value, 'TaskType'); + } + + /// Performs the `encodeTaskStatus` 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. + static String encodeTaskStatus(TaskStatus value) => switch (value) { + TaskStatus.planned => 'planned', + TaskStatus.active => 'active', + TaskStatus.completed => 'completed', + TaskStatus.missed => 'missed', + TaskStatus.cancelled => 'cancelled', + TaskStatus.noLongerRelevant => 'no_longer_relevant', + TaskStatus.backlog => 'backlog', + }; + + /// Performs the `decodeTaskStatus` 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. + static TaskStatus decodeTaskStatus(String value) { + return _decodeCode(_taskStatusByCode, value, 'TaskStatus'); + } + + /// Performs the `encodePriority` 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. + static String encodePriority(PriorityLevel value) => switch (value) { + PriorityLevel.veryLow => 'very_low', + PriorityLevel.low => 'low', + PriorityLevel.medium => 'medium', + PriorityLevel.high => 'high', + PriorityLevel.veryHigh => 'very_high', + }; + + /// Performs the `decodeNullablePriority` 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. + static PriorityLevel? decodeNullablePriority(String? value) { + return value == null + ? null + : _decodeCode(_priorityByCode, value, 'PriorityLevel'); + } + + /// Performs the `encodeReward` 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. + static String encodeReward(RewardLevel value) => switch (value) { + RewardLevel.notSet => 'not_set', + RewardLevel.veryLow => 'very_low', + RewardLevel.low => 'low', + RewardLevel.medium => 'medium', + RewardLevel.high => 'high', + RewardLevel.veryHigh => 'very_high', + }; + + /// Performs the `decodeReward` 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. + static RewardLevel decodeReward(String value) { + return _decodeCode(_rewardByCode, value, 'RewardLevel'); + } + + /// Performs the `encodeDifficulty` 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. + static String encodeDifficulty(DifficultyLevel value) => switch (value) { + DifficultyLevel.notSet => 'not_set', + DifficultyLevel.veryEasy => 'very_easy', + DifficultyLevel.easy => 'easy', + DifficultyLevel.medium => 'medium', + DifficultyLevel.hard => 'hard', + DifficultyLevel.veryHard => 'very_hard', + }; + + /// Performs the `decodeDifficulty` 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. + static DifficultyLevel decodeDifficulty(String value) { + return _decodeCode(_difficultyByCode, value, 'DifficultyLevel'); + } + + /// Performs the `encodeReminderProfile` 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. + static String encodeReminderProfile(ReminderProfile value) => switch (value) { + ReminderProfile.silent => 'silent', + ReminderProfile.gentle => 'gentle', + ReminderProfile.persistent => 'persistent', + ReminderProfile.strict => 'strict', + }; + + /// Performs the `decodeNullableReminderProfile` 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. + static ReminderProfile? decodeNullableReminderProfile(String? value) { + return value == null + ? null + : _decodeCode(_reminderProfileByCode, value, 'ReminderProfile'); + } + + /// Performs the `encodeBacklogTag` 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. + static String encodeBacklogTag(BacklogTag value) => switch (value) { + BacklogTag.wishlist => 'wishlist', + }; + + /// Performs the `decodeBacklogTag` 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. + static BacklogTag decodeBacklogTag(String value) { + return _decodeCode(_backlogTagByCode, value, 'BacklogTag'); + } + + /// Performs the `encodeLockedWeekday` 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. + static String encodeLockedWeekday(LockedWeekday value) => switch (value) { + LockedWeekday.monday => 'monday', + LockedWeekday.tuesday => 'tuesday', + LockedWeekday.wednesday => 'wednesday', + LockedWeekday.thursday => 'thursday', + LockedWeekday.friday => 'friday', + LockedWeekday.saturday => 'saturday', + LockedWeekday.sunday => 'sunday', + }; + + /// Performs the `decodeLockedWeekday` 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. + static LockedWeekday decodeLockedWeekday(String value) { + return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday'); + } + + /// Performs the `encodeLockedBlockOverrideType` 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. + static String encodeLockedBlockOverrideType( + LockedBlockOverrideType value, + ) => + switch (value) { + LockedBlockOverrideType.remove => 'remove', + LockedBlockOverrideType.replace => 'replace', + LockedBlockOverrideType.add => 'add', + }; + + /// Performs the `decodeLockedBlockOverrideType` 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. + static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) { + return _decodeCode( + _lockedBlockOverrideTypeByCode, + value, + 'LockedBlockOverrideType', + ); + } + + /// Performs the `encodeTaskActivityCode` 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. + static String encodeTaskActivityCode(TaskActivityCode value) => + switch (value) { + TaskActivityCode.completed => 'completed', + TaskActivityCode.missed => 'missed', + TaskActivityCode.cancelled => 'cancelled', + TaskActivityCode.noLongerRelevant => 'no_longer_relevant', + TaskActivityCode.manuallyPushed => 'manually_pushed', + TaskActivityCode.automaticallyPushed => 'automatically_pushed', + TaskActivityCode.movedToBacklog => 'moved_to_backlog', + TaskActivityCode.restoredFromBacklog => 'restored_from_backlog', + TaskActivityCode.activated => 'activated', + }; + + /// Performs the `decodeTaskActivityCode` 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. + static TaskActivityCode decodeTaskActivityCode(String value) { + return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode'); + } + + /// Performs the `encodeSchedulingNoticeType` 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. + static String encodeSchedulingNoticeType(SchedulingNoticeType value) => + switch (value) { + SchedulingNoticeType.info => 'info', + SchedulingNoticeType.moved => 'moved', + SchedulingNoticeType.overlap => 'overlap', + SchedulingNoticeType.noFit => 'no_fit', + SchedulingNoticeType.overflow => 'overflow', + }; + + /// Performs the `decodeSchedulingNoticeType` 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. + static SchedulingNoticeType decodeSchedulingNoticeType(String value) { + return _decodeCode( + _schedulingNoticeTypeByCode, + value, + 'SchedulingNoticeType', + ); + } + + /// Performs the `encodeSchedulingIssueCode` 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. + static String encodeSchedulingIssueCode(SchedulingIssueCode value) => + switch (value) { + SchedulingIssueCode.taskNotFound => 'task_not_found', + SchedulingIssueCode.invalidTaskState => 'invalid_task_state', + SchedulingIssueCode.missingDuration => 'missing_duration', + SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', + SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', + SchedulingIssueCode.noAvailableSlot => 'no_available_slot', + SchedulingIssueCode.unfinishedTasksCouldNotFit => + 'unfinished_tasks_could_not_fit', + SchedulingIssueCode.noUnfinishedFlexibleTasks => + 'no_unfinished_flexible_tasks', + SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log', + }; + + /// Performs the `decodeNullableSchedulingIssueCode` 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. + static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) { + return value == null + ? null + : _decodeCode( + _schedulingIssueCodeByCode, + value, + 'SchedulingIssueCode', + ); + } + + /// Performs the `encodeSchedulingMovementCode` 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. + static String encodeSchedulingMovementCode(SchedulingMovementCode value) => + switch (value) { + SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted', + SchedulingMovementCode.flexibleTaskMovedToMakeRoom => + 'flexible_task_moved_to_make_room', + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot => + 'flexible_task_pushed_to_next_available_slot', + SchedulingMovementCode.flexibleTaskMovedToTomorrow => + 'flexible_task_moved_to_tomorrow', + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver => + 'unfinished_flexible_tasks_rolled_over', + SchedulingMovementCode.flexibleTaskMovedToBacklog => + 'flexible_task_moved_to_backlog', + SchedulingMovementCode.requiredCommitmentScheduled => + 'required_commitment_scheduled', + }; + + /// Performs the `decodeNullableSchedulingMovementCode` 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. + static SchedulingMovementCode? decodeNullableSchedulingMovementCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingMovementCodeByCode, + value, + 'SchedulingMovementCode', + ); + } + + /// Performs the `encodeSchedulingConflictCode` 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. + static String encodeSchedulingConflictCode(SchedulingConflictCode value) => + switch (value) { + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime => + 'flexible_task_overlaps_blocked_time', + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime => + 'surprise_task_overlaps_required_visible_time', + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot => + 'required_commitment_overlaps_protected_free_slot', + }; + + /// Performs the `decodeNullableSchedulingConflictCode` 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. + static SchedulingConflictCode? decodeNullableSchedulingConflictCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingConflictCodeByCode, + value, + 'SchedulingConflictCode', + ); + } + + /// Performs the `encodeProjectCompletionTimeBucket` 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. + static String encodeProjectCompletionTimeBucket( + ProjectCompletionTimeBucket value, + ) => + switch (value) { + ProjectCompletionTimeBucket.overnight => 'overnight', + ProjectCompletionTimeBucket.morning => 'morning', + ProjectCompletionTimeBucket.afternoon => 'afternoon', + ProjectCompletionTimeBucket.evening => 'evening', + ProjectCompletionTimeBucket.night => 'night', + }; + + /// Performs the `decodeProjectCompletionTimeBucket` 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. + static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( + String value, + ) { + return _decodeCode( + _projectCompletionTimeBucketByCode, + value, + 'ProjectCompletionTimeBucket', + ); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_exception.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_exception.dart new file mode 100644 index 0000000..de1b8b2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_exception.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Typed mapping failure for malformed V1 documents. +class DocumentMappingException implements Exception { + /// Creates a `DocumentMappingException` 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 DocumentMappingException({ + required this.code, + this.fieldName, + this.detailCode, + }); + + /// Stable category for caller branching. + final DocumentMappingFailureCode code; + + /// Field related to the failure, when known. + final String? fieldName; + + /// Optional narrower reason. + final String? detailCode; + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() { + return 'DocumentMappingException($code, field: $fieldName, detail: ' + '$detailCode)'; + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_failure_code.dart new file mode 100644 index 0000000..ce0b0b2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_failure_code.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Stable document mapping failure categories. +enum DocumentMappingFailureCode { + /// Selects the `missingField` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + missingField, + + /// Selects the `wrongType` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + wrongType, + + /// Selects the `unknownCode` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + unknownCode, + + /// Selects the `invalidSchemaVersion` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidSchemaVersion, + + /// Selects the `invalidRevision` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidRevision, + + /// Selects the `invalidValue` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidValue, +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_document_mapping.dart new file mode 100644 index 0000000..e201c33 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_document_mapping.dart @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [LockedBlock]. +abstract final class LockedBlockDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + LockedBlock block, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: block.id, + ownerId: ownerId, + revision: revision, + createdAt: block.createdAt, + updatedAt: block.updatedAt, + ), + LockedBlockDocumentFields.name: block.name, + LockedBlockDocumentFields.startTime: + PersistenceWallTimeConvention.toStoredString(block.startTime), + LockedBlockDocumentFields.endTime: + PersistenceWallTimeConvention.toStoredString(block.endTime), + LockedBlockDocumentFields.date: _civilDateToStored(block.date), + LockedBlockDocumentFields.recurrence: block.recurrence == null + ? null + : LockedBlockRecurrenceDocumentMapping.toDocument( + block.recurrence!, + ), + LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault, + LockedBlockDocumentFields.projectId: block.projectId, + LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static LockedBlock fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + final recurrenceDocument = _requiredNullableMap( + document, + LockedBlockDocumentFields.recurrence, + ); + return LockedBlock( + id: _requiredString(document, LockedBlockDocumentFields.id), + name: _requiredString(document, LockedBlockDocumentFields.name), + startTime: _requiredWallTime( + document, + LockedBlockDocumentFields.startTime, + ), + endTime: _requiredWallTime( + document, + LockedBlockDocumentFields.endTime, + ), + date: _requiredNullableCivilDate( + document, + LockedBlockDocumentFields.date, + ), + recurrence: recurrenceDocument == null + ? null + : LockedBlockRecurrenceDocumentMapping.fromDocument( + recurrenceDocument, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + createdAt: + _requiredDateTime(document, LockedBlockDocumentFields.createdAt), + updatedAt: + _requiredDateTime(document, LockedBlockDocumentFields.updatedAt), + archivedAt: _requiredNullableDateTime( + document, + LockedBlockDocumentFields.archivedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_override_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_override_document_mapping.dart new file mode 100644 index 0000000..0d8cb15 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_override_document_mapping.dart @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [LockedBlockOverride]. +abstract final class LockedBlockOverrideDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + LockedBlockOverride override, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: override.id, + ownerId: ownerId, + revision: revision, + createdAt: override.createdAt, + updatedAt: override.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId, + LockedBlockOverrideDocumentFields.date: + PersistenceCivilDateConvention.toStoredString(override.date), + LockedBlockOverrideDocumentFields.type: + PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type), + LockedBlockOverrideDocumentFields.name: override.name, + LockedBlockOverrideDocumentFields.startTime: + _wallTimeToStored(override.startTime), + LockedBlockOverrideDocumentFields.endTime: + _wallTimeToStored(override.endTime), + LockedBlockOverrideDocumentFields.hiddenByDefault: + override.hiddenByDefault, + LockedBlockOverrideDocumentFields.projectId: override.projectId, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static LockedBlockOverride fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return LockedBlockOverride( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + type: PersistenceEnumMapping.decodeLockedBlockOverrideType( + _requiredString(document, LockedBlockOverrideDocumentFields.type), + ), + createdAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.createdAt, + ), + updatedAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.updatedAt, + ), + lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart new file mode 100644 index 0000000..bba354b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [LockedBlockRecurrence]. +abstract final class LockedBlockRecurrenceDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(LockedBlockRecurrence recurrence) { + return { + LockedBlockRecurrenceDocumentFields.type: 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays + .map(PersistenceEnumMapping.encodeLockedWeekday) + .toList(growable: false), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static LockedBlockRecurrence fromDocument(Map document) { + return _mapInvalidValues(() { + final type = _requiredString( + document, + LockedBlockRecurrenceDocumentFields.type, + ); + if (type != 'weekly') { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: LockedBlockRecurrenceDocumentFields.type, + detailCode: type, + ); + } + final weekdays = _requiredList( + document, + LockedBlockRecurrenceDocumentFields.weekdays, + ).map((value) { + if (value is! String) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + ); + } + return PersistenceEnumMapping.decodeLockedWeekday(value); + }).toSet(); + return LockedBlockRecurrence.weekly(weekdays: weekdays); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/metadata/document_metadata.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/metadata/document_metadata.dart new file mode 100644 index 0000000..69177f1 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/metadata/document_metadata.dart @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Common metadata decoded from a V1 top-level document. +class DocumentMetadata { + /// Creates a `DocumentMetadata` 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 DocumentMetadata({ + required this.id, + required this.ownerId, + required this.revision, + required this.createdAt, + required this.updatedAt, + }); + + /// Stores the `id` 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 id; + + /// Stores the `ownerId` 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 ownerId; + + /// Stores the `revision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int revision; + + /// Stores the `createdAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime createdAt; + + /// Stores the `updatedAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime updatedAt; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_document_mapping.dart new file mode 100644 index 0000000..982e54b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_document_mapping.dart @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [ProjectProfile]. +abstract final class ProjectDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + ProjectProfile project, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: project.id, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + ProjectDocumentFields.name: project.name, + ProjectDocumentFields.colorKey: project.colorKey, + ProjectDocumentFields.defaultPriority: + PersistenceEnumMapping.encodePriority(project.defaultPriority), + ProjectDocumentFields.defaultReward: + PersistenceEnumMapping.encodeReward(project.defaultReward), + ProjectDocumentFields.defaultDifficulty: + PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty), + ProjectDocumentFields.defaultReminderProfile: + PersistenceEnumMapping.encodeReminderProfile( + project.defaultReminderProfile, + ), + ProjectDocumentFields.defaultDurationMinutes: + project.defaultDurationMinutes, + ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static ProjectProfile fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return ProjectProfile( + id: _requiredString(document, ProjectDocumentFields.id), + name: _requiredString(document, ProjectDocumentFields.name), + colorKey: _requiredString(document, ProjectDocumentFields.colorKey), + defaultPriority: PersistenceEnumMapping.decodeNullablePriority( + _requiredString(document, ProjectDocumentFields.defaultPriority), + )!, + defaultReward: PersistenceEnumMapping.decodeReward( + _requiredString(document, ProjectDocumentFields.defaultReward), + ), + defaultDifficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, ProjectDocumentFields.defaultDifficulty), + ), + defaultReminderProfile: + PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredString( + document, + ProjectDocumentFields.defaultReminderProfile, + ), + )!, + defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + archivedAt: _requiredNullableDateTime( + document, + ProjectDocumentFields.archivedAt, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart new file mode 100644 index 0000000..64794ec --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart @@ -0,0 +1,670 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Convenience extension for converting [ProjectStatistics] into a document. +extension ProjectStatisticsDocumentExtension on ProjectStatistics { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + Map toDocument({ + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { + return ProjectStatisticsDocumentMapping.toDocument( + this, + ownerId: ownerId, + createdAt: createdAt, + updatedAt: updatedAt, + revision: revision, + appliedActivityIds: appliedActivityIds, + ); + } +} + +/// Private file-level value for `_taskTypeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _taskTypeByCode = { + 'flexible': TaskType.flexible, + 'inflexible': TaskType.inflexible, + 'critical': TaskType.critical, + 'locked': TaskType.locked, + 'surprise': TaskType.surprise, + 'free_slot': TaskType.freeSlot, +}; + +/// Private file-level value for `_taskStatusByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _taskStatusByCode = { + 'planned': TaskStatus.planned, + 'active': TaskStatus.active, + 'completed': TaskStatus.completed, + 'missed': TaskStatus.missed, + 'cancelled': TaskStatus.cancelled, + 'no_longer_relevant': TaskStatus.noLongerRelevant, + 'backlog': TaskStatus.backlog, +}; + +/// Private file-level value for `_priorityByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _priorityByCode = { + 'very_low': PriorityLevel.veryLow, + 'low': PriorityLevel.low, + 'medium': PriorityLevel.medium, + 'high': PriorityLevel.high, + 'very_high': PriorityLevel.veryHigh, +}; + +/// Private file-level value for `_rewardByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _rewardByCode = { + 'not_set': RewardLevel.notSet, + 'very_low': RewardLevel.veryLow, + 'low': RewardLevel.low, + 'medium': RewardLevel.medium, + 'high': RewardLevel.high, + 'very_high': RewardLevel.veryHigh, +}; + +/// Private file-level value for `_difficultyByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _difficultyByCode = { + 'not_set': DifficultyLevel.notSet, + 'very_easy': DifficultyLevel.veryEasy, + 'easy': DifficultyLevel.easy, + 'medium': DifficultyLevel.medium, + 'hard': DifficultyLevel.hard, + 'very_hard': DifficultyLevel.veryHard, +}; + +/// Private file-level value for `_reminderProfileByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _reminderProfileByCode = { + 'silent': ReminderProfile.silent, + 'gentle': ReminderProfile.gentle, + 'persistent': ReminderProfile.persistent, + 'strict': ReminderProfile.strict, +}; + +/// Private file-level value for `_backlogTagByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _backlogTagByCode = { + 'wishlist': BacklogTag.wishlist, +}; + +/// Private file-level value for `_lockedWeekdayByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _lockedWeekdayByCode = { + 'monday': LockedWeekday.monday, + 'tuesday': LockedWeekday.tuesday, + 'wednesday': LockedWeekday.wednesday, + 'thursday': LockedWeekday.thursday, + 'friday': LockedWeekday.friday, + 'saturday': LockedWeekday.saturday, + 'sunday': LockedWeekday.sunday, +}; + +/// Private file-level value for `_lockedBlockOverrideTypeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _lockedBlockOverrideTypeByCode = { + 'remove': LockedBlockOverrideType.remove, + 'replace': LockedBlockOverrideType.replace, + 'add': LockedBlockOverrideType.add, +}; + +/// Private file-level value for `_taskActivityCodeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _taskActivityCodeByCode = { + 'completed': TaskActivityCode.completed, + 'missed': TaskActivityCode.missed, + 'cancelled': TaskActivityCode.cancelled, + 'no_longer_relevant': TaskActivityCode.noLongerRelevant, + 'manually_pushed': TaskActivityCode.manuallyPushed, + 'automatically_pushed': TaskActivityCode.automaticallyPushed, + 'moved_to_backlog': TaskActivityCode.movedToBacklog, + 'restored_from_backlog': TaskActivityCode.restoredFromBacklog, + 'activated': TaskActivityCode.activated, +}; + +/// Private file-level value for `_schedulingNoticeTypeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _schedulingNoticeTypeByCode = { + 'info': SchedulingNoticeType.info, + 'moved': SchedulingNoticeType.moved, + 'overlap': SchedulingNoticeType.overlap, + 'no_fit': SchedulingNoticeType.noFit, + 'overflow': SchedulingNoticeType.overflow, +}; + +/// Private file-level value for `_schedulingIssueCodeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _schedulingIssueCodeByCode = { + 'task_not_found': SchedulingIssueCode.taskNotFound, + 'invalid_task_state': SchedulingIssueCode.invalidTaskState, + 'missing_duration': SchedulingIssueCode.missingDuration, + 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, + 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, + 'no_available_slot': SchedulingIssueCode.noAvailableSlot, + 'unfinished_tasks_could_not_fit': + SchedulingIssueCode.unfinishedTasksCouldNotFit, + 'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks, + 'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog, +}; + +/// Private file-level value for `_schedulingMovementCodeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _schedulingMovementCodeByCode = { + 'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted, + 'flexible_task_moved_to_make_room': + SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + 'flexible_task_pushed_to_next_available_slot': + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + 'flexible_task_moved_to_tomorrow': + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + 'unfinished_flexible_tasks_rolled_over': + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + 'flexible_task_moved_to_backlog': + SchedulingMovementCode.flexibleTaskMovedToBacklog, + 'required_commitment_scheduled': + SchedulingMovementCode.requiredCommitmentScheduled, +}; + +/// Private file-level value for `_schedulingConflictCodeByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _schedulingConflictCodeByCode = { + 'flexible_task_overlaps_blocked_time': + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + 'surprise_task_overlaps_required_visible_time': + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + 'required_commitment_overlaps_protected_free_slot': + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot, +}; + +/// Private file-level value for `_projectCompletionTimeBucketByCode`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _projectCompletionTimeBucketByCode = + { + 'overnight': ProjectCompletionTimeBucket.overnight, + 'morning': ProjectCompletionTimeBucket.morning, + 'afternoon': ProjectCompletionTimeBucket.afternoon, + 'evening': ProjectCompletionTimeBucket.evening, + 'night': ProjectCompletionTimeBucket.night, +}; + +/// Top-level helper that performs the `_decodeCode` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +T _decodeCode(Map valuesByCode, String code, String fieldName) { + final value = valuesByCode[code]; + if (value == null) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: fieldName, + detailCode: code, + ); + } + return value; +} + +/// Top-level helper that performs the `_commonFields` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _commonFields({ + required String id, + required String ownerId, + required int revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + if (revision <= 0) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + ); + } + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: revision, + DocumentFields.createdAt: + PersistenceDateTimeConvention.toStoredString(createdAt), + DocumentFields.updatedAt: + PersistenceDateTimeConvention.toStoredString(updatedAt), + }; +} + +/// Top-level helper that performs the `_readCommon` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DocumentMetadata _readCommon(Map document) { + final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion); + if (schemaVersion != v1SchemaVersion) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaVersion.toString(), + ); + } + final revision = _requiredInt(document, DocumentFields.revision); + if (revision <= 0) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + detailCode: revision.toString(), + ); + } + return DocumentMetadata( + id: _requiredString(document, DocumentFields.id), + ownerId: _requiredString(document, DocumentFields.ownerId), + revision: revision, + createdAt: _requiredDateTime(document, DocumentFields.createdAt), + updatedAt: _requiredDateTime(document, DocumentFields.updatedAt), + ); +} + +/// Top-level helper that performs the `_mapInvalidValues` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +T _mapInvalidValues(T Function() read) { + try { + return read(); + } on DocumentMappingException { + rethrow; + } on DomainValidationException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + detailCode: error.code.name, + ); + } on FormatException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + detailCode: error.message, + ); + } on ArgumentError catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + ); + } +} + +/// Top-level helper that performs the `_dateTimeToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _dateTimeToStored(DateTime? value) { + return value == null + ? null + : PersistenceDateTimeConvention.toStoredString(value); +} + +/// Top-level helper that performs the `_civilDateToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _civilDateToStored(CivilDate? value) { + return value == null + ? null + : PersistenceCivilDateConvention.toStoredString(value); +} + +/// Top-level helper that performs the `_wallTimeToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _wallTimeToStored(WallTime? value) { + return value == null + ? null + : PersistenceWallTimeConvention.toStoredString(value); +} + +/// Top-level helper that performs the `_requiredString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is String) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredNullableString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredInt` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _requiredInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredNullableInt` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _requiredNullableInt( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredBool` 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 _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is bool) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredDateTime` 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 _requiredDateTime(Map document, String fieldName) { + return PersistenceDateTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +/// Top-level helper that performs the `_requiredNullableDateTime` 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? _requiredNullableDateTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceDateTimeConvention.fromStoredString(value); +} + +/// Top-level helper that performs the `_requiredCivilDate` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +CivilDate _requiredCivilDate(Map document, String fieldName) { + return PersistenceCivilDateConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +/// Top-level helper that performs the `_requiredNullableCivilDate` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +CivilDate? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceCivilDateConvention.fromStoredString(value); +} + +/// Top-level helper that performs the `_requiredWallTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +WallTime _requiredWallTime(Map document, String fieldName) { + return PersistenceWallTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +/// Top-level helper that performs the `_requiredNullableWallTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +WallTime? _requiredNullableWallTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceWallTimeConvention.fromStoredString(value); +} + +/// Top-level helper that performs the `_requiredMap` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _requiredMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredNullableMap` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map? _requiredNullableMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_requiredList` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _requiredList(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is List) { + return List.unmodifiable(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_mappedList` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _mappedList( + Map document, + String fieldName, + T Function(Map document) decode, +) { + return _requiredList(document, fieldName).map((value) { + if (value is Map) { + return decode(value); + } + if (value is Map) { + return decode(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); + }).toList(growable: false); +} + +/// Top-level helper that performs the `_intKeyCountMapToDocument` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _intKeyCountMapToDocument(Map counts) { + return { + for (final entry in counts.entries) entry.key.toString(): entry.value, + }; +} + +/// Runs the `Enum>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +Map _enumCountMapToDocument( + Map counts, + String Function(T value) encode, +) { + return { + for (final entry in counts.entries) encode(entry.key): entry.value, + }; +} + +/// Top-level helper that performs the `_intKeyCountMapFromDocument` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _intKeyCountMapFromDocument( + Map document, + String fieldName, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + int.parse(entry.key): _countValue(entry.value, fieldName), + }; +} + +/// Runs the `Enum>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +Map _enumCountMapFromDocument( + Map document, + String fieldName, + T Function(String value) decode, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + decode(entry.key): _countValue(entry.value, fieldName), + }; +} + +/// Top-level helper that performs the `_countValue` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _countValue(Object? value, String fieldName) { + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +/// Top-level helper that performs the `_plainDocument` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _plainDocument(Map value) { + return Map.unmodifiable( + value.map((key, value) => MapEntry(key, _plainDocumentValue(value))), + ); +} + +/// Top-level helper that performs the `_plainDocumentValue` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Object? _plainDocumentValue(Object? value) { + if (value == null || + value is String || + value is num || + value is bool || + value is DateTime) { + return value is DateTime + ? PersistenceDateTimeConvention.toStoredString(value) + : value; + } + if (value is CivilDate) { + return PersistenceCivilDateConvention.toStoredString(value); + } + if (value is WallTime) { + return PersistenceWallTimeConvention.toStoredString(value); + } + if (value is Enum) { + return PersistenceEnumMapping.encode(value); + } + if (value is List) { + return value.map(_plainDocumentValue).toList(growable: false); + } + if (value is Map) { + return _plainDocument(value); + } + if (value is Map) { + return _plainDocument(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + detailCode: value.runtimeType.toString(), + ); +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_mapping.dart new file mode 100644 index 0000000..9acc263 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_mapping.dart @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [ProjectStatistics]. +abstract final class ProjectStatisticsDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + ProjectStatistics statistics, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { + return { + ..._commonFields( + id: statistics.projectId, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + ProjectStatisticsDocumentFields.projectId: statistics.projectId, + ProjectStatisticsDocumentFields.completedTaskCount: + statistics.completedTaskCount, + ProjectStatisticsDocumentFields.durationMinuteCounts: + _intKeyCountMapToDocument(statistics.durationMinuteCounts), + ProjectStatisticsDocumentFields.completionTimeBucketCounts: + _enumCountMapToDocument( + statistics.completionTimeBucketCounts, + PersistenceEnumMapping.encodeProjectCompletionTimeBucket, + ), + ProjectStatisticsDocumentFields.totalPushesBeforeCompletion: + statistics.totalPushesBeforeCompletion, + ProjectStatisticsDocumentFields.completedAfterPushCount: + statistics.completedAfterPushCount, + ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument( + statistics.rewardCounts, + PersistenceEnumMapping.encodeReward, + ), + ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument( + statistics.difficultyCounts, + PersistenceEnumMapping.encodeDifficulty, + ), + ProjectStatisticsDocumentFields.reminderProfileCounts: + _enumCountMapToDocument( + statistics.reminderProfileCounts, + PersistenceEnumMapping.encodeReminderProfile, + ), + ProjectStatisticsDocumentFields.appliedActivityIds: + appliedActivityIds.toList(growable: false), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static ProjectStatistics fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return ProjectStatistics( + projectId: _requiredString( + document, + ProjectStatisticsDocumentFields.projectId, + ), + completedTaskCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedTaskCount, + ), + durationMinuteCounts: _intKeyCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.durationMinuteCounts, + ), + completionTimeBucketCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.completionTimeBucketCounts, + PersistenceEnumMapping.decodeProjectCompletionTimeBucket, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + completedAfterPushCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedAfterPushCount, + ), + rewardCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.rewardCounts, + PersistenceEnumMapping.decodeReward, + ), + difficultyCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.difficultyCounts, + PersistenceEnumMapping.decodeDifficulty, + ), + reminderProfileCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.reminderProfileCounts, + (value) => + PersistenceEnumMapping.decodeNullableReminderProfile(value)!, + ), + ).._validateProjectStatisticsDocumentMetadata(document); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_change_document_mapping.dart new file mode 100644 index 0000000..3c26120 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_change_document_mapping.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [SchedulingChange]. +abstract final class SchedulingChangeDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(SchedulingChange change) { + return { + SchedulingChangeDocumentFields.taskId: change.taskId, + SchedulingChangeDocumentFields.previousStart: + _dateTimeToStored(change.previousStart), + SchedulingChangeDocumentFields.previousEnd: + _dateTimeToStored(change.previousEnd), + SchedulingChangeDocumentFields.nextStart: + _dateTimeToStored(change.nextStart), + SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static SchedulingChange fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingChange( + taskId: + _requiredString(document, SchedulingChangeDocumentFields.taskId), + previousStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousStart, + ), + previousEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousEnd, + ), + nextStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextStart, + ), + nextEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextEnd, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_notice_document_mapping.dart new file mode 100644 index 0000000..bee49d8 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_notice_document_mapping.dart @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [SchedulingNotice]. +abstract final class SchedulingNoticeDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(SchedulingNotice notice) { + return { + SchedulingNoticeDocumentFields.message: notice.message, + SchedulingNoticeDocumentFields.type: + PersistenceEnumMapping.encodeSchedulingNoticeType(notice.type), + SchedulingNoticeDocumentFields.taskId: notice.taskId, + SchedulingNoticeDocumentFields.issueCode: notice.issueCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingIssueCode(notice.issueCode!), + SchedulingNoticeDocumentFields.movementCode: notice.movementCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingMovementCode( + notice.movementCode!, + ), + SchedulingNoticeDocumentFields.conflictCode: notice.conflictCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingConflictCode( + notice.conflictCode!, + ), + SchedulingNoticeDocumentFields.parameters: + _plainDocument(notice.parameters), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static SchedulingNotice fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingNotice( + _requiredString(document, SchedulingNoticeDocumentFields.message), + type: PersistenceEnumMapping.decodeSchedulingNoticeType( + _requiredString(document, SchedulingNoticeDocumentFields.type), + ), + taskId: _requiredNullableString( + document, + SchedulingNoticeDocumentFields.taskId, + ), + issueCode: PersistenceEnumMapping.decodeNullableSchedulingIssueCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.issueCode, + ), + ), + movementCode: + PersistenceEnumMapping.decodeNullableSchedulingMovementCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.movementCode, + ), + ), + conflictCode: + PersistenceEnumMapping.decodeNullableSchedulingConflictCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.conflictCode, + ), + ), + parameters: _requiredMap( + document, + SchedulingNoticeDocumentFields.parameters, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_overlap_document_mapping.dart new file mode 100644 index 0000000..b5e6971 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_overlap_document_mapping.dart @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [SchedulingOverlap]. +abstract final class SchedulingOverlapDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(SchedulingOverlap overlap) { + return { + SchedulingOverlapDocumentFields.taskId: overlap.taskId, + SchedulingOverlapDocumentFields.taskInterval: + TimeIntervalDocumentMapping.toDocument(overlap.taskInterval), + SchedulingOverlapDocumentFields.blockedInterval: + TimeIntervalDocumentMapping.toDocument(overlap.blockedInterval), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static SchedulingOverlap fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingOverlap( + taskId: + _requiredString(document, SchedulingOverlapDocumentFields.taskId), + taskInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap(document, SchedulingOverlapDocumentFields.taskInterval), + ), + blockedInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap( + document, + SchedulingOverlapDocumentFields.blockedInterval, + ), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart new file mode 100644 index 0000000..4533181 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [SchedulingStateSnapshot]. +abstract final class SchedulingSnapshotDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + SchedulingStateSnapshot snapshot, { + required String ownerId, + int revision = 1, + CivilDate? sourceDate, + CivilDate? targetDate, + DateTime? retentionExpiresAt, + bool truncated = false, + }) { + return { + ..._commonFields( + id: snapshot.id, + ownerId: ownerId, + revision: revision, + createdAt: snapshot.capturedAt, + updatedAt: snapshot.capturedAt, + ), + SchedulingSnapshotDocumentFields.capturedAt: + PersistenceDateTimeConvention.toStoredString(snapshot.capturedAt), + SchedulingSnapshotDocumentFields.sourceDate: + _civilDateToStored(sourceDate), + SchedulingSnapshotDocumentFields.targetDate: + _civilDateToStored(targetDate), + SchedulingSnapshotDocumentFields.window: + SchedulingWindowDocumentMapping.toDocument(snapshot.window), + SchedulingSnapshotDocumentFields.tasks: snapshot.tasks + .map( + (task) => TaskDocumentMapping.toDocument( + task, + ownerId: ownerId, + ), + ) + .toList(growable: false), + SchedulingSnapshotDocumentFields.lockedIntervals: snapshot.lockedIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.requiredVisibleIntervals: snapshot + .requiredVisibleIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.notices: snapshot.notices + .map(SchedulingNoticeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.changes: snapshot.changes + .map(SchedulingChangeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.overlaps: snapshot.overlaps + .map(SchedulingOverlapDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.operationName: snapshot.operationName, + SchedulingSnapshotDocumentFields.retentionExpiresAt: + _dateTimeToStored(retentionExpiresAt), + SchedulingSnapshotDocumentFields.truncated: truncated, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static SchedulingStateSnapshot fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return SchedulingStateSnapshot( + id: _requiredString(document, SchedulingSnapshotDocumentFields.id), + capturedAt: _requiredDateTime( + document, + SchedulingSnapshotDocumentFields.capturedAt, + ), + window: SchedulingWindowDocumentMapping.fromDocument( + _requiredMap(document, SchedulingSnapshotDocumentFields.window), + ), + tasks: _mappedList( + document, + SchedulingSnapshotDocumentFields.tasks, + TaskDocumentMapping.fromDocument, + ), + lockedIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.lockedIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + requiredVisibleIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.requiredVisibleIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + notices: _mappedList( + document, + SchedulingSnapshotDocumentFields.notices, + SchedulingNoticeDocumentMapping.fromDocument, + ), + changes: _mappedList( + document, + SchedulingSnapshotDocumentFields.changes, + SchedulingChangeDocumentMapping.fromDocument, + ), + overlaps: _mappedList( + document, + SchedulingSnapshotDocumentFields.overlaps, + SchedulingOverlapDocumentMapping.fromDocument, + ), + operationName: _requiredNullableString( + document, + SchedulingSnapshotDocumentFields.operationName, + ), + ).._validateSchedulingSnapshotDocumentMetadata(document); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_window_document_mapping.dart new file mode 100644 index 0000000..eadc7c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_window_document_mapping.dart @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [SchedulingWindow]. +abstract final class SchedulingWindowDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(SchedulingWindow window) { + return { + SchedulingWindowDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(window.start), + SchedulingWindowDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(window.end), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static SchedulingWindow fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingWindow( + start: + _requiredDateTime(document, SchedulingWindowDocumentFields.start), + end: _requiredDateTime(document, SchedulingWindowDocumentFields.end), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_activity_document_mapping.dart new file mode 100644 index 0000000..dc2a837 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_activity_document_mapping.dart @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [TaskActivity]. +abstract final class TaskActivityDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + TaskActivity activity, { + required String ownerId, + int revision = 1, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final created = createdAt ?? activity.occurredAt; + final updated = updatedAt ?? created; + return { + ..._commonFields( + id: activity.id, + ownerId: ownerId, + revision: revision, + createdAt: created, + updatedAt: updated, + ), + TaskActivityDocumentFields.operationId: activity.operationId, + TaskActivityDocumentFields.code: + PersistenceEnumMapping.encodeTaskActivityCode(activity.code), + TaskActivityDocumentFields.taskId: activity.taskId, + TaskActivityDocumentFields.projectId: activity.projectId, + TaskActivityDocumentFields.occurredAt: + PersistenceDateTimeConvention.toStoredString(activity.occurredAt), + TaskActivityDocumentFields.metadata: _plainDocument(activity.metadata), + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static TaskActivity fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return TaskActivity( + id: _requiredString(document, TaskActivityDocumentFields.id), + operationId: _requiredString( + document, + TaskActivityDocumentFields.operationId, + ), + code: PersistenceEnumMapping.decodeTaskActivityCode( + _requiredString(document, TaskActivityDocumentFields.code), + ), + taskId: _requiredString(document, TaskActivityDocumentFields.taskId), + projectId: + _requiredString(document, TaskActivityDocumentFields.projectId), + occurredAt: _requiredDateTime( + document, + TaskActivityDocumentFields.occurredAt, + ), + metadata: _requiredMap(document, TaskActivityDocumentFields.metadata), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_extension.dart new file mode 100644 index 0000000..d289a3f --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_extension.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Convenience extension for converting a [Task] into a V1 document map. +extension TaskDocumentExtension on Task { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + Map toDocument({ + required String ownerId, + int revision = 1, + bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, + }) { + return TaskDocumentMapping.toDocument( + this, + ownerId: ownerId, + revision: revision, + clearSchedule: clearSchedule, + backlogEnteredAt: backlogEnteredAt, + backlogEnteredAtProvenance: backlogEnteredAtProvenance, + ); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart new file mode 100644 index 0000000..3f43df4 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [Task]. +abstract final class TaskDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument( + Task task, { + required String ownerId, + int revision = 1, + bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, + }) { + final scheduledStart = clearSchedule ? null : task.scheduledStart; + final scheduledEnd = clearSchedule ? null : task.scheduledEnd; + + return { + ..._commonFields( + id: task.id, + ownerId: ownerId, + revision: revision, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ), + TaskDocumentFields.title: task.title, + TaskDocumentFields.projectId: task.projectId, + TaskDocumentFields.type: PersistenceEnumMapping.encodeTaskType(task.type), + TaskDocumentFields.status: + PersistenceEnumMapping.encodeTaskStatus(task.status), + TaskDocumentFields.priority: task.priority == null + ? null + : PersistenceEnumMapping.encodePriority(task.priority!), + TaskDocumentFields.reward: + PersistenceEnumMapping.encodeReward(task.reward), + TaskDocumentFields.difficulty: + PersistenceEnumMapping.encodeDifficulty(task.difficulty), + TaskDocumentFields.durationMinutes: task.durationMinutes, + TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), + TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), + TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), + TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), + TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), + TaskDocumentFields.parentTaskId: task.parentTaskId, + TaskDocumentFields.backlogTags: task.backlogTags + .map(PersistenceEnumMapping.encodeBacklogTag) + .toList(), + TaskDocumentFields.reminderOverride: task.reminderOverride == null + ? null + : PersistenceEnumMapping.encodeReminderProfile( + task.reminderOverride!), + TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( + task.stats, + ), + TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), + TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Task fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return Task( + id: _requiredString(document, TaskDocumentFields.id), + title: _requiredString(document, TaskDocumentFields.title), + projectId: _requiredString(document, TaskDocumentFields.projectId), + type: PersistenceEnumMapping.decodeTaskType( + _requiredString(document, TaskDocumentFields.type), + ), + status: PersistenceEnumMapping.decodeTaskStatus( + _requiredString(document, TaskDocumentFields.status), + ), + priority: PersistenceEnumMapping.decodeNullablePriority( + _requiredNullableString(document, TaskDocumentFields.priority), + ), + reward: PersistenceEnumMapping.decodeReward( + _requiredString(document, TaskDocumentFields.reward), + ), + difficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, TaskDocumentFields.difficulty), + ), + durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + scheduledStart: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledStart, + ), + scheduledEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledEnd, + ), + actualStart: _requiredNullableDateTime( + document, + TaskDocumentFields.actualStart, + ), + actualEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.actualEnd, + ), + completedAt: _requiredNullableDateTime( + document, + TaskDocumentFields.completedAt, + ), + parentTaskId: _requiredNullableString( + document, + TaskDocumentFields.parentTaskId, + ), + backlogTags: _backlogTagsFromDocument(document), + reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredNullableString( + document, + TaskDocumentFields.reminderOverride, + ), + ), + createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), + updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), + stats: TaskStatisticsDocumentMapping.fromDocument( + _requiredMap(document, TaskDocumentFields.stats), + ), + ).._validateTaskDocumentMetadata(document); + }); + } + + /// Converts scheduler data for `_backlogTagsFromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Set _backlogTagsFromDocument( + Map document, + ) { + final rawTags = _requiredList(document, TaskDocumentFields.backlogTags); + return rawTags.map((tag) { + if (tag is! String) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: TaskDocumentFields.backlogTags, + ); + } + return PersistenceEnumMapping.decodeBacklogTag(tag); + }).toSet(); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_extension.dart new file mode 100644 index 0000000..4914af7 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_extension.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Convenience extension for converting [TaskStatistics] into an embedded map. +extension TaskStatisticsDocumentExtension on TaskStatistics { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + Map toDocument() { + return TaskStatisticsDocumentMapping.toDocument(this); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_mapping.dart new file mode 100644 index 0000000..f0f1dec --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_mapping.dart @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [TaskStatistics]. +abstract final class TaskStatisticsDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(TaskStatistics stats) { + return { + TaskStatisticsDocumentFields.skippedDuringBurnoutCount: + stats.skippedDuringBurnoutCount, + TaskStatisticsDocumentFields.manuallyPushedCount: + stats.manuallyPushedCount, + TaskStatisticsDocumentFields.autoPushedCount: stats.autoPushedCount, + TaskStatisticsDocumentFields.movedToBacklogCount: + stats.movedToBacklogCount, + TaskStatisticsDocumentFields.restoredFromBacklogCount: + stats.restoredFromBacklogCount, + TaskStatisticsDocumentFields.missedCount: stats.missedCount, + TaskStatisticsDocumentFields.cancelledCount: stats.cancelledCount, + TaskStatisticsDocumentFields.completedLateCount: stats.completedLateCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount: + stats.completedDuringLockedHoursCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: + stats.completedDuringLockedHoursMinutes, + TaskStatisticsDocumentFields.completedAfterShieldCount: + stats.completedAfterShieldCount, + TaskStatisticsDocumentFields.completedAfterPushCount: + stats.completedAfterPushCount, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion: + stats.totalPushesBeforeCompletion, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static TaskStatistics fromDocument(Map document) { + return _mapInvalidValues(() { + return TaskStatistics( + skippedDuringBurnoutCount: _requiredInt( + document, + TaskStatisticsDocumentFields.skippedDuringBurnoutCount, + ), + manuallyPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.manuallyPushedCount, + ), + autoPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.autoPushedCount, + ), + movedToBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.movedToBacklogCount, + ), + restoredFromBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.restoredFromBacklogCount, + ), + missedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.missedCount, + ), + cancelledCount: _requiredInt( + document, + TaskStatisticsDocumentFields.cancelledCount, + ), + completedLateCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedLateCount, + ), + completedDuringLockedHoursCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount, + ), + completedDuringLockedHoursMinutes: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, + ), + completedAfterShieldCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterShieldCount, + ), + completedAfterPushCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterPushCount, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/time/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/time/time_interval_document_mapping.dart new file mode 100644 index 0000000..7f78c2c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/time/time_interval_document_mapping.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_mapping.dart'; + +/// Document mapping for [TimeInterval]. +abstract final class TimeIntervalDocumentMapping { + /// Converts scheduler data for `toDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static Map toDocument(TimeInterval interval) { + return { + TimeIntervalDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(interval.start), + TimeIntervalDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(interval.end), + TimeIntervalDocumentFields.label: interval.label, + }; + } + + /// Converts scheduler data for `fromDocument` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + static TimeInterval fromDocument(Map document) { + return _mapInvalidValues(() { + return TimeInterval( + start: _requiredDateTime(document, TimeIntervalDocumentFields.start), + end: _requiredDateTime(document, TimeIntervalDocumentFields.end), + label: _requiredNullableString( + document, + TimeIntervalDocumentFields.label, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration.dart b/packages/scheduler_core/lib/src/persistence/document_migration.dart new file mode 100644 index 0000000..b57cefe --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Document Migration behavior for the Scheduler Core package. +library; + +// Legacy document migration helpers for the V1 persistence contract. +// +// The migration layer is intentionally plain Dart and side-effect free. Future +// adapters can use these results for dry-runs before deciding whether to write +// the migrated document back to storage. + +import 'document_mapping.dart'; +import 'persistence_contract.dart'; +import '../domain/time_contracts.dart'; +part 'document_migration/codes/document_migration_entity.dart'; +part 'document_migration/codes/document_migration_status.dart'; +part 'document_migration/codes/document_migration_failure_code.dart'; +part 'document_migration/codes/document_migration_note_code.dart'; +part 'document_migration/reports/document_migration_provenance.dart'; +part 'document_migration/reports/document_migration_note.dart'; +part 'document_migration/reports/document_migration_report.dart'; +part 'document_migration/reports/document_migration_result.dart'; +part 'document_migration/service/v1_document_migration_service.dart'; +part 'document_migration/service/v0_migrator.dart'; +part 'document_migration/service/schema_version_read.dart'; +part 'document_migration/legacy/legacy_document_exception.dart'; +part 'document_migration/legacy/legacy_metadata_instants.dart'; diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_entity.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_entity.dart new file mode 100644 index 0000000..e355e1a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_entity.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Entity shape being migrated. +enum DocumentMigrationEntity { + /// Selects the `task` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + task, + + /// Selects the `project` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + project, + + /// Selects the `lockedBlock` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + lockedBlock, + + /// Selects the `lockedBlockOverride` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + lockedBlockOverride, +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_failure_code.dart new file mode 100644 index 0000000..6b7bd20 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_failure_code.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Typed categories for migration failures. +enum DocumentMigrationFailureCode { + /// Selects the `invalidSchemaVersion` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidSchemaVersion, + + /// Selects the `unsupportedSchemaVersion` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + unsupportedSchemaVersion, + + /// Selects the `legacyDocumentFailure` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + legacyDocumentFailure, + + /// Selects the `currentDocumentFailure` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + currentDocumentFailure, +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_note_code.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_note_code.dart new file mode 100644 index 0000000..56a38a7 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_note_code.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Non-fatal migration notes surfaced to callers during dry-runs. +enum DocumentMigrationNoteCode { + /// Selects the `approximatedBacklogEnteredAt` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + approximatedBacklogEnteredAt, + + /// Selects the `synthesizedMetadataTimestamp` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + synthesizedMetadataTimestamp, +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_status.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_status.dart new file mode 100644 index 0000000..5b420ac --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_status.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Result state for a migration attempt. +enum DocumentMigrationStatus { + /// Selects the `migrated` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + migrated, + + /// Selects the `alreadyCurrent` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + alreadyCurrent, + + /// Selects the `failed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + failed, +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_document_exception.dart b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_document_exception.dart new file mode 100644 index 0000000..cb5e20b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_document_exception.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Private implementation type for `_LegacyDocumentException` 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 _LegacyDocumentException implements Exception { + /// Creates a `_LegacyDocumentException` 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 _LegacyDocumentException({ + required this.fieldName, + required this.detailCode, + }); + + /// Stores the `fieldName` 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 fieldName; + + /// Stores the `detailCode` 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 detailCode; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_metadata_instants.dart new file mode 100644 index 0000000..b163ff3 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_metadata_instants.dart @@ -0,0 +1,688 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Private implementation type for `_LegacyMetadataInstants` 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 _LegacyMetadataInstants { + /// Creates a `_LegacyMetadataInstants` 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 _LegacyMetadataInstants({ + required this.createdAt, + required this.updatedAt, + }); + + /// Stores the `createdAt` 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 createdAt; + + /// Stores the `updatedAt` 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 updatedAt; +} + +/// Private file-level value for `_taskTypeCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _taskTypeCodes = { + 'flexible': 'flexible', + 'inflexible': 'inflexible', + 'critical': 'critical', + 'locked': 'locked', + 'surprise': 'surprise', + 'freeSlot': 'free_slot', + 'free_slot': 'free_slot', +}; + +/// Private file-level value for `_taskStatusCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _taskStatusCodes = { + 'planned': 'planned', + 'active': 'active', + 'completed': 'completed', + 'missed': 'missed', + 'cancelled': 'cancelled', + 'noLongerRelevant': 'no_longer_relevant', + 'no_longer_relevant': 'no_longer_relevant', + 'backlog': 'backlog', +}; + +/// Private file-level value for `_priorityCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _priorityCodes = { + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +/// Private file-level value for `_rewardCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _rewardCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +/// Private file-level value for `_difficultyCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _difficultyCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryEasy': 'very_easy', + 'very_easy': 'very_easy', + 'easy': 'easy', + 'medium': 'medium', + 'hard': 'hard', + 'veryHard': 'very_hard', + 'very_hard': 'very_hard', +}; + +/// Private file-level value for `_reminderProfileCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _reminderProfileCodes = { + 'silent': 'silent', + 'gentle': 'gentle', + 'persistent': 'persistent', + 'strict': 'strict', +}; + +/// Private file-level value for `_backlogTagCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _backlogTagCodes = { + 'wishlist': 'wishlist', +}; + +/// Private file-level value for `_lockedWeekdayCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _lockedWeekdayCodes = { + 'monday': 'monday', + 'tuesday': 'tuesday', + 'wednesday': 'wednesday', + 'thursday': 'thursday', + 'friday': 'friday', + 'saturday': 'saturday', + 'sunday': 'sunday', +}; + +/// Private file-level value for `_lockedOverrideTypeCodes`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const _lockedOverrideTypeCodes = { + 'remove': 'remove', + 'replace': 'replace', + 'add': 'add', +}; + +/// Private file-level value for `_explicitOffsetPattern`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); + +/// Top-level helper that performs the `_readSchemaVersion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +_SchemaVersionRead _readSchemaVersion(Map document) { + if (!document.containsKey(DocumentFields.schemaVersion)) { + return const _SchemaVersionRead.version(0); + } + final value = document[DocumentFields.schemaVersion]; + if (value is int) { + return _SchemaVersionRead.version(value); + } + return const _SchemaVersionRead.failure('wrongType'); +} + +/// Top-level helper that performs the `_documentId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _documentId(Map document) { + final value = document[DocumentFields.id]; + return value is String ? value : null; +} + +/// Top-level helper that performs the `_failed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DocumentMigrationResult _failed({ + required DocumentMigrationEntity entity, + required String? documentId, + required int? fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + String? fieldName, + String? detailCode, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + fieldName: fieldName, + detailCode: detailCode, + ), + ); +} + +/// Top-level helper that performs the `_failedFromMapping` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DocumentMigrationResult _failedFromMapping({ + required DocumentMigrationEntity entity, + required String? documentId, + required int fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + required DocumentMappingException error, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + mappingFailureCode: error.code, + fieldName: error.fieldName, + detailCode: error.detailCode, + ), + ); +} + +/// Top-level helper that performs the `_requiredString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is String) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredNullableString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredNullableInt` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _requiredNullableInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredBool` 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 _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is bool) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredMap` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _requiredMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredLegacyCode` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredString(document, fieldName); + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +/// Top-level helper that performs the `_nullableLegacyCode` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _nullableLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredNullableString(document, fieldName); + if (raw == null) { + return null; + } + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +/// Top-level helper that performs the `_requiredLegacyCodeList` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _requiredLegacyCodeList( + Map document, + String fieldName, + Map codes, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is! List) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value.map((entry) { + if (entry is! String) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final code = codes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false); +} + +/// Top-level helper that performs the `_requiredInstant` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + final stored = _instantToStored(value, fieldName); + if (stored == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return stored; +} + +/// Top-level helper that performs the `_nullableInstant` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _nullableInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _instantToStored(document[fieldName], fieldName); +} + +/// Top-level helper that performs the `_optionalNullableInstant` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _optionalNullableInstant( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + return null; + } + return _instantToStored(document[fieldName], fieldName); +} + +/// Top-level helper that performs the `_instantToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _instantToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is DateTime) { + if (!value.isUtc) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + return PersistenceDateTimeConvention.toStoredString(value); + } + if (value is String) { + if (!_explicitOffsetPattern.hasMatch(value)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + try { + return PersistenceDateTimeConvention.toStoredString( + DateTime.parse(value), + ); + } on FormatException { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidDateTime', + ); + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredCivilDate` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredCivilDate(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _civilDateToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +/// Top-level helper that performs the `_requiredNullableCivilDate` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _civilDateToStored(document[fieldName], fieldName); +} + +/// Top-level helper that performs the `_civilDateToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _civilDateToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceCivilDateConvention.toStoredString( + PersistenceCivilDateConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidCivilDate', + ); + } + } + if (value is DateTime) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousCivilDateTime', + ); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_requiredWallTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredWallTime(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _wallTimeToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +/// Top-level helper that performs the `_requiredNullableWallTime` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _requiredNullableWallTime( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _wallTimeToStored(document[fieldName], fieldName); +} + +/// Top-level helper that performs the `_wallTimeToStored` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _wallTimeToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceWallTimeConvention.toStoredString( + PersistenceWallTimeConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + if (value is Map) { + final map = _deepCopyMap(value, fieldName); + final legacyValue = map[ClockTimeDocumentFields.value]; + if (legacyValue is String) { + return _wallTimeToStored(legacyValue, fieldName); + } + final hour = map['hour']; + final minute = map['minute']; + if (hour is int && minute is int) { + try { + return WallTime(hour: hour, minute: minute).toIsoString(); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +/// Top-level helper that performs the `_nullableRecurrence` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map? _nullableRecurrence( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is! Map) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final recurrence = _deepCopyMap(value, fieldName); + final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays]; + if (weekdays is! List) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + return { + LockedBlockRecurrenceDocumentFields.type: + recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) { + if (entry is! String) { + throw const _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + final code = _lockedWeekdayCodes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false), + }; +} + +/// Top-level helper that performs the `_deepCopyDocument` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _deepCopyDocument(Map document) { + return _deepCopyMap(document, 'document'); +} + +/// Top-level helper that performs the `_deepCopyMap` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _deepCopyMap(Map map, String fieldName) { + return { + for (final entry in map.entries) + _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName), + }; +} + +/// Top-level helper that performs the `_stringKey` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _stringKey(Object? key, String fieldName) { + if (key is String) { + return key; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'nonStringMapKey', + ); +} + +/// Top-level helper that performs the `_deepCopyValue` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Object? _deepCopyValue(Object? value, String fieldName) { + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + if (value is List) { + return value.map((entry) => _deepCopyValue(entry, fieldName)).toList(); + } + return value; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_note.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_note.dart new file mode 100644 index 0000000..ce0258f --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_note.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// A non-fatal detail about a migrated document. +class DocumentMigrationNote { + /// Creates a `DocumentMigrationNote` 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 DocumentMigrationNote({ + required this.code, + this.fieldName, + this.detailCode, + }); + + /// Stores the `code` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMigrationNoteCode code; + + /// Stores the `fieldName` 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? fieldName; + + /// Stores the `detailCode` 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? detailCode; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_provenance.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_provenance.dart new file mode 100644 index 0000000..831885d --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_provenance.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Provenance labels written by migrations. +abstract final class DocumentMigrationProvenance { + /// Shared constant value for `approximatedFromCreatedAt`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const approximatedFromCreatedAt = 'approximated_from_created_at'; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_report.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_report.dart new file mode 100644 index 0000000..7c2e0d3 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_report.dart @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Typed dry-run report for one document migration attempt. +class DocumentMigrationReport { + /// Creates a `DocumentMigrationReport` 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 DocumentMigrationReport({ + required this.entity, + required this.status, + required this.documentId, + required this.fromSchemaVersion, + this.targetSchemaVersion = v1SchemaVersion, + this.failureCode, + this.mappingFailureCode, + this.fieldName, + this.detailCode, + this.notes = const [], + }); + + /// Stores the `entity` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMigrationEntity entity; + + /// Stores the `status` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMigrationStatus status; + + /// Stores the `documentId` 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? documentId; + + /// Stores the `fromSchemaVersion` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? fromSchemaVersion; + + /// Stores the `targetSchemaVersion` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int targetSchemaVersion; + + /// Stores the `failureCode` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMigrationFailureCode? failureCode; + + /// Stores the `mappingFailureCode` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMappingFailureCode? mappingFailureCode; + + /// Stores the `fieldName` 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? fieldName; + + /// Stores the `detailCode` 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? detailCode; + + /// Stores the `notes` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final List notes; + + /// Returns the derived `canWrite` 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. + bool get canWrite => status != DocumentMigrationStatus.failed; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_result.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_result.dart new file mode 100644 index 0000000..c5f56c8 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_result.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Migration output plus report. +class DocumentMigrationResult { + /// Creates a `DocumentMigrationResult` 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 DocumentMigrationResult({ + required this.report, + required this.document, + }); + + /// Migrated/current document. Null means the source must not be overwritten. + final Map? document; + + /// Stores the `report` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DocumentMigrationReport report; + + /// Returns the derived `isSuccess` 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. + bool get isSuccess => report.status != DocumentMigrationStatus.failed; + + /// True only when the caller should persist a transformed replacement. + bool get shouldWrite => report.status == DocumentMigrationStatus.migrated; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/service/schema_version_read.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/schema_version_read.dart new file mode 100644 index 0000000..3466740 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/service/schema_version_read.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Private implementation type for `_SchemaVersionRead` 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 _SchemaVersionRead { + /// Creates a `_SchemaVersionRead.version` 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 _SchemaVersionRead.version(this.version) : failure = null; + + /// Creates a `_SchemaVersionRead.failure` 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 _SchemaVersionRead.failure(this.failure) : version = 0; + + /// Stores the `version` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int version; + + /// Stores the `failure` 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? failure; +} diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/service/v0_migrator.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/v0_migrator.dart new file mode 100644 index 0000000..91558cc --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/service/v0_migrator.dart @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +typedef _V0Migrator = Map Function( + Map document, + List notes, +); diff --git a/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart new file mode 100644 index 0000000..9c71f0a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart @@ -0,0 +1,444 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../document_migration.dart'; + +/// Pure V0-to-V1 migration service. +/// +/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner +/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry +/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future +/// versions fail closed. +class V1DocumentMigrationService { + /// Creates a `V1DocumentMigrationService` 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 V1DocumentMigrationService({ + required this.ownerId, + required this.migratedAt, + }); + + /// Stores the `ownerId` 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 ownerId; + + /// Stores the `migratedAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime migratedAt; + + /// Performs the `migrateTask` 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. + DocumentMigrationResult migrateTask(Map document) { + return _migrate( + entity: DocumentMigrationEntity.task, + document: document, + migrateV0: _taskV0ToV1, + validateCurrent: TaskDocumentMapping.fromDocument, + ); + } + + /// Performs the `migrateProject` 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. + DocumentMigrationResult migrateProject(Map document) { + return _migrate( + entity: DocumentMigrationEntity.project, + document: document, + migrateV0: _projectV0ToV1, + validateCurrent: ProjectDocumentMapping.fromDocument, + ); + } + + /// Performs the `migrateLockedBlock` 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. + DocumentMigrationResult migrateLockedBlock(Map document) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlock, + document: document, + migrateV0: _lockedBlockV0ToV1, + validateCurrent: LockedBlockDocumentMapping.fromDocument, + ); + } + + /// Performs the `migrateLockedBlockOverride` 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. + DocumentMigrationResult migrateLockedBlockOverride( + Map document, + ) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlockOverride, + document: document, + migrateV0: _lockedBlockOverrideV0ToV1, + validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument, + ); + } + + /// Runs the `_migrate` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + DocumentMigrationResult _migrate({ + required DocumentMigrationEntity entity, + required Map document, + required _V0Migrator migrateV0, + required void Function(Map document) validateCurrent, + }) { + final documentId = _documentId(document); + final schemaRead = _readSchemaVersion(document); + if (schemaRead.failure != null) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: null, + failureCode: DocumentMigrationFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaRead.failure, + ); + } + + final fromVersion = schemaRead.version; + if (fromVersion == v1SchemaVersion) { + try { + final copy = _deepCopyDocument(document); + validateCurrent(copy); + return DocumentMigrationResult( + document: copy, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.alreadyCurrent, + documentId: documentId, + fromSchemaVersion: fromVersion, + ), + ); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + } + + if (fromVersion > v1SchemaVersion || fromVersion < 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + if (fromVersion != 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + final notes = []; + late final Map migrated; + try { + migrated = migrateV0(document, notes); + } on _LegacyDocumentException catch (error) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.legacyDocumentFailure, + fieldName: error.fieldName, + detailCode: error.detailCode, + ); + } + + try { + validateCurrent(migrated); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + + return DocumentMigrationResult( + document: migrated, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.migrated, + documentId: documentId, + fromSchemaVersion: fromVersion, + notes: List.unmodifiable(notes), + ), + ); + } + + /// Runs the `_taskV0ToV1` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Map _taskV0ToV1( + Map document, + List notes, + ) { + final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt); + final status = _requiredLegacyCode( + document, + TaskDocumentFields.status, + _taskStatusCodes, + ); + final isBacklog = status == 'backlog'; + if (isBacklog) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt, + fieldName: TaskDocumentFields.backlogEnteredAt, + detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt, + ), + ); + } + + return { + ..._commonV1Fields( + id: _requiredString(document, TaskDocumentFields.id), + createdAt: createdAt, + updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt), + ), + TaskDocumentFields.title: + _requiredString(document, TaskDocumentFields.title), + TaskDocumentFields.projectId: + _requiredString(document, TaskDocumentFields.projectId), + TaskDocumentFields.type: _requiredLegacyCode( + document, + TaskDocumentFields.type, + _taskTypeCodes, + ), + TaskDocumentFields.status: status, + TaskDocumentFields.priority: _nullableLegacyCode( + document, + TaskDocumentFields.priority, + _priorityCodes, + ), + TaskDocumentFields.reward: _requiredLegacyCode( + document, + TaskDocumentFields.reward, + _rewardCodes, + ), + TaskDocumentFields.difficulty: _requiredLegacyCode( + document, + TaskDocumentFields.difficulty, + _difficultyCodes, + ), + TaskDocumentFields.durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + TaskDocumentFields.scheduledStart: _nullableInstant( + document, + TaskDocumentFields.scheduledStart, + ), + TaskDocumentFields.scheduledEnd: _nullableInstant( + document, + TaskDocumentFields.scheduledEnd, + ), + TaskDocumentFields.actualStart: + _nullableInstant(document, TaskDocumentFields.actualStart), + TaskDocumentFields.actualEnd: + _nullableInstant(document, TaskDocumentFields.actualEnd), + TaskDocumentFields.completedAt: + _nullableInstant(document, TaskDocumentFields.completedAt), + TaskDocumentFields.parentTaskId: + _requiredNullableString(document, TaskDocumentFields.parentTaskId), + TaskDocumentFields.backlogTags: _requiredLegacyCodeList( + document, + TaskDocumentFields.backlogTags, + _backlogTagCodes, + ), + TaskDocumentFields.reminderOverride: _nullableLegacyCode( + document, + TaskDocumentFields.reminderOverride, + _reminderProfileCodes, + ), + TaskDocumentFields.stats: + _requiredMap(document, TaskDocumentFields.stats), + TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null, + TaskDocumentFields.backlogEnteredAtProvenance: isBacklog + ? DocumentMigrationProvenance.approximatedFromCreatedAt + : null, + }; + } + + /// Runs the `_projectV0ToV1` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Map _projectV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, ProjectDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + ProjectDocumentFields.name: + _requiredString(document, ProjectDocumentFields.name), + ProjectDocumentFields.colorKey: + _requiredString(document, ProjectDocumentFields.colorKey), + ProjectDocumentFields.defaultPriority: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultPriority, + _priorityCodes, + ), + ProjectDocumentFields.defaultReward: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReward, + _rewardCodes, + ), + ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultDifficulty, + _difficultyCodes, + ), + ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReminderProfile, + _reminderProfileCodes, + ), + ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + ProjectDocumentFields.archivedAt: + _optionalNullableInstant(document, ProjectDocumentFields.archivedAt), + }; + } + + /// Runs the `_lockedBlockV0ToV1` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Map _lockedBlockV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockDocumentFields.name: + _requiredString(document, LockedBlockDocumentFields.name), + LockedBlockDocumentFields.startTime: + _requiredWallTime(document, LockedBlockDocumentFields.startTime), + LockedBlockDocumentFields.endTime: + _requiredWallTime(document, LockedBlockDocumentFields.endTime), + LockedBlockDocumentFields.date: + _requiredNullableCivilDate(document, LockedBlockDocumentFields.date), + LockedBlockDocumentFields.recurrence: + _nullableRecurrence(document, LockedBlockDocumentFields.recurrence), + LockedBlockDocumentFields.hiddenByDefault: + _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault), + LockedBlockDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + LockedBlockDocumentFields.archivedAt: _optionalNullableInstant( + document, + LockedBlockDocumentFields.archivedAt, + ), + }; + } + + /// Runs the `_lockedBlockOverrideV0ToV1` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Map _lockedBlockOverrideV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + LockedBlockOverrideDocumentFields.date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + LockedBlockOverrideDocumentFields.type: _requiredLegacyCode( + document, + LockedBlockOverrideDocumentFields.type, + _lockedOverrideTypeCodes, + ), + LockedBlockOverrideDocumentFields.name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + LockedBlockOverrideDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + }; + } + + /// Runs the `_commonV1Fields` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Map _commonV1Fields({ + required String id, + required String createdAt, + required String updatedAt, + }) { + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: 1, + DocumentFields.createdAt: createdAt, + DocumentFields.updatedAt: updatedAt, + }; + } + + /// Runs the `_metadataInstants` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + _LegacyMetadataInstants _metadataInstants( + Map document, + List notes, + ) { + final createdAt = + _optionalNullableInstant(document, DocumentFields.createdAt); + final updatedAt = + _optionalNullableInstant(document, DocumentFields.updatedAt); + if (createdAt == null || updatedAt == null) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp, + detailCode: 'migratedAt', + ), + ); + } + final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt); + return _LegacyMetadataInstants( + createdAt: createdAt ?? fallback, + updatedAt: updatedAt ?? createdAt ?? fallback, + ); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract.dart new file mode 100644 index 0000000..11fb4de --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract.dart @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Persistence Contract behavior for the Scheduler Core package. +library; + +// Adapter-neutral persistence contract helpers. +// +// This file defines stable field names and encoding conventions that document +// codecs and future adapters should use. It deliberately does not serialize +// domain models or import a database client. + +import '../domain/time_contracts.dart'; +part 'persistence_contract/collections/persistence_collections.dart'; +part 'persistence_contract/indexes/persistence_index_direction.dart'; +part 'persistence_contract/indexes/persistence_index_field.dart'; +part 'persistence_contract/indexes/persistence_index_spec.dart'; +part 'persistence_contract/indexes/repository_query_names.dart'; +part 'persistence_contract/indexes/persistence_index_catalog.dart'; +part 'persistence_contract/payloads/persistence_payload_limits.dart'; +part 'persistence_contract/payloads/persistence_guard_result.dart'; +part 'persistence_contract/payloads/persistence_payload_guard.dart'; +part 'persistence_contract/conventions/persistence_date_time_convention.dart'; +part 'persistence_contract/conventions/persistence_civil_date_convention.dart'; +part 'persistence_contract/conventions/persistence_wall_time_convention.dart'; +part 'persistence_contract/conventions/persistence_enum_name.dart'; +part 'persistence_contract/fields/core/document_fields.dart'; +part 'persistence_contract/fields/tasks/task_document_fields.dart'; +part 'persistence_contract/fields/tasks/task_activity_document_fields.dart'; +part 'persistence_contract/fields/tasks/task_statistics_document_fields.dart'; +part 'persistence_contract/fields/projects/project_document_fields.dart'; +part 'persistence_contract/fields/projects/project_statistics_document_fields.dart'; +part 'persistence_contract/fields/time/clock_time_document_fields.dart'; +part 'persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart'; +part 'persistence_contract/fields/locked_time/locked_block_document_fields.dart'; +part 'persistence_contract/fields/locked_time/locked_block_override_document_fields.dart'; +part 'persistence_contract/fields/application/owner_settings_document_fields.dart'; +part 'persistence_contract/fields/application/backlog_staleness_document_fields.dart'; +part 'persistence_contract/fields/application/notice_acknowledgement_document_fields.dart'; +part 'persistence_contract/fields/application/application_operation_document_fields.dart'; +part 'persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart'; +part 'persistence_contract/fields/scheduling/scheduling_window_document_fields.dart'; +part 'persistence_contract/fields/time/time_interval_document_fields.dart'; +part 'persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart'; +part 'persistence_contract/fields/scheduling/scheduling_change_document_fields.dart'; +part 'persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart'; +part 'persistence_contract/persistence_document_field_sets.dart'; + +/// V1 document-schema version. +const v1SchemaVersion = 1; diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/collections/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/collections/persistence_collections.dart new file mode 100644 index 0000000..79f24ec --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/collections/persistence_collections.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Stable V1 logical collection names for persistence adapters. +abstract final class PersistenceCollections { + /// Shared constant value for `tasks`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const tasks = 'tasks'; + + /// Shared constant value for `projects`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const projects = 'projects'; + + /// Shared constant value for `projectStatistics`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const projectStatistics = 'project_statistics'; + + /// Shared constant value for `lockedBlocks`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const lockedBlocks = 'locked_blocks'; + + /// Shared constant value for `lockedOverrides`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const lockedOverrides = 'locked_overrides'; + + /// Shared constant value for `taskActivities`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivities = 'task_activities'; + + /// Shared constant value for `ownerSettings`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const ownerSettings = 'owner_settings'; + + /// Shared constant value for `noticeAcknowledgements`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const noticeAcknowledgements = 'notice_acknowledgements'; + + /// Shared constant value for `applicationOperations`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const applicationOperations = 'application_operations'; + + /// Shared constant value for `schedulingSnapshots`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const schedulingSnapshots = 'scheduling_snapshots'; + + /// Shared constant value for `authoritative`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const authoritative = { + tasks, + projects, + projectStatistics, + lockedBlocks, + lockedOverrides, + ownerSettings, + }; + + /// Shared constant value for `internalRetained`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const internalRetained = { + taskActivities, + noticeAcknowledgements, + applicationOperations, + schedulingSnapshots, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_civil_date_convention.dart new file mode 100644 index 0000000..e238af5 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_civil_date_convention.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Civil-date storage convention for future document persistence. +abstract final class PersistenceCivilDateConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store CivilDate values as YYYY-MM-DD strings without timezone conversion.'; + + /// Convert a CivilDate into the persisted string convention. + static String toStoredString(CivilDate value) { + return value.toIsoString(); + } + + /// Parse a persisted CivilDate string without applying a timezone. + static CivilDate fromStoredString(String value) { + return CivilDate.fromIsoString(value); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_date_time_convention.dart new file mode 100644 index 0000000..4362ae8 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_date_time_convention.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// DateTime storage convention for future document persistence. +abstract final class PersistenceDateTimeConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.'; + + /// Convert a DateTime into the persisted string convention. + static String toStoredString(DateTime value) { + return value.toUtc().toIso8601String(); + } + + /// Parse a persisted DateTime string back into a UTC DateTime. + static DateTime fromStoredString(String value) { + return DateTime.parse(value).toUtc(); + } + + /// Compare two DateTime values by their UTC instant. + static int compare(DateTime left, DateTime right) { + return left.toUtc().compareTo(right.toUtc()); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_enum_name.dart new file mode 100644 index 0000000..21f44d3 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_enum_name.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Legacy enum-name extension retained for older callers. +/// +/// V1 codecs use explicit stable code maps in `document_mapping.dart` instead +/// of relying on this extension. +extension PersistenceEnumName on Enum { + /// Legacy persisted name. + String get persistenceName => name; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_wall_time_convention.dart new file mode 100644 index 0000000..8b4034e --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_wall_time_convention.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Wall-time storage convention for future document persistence. +abstract final class PersistenceWallTimeConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store WallTime values as HH:MM strings without date or timezone data.'; + + /// Convert a WallTime into the persisted string convention. + static String toStoredString(WallTime value) { + return value.toIsoString(); + } + + /// Parse a persisted WallTime string without applying a date or timezone. + static WallTime fromStoredString(String value) { + return WallTime.fromIsoString(value); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/application_operation_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/application_operation_document_fields.dart new file mode 100644 index 0000000..48310e9 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/application_operation_document_fields.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for idempotent operation records. +abstract final class ApplicationOperationDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `operationId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const operationId = 'operationId'; + + /// Stable storage field for `operationName`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const operationName = 'operationName'; + + /// Stable storage field for `committedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const committedAt = 'committedAt'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + operationId, + operationName, + committedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/backlog_staleness_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/backlog_staleness_document_fields.dart new file mode 100644 index 0000000..b72f3f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/backlog_staleness_document_fields.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for backlog staleness settings. +abstract final class BacklogStalenessDocumentFields { + /// Stable storage field for `greenMaxAgeDays`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const greenMaxAgeDays = 'greenMaxAgeDays'; + + /// Stable storage field for `blueMaxAgeDays`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const blueMaxAgeDays = 'blueMaxAgeDays'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + greenMaxAgeDays, + blueMaxAgeDays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart new file mode 100644 index 0000000..004c68d --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for notice acknowledgement documents. +abstract final class NoticeAcknowledgementDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `noticeId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const noticeId = 'noticeId'; + + /// Stable storage field for `acknowledgedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const acknowledgedAt = 'acknowledgedAt'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + noticeId, + acknowledgedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/owner_settings_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/owner_settings_document_fields.dart new file mode 100644 index 0000000..4ff8bce --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/owner_settings_document_fields.dart @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [OwnerSettings] documents. +abstract final class OwnerSettingsDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `timeZoneId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const timeZoneId = 'timeZoneId'; + + /// Stable storage field for `dayStart`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const dayStart = 'dayStart'; + + /// Stable storage field for `dayEnd`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const dayEnd = 'dayEnd'; + + /// Stable storage field for `compactModeEnabled`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const compactModeEnabled = 'compactModeEnabled'; + + /// Stable storage field for `backlogStaleness`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const backlogStaleness = 'backlogStaleness'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + timeZoneId, + dayStart, + dayEnd, + compactModeEnabled, + backlogStaleness, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/core/document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/core/document_fields.dart new file mode 100644 index 0000000..1de4c5f --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/core/document_fields.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Shared V1 document field names. +abstract final class DocumentFields { + /// Primary id field. + static const id = '_id'; + + /// Version of the document shape. + static const schemaVersion = 'schemaVersion'; + + /// Authorization-neutral owner scope. + static const ownerId = 'ownerId'; + + /// Optimistic-concurrency revision. + static const revision = 'revision'; + + /// Creation instant. + static const createdAt = 'createdAt'; + + /// Last update instant. + static const updatedAt = 'updatedAt'; + + /// Stable storage field for `common`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const common = { + schemaVersion, + id, + ownerId, + revision, + createdAt, + updatedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_document_fields.dart new file mode 100644 index 0000000..155e259 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_document_fields.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [LockedBlock] documents. +abstract final class LockedBlockDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `name`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const name = 'name'; + + /// Stable storage field for `startTime`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const startTime = 'startTime'; + + /// Stable storage field for `endTime`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const endTime = 'endTime'; + + /// Stable storage field for `date`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const date = 'date'; + + /// Stable storage field for `recurrence`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const recurrence = 'recurrence'; + + /// Stable storage field for `hiddenByDefault`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const hiddenByDefault = 'hiddenByDefault'; + + /// Stable storage field for `projectId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const projectId = 'projectId'; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `archivedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const archivedAt = 'archivedAt'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + name, + startTime, + endTime, + date, + recurrence, + hiddenByDefault, + projectId, + archivedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart new file mode 100644 index 0000000..5a6f86c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [LockedBlockOverride] documents. +abstract final class LockedBlockOverrideDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `lockedBlockId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const lockedBlockId = 'lockedBlockId'; + + /// Stable storage field for `date`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const date = 'date'; + + /// Stable storage field for `type`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const type = 'type'; + + /// Stable storage field for `name`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const name = 'name'; + + /// Stable storage field for `startTime`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const startTime = 'startTime'; + + /// Stable storage field for `endTime`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const endTime = 'endTime'; + + /// Stable storage field for `hiddenByDefault`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const hiddenByDefault = 'hiddenByDefault'; + + /// Stable storage field for `projectId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const projectId = 'projectId'; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + lockedBlockId, + date, + type, + name, + startTime, + endTime, + hiddenByDefault, + projectId, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart new file mode 100644 index 0000000..61f1ada --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [LockedBlockRecurrence] embedded documents. +abstract final class LockedBlockRecurrenceDocumentFields { + /// Stable storage field for `type`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const type = 'type'; + + /// Stable storage field for `weekdays`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const weekdays = 'weekdays'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + type, + weekdays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_document_fields.dart new file mode 100644 index 0000000..62bb015 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_document_fields.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [ProjectProfile] documents. +abstract final class ProjectDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `name`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const name = 'name'; + + /// Stable storage field for `colorKey`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const colorKey = 'colorKey'; + + /// Stable storage field for `defaultPriority`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const defaultPriority = 'defaultPriority'; + + /// Stable storage field for `defaultReward`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const defaultReward = 'defaultReward'; + + /// Stable storage field for `defaultDifficulty`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const defaultDifficulty = 'defaultDifficulty'; + + /// Stable storage field for `defaultReminderProfile`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const defaultReminderProfile = 'defaultReminderProfile'; + + /// Stable storage field for `defaultDurationMinutes`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const defaultDurationMinutes = 'defaultDurationMinutes'; + + /// Stable storage field for `archivedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const archivedAt = 'archivedAt'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + name, + colorKey, + defaultPriority, + defaultReward, + defaultDifficulty, + defaultReminderProfile, + defaultDurationMinutes, + archivedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_statistics_document_fields.dart new file mode 100644 index 0000000..66d55b4 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_statistics_document_fields.dart @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [ProjectStatistics] documents. +abstract final class ProjectStatisticsDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `projectId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const projectId = 'projectId'; + + /// Stable storage field for `completedTaskCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedTaskCount = 'completedTaskCount'; + + /// Stable storage field for `durationMinuteCounts`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const durationMinuteCounts = 'durationMinuteCounts'; + + /// Stable storage field for `completionTimeBucketCounts`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completionTimeBucketCounts = 'completionTimeBucketCounts'; + + /// Stable storage field for `totalPushesBeforeCompletion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; + + /// Stable storage field for `completedAfterPushCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedAfterPushCount = 'completedAfterPushCount'; + + /// Stable storage field for `rewardCounts`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const rewardCounts = 'rewardCounts'; + + /// Stable storage field for `difficultyCounts`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const difficultyCounts = 'difficultyCounts'; + + /// Stable storage field for `reminderProfileCounts`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const reminderProfileCounts = 'reminderProfileCounts'; + + /// Stable storage field for `appliedActivityIds`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const appliedActivityIds = 'appliedActivityIds'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + projectId, + completedTaskCount, + durationMinuteCounts, + completionTimeBucketCounts, + totalPushesBeforeCompletion, + completedAfterPushCount, + rewardCounts, + difficultyCounts, + reminderProfileCounts, + appliedActivityIds, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart new file mode 100644 index 0000000..8340294 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [SchedulingChange] embedded documents. +abstract final class SchedulingChangeDocumentFields { + /// Stable storage field for `taskId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const taskId = 'taskId'; + + /// Stable storage field for `previousStart`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const previousStart = 'previousStart'; + + /// Stable storage field for `previousEnd`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const previousEnd = 'previousEnd'; + + /// Stable storage field for `nextStart`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const nextStart = 'nextStart'; + + /// Stable storage field for `nextEnd`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const nextEnd = 'nextEnd'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + taskId, + previousStart, + previousEnd, + nextStart, + nextEnd, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart new file mode 100644 index 0000000..2186fc5 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [SchedulingNotice] embedded documents. +abstract final class SchedulingNoticeDocumentFields { + /// Stable storage field for `message`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const message = 'message'; + + /// Stable storage field for `type`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const type = 'type'; + + /// Stable storage field for `taskId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const taskId = 'taskId'; + + /// Stable storage field for `issueCode`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const issueCode = 'issueCode'; + + /// Stable storage field for `movementCode`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const movementCode = 'movementCode'; + + /// Stable storage field for `conflictCode`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const conflictCode = 'conflictCode'; + + /// Stable storage field for `parameters`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const parameters = 'parameters'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + message, + type, + taskId, + issueCode, + movementCode, + conflictCode, + parameters, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart new file mode 100644 index 0000000..e681ff7 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [SchedulingOverlap] embedded documents. +abstract final class SchedulingOverlapDocumentFields { + /// Stable storage field for `taskId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const taskId = 'taskId'; + + /// Stable storage field for `taskInterval`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const taskInterval = 'taskInterval'; + + /// Stable storage field for `blockedInterval`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const blockedInterval = 'blockedInterval'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + taskId, + taskInterval, + blockedInterval, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart new file mode 100644 index 0000000..4fd0a13 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [SchedulingStateSnapshot] documents. +abstract final class SchedulingSnapshotDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `capturedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const capturedAt = 'capturedAt'; + + /// Stable storage field for `sourceDate`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const sourceDate = 'sourceDate'; + + /// Stable storage field for `targetDate`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const targetDate = 'targetDate'; + + /// Stable storage field for `window`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const window = 'window'; + + /// Stable storage field for `tasks`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const tasks = 'tasks'; + + /// Stable storage field for `lockedIntervals`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const lockedIntervals = 'lockedIntervals'; + + /// Stable storage field for `requiredVisibleIntervals`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const requiredVisibleIntervals = 'requiredVisibleIntervals'; + + /// Stable storage field for `notices`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const notices = 'notices'; + + /// Stable storage field for `changes`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const changes = 'changes'; + + /// Stable storage field for `overlaps`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const overlaps = 'overlaps'; + + /// Stable storage field for `operationName`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const operationName = 'operationName'; + + /// Stable storage field for `retentionExpiresAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const retentionExpiresAt = 'retentionExpiresAt'; + + /// Stable storage field for `truncated`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const truncated = 'truncated'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + capturedAt, + sourceDate, + targetDate, + window, + tasks, + lockedIntervals, + requiredVisibleIntervals, + notices, + changes, + overlaps, + operationName, + retentionExpiresAt, + truncated, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart new file mode 100644 index 0000000..533495b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [SchedulingWindow] embedded documents. +abstract final class SchedulingWindowDocumentFields { + /// Stable storage field for `start`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const start = 'start'; + + /// Stable storage field for `end`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const end = 'end'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + start, + end, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_activity_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_activity_document_fields.dart new file mode 100644 index 0000000..f4381a6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_activity_document_fields.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for internal [TaskActivity] documents. +abstract final class TaskActivityDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `operationId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const operationId = 'operationId'; + + /// Stable storage field for `code`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const code = 'code'; + + /// Stable storage field for `taskId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const taskId = 'taskId'; + + /// Stable storage field for `projectId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const projectId = 'projectId'; + + /// Stable storage field for `occurredAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const occurredAt = 'occurredAt'; + + /// Stable storage field for `metadata`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const metadata = 'metadata'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + operationId, + code, + taskId, + projectId, + occurredAt, + metadata, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_document_fields.dart new file mode 100644 index 0000000..dc76f03 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_document_fields.dart @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [Task] documents. +abstract final class TaskDocumentFields { + /// Stable storage field for `schemaVersion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const schemaVersion = DocumentFields.schemaVersion; + + /// Stable storage field for `id`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const id = DocumentFields.id; + + /// Stable storage field for `ownerId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const ownerId = DocumentFields.ownerId; + + /// Stable storage field for `revision`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const revision = DocumentFields.revision; + + /// Stable storage field for `title`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const title = 'title'; + + /// Stable storage field for `projectId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const projectId = 'projectId'; + + /// Stable storage field for `type`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const type = 'type'; + + /// Stable storage field for `status`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const status = 'status'; + + /// Stable storage field for `priority`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const priority = 'priority'; + + /// Stable storage field for `reward`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const reward = 'reward'; + + /// Stable storage field for `difficulty`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const difficulty = 'difficulty'; + + /// Stable storage field for `durationMinutes`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const durationMinutes = 'durationMinutes'; + + /// Stable storage field for `scheduledStart`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const scheduledStart = 'scheduledStart'; + + /// Stable storage field for `scheduledEnd`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const scheduledEnd = 'scheduledEnd'; + + /// Stable storage field for `actualStart`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const actualStart = 'actualStart'; + + /// Stable storage field for `actualEnd`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const actualEnd = 'actualEnd'; + + /// Stable storage field for `completedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedAt = 'completedAt'; + + /// Stable storage field for `parentTaskId`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const parentTaskId = 'parentTaskId'; + + /// Stable storage field for `backlogTags`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const backlogTags = 'backlogTags'; + + /// Stable storage field for `reminderOverride`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const reminderOverride = 'reminderOverride'; + + /// Stable storage field for `createdAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const createdAt = DocumentFields.createdAt; + + /// Stable storage field for `updatedAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const updatedAt = DocumentFields.updatedAt; + + /// Stable storage field for `stats`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const stats = 'stats'; + + /// Stable storage field for `backlogEnteredAt`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const backlogEnteredAt = 'backlogEnteredAt'; + + /// Stable storage field for `backlogEnteredAtProvenance`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const backlogEnteredAtProvenance = 'backlogEnteredAtProvenance'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + ...DocumentFields.common, + title, + projectId, + type, + status, + priority, + reward, + difficulty, + durationMinutes, + scheduledStart, + scheduledEnd, + actualStart, + actualEnd, + completedAt, + parentTaskId, + backlogTags, + reminderOverride, + stats, + backlogEnteredAt, + backlogEnteredAtProvenance, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_statistics_document_fields.dart new file mode 100644 index 0000000..649d8fa --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_statistics_document_fields.dart @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [TaskStatistics] embedded documents. +abstract final class TaskStatisticsDocumentFields { + /// Stable storage field for `skippedDuringBurnoutCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; + + /// Stable storage field for `manuallyPushedCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const manuallyPushedCount = 'manuallyPushedCount'; + + /// Stable storage field for `autoPushedCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const autoPushedCount = 'autoPushedCount'; + + /// Stable storage field for `movedToBacklogCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const movedToBacklogCount = 'movedToBacklogCount'; + + /// Stable storage field for `restoredFromBacklogCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const restoredFromBacklogCount = 'restoredFromBacklogCount'; + + /// Stable storage field for `missedCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const missedCount = 'missedCount'; + + /// Stable storage field for `cancelledCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const cancelledCount = 'cancelledCount'; + + /// Stable storage field for `completedLateCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedLateCount = 'completedLateCount'; + + /// Stable storage field for `completedDuringLockedHoursCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedDuringLockedHoursCount = + 'completedDuringLockedHoursCount'; + + /// Stable storage field for `completedDuringLockedHoursMinutes`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedDuringLockedHoursMinutes = + 'completedDuringLockedHoursMinutes'; + + /// Stable storage field for `completedAfterShieldCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedAfterShieldCount = 'completedAfterShieldCount'; + + /// Stable storage field for `completedAfterPushCount`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const completedAfterPushCount = 'completedAfterPushCount'; + + /// Stable storage field for `totalPushesBeforeCompletion`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + skippedDuringBurnoutCount, + manuallyPushedCount, + autoPushedCount, + movedToBacklogCount, + restoredFromBacklogCount, + missedCount, + cancelledCount, + completedLateCount, + completedDuringLockedHoursCount, + completedDuringLockedHoursMinutes, + completedAfterShieldCount, + completedAfterPushCount, + totalPushesBeforeCompletion, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/clock_time_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/clock_time_document_fields.dart new file mode 100644 index 0000000..bfa044a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/clock_time_document_fields.dart @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [ClockTime] embedded documents. +abstract final class ClockTimeDocumentFields { + /// Stable storage field for `value`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const value = 'value'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + value, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/time_interval_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/time_interval_document_fields.dart new file mode 100644 index 0000000..5e87870 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/time_interval_document_fields.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence_contract.dart'; + +/// Document field names for [TimeInterval] embedded documents. +abstract final class TimeIntervalDocumentFields { + /// Stable storage field for `start`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const start = 'start'; + + /// Stable storage field for `end`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const end = 'end'; + + /// Stable storage field for `label`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const label = 'label'; + + /// Stable storage field for `all`. + /// Keeping the key in one documented constant protects repository adapters, migrations, and tests from drifting string literals. + static const all = { + start, + end, + label, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_catalog.dart new file mode 100644 index 0000000..d961cb1 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_catalog.dart @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Required V1 index plan. +abstract final class PersistenceIndexCatalog { + /// Shared constant value for `all`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const all = [ + PersistenceIndexSpec( + name: 'tasks_owner_id_unique', + collection: PersistenceCollections.tasks, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskById, + RepositoryQueryNames.taskByOwner, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_status_backlog_age', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(TaskDocumentFields.backlogEnteredAt), + PersistenceIndexField(TaskDocumentFields.createdAt), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskByStatus, + RepositoryQueryNames.taskBacklogCandidates, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_scheduled_window', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.scheduledStart), + PersistenceIndexField(TaskDocumentFields.scheduledEnd), + PersistenceIndexField(DocumentFields.id), + ], + partialFilterFields: { + TaskDocumentFields.scheduledStart, + TaskDocumentFields.scheduledEnd, + }, + supportsQueries: { + RepositoryQueryNames.taskScheduledWindow, + RepositoryQueryNames.taskLocalDay, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_project_status', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.projectId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskByProject, + RepositoryQueryNames.taskBacklogCandidates, + }, + ), + PersistenceIndexSpec( + name: 'tasks_owner_parent_status', + collection: PersistenceCollections.tasks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskDocumentFields.parentTaskId), + PersistenceIndexField(TaskDocumentFields.status), + PersistenceIndexField(DocumentFields.id), + ], + partialFilterFields: { + TaskDocumentFields.parentTaskId, + }, + supportsQueries: { + RepositoryQueryNames.taskByParent, + }, + ), + PersistenceIndexSpec( + name: 'projects_owner_id_unique', + collection: PersistenceCollections.projects, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.projectById, + }, + ), + PersistenceIndexSpec( + name: 'projects_owner_archived_name', + collection: PersistenceCollections.projects, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(ProjectDocumentFields.archivedAt), + PersistenceIndexField(ProjectDocumentFields.name), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.projectByOwner, + }, + ), + PersistenceIndexSpec( + name: 'project_stats_owner_project_unique', + collection: PersistenceCollections.projectStatistics, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(ProjectStatisticsDocumentFields.projectId), + ], + supportsQueries: { + RepositoryQueryNames.projectStatisticsByProject, + }, + ), + PersistenceIndexSpec( + name: 'locked_blocks_owner_id_unique', + collection: PersistenceCollections.lockedBlocks, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedBlockById, + }, + ), + PersistenceIndexSpec( + name: 'locked_blocks_owner_archived_name', + collection: PersistenceCollections.lockedBlocks, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(LockedBlockDocumentFields.archivedAt), + PersistenceIndexField(LockedBlockDocumentFields.name), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedBlockByOwner, + }, + ), + PersistenceIndexSpec( + name: 'locked_overrides_owner_id_unique', + collection: PersistenceCollections.lockedOverrides, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + ), + PersistenceIndexSpec( + name: 'locked_overrides_owner_date_block', + collection: PersistenceCollections.lockedOverrides, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(LockedBlockOverrideDocumentFields.date), + PersistenceIndexField(LockedBlockOverrideDocumentFields.lockedBlockId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.lockedOverrideByDate, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_id_unique', + collection: PersistenceCollections.taskActivities, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + ), + PersistenceIndexSpec( + name: 'task_activities_owner_operation', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.operationId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByOperation, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_task_time', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.taskId), + PersistenceIndexField(TaskActivityDocumentFields.occurredAt), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByOwner, + RepositoryQueryNames.taskActivityByTask, + }, + ), + PersistenceIndexSpec( + name: 'task_activities_owner_project_code_time', + collection: PersistenceCollections.taskActivities, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(TaskActivityDocumentFields.projectId), + PersistenceIndexField(TaskActivityDocumentFields.code), + PersistenceIndexField(TaskActivityDocumentFields.occurredAt), + ], + supportsQueries: { + RepositoryQueryNames.taskActivityByProject, + }, + ), + PersistenceIndexSpec( + name: 'owner_settings_owner_unique', + collection: PersistenceCollections.ownerSettings, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.ownerSettingsByOwner, + }, + ), + PersistenceIndexSpec( + name: 'notice_ack_owner_notice_unique', + collection: PersistenceCollections.noticeAcknowledgements, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), + ], + supportsQueries: { + RepositoryQueryNames.noticeAcknowledgementById, + }, + ), + PersistenceIndexSpec( + name: 'notice_ack_owner_acknowledged', + collection: PersistenceCollections.noticeAcknowledgements, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField( + NoticeAcknowledgementDocumentFields.acknowledgedAt, + ), + PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), + ], + supportsQueries: { + RepositoryQueryNames.noticeAcknowledgementByOwner, + }, + ), + PersistenceIndexSpec( + name: 'operations_owner_operation_unique', + collection: PersistenceCollections.applicationOperations, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField( + ApplicationOperationDocumentFields.operationId, + ), + ], + supportsQueries: { + RepositoryQueryNames.operationByIdempotencyKey, + }, + ), + PersistenceIndexSpec( + name: 'snapshots_owner_id_unique', + collection: PersistenceCollections.schedulingSnapshots, + unique: true, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(DocumentFields.id), + ], + supportsQueries: { + RepositoryQueryNames.schedulingSnapshotById, + }, + ), + PersistenceIndexSpec( + name: 'snapshots_owner_dates_retention', + collection: PersistenceCollections.schedulingSnapshots, + keys: [ + PersistenceIndexField(DocumentFields.ownerId), + PersistenceIndexField(SchedulingSnapshotDocumentFields.sourceDate), + PersistenceIndexField(SchedulingSnapshotDocumentFields.targetDate), + PersistenceIndexField( + SchedulingSnapshotDocumentFields.retentionExpiresAt, + ), + ], + supportsQueries: { + RepositoryQueryNames.schedulingSnapshotWindow, + }, + ), + ]; + + /// Returns the derived `supportedQueries` 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. + static Set get supportedQueries { + return { + for (final spec in all) ...spec.supportsQueries, + }; + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_direction.dart new file mode 100644 index 0000000..43edde9 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_direction.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Sort direction in an adapter-neutral index specification. +enum PersistenceIndexDirection { + /// Selects the `ascending` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + ascending, + + /// Selects the `descending` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + descending, +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_field.dart new file mode 100644 index 0000000..6e69031 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_field.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// One field in a declarative index key. +class PersistenceIndexField { + /// Creates a `PersistenceIndexField` 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 PersistenceIndexField( + this.fieldName, { + this.direction = PersistenceIndexDirection.ascending, + }); + + /// Stores the `fieldName` 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 fieldName; + + /// Stores the `direction` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final PersistenceIndexDirection direction; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_spec.dart new file mode 100644 index 0000000..525b807 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_spec.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// A stable index plan future adapters can translate to driver-specific calls. +class PersistenceIndexSpec { + /// Creates a `PersistenceIndexSpec` 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 PersistenceIndexSpec({ + required this.name, + required this.collection, + required this.keys, + this.unique = false, + this.partialFilterFields = const {}, + this.supportsQueries = const {}, + }); + + /// Stores the `name` 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 name; + + /// Stores the `collection` 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 collection; + + /// Stores the `keys` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final List keys; + + /// Stores the `unique` 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 unique; + + /// Stores the `partialFilterFields` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Set partialFilterFields; + + /// Stores the `supportsQueries` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Set supportsQueries; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/repository_query_names.dart new file mode 100644 index 0000000..305b0ab --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/repository_query_names.dart @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Stable repository query identifiers for index coverage tests. +abstract final class RepositoryQueryNames { + /// Shared constant value for `taskById`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskById = 'TaskRepository.findById'; + + /// Shared constant value for `taskByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskByOwner = 'TaskRepository.findByOwner'; + + /// Shared constant value for `taskByStatus`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskByStatus = 'TaskRepository.findByStatus'; + + /// Shared constant value for `taskByProject`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskByProject = 'TaskRepository.findByProjectId'; + + /// Shared constant value for `taskByParent`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskByParent = 'TaskRepository.findByParentTaskId'; + + /// Shared constant value for `taskBacklogCandidates`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskBacklogCandidates = 'TaskRepository.findBacklogCandidates'; + + /// Shared constant value for `taskScheduledWindow`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskScheduledWindow = + 'TaskRepository.findScheduledInWindowForOwner'; + + /// Shared constant value for `taskLocalDay`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskLocalDay = 'TaskRepository.findForLocalDay'; + + /// Shared constant value for `projectByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const projectByOwner = 'ProjectRepository.findByOwner'; + + /// Shared constant value for `projectById`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const projectById = 'ProjectRepository.findById'; + + /// Shared constant value for `lockedBlockByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const lockedBlockByOwner = 'LockedBlockRepository.findBlocksByOwner'; + + /// Shared constant value for `lockedBlockById`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const lockedBlockById = 'LockedBlockRepository.findBlockById'; + + /// Shared constant value for `lockedOverrideByDate`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const lockedOverrideByDate = + 'LockedBlockRepository.findOverridesForDate'; + + /// Shared constant value for `taskActivityByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivityByOwner = 'TaskActivityRepository.findByOwner'; + + /// Shared constant value for `taskActivityByOperation`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivityByOperation = + 'TaskActivityRepository.findByOperationId'; + + /// Shared constant value for `taskActivityByTask`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivityByTask = 'TaskActivityRepository.findByTaskId'; + + /// Shared constant value for `taskActivityByProject`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivityByProject = 'TaskActivityRepository.findByProjectId'; + + /// Shared constant value for `projectStatisticsByProject`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const projectStatisticsByProject = + 'ProjectStatisticsRepository.findByProjectId'; + + /// Shared constant value for `ownerSettingsByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const ownerSettingsByOwner = 'OwnerSettingsRepository.findByOwnerId'; + + /// Shared constant value for `noticeAcknowledgementByOwner`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const noticeAcknowledgementByOwner = + 'NoticeAcknowledgementRepository.findByOwner'; + + /// Shared constant value for `noticeAcknowledgementById`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const noticeAcknowledgementById = + 'NoticeAcknowledgementRepository.findById'; + + /// Shared constant value for `operationByIdempotencyKey`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const operationByIdempotencyKey = + 'ApplicationOperationRepository.findByIdempotencyKey'; + + /// Shared constant value for `schedulingSnapshotById`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const schedulingSnapshotById = 'SchedulingSnapshotRepository.findById'; + + /// Shared constant value for `schedulingSnapshotWindow`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const schedulingSnapshotWindow = + 'SchedulingSnapshotRepository.findInWindow'; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_guard_result.dart new file mode 100644 index 0000000..b534fea --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_guard_result.dart @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Guard result for bounded document payload checks. +class PersistenceGuardResult { + /// Creates a `PersistenceGuardResult._` 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 PersistenceGuardResult._({ + required this.isValid, + this.fieldName, + this.limit, + this.actual, + }); + + /// Creates a `PersistenceGuardResult.valid` 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. + factory PersistenceGuardResult.valid() { + return const PersistenceGuardResult._(isValid: true); + } + + /// Creates a `PersistenceGuardResult.tooLarge` 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. + factory PersistenceGuardResult.tooLarge({ + required String fieldName, + required int limit, + required int actual, + }) { + return PersistenceGuardResult._( + isValid: false, + fieldName: fieldName, + limit: limit, + actual: actual, + ); + } + + /// Stores the `isValid` 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 isValid; + + /// Stores the `fieldName` 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? fieldName; + + /// Stores the `limit` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? limit; + + /// Stores the `actual` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? actual; +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_guard.dart new file mode 100644 index 0000000..fadd3a4 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_guard.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Pure guard helpers for bounded embedded document payloads. +abstract final class PersistencePayloadGuard { + /// Performs the `mapEntries` 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. + static PersistenceGuardResult mapEntries({ + required String fieldName, + required Map value, + required int limit, + }) { + if (value.length > limit) { + return PersistenceGuardResult.tooLarge( + fieldName: fieldName, + limit: limit, + actual: value.length, + ); + } + return PersistenceGuardResult.valid(); + } + + /// Performs the `listLength` 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. + static PersistenceGuardResult listLength({ + required String fieldName, + required Iterable value, + required int limit, + }) { + final actual = value.length; + if (actual > limit) { + return PersistenceGuardResult.tooLarge( + fieldName: fieldName, + limit: limit, + actual: actual, + ); + } + return PersistenceGuardResult.valid(); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_limits.dart new file mode 100644 index 0000000..c737e2c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_limits.dart @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_contract.dart'; + +/// Bounded payload and retention policy for non-authoritative documents. +abstract final class PersistencePayloadLimits { + /// Shared constant value for `maxTaskActivityMetadataEntries`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const maxTaskActivityMetadataEntries = 50; + + /// Shared constant value for `maxSchedulingSnapshotTasks`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const maxSchedulingSnapshotTasks = 250; + + /// Shared constant value for `maxSchedulingSnapshotNotices`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const maxSchedulingSnapshotNotices = 100; + + /// Shared constant value for `maxSchedulingSnapshotChanges`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const maxSchedulingSnapshotChanges = 250; + + /// Shared constant value for `maxAppliedActivityIds`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const maxAppliedActivityIds = 1000; + + /// Shared constant value for `taskActivityRetention`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const taskActivityRetention = Duration(days: 365); + + /// Shared constant value for `applicationOperationRetention`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const applicationOperationRetention = Duration(days: 90); + + /// Shared constant value for `schedulingSnapshotRetention`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const schedulingSnapshotRetention = Duration(days: 30); +} diff --git a/packages/scheduler_core/lib/src/persistence/persistence_contract/persistence_document_field_sets.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/persistence_document_field_sets.dart new file mode 100644 index 0000000..5431251 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract/persistence_document_field_sets.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../persistence_contract.dart'; + +/// Every field-name set currently committed for future document mapping. +abstract final class PersistenceDocumentFieldSets { + /// Shared constant value for `all`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const all = >[ + DocumentFields.common, + TaskDocumentFields.all, + TaskActivityDocumentFields.all, + TaskStatisticsDocumentFields.all, + ProjectDocumentFields.all, + ProjectStatisticsDocumentFields.all, + ClockTimeDocumentFields.all, + LockedBlockRecurrenceDocumentFields.all, + LockedBlockDocumentFields.all, + LockedBlockOverrideDocumentFields.all, + OwnerSettingsDocumentFields.all, + BacklogStalenessDocumentFields.all, + NoticeAcknowledgementDocumentFields.all, + ApplicationOperationDocumentFields.all, + SchedulingSnapshotDocumentFields.all, + SchedulingWindowDocumentFields.all, + TimeIntervalDocumentFields.all, + SchedulingNoticeDocumentFields.all, + SchedulingChangeDocumentFields.all, + SchedulingOverlapDocumentFields.all, + ]; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories.dart b/packages/scheduler_core/lib/src/persistence/repositories.dart new file mode 100644 index 0000000..56d42b8 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Repositories behavior for the Scheduler Core package. +library; + +// Persistence boundary interfaces for the scheduling core. +// +// These interfaces are intentionally database-client-free. SQLite, in-memory, +// and future adapters implement the same pure Dart contracts. + +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../scheduling/scheduling_engine.dart'; +import '../domain/time_contracts.dart'; +part 'repositories/failures/repository_failure_code.dart'; +part 'repositories/failures/repository_failure.dart'; +part 'repositories/failures/repository_mutation_result.dart'; +part 'repositories/pagination/repository_record.dart'; +part 'repositories/pagination/repository_page_request.dart'; +part 'repositories/pagination/repository_page.dart'; +part 'repositories/queries/backlog_candidate_query.dart'; +part 'repositories/interfaces/task_repository.dart'; +part 'repositories/interfaces/project_repository.dart'; +part 'repositories/interfaces/locked_block_repository.dart'; +part 'repositories/queries/scheduling_state_snapshot.dart'; +part 'repositories/interfaces/scheduling_snapshot_repository.dart'; +part 'repositories/in_memory/in_memory_task_repository.dart'; +part 'repositories/in_memory/in_memory_project_repository.dart'; +part 'repositories/in_memory/in_memory_locked_block_repository.dart'; +part 'repositories/in_memory/in_memory_scheduling_snapshot_repository.dart'; diff --git a/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure.dart new file mode 100644 index 0000000..e3e783b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Typed repository failure for optimistic writes and uniqueness checks. +class RepositoryFailure { + /// Creates a `RepositoryFailure` 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 RepositoryFailure({ + required this.code, + this.entityId, + this.expectedRevision, + this.actualRevision, + }); + + /// Stores the `code` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RepositoryFailureCode code; + + /// Stores the `entityId` 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? entityId; + + /// Stores the `expectedRevision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? expectedRevision; + + /// Stores the `actualRevision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? actualRevision; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure_code.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure_code.dart new file mode 100644 index 0000000..f9d1c88 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure_code.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Stable repository mutation failure categories. +enum RepositoryFailureCode { + /// Selects the `notFound` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + notFound, + + /// Selects the `staleRevision` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + staleRevision, + + /// Selects the `invalidRevision` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidRevision, + + /// Selects the `ownerMismatch` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + ownerMismatch, + + /// Selects the `duplicateId` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + duplicateId, + + /// Selects the `duplicateOperation` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + duplicateOperation, +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_mutation_result.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_mutation_result.dart new file mode 100644 index 0000000..8bb383c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_mutation_result.dart @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Mutation result that avoids throwing for expected repository conflicts. +class RepositoryMutationResult { + /// Creates a `RepositoryMutationResult._` 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 RepositoryMutationResult._({ + this.revision, + this.failure, + }); + + /// Creates a `RepositoryMutationResult.success` 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. + factory RepositoryMutationResult.success({required int revision}) { + return RepositoryMutationResult._(revision: revision); + } + + /// Creates a `RepositoryMutationResult.failure` 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. + factory RepositoryMutationResult.failure(RepositoryFailure failure) { + return RepositoryMutationResult._(failure: failure); + } + + /// Stores the `revision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int? revision; + + /// Stores the `failure` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RepositoryFailure? failure; + + /// Returns the derived `isSuccess` 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. + bool get isSuccess => failure == null; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_locked_block_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..4c8ced5 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_locked_block_repository.dart @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// In-memory locked-time repository useful for tests and early wiring. +class InMemoryLockedBlockRepository implements LockedBlockRepository { + /// Creates a `InMemoryLockedBlockRepository` 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. + InMemoryLockedBlockRepository({ + Iterable initialBlocks = const [], + Iterable initialOverrides = const [], + this.defaultOwnerId = 'owner-1', + }) : _blocksById = { + for (final block in initialBlocks) block.id: block, + }, + _blockOwnersById = { + for (final block in initialBlocks) block.id: defaultOwnerId, + }, + _blockRevisionsById = { + for (final block in initialBlocks) block.id: 1, + }, + _overridesById = { + for (final override in initialOverrides) override.id: override, + }, + _overrideOwnersById = { + for (final override in initialOverrides) override.id: defaultOwnerId, + }, + _overrideRevisionsById = { + for (final override in initialOverrides) override.id: 1, + }; + + /// Private state stored as `_blocksById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blocksById; + + /// Private state stored as `_blockOwnersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blockOwnersById; + + /// Private state stored as `_blockRevisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _blockRevisionsById; + + /// Private state stored as `_overridesById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overridesById; + + /// Private state stored as `_overrideOwnersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overrideOwnersById; + + /// Private state stored as `_overrideRevisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _overrideRevisionsById; + + /// Stores the `defaultOwnerId` 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 defaultOwnerId; + + /// Runs the `findBlockById` 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 findBlockById(String id) async { + return _blocksById[id]; + } + + /// Runs the `findBlockRecordById` 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?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + + /// Runs the `findAllBlocks` 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> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + /// Runs the `findBlocksByOwner` 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> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + + /// Runs the `findOverrideById` 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 findOverrideById(String id) async { + return _overridesById[id]; + } + + /// Runs the `findOverrideRecordById` 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?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + + /// Runs the `findAllOverrides` 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> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + /// Runs the `findOverridesForDate` 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> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + + /// Runs the `saveBlock` 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 saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => defaultOwnerId); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; + } + + /// Runs the `saveOverride` 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 saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => defaultOwnerId); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; + } + + /// Runs the `insertBlock` 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 insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveBlockIfRevision` 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 saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: block.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _blocksById[block.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + if (_blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `archiveBlock` 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 archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + /// Runs the `insertOverride` 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 insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveOverrideIfRevision` 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 saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: override.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _overridesById[override.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + if (_overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `_blockOwnerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? defaultOwnerId; + + /// Runs the `_blockRevisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + /// Runs the `_overrideOwnerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _overrideOwnerFor(String id) => + _overrideOwnersById[id] ?? defaultOwnerId; + + /// Runs the `_overrideRevisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_project_repository.dart new file mode 100644 index 0000000..0c5fc4b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_project_repository.dart @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// In-memory project repository useful for tests and early application wiring. +class InMemoryProjectRepository implements ProjectRepository { + /// Creates a `InMemoryProjectRepository` 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. + InMemoryProjectRepository( + [Iterable initialProjects = const [], + this.defaultOwnerId = 'owner-1']) + : _projectsById = { + for (final project in initialProjects) project.id: project, + }, + _ownersById = { + for (final project in initialProjects) project.id: defaultOwnerId, + }, + _revisionsById = { + for (final project in initialProjects) project.id: 1, + }; + + /// Private state stored as `_projectsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _projectsById; + + /// Private state stored as `_ownersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersById; + + /// Private state stored as `_revisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsById; + + /// Stores the `defaultOwnerId` 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 defaultOwnerId; + + /// Runs the `findById` 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 findById(String id) async { + return _projectsById[id]; + } + + /// Runs the `findRecordById` 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?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + + /// Runs the `save` 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 save(ProjectProfile project) async { + _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => defaultOwnerId); + _revisionsById[project.id] = _revisionFor(project.id) + 1; + } + + /// Runs the `insert` 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 insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: project.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _projectsById[project.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + if (_ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `archive` 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 archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + /// Runs the `_ownerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + /// Runs the `_revisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..ce264ff --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// In-memory snapshot repository useful for tests and early application wiring. +class InMemorySchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + /// Creates a `InMemorySchedulingSnapshotRepository` 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. + InMemorySchedulingSnapshotRepository([ + Iterable initialSnapshots = const [], + ]) : _snapshotsById = { + for (final snapshot in initialSnapshots) snapshot.id: snapshot, + }; + + /// Private state stored as `_snapshotsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _snapshotsById; + + /// Runs the `findById` 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 findById(String id) async { + return _snapshotsById[id]; + } + + /// Runs the `findInWindow` 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> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + /// Runs the `save` 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 save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} + +/// Top-level helper that performs the `_page` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +/// Top-level helper that performs the `_taskOverlapsWindow` 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 _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +/// Top-level helper that performs the `_compareTaskIds` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +/// Top-level helper that performs the `_compareScheduledTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareBacklogCandidates` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareProjects` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareLockedBlocks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +/// Top-level helper that performs the `_compareLockedOverrides` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart new file mode 100644 index 0000000..952608a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// In-memory task repository useful for tests and early application wiring. +class InMemoryTaskRepository implements TaskRepository { + /// Creates a `InMemoryTaskRepository` 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. + InMemoryTaskRepository([ + Iterable initialTasks = const [], + this.defaultOwnerId = 'owner-1', + ]) : _tasksById = { + for (final task in initialTasks) task.id: task, + }, + _ownersById = { + for (final task in initialTasks) task.id: defaultOwnerId, + }, + _revisionsById = { + for (final task in initialTasks) task.id: 1, + }; + + /// Private state stored as `_tasksById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _tasksById; + + /// Private state stored as `_ownersById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _ownersById; + + /// Private state stored as `_revisionsById` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _revisionsById; + + /// Stores the `defaultOwnerId` 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 defaultOwnerId; + + /// Runs the `findById` 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 findById(String id) async { + return _tasksById[id]; + } + + /// Runs the `findRecordById` 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?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + + /// Runs the `findAll` 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> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + /// Runs the `findByStatus` 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> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + /// Runs the `findByOwner` 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> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findByProjectId` 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> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findByParentTaskId` 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> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + /// Runs the `findBacklogCandidates` 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> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + + /// Runs the `findScheduledInWindow` 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> findScheduledInWindow(SchedulingWindow window) async { + return List.unmodifiable( + _tasksById.values.where((task) { + return _taskOverlapsWindow(task, window); + }), + ); + } + + /// Runs the `findScheduledInWindowForOwner` 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> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow( + task, + window, + ), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + /// Runs the `findForLocalDay` 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> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + + /// Runs the `save` 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 save(Task task) async { + _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => defaultOwnerId); + _revisionsById[task.id] = _revisionFor(task.id) + 1; + } + + /// Runs the `saveAll` 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 saveAll(Iterable tasks) async { + for (final task in tasks) { + await save(task); + } + } + + /// Runs the `insert` 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 insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + /// Runs the `saveIfRevision` 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 saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: task.id, + expectedRevision: expectedRevision, + ), + ); + } + final current = _tasksById[task.id]; + if (current == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: task.id, + ), + ); + } + if (_ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.ownerMismatch, + entityId: task.id, + ), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + /// Runs the `_ownerFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + /// Runs the `_revisionFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _revisionFor(String id) => _revisionsById[id] ?? 1; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/locked_block_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/locked_block_repository.dart new file mode 100644 index 0000000..0178653 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/locked_block_repository.dart @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Repository contract for locked-block and one-day override documents. +abstract interface class LockedBlockRepository { + /// Return a locked block by stable id, or null when it does not exist. + Future findBlockById(String id); + + /// Return a locked block plus owner/revision metadata. + Future?> findBlockRecordById(String id); + + /// Return all locked block definitions. + Future> findAllBlocks(); + + /// Return owner-scoped locked blocks, optionally including archived blocks. + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return an override by stable id, or null when it does not exist. + Future findOverrideById(String id); + + /// Return an override plus owner/revision metadata. + Future?> findOverrideRecordById( + String id, + ); + + /// Return all one-day overrides. + Future> findAllOverrides(); + + /// Return owner-scoped one-day overrides for [date]. + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [block] by stable id. + Future saveBlock(LockedBlock block); + + /// Save or replace [override] by stable id. + Future saveOverride(LockedBlockOverride override); + + /// Runs the `insertBlock` 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 insertBlock({ + required LockedBlock block, + required String ownerId, + }); + + /// Runs the `saveBlockIfRevision` 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 saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }); + + /// Runs the `archiveBlock` 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 archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); + + /// Runs the `insertOverride` 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 insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }); + + /// Runs the `saveOverrideIfRevision` 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 saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/project_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/project_repository.dart new file mode 100644 index 0000000..e4d5963 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/project_repository.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Repository contract for project profile documents. +abstract interface class ProjectRepository { + /// Return a project by stable id, or null when it does not exist. + Future findById(String id); + + /// Return a project plus owner/revision metadata. + Future?> findRecordById(String id); + + /// Return all projects currently known to the repository. + Future> findAll(); + + /// Return owner-scoped projects, optionally including archived profiles. + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [project] by stable id. + Future save(ProjectProfile project); + + /// Insert [project] if its id is unused. + Future insert({ + required ProjectProfile project, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }); + + /// Archive a project without deleting task history. + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/scheduling_snapshot_repository.dart new file mode 100644 index 0000000..49fdaff --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/scheduling_snapshot_repository.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Repository contract for scheduling operation/state snapshot documents. +abstract interface class SchedulingSnapshotRepository { + /// Return a snapshot by stable id, or null when it does not exist. + Future findById(String id); + + /// Return all snapshots overlapping [window]. + Future> findInWindow(SchedulingWindow window); + + /// Save or replace [snapshot] by stable id. + Future save(SchedulingStateSnapshot snapshot); +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart new file mode 100644 index 0000000..d479d6a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Repository contract for task documents. +abstract interface class TaskRepository { + /// Return a task by stable id, or null when it does not exist. + Future findById(String id); + + /// Return a task plus owner/revision metadata. + Future?> findRecordById(String id); + + /// Return all tasks currently known to the repository. + Future> findAll(); + + /// Return tasks with a lifecycle status. + Future> findByStatus(TaskStatus status); + + /// Return owner-scoped tasks in deterministic id order. + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks assigned to [projectId]. + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return direct children owned by [parentTaskId]. + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return backlog candidates ordered by backlog age, then id. + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ); + + /// Return tasks that overlap [window] by scheduled start/end. + Future> findScheduledInWindow(SchedulingWindow window); + + /// Return owner-scoped scheduled tasks that overlap [window]. + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks in a local-day window. Civil date is supplied for adapters + /// that store date-derived keys; in-memory filtering uses [window]. + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Save or replace [task] by stable id. + Future save(Task task); + + /// Save or replace every task by stable id. + Future saveAll(Iterable tasks); + + /// Insert [task] if its id is unused. + Future insert({ + required Task task, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }); +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page.dart new file mode 100644 index 0000000..7072fff --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// One bounded page of repository query results. +class RepositoryPage { + /// Creates a `RepositoryPage` 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 RepositoryPage({ + required this.items, + this.nextCursor, + }); + + /// Stores the `items` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final List items; + + /// Stores the `nextCursor` 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? nextCursor; + + /// Returns the derived `hasMore` 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. + bool get hasMore => nextCursor != null; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page_request.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page_request.dart new file mode 100644 index 0000000..b38d50a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page_request.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Cursor contract for bounded repository queries. +class RepositoryPageRequest { + /// Creates a `RepositoryPageRequest` 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 RepositoryPageRequest({ + this.cursor, + this.limit = 100, + }) : assert(limit > 0); + + /// Adapter-neutral cursor. In-memory repositories use an integer offset. + final String? cursor; + + /// Maximum number of records to return. + final int limit; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_record.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_record.dart new file mode 100644 index 0000000..5bb81f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_record.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Repository value plus adapter-neutral metadata. +class RepositoryRecord { + /// Creates a `RepositoryRecord` 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 RepositoryRecord({ + required this.value, + required this.ownerId, + required this.revision, + this.archivedAt, + }); + + /// Stores the `value` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final T value; + + /// Stores the `ownerId` 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 ownerId; + + /// Stores the `revision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int revision; + + /// Stores the `archivedAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime? archivedAt; + + /// Returns the derived `isArchived` 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. + bool get isArchived => archivedAt != null; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/queries/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/persistence/repositories/queries/backlog_candidate_query.dart new file mode 100644 index 0000000..f8d03f6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/queries/backlog_candidate_query.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Status/project filters for backlog candidate queries. +class BacklogCandidateQuery { + /// Creates a `BacklogCandidateQuery` 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 BacklogCandidateQuery({ + required this.ownerId, + this.projectId, + this.tags = const {}, + this.page = const RepositoryPageRequest(), + }); + + /// Stores the `ownerId` 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 ownerId; + + /// Stores the `projectId` 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? projectId; + + /// Stores the `tags` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Set tags; + + /// Stores the `page` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final RepositoryPageRequest page; +} diff --git a/packages/scheduler_core/lib/src/persistence/repositories/queries/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/persistence/repositories/queries/scheduling_state_snapshot.dart new file mode 100644 index 0000000..4f323d9 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repositories/queries/scheduling_state_snapshot.dart @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../repositories.dart'; + +/// Snapshot of one scheduling operation or persisted planning state. +class SchedulingStateSnapshot { + /// Creates a `SchedulingStateSnapshot` 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. + SchedulingStateSnapshot({ + required this.id, + required this.capturedAt, + required this.window, + required List tasks, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], + this.operationName, + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); + + /// Stable snapshot id. + final String id; + + /// Time this snapshot was created. + final DateTime capturedAt; + + /// Planning window represented by the snapshot. + final SchedulingWindow window; + + /// Task documents visible to the operation. + final List tasks; + + /// Locked intervals supplied to the scheduler. + final List lockedIntervals; + + /// Extra visible required intervals supplied to the scheduler. + final List requiredVisibleIntervals; + + /// Human-readable scheduling notices. + final List notices; + + /// Machine-readable task movements. + final List changes; + + /// Analysis-only overlap findings. + final List overlaps; + + /// Optional operation label, such as `push` or `rollover`. + final String? operationName; + + /// Rebuild scheduler input from the persisted state shape. + SchedulingInput get input { + return SchedulingInput( + tasks: List.unmodifiable(tasks), + window: window, + lockedIntervals: List.unmodifiable(lockedIntervals), + requiredVisibleIntervals: + List.unmodifiable(requiredVisibleIntervals), + ); + } + + /// Rebuild scheduler result details from the persisted state shape. + SchedulingResult get result { + return SchedulingResult( + tasks: List.unmodifiable(tasks), + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(overlaps), + ); + } +} diff --git a/packages/scheduler_core/lib/src/persistence/repository_values.dart b/packages/scheduler_core/lib/src/persistence/repository_values.dart new file mode 100644 index 0000000..23802a2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repository_values.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Shared repository value objects used by persistence adapters. +/// +/// These types are deliberately small and database-agnostic. They live in the +/// core package so every persistence adapter can use the same owner, revision, +/// and paging contracts without importing a storage implementation. +library; + +part 'repository_values/owner_id.dart'; +part 'repository_values/revision.dart'; +part 'repository_values/page_request.dart'; +part 'repository_values/page.dart'; diff --git a/packages/scheduler_core/lib/src/persistence/repository_values/owner_id.dart b/packages/scheduler_core/lib/src/persistence/repository_values/owner_id.dart new file mode 100644 index 0000000..01163c0 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repository_values/owner_id.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../repository_values.dart'; + +/// Stable owner scope for repository operations. +class OwnerId { + /// Creates a `OwnerId` 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. + OwnerId(String value) : value = _requiredTrimmed(value, 'ownerId'); + + /// Raw owner identifier. + final String value; + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => value; + + /// Performs the `operator ==` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool operator ==(Object other) { + return other is OwnerId && other.value == value; + } + + /// Returns the derived `hashCode` 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. + @override + int get hashCode => value.hashCode; +} diff --git a/packages/scheduler_core/lib/src/persistence/repository_values/page.dart b/packages/scheduler_core/lib/src/persistence/repository_values/page.dart new file mode 100644 index 0000000..cfc5da6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repository_values/page.dart @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../repository_values.dart'; + +/// One bounded page of repository query results. +class Page { + /// Creates a `Page` 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. + Page({ + required Iterable items, + this.nextCursor, + }) : items = List.unmodifiable(items); + + /// Items returned for this page. + final List items; + + /// Cursor for the next page, or null when this is the last page. + final String? nextCursor; + + /// Whether another page is available. + bool get hasMore => nextCursor != null; +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/persistence/repository_values/page_request.dart b/packages/scheduler_core/lib/src/persistence/repository_values/page_request.dart new file mode 100644 index 0000000..5dc598a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repository_values/page_request.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../repository_values.dart'; + +/// Cursor contract for bounded repository queries. +class PageRequest { + /// Creates a `PageRequest` 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 PageRequest({ + this.cursor, + this.limit = 100, + }) : assert(limit > 0); + + /// Adapter-neutral cursor. Implementations choose the cursor format. + final String? cursor; + + /// Maximum number of records to return. + final int limit; +} diff --git a/packages/scheduler_core/lib/src/persistence/repository_values/revision.dart b/packages/scheduler_core/lib/src/persistence/repository_values/revision.dart new file mode 100644 index 0000000..8fe5227 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence/repository_values/revision.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../repository_values.dart'; + +/// Optimistic concurrency revision for mutable repository records. +class Revision implements Comparable { + /// Creates a `Revision` 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 Revision(this.value) : assert(value >= 0); + + /// First revision assigned to a newly inserted record. + static const initial = Revision(1); + + /// Numeric revision value. + final int value; + + /// Return the next monotonically increasing revision. + Revision next() => Revision(value + 1); + + /// Performs the `compareTo` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + int compareTo(Revision other) => value.compareTo(other.value); + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => value.toString(); + + /// Performs the `operator ==` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool operator ==(Object other) { + return other is Revision && other.value == value; + } + + /// Returns the derived `hashCode` 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. + @override + int get hashCode => value.hashCode; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence_contract.dart deleted file mode 100644 index d7286f1..0000000 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ /dev/null @@ -1,1058 +0,0 @@ -/// Implements Persistence Contract behavior for the Scheduler Core package. -library; - -// Adapter-neutral persistence contract helpers. -// -// This file defines stable field names and encoding conventions that document -// codecs and future adapters should use. It deliberately does not serialize -// domain models or import a database client. - -import 'time_contracts.dart'; - -/// V1 document-schema version. -const v1SchemaVersion = 1; - -/// Stable V1 logical collection names for persistence adapters. -abstract final class PersistenceCollections { - static const tasks = 'tasks'; - static const projects = 'projects'; - static const projectStatistics = 'project_statistics'; - static const lockedBlocks = 'locked_blocks'; - static const lockedOverrides = 'locked_overrides'; - static const taskActivities = 'task_activities'; - static const ownerSettings = 'owner_settings'; - static const noticeAcknowledgements = 'notice_acknowledgements'; - static const applicationOperations = 'application_operations'; - static const schedulingSnapshots = 'scheduling_snapshots'; - - static const authoritative = { - tasks, - projects, - projectStatistics, - lockedBlocks, - lockedOverrides, - ownerSettings, - }; - - static const internalRetained = { - taskActivities, - noticeAcknowledgements, - applicationOperations, - schedulingSnapshots, - }; -} - -/// Sort direction in an adapter-neutral index specification. -enum PersistenceIndexDirection { - ascending, - descending, -} - -/// One field in a declarative index key. -class PersistenceIndexField { - const PersistenceIndexField( - this.fieldName, { - this.direction = PersistenceIndexDirection.ascending, - }); - - final String fieldName; - final PersistenceIndexDirection direction; -} - -/// A stable index plan future adapters can translate to driver-specific calls. -class PersistenceIndexSpec { - const PersistenceIndexSpec({ - required this.name, - required this.collection, - required this.keys, - this.unique = false, - this.partialFilterFields = const {}, - this.supportsQueries = const {}, - }); - - final String name; - final String collection; - final List keys; - final bool unique; - final Set partialFilterFields; - final Set supportsQueries; -} - -/// Stable repository query identifiers for index coverage tests. -abstract final class RepositoryQueryNames { - static const taskById = 'TaskRepository.findById'; - static const taskByOwner = 'TaskRepository.findByOwner'; - static const taskByStatus = 'TaskRepository.findByStatus'; - static const taskByProject = 'TaskRepository.findByProjectId'; - static const taskByParent = 'TaskRepository.findByParentTaskId'; - static const taskBacklogCandidates = 'TaskRepository.findBacklogCandidates'; - static const taskScheduledWindow = - 'TaskRepository.findScheduledInWindowForOwner'; - static const taskLocalDay = 'TaskRepository.findForLocalDay'; - static const projectByOwner = 'ProjectRepository.findByOwner'; - static const projectById = 'ProjectRepository.findById'; - static const lockedBlockByOwner = 'LockedBlockRepository.findBlocksByOwner'; - static const lockedBlockById = 'LockedBlockRepository.findBlockById'; - static const lockedOverrideByDate = - 'LockedBlockRepository.findOverridesForDate'; - static const taskActivityByOwner = 'TaskActivityRepository.findByOwner'; - static const taskActivityByOperation = - 'TaskActivityRepository.findByOperationId'; - static const taskActivityByTask = 'TaskActivityRepository.findByTaskId'; - static const taskActivityByProject = 'TaskActivityRepository.findByProjectId'; - static const projectStatisticsByProject = - 'ProjectStatisticsRepository.findByProjectId'; - static const ownerSettingsByOwner = 'OwnerSettingsRepository.findByOwnerId'; - static const noticeAcknowledgementByOwner = - 'NoticeAcknowledgementRepository.findByOwner'; - static const noticeAcknowledgementById = - 'NoticeAcknowledgementRepository.findById'; - static const operationByIdempotencyKey = - 'ApplicationOperationRepository.findByIdempotencyKey'; - static const schedulingSnapshotById = 'SchedulingSnapshotRepository.findById'; - static const schedulingSnapshotWindow = - 'SchedulingSnapshotRepository.findInWindow'; -} - -/// Required V1 index plan. -abstract final class PersistenceIndexCatalog { - static const all = [ - PersistenceIndexSpec( - name: 'tasks_owner_id_unique', - collection: PersistenceCollections.tasks, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskById, - RepositoryQueryNames.taskByOwner, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_status_backlog_age', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(TaskDocumentFields.backlogEnteredAt), - PersistenceIndexField(TaskDocumentFields.createdAt), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskByStatus, - RepositoryQueryNames.taskBacklogCandidates, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_scheduled_window', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.scheduledStart), - PersistenceIndexField(TaskDocumentFields.scheduledEnd), - PersistenceIndexField(DocumentFields.id), - ], - partialFilterFields: { - TaskDocumentFields.scheduledStart, - TaskDocumentFields.scheduledEnd, - }, - supportsQueries: { - RepositoryQueryNames.taskScheduledWindow, - RepositoryQueryNames.taskLocalDay, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_project_status', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.projectId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskByProject, - RepositoryQueryNames.taskBacklogCandidates, - }, - ), - PersistenceIndexSpec( - name: 'tasks_owner_parent_status', - collection: PersistenceCollections.tasks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskDocumentFields.parentTaskId), - PersistenceIndexField(TaskDocumentFields.status), - PersistenceIndexField(DocumentFields.id), - ], - partialFilterFields: { - TaskDocumentFields.parentTaskId, - }, - supportsQueries: { - RepositoryQueryNames.taskByParent, - }, - ), - PersistenceIndexSpec( - name: 'projects_owner_id_unique', - collection: PersistenceCollections.projects, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.projectById, - }, - ), - PersistenceIndexSpec( - name: 'projects_owner_archived_name', - collection: PersistenceCollections.projects, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(ProjectDocumentFields.archivedAt), - PersistenceIndexField(ProjectDocumentFields.name), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.projectByOwner, - }, - ), - PersistenceIndexSpec( - name: 'project_stats_owner_project_unique', - collection: PersistenceCollections.projectStatistics, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(ProjectStatisticsDocumentFields.projectId), - ], - supportsQueries: { - RepositoryQueryNames.projectStatisticsByProject, - }, - ), - PersistenceIndexSpec( - name: 'locked_blocks_owner_id_unique', - collection: PersistenceCollections.lockedBlocks, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedBlockById, - }, - ), - PersistenceIndexSpec( - name: 'locked_blocks_owner_archived_name', - collection: PersistenceCollections.lockedBlocks, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(LockedBlockDocumentFields.archivedAt), - PersistenceIndexField(LockedBlockDocumentFields.name), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedBlockByOwner, - }, - ), - PersistenceIndexSpec( - name: 'locked_overrides_owner_id_unique', - collection: PersistenceCollections.lockedOverrides, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - ), - PersistenceIndexSpec( - name: 'locked_overrides_owner_date_block', - collection: PersistenceCollections.lockedOverrides, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(LockedBlockOverrideDocumentFields.date), - PersistenceIndexField(LockedBlockOverrideDocumentFields.lockedBlockId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.lockedOverrideByDate, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_id_unique', - collection: PersistenceCollections.taskActivities, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - ), - PersistenceIndexSpec( - name: 'task_activities_owner_operation', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.operationId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByOperation, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_task_time', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.taskId), - PersistenceIndexField(TaskActivityDocumentFields.occurredAt), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByOwner, - RepositoryQueryNames.taskActivityByTask, - }, - ), - PersistenceIndexSpec( - name: 'task_activities_owner_project_code_time', - collection: PersistenceCollections.taskActivities, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(TaskActivityDocumentFields.projectId), - PersistenceIndexField(TaskActivityDocumentFields.code), - PersistenceIndexField(TaskActivityDocumentFields.occurredAt), - ], - supportsQueries: { - RepositoryQueryNames.taskActivityByProject, - }, - ), - PersistenceIndexSpec( - name: 'owner_settings_owner_unique', - collection: PersistenceCollections.ownerSettings, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.ownerSettingsByOwner, - }, - ), - PersistenceIndexSpec( - name: 'notice_ack_owner_notice_unique', - collection: PersistenceCollections.noticeAcknowledgements, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), - ], - supportsQueries: { - RepositoryQueryNames.noticeAcknowledgementById, - }, - ), - PersistenceIndexSpec( - name: 'notice_ack_owner_acknowledged', - collection: PersistenceCollections.noticeAcknowledgements, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField( - NoticeAcknowledgementDocumentFields.acknowledgedAt, - ), - PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId), - ], - supportsQueries: { - RepositoryQueryNames.noticeAcknowledgementByOwner, - }, - ), - PersistenceIndexSpec( - name: 'operations_owner_operation_unique', - collection: PersistenceCollections.applicationOperations, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField( - ApplicationOperationDocumentFields.operationId, - ), - ], - supportsQueries: { - RepositoryQueryNames.operationByIdempotencyKey, - }, - ), - PersistenceIndexSpec( - name: 'snapshots_owner_id_unique', - collection: PersistenceCollections.schedulingSnapshots, - unique: true, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(DocumentFields.id), - ], - supportsQueries: { - RepositoryQueryNames.schedulingSnapshotById, - }, - ), - PersistenceIndexSpec( - name: 'snapshots_owner_dates_retention', - collection: PersistenceCollections.schedulingSnapshots, - keys: [ - PersistenceIndexField(DocumentFields.ownerId), - PersistenceIndexField(SchedulingSnapshotDocumentFields.sourceDate), - PersistenceIndexField(SchedulingSnapshotDocumentFields.targetDate), - PersistenceIndexField( - SchedulingSnapshotDocumentFields.retentionExpiresAt, - ), - ], - supportsQueries: { - RepositoryQueryNames.schedulingSnapshotWindow, - }, - ), - ]; - - static Set get supportedQueries { - return { - for (final spec in all) ...spec.supportsQueries, - }; - } -} - -/// Bounded payload and retention policy for non-authoritative documents. -abstract final class PersistencePayloadLimits { - static const maxTaskActivityMetadataEntries = 50; - static const maxSchedulingSnapshotTasks = 250; - static const maxSchedulingSnapshotNotices = 100; - static const maxSchedulingSnapshotChanges = 250; - static const maxAppliedActivityIds = 1000; - static const taskActivityRetention = Duration(days: 365); - static const applicationOperationRetention = Duration(days: 90); - static const schedulingSnapshotRetention = Duration(days: 30); -} - -/// Guard result for bounded document payload checks. -class PersistenceGuardResult { - const PersistenceGuardResult._({ - required this.isValid, - this.fieldName, - this.limit, - this.actual, - }); - - factory PersistenceGuardResult.valid() { - return const PersistenceGuardResult._(isValid: true); - } - - factory PersistenceGuardResult.tooLarge({ - required String fieldName, - required int limit, - required int actual, - }) { - return PersistenceGuardResult._( - isValid: false, - fieldName: fieldName, - limit: limit, - actual: actual, - ); - } - - final bool isValid; - final String? fieldName; - final int? limit; - final int? actual; -} - -/// Pure guard helpers for bounded embedded document payloads. -abstract final class PersistencePayloadGuard { - static PersistenceGuardResult mapEntries({ - required String fieldName, - required Map value, - required int limit, - }) { - if (value.length > limit) { - return PersistenceGuardResult.tooLarge( - fieldName: fieldName, - limit: limit, - actual: value.length, - ); - } - return PersistenceGuardResult.valid(); - } - - static PersistenceGuardResult listLength({ - required String fieldName, - required Iterable value, - required int limit, - }) { - final actual = value.length; - if (actual > limit) { - return PersistenceGuardResult.tooLarge( - fieldName: fieldName, - limit: limit, - actual: actual, - ); - } - return PersistenceGuardResult.valid(); - } -} - -/// DateTime storage convention for future document persistence. -abstract final class PersistenceDateTimeConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.'; - - /// Convert a DateTime into the persisted string convention. - static String toStoredString(DateTime value) { - return value.toUtc().toIso8601String(); - } - - /// Parse a persisted DateTime string back into a UTC DateTime. - static DateTime fromStoredString(String value) { - return DateTime.parse(value).toUtc(); - } - - /// Compare two DateTime values by their UTC instant. - static int compare(DateTime left, DateTime right) { - return left.toUtc().compareTo(right.toUtc()); - } -} - -/// Civil-date storage convention for future document persistence. -abstract final class PersistenceCivilDateConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store CivilDate values as YYYY-MM-DD strings without timezone conversion.'; - - /// Convert a CivilDate into the persisted string convention. - static String toStoredString(CivilDate value) { - return value.toIsoString(); - } - - /// Parse a persisted CivilDate string without applying a timezone. - static CivilDate fromStoredString(String value) { - return CivilDate.fromIsoString(value); - } -} - -/// Wall-time storage convention for future document persistence. -abstract final class PersistenceWallTimeConvention { - /// Human-readable convention kept close to the helper for tests and docs. - static const description = - 'Store WallTime values as HH:MM strings without date or timezone data.'; - - /// Convert a WallTime into the persisted string convention. - static String toStoredString(WallTime value) { - return value.toIsoString(); - } - - /// Parse a persisted WallTime string without applying a date or timezone. - static WallTime fromStoredString(String value) { - return WallTime.fromIsoString(value); - } -} - -/// Legacy enum-name extension retained for older callers. -/// -/// V1 codecs use explicit stable code maps in `document_mapping.dart` instead -/// of relying on this extension. -extension PersistenceEnumName on Enum { - /// Legacy persisted name. - String get persistenceName => name; -} - -/// Shared V1 document field names. -abstract final class DocumentFields { - /// Primary id field. - static const id = '_id'; - - /// Version of the document shape. - static const schemaVersion = 'schemaVersion'; - - /// Authorization-neutral owner scope. - static const ownerId = 'ownerId'; - - /// Optimistic-concurrency revision. - static const revision = 'revision'; - - /// Creation instant. - static const createdAt = 'createdAt'; - - /// Last update instant. - static const updatedAt = 'updatedAt'; - - static const common = { - schemaVersion, - id, - ownerId, - revision, - createdAt, - updatedAt, - }; -} - -/// Document field names for [Task] documents. -abstract final class TaskDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const title = 'title'; - static const projectId = 'projectId'; - static const type = 'type'; - static const status = 'status'; - static const priority = 'priority'; - static const reward = 'reward'; - static const difficulty = 'difficulty'; - static const durationMinutes = 'durationMinutes'; - static const scheduledStart = 'scheduledStart'; - static const scheduledEnd = 'scheduledEnd'; - static const actualStart = 'actualStart'; - static const actualEnd = 'actualEnd'; - static const completedAt = 'completedAt'; - static const parentTaskId = 'parentTaskId'; - static const backlogTags = 'backlogTags'; - static const reminderOverride = 'reminderOverride'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const stats = 'stats'; - static const backlogEnteredAt = 'backlogEnteredAt'; - static const backlogEnteredAtProvenance = 'backlogEnteredAtProvenance'; - - static const all = { - ...DocumentFields.common, - title, - projectId, - type, - status, - priority, - reward, - difficulty, - durationMinutes, - scheduledStart, - scheduledEnd, - actualStart, - actualEnd, - completedAt, - parentTaskId, - backlogTags, - reminderOverride, - stats, - backlogEnteredAt, - backlogEnteredAtProvenance, - }; -} - -/// Document field names for internal [TaskActivity] documents. -abstract final class TaskActivityDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const operationId = 'operationId'; - static const code = 'code'; - static const taskId = 'taskId'; - static const projectId = 'projectId'; - static const occurredAt = 'occurredAt'; - static const metadata = 'metadata'; - - static const all = { - ...DocumentFields.common, - operationId, - code, - taskId, - projectId, - occurredAt, - metadata, - }; -} - -/// Document field names for [TaskStatistics] embedded documents. -abstract final class TaskStatisticsDocumentFields { - static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; - static const manuallyPushedCount = 'manuallyPushedCount'; - static const autoPushedCount = 'autoPushedCount'; - static const movedToBacklogCount = 'movedToBacklogCount'; - static const restoredFromBacklogCount = 'restoredFromBacklogCount'; - static const missedCount = 'missedCount'; - static const cancelledCount = 'cancelledCount'; - static const completedLateCount = 'completedLateCount'; - static const completedDuringLockedHoursCount = - 'completedDuringLockedHoursCount'; - static const completedDuringLockedHoursMinutes = - 'completedDuringLockedHoursMinutes'; - static const completedAfterShieldCount = 'completedAfterShieldCount'; - static const completedAfterPushCount = 'completedAfterPushCount'; - static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; - - static const all = { - skippedDuringBurnoutCount, - manuallyPushedCount, - autoPushedCount, - movedToBacklogCount, - restoredFromBacklogCount, - missedCount, - cancelledCount, - completedLateCount, - completedDuringLockedHoursCount, - completedDuringLockedHoursMinutes, - completedAfterShieldCount, - completedAfterPushCount, - totalPushesBeforeCompletion, - }; -} - -/// Document field names for [ProjectProfile] documents. -abstract final class ProjectDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const name = 'name'; - static const colorKey = 'colorKey'; - static const defaultPriority = 'defaultPriority'; - static const defaultReward = 'defaultReward'; - static const defaultDifficulty = 'defaultDifficulty'; - static const defaultReminderProfile = 'defaultReminderProfile'; - static const defaultDurationMinutes = 'defaultDurationMinutes'; - static const archivedAt = 'archivedAt'; - - static const all = { - ...DocumentFields.common, - name, - colorKey, - defaultPriority, - defaultReward, - defaultDifficulty, - defaultReminderProfile, - defaultDurationMinutes, - archivedAt, - }; -} - -/// Document field names for [ProjectStatistics] documents. -abstract final class ProjectStatisticsDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const projectId = 'projectId'; - static const completedTaskCount = 'completedTaskCount'; - static const durationMinuteCounts = 'durationMinuteCounts'; - static const completionTimeBucketCounts = 'completionTimeBucketCounts'; - static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; - static const completedAfterPushCount = 'completedAfterPushCount'; - static const rewardCounts = 'rewardCounts'; - static const difficultyCounts = 'difficultyCounts'; - static const reminderProfileCounts = 'reminderProfileCounts'; - static const appliedActivityIds = 'appliedActivityIds'; - - static const all = { - ...DocumentFields.common, - projectId, - completedTaskCount, - durationMinuteCounts, - completionTimeBucketCounts, - totalPushesBeforeCompletion, - completedAfterPushCount, - rewardCounts, - difficultyCounts, - reminderProfileCounts, - appliedActivityIds, - }; -} - -/// Document field names for [ClockTime] embedded documents. -abstract final class ClockTimeDocumentFields { - static const value = 'value'; - - static const all = { - value, - }; -} - -/// Document field names for [LockedBlockRecurrence] embedded documents. -abstract final class LockedBlockRecurrenceDocumentFields { - static const type = 'type'; - static const weekdays = 'weekdays'; - - static const all = { - type, - weekdays, - }; -} - -/// Document field names for [LockedBlock] documents. -abstract final class LockedBlockDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const name = 'name'; - static const startTime = 'startTime'; - static const endTime = 'endTime'; - static const date = 'date'; - static const recurrence = 'recurrence'; - static const hiddenByDefault = 'hiddenByDefault'; - static const projectId = 'projectId'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const archivedAt = 'archivedAt'; - - static const all = { - ...DocumentFields.common, - name, - startTime, - endTime, - date, - recurrence, - hiddenByDefault, - projectId, - archivedAt, - }; -} - -/// Document field names for [LockedBlockOverride] documents. -abstract final class LockedBlockOverrideDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const lockedBlockId = 'lockedBlockId'; - static const date = 'date'; - static const type = 'type'; - static const name = 'name'; - static const startTime = 'startTime'; - static const endTime = 'endTime'; - static const hiddenByDefault = 'hiddenByDefault'; - static const projectId = 'projectId'; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - - static const all = { - ...DocumentFields.common, - lockedBlockId, - date, - type, - name, - startTime, - endTime, - hiddenByDefault, - projectId, - }; -} - -/// Document field names for [OwnerSettings] documents. -abstract final class OwnerSettingsDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const timeZoneId = 'timeZoneId'; - static const dayStart = 'dayStart'; - static const dayEnd = 'dayEnd'; - static const compactModeEnabled = 'compactModeEnabled'; - static const backlogStaleness = 'backlogStaleness'; - - static const all = { - ...DocumentFields.common, - timeZoneId, - dayStart, - dayEnd, - compactModeEnabled, - backlogStaleness, - }; -} - -/// Document field names for backlog staleness settings. -abstract final class BacklogStalenessDocumentFields { - static const greenMaxAgeDays = 'greenMaxAgeDays'; - static const blueMaxAgeDays = 'blueMaxAgeDays'; - - static const all = { - greenMaxAgeDays, - blueMaxAgeDays, - }; -} - -/// Document field names for notice acknowledgement documents. -abstract final class NoticeAcknowledgementDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const noticeId = 'noticeId'; - static const acknowledgedAt = 'acknowledgedAt'; - - static const all = { - ...DocumentFields.common, - noticeId, - acknowledgedAt, - }; -} - -/// Document field names for idempotent operation records. -abstract final class ApplicationOperationDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const operationId = 'operationId'; - static const operationName = 'operationName'; - static const committedAt = 'committedAt'; - - static const all = { - ...DocumentFields.common, - operationId, - operationName, - committedAt, - }; -} - -/// Document field names for [SchedulingStateSnapshot] documents. -abstract final class SchedulingSnapshotDocumentFields { - static const schemaVersion = DocumentFields.schemaVersion; - static const id = DocumentFields.id; - static const ownerId = DocumentFields.ownerId; - static const revision = DocumentFields.revision; - static const createdAt = DocumentFields.createdAt; - static const updatedAt = DocumentFields.updatedAt; - static const capturedAt = 'capturedAt'; - static const sourceDate = 'sourceDate'; - static const targetDate = 'targetDate'; - static const window = 'window'; - static const tasks = 'tasks'; - static const lockedIntervals = 'lockedIntervals'; - static const requiredVisibleIntervals = 'requiredVisibleIntervals'; - static const notices = 'notices'; - static const changes = 'changes'; - static const overlaps = 'overlaps'; - static const operationName = 'operationName'; - static const retentionExpiresAt = 'retentionExpiresAt'; - static const truncated = 'truncated'; - - static const all = { - ...DocumentFields.common, - capturedAt, - sourceDate, - targetDate, - window, - tasks, - lockedIntervals, - requiredVisibleIntervals, - notices, - changes, - overlaps, - operationName, - retentionExpiresAt, - truncated, - }; -} - -/// Document field names for [SchedulingWindow] embedded documents. -abstract final class SchedulingWindowDocumentFields { - static const start = 'start'; - static const end = 'end'; - - static const all = { - start, - end, - }; -} - -/// Document field names for [TimeInterval] embedded documents. -abstract final class TimeIntervalDocumentFields { - static const start = 'start'; - static const end = 'end'; - static const label = 'label'; - - static const all = { - start, - end, - label, - }; -} - -/// Document field names for [SchedulingNotice] embedded documents. -abstract final class SchedulingNoticeDocumentFields { - static const message = 'message'; - static const type = 'type'; - static const taskId = 'taskId'; - static const issueCode = 'issueCode'; - static const movementCode = 'movementCode'; - static const conflictCode = 'conflictCode'; - static const parameters = 'parameters'; - - static const all = { - message, - type, - taskId, - issueCode, - movementCode, - conflictCode, - parameters, - }; -} - -/// Document field names for [SchedulingChange] embedded documents. -abstract final class SchedulingChangeDocumentFields { - static const taskId = 'taskId'; - static const previousStart = 'previousStart'; - static const previousEnd = 'previousEnd'; - static const nextStart = 'nextStart'; - static const nextEnd = 'nextEnd'; - - static const all = { - taskId, - previousStart, - previousEnd, - nextStart, - nextEnd, - }; -} - -/// Document field names for [SchedulingOverlap] embedded documents. -abstract final class SchedulingOverlapDocumentFields { - static const taskId = 'taskId'; - static const taskInterval = 'taskInterval'; - static const blockedInterval = 'blockedInterval'; - - static const all = { - taskId, - taskInterval, - blockedInterval, - }; -} - -/// Every field-name set currently committed for future document mapping. -abstract final class PersistenceDocumentFieldSets { - static const all = >[ - DocumentFields.common, - TaskDocumentFields.all, - TaskActivityDocumentFields.all, - TaskStatisticsDocumentFields.all, - ProjectDocumentFields.all, - ProjectStatisticsDocumentFields.all, - ClockTimeDocumentFields.all, - LockedBlockRecurrenceDocumentFields.all, - LockedBlockDocumentFields.all, - LockedBlockOverrideDocumentFields.all, - OwnerSettingsDocumentFields.all, - BacklogStalenessDocumentFields.all, - NoticeAcknowledgementDocumentFields.all, - ApplicationOperationDocumentFields.all, - SchedulingSnapshotDocumentFields.all, - SchedulingWindowDocumentFields.all, - TimeIntervalDocumentFields.all, - SchedulingNoticeDocumentFields.all, - SchedulingChangeDocumentFields.all, - SchedulingOverlapDocumentFields.all, - ]; -} diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics.dart deleted file mode 100644 index 30fdbdc..0000000 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ /dev/null @@ -1,700 +0,0 @@ -/// Implements Project Statistics behavior for the Scheduler Core package. -library; - -// Project-level aggregate statistics and deterministic learned suggestions. -// -// These types stay in the pure Dart core. They provide persistence-friendly -// counters and optional suggestions, but they do not overwrite configured -// project defaults or implement reports UI. - -import 'models.dart'; -import 'task_lifecycle.dart'; - -/// Coarse completion-time buckets used for learned project suggestions. -enum ProjectCompletionTimeBucket { - overnight, - morning, - afternoon, - evening, - night, -} - -/// Suggestion categories derived from project observations. -enum ProjectSuggestionType { - durationMinutes, - completionTimeBucket, - reward, - difficulty, - reminderProfile, -} - -/// How concentrated the supporting observations are for a suggestion. -enum ProjectSuggestionConfidence { - low, - medium, - high, -} - -/// Immutable project-level observations used for future filtering and hints. -class ProjectStatistics { - ProjectStatistics({ - required String projectId, - this.completedTaskCount = 0, - Map durationMinuteCounts = const {}, - Map completionTimeBucketCounts = - const {}, - this.totalPushesBeforeCompletion = 0, - this.completedAfterPushCount = 0, - Map rewardCounts = const {}, - Map difficultyCounts = const {}, - Map reminderProfileCounts = - const {}, - }) : projectId = _requiredTrimmed(projectId, 'projectId'), - durationMinuteCounts = Map.unmodifiable( - _positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'), - ), - completionTimeBucketCounts = - Map.unmodifiable( - _enumCounts( - completionTimeBucketCounts, - 'completionTimeBucketCounts', - ), - ), - rewardCounts = Map.unmodifiable( - _enumCounts(rewardCounts, 'rewardCounts'), - ), - difficultyCounts = Map.unmodifiable( - _enumCounts(difficultyCounts, 'difficultyCounts'), - ), - reminderProfileCounts = Map.unmodifiable( - _enumCounts(reminderProfileCounts, 'reminderProfileCounts'), - ) { - _validateNonNegative(completedTaskCount, 'completedTaskCount'); - _validateNonNegative( - totalPushesBeforeCompletion, - 'totalPushesBeforeCompletion', - ); - _validateNonNegative(completedAfterPushCount, 'completedAfterPushCount'); - } - - /// Project these observations belong to. - final String projectId; - - /// Completion activities applied to this project aggregate. - final int completedTaskCount; - - /// Duration samples stored as minute-to-count buckets. - final Map durationMinuteCounts; - - /// Completion timestamp samples stored as coarse bucket counts. - final Map completionTimeBucketCounts; - - /// Sum of push counts present when tasks were completed. - final int totalPushesBeforeCompletion; - - /// Number of completed tasks that had at least one prior push. - final int completedAfterPushCount; - - /// Reward observations from completed tasks. - final Map rewardCounts; - - /// Difficulty observations from completed tasks. - final Map difficultyCounts; - - /// Reminder-profile observations when explicitly known. - final Map reminderProfileCounts; - - /// Known duration sample count derived from the duration distribution. - int get knownDurationSampleCount => _countTotal(durationMinuteCounts); - - /// Sum of known duration samples in minutes. - int get totalKnownDurationMinutes { - var total = 0; - for (final entry in durationMinuteCounts.entries) { - total += entry.key * entry.value; - } - return total; - } - - /// Numerator for average pushes before completion. - int get averagePushesBeforeCompletionNumerator { - return totalPushesBeforeCompletion; - } - - /// Denominator for average pushes before completion. - int get averagePushesBeforeCompletionDenominator { - return completedTaskCount; - } - - /// Derived average push count, without storing floating-point aggregate state. - double? get averagePushesBeforeCompletion { - if (completedTaskCount == 0) { - return null; - } - return totalPushesBeforeCompletion / completedTaskCount; - } - - /// Return a copy with selected aggregate fields changed. - ProjectStatistics copyWith({ - String? projectId, - int? completedTaskCount, - Map? durationMinuteCounts, - Map? completionTimeBucketCounts, - int? totalPushesBeforeCompletion, - int? completedAfterPushCount, - Map? rewardCounts, - Map? difficultyCounts, - Map? reminderProfileCounts, - }) { - return ProjectStatistics( - projectId: projectId ?? this.projectId, - completedTaskCount: completedTaskCount ?? this.completedTaskCount, - durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts, - completionTimeBucketCounts: - completionTimeBucketCounts ?? this.completionTimeBucketCounts, - totalPushesBeforeCompletion: - totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion, - completedAfterPushCount: - completedAfterPushCount ?? this.completedAfterPushCount, - rewardCounts: rewardCounts ?? this.rewardCounts, - difficultyCounts: difficultyCounts ?? this.difficultyCounts, - reminderProfileCounts: - reminderProfileCounts ?? this.reminderProfileCounts, - ); - } - - /// Record one completed task observation. - ProjectStatistics recordCompletion({ - required DateTime completedAt, - int? durationMinutes, - int pushesBeforeCompletion = 0, - RewardLevel? reward, - DifficultyLevel? difficulty, - ReminderProfile? reminderProfile, - }) { - if (durationMinutes != null && durationMinutes <= 0) { - throw ArgumentError.value( - durationMinutes, - 'durationMinutes', - 'Duration minutes must be positive when present.', - ); - } - if (pushesBeforeCompletion < 0) { - throw ArgumentError.value( - pushesBeforeCompletion, - 'pushesBeforeCompletion', - 'Push count cannot be negative.', - ); - } - - return copyWith( - completedTaskCount: completedTaskCount + 1, - durationMinuteCounts: durationMinutes == null - ? durationMinuteCounts - : _incrementCount(durationMinuteCounts, durationMinutes), - completionTimeBucketCounts: _incrementCount( - completionTimeBucketCounts, - bucketForCompletion(completedAt), - ), - totalPushesBeforeCompletion: - totalPushesBeforeCompletion + pushesBeforeCompletion, - completedAfterPushCount: pushesBeforeCompletion > 0 - ? completedAfterPushCount + 1 - : completedAfterPushCount, - rewardCounts: reward == null - ? rewardCounts - : _incrementCount( - rewardCounts, - reward, - ), - difficultyCounts: difficulty == null - ? difficultyCounts - : _incrementCount( - difficultyCounts, - difficulty, - ), - reminderProfileCounts: reminderProfile == null - ? reminderProfileCounts - : _incrementCount( - reminderProfileCounts, - reminderProfile, - ), - ); - } - - /// Resolve the completion-time bucket for [completedAt]. - static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) { - final hour = completedAt.hour; - if (hour < 5) { - return ProjectCompletionTimeBucket.overnight; - } - if (hour < 12) { - return ProjectCompletionTimeBucket.morning; - } - if (hour < 17) { - return ProjectCompletionTimeBucket.afternoon; - } - if (hour < 21) { - return ProjectCompletionTimeBucket.evening; - } - return ProjectCompletionTimeBucket.night; - } -} - -/// Result of applying project-statistic effects from activities. -class ProjectStatisticsApplicationResult { - ProjectStatisticsApplicationResult({ - required this.statistics, - List appliedActivityIds = const [], - }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); - - /// Aggregate after newly applied activity effects. - final ProjectStatistics statistics; - - /// Completion activity ids that changed the aggregate. - final List appliedActivityIds; -} - -/// Updates project statistics from canonical task activities exactly once. -class ProjectStatisticsAggregationService { - const ProjectStatisticsAggregationService(); - - /// Apply completion [activities] to [statistics]. - /// - /// [alreadyAppliedActivityIds] is supplied by the application/persistence - /// boundary. This service returns the new ids so Block 14 can persist task, - /// activity, and aggregate mutations atomically. - ProjectStatisticsApplicationResult apply({ - required ProjectStatistics statistics, - required Iterable activities, - Iterable tasks = const [], - Iterable alreadyAppliedActivityIds = const [], - }) { - final taskById = { - for (final task in tasks) task.id: task, - }; - final appliedIds = alreadyAppliedActivityIds.toSet(); - final newlyAppliedIds = []; - var current = statistics; - - for (final activity in activities) { - if (activity.projectId != statistics.projectId || - activity.code != TaskActivityCode.completed || - appliedIds.contains(activity.id)) { - continue; - } - - final task = taskById[activity.taskId]; - final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ?? - activity.occurredAt; - final pushesBeforeCompletion = - _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? - _pushesBeforeCompletion(task); - - current = current.recordCompletion( - completedAt: completedAt, - durationMinutes: _durationMinutes(activity, task), - pushesBeforeCompletion: pushesBeforeCompletion, - reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward, - difficulty: _difficultyLevel(activity.metadata['difficulty']) ?? - task?.difficulty, - reminderProfile: - _reminderProfile(activity.metadata['reminderProfile']) ?? - task?.reminderOverride, - ); - appliedIds.add(activity.id); - newlyAppliedIds.add(activity.id); - } - - return ProjectStatisticsApplicationResult( - statistics: current, - appliedActivityIds: newlyAppliedIds, - ); - } -} - -/// Minimum sample sizes for deterministic project suggestions. -class ProjectSuggestionPolicy { - const ProjectSuggestionPolicy({ - this.minimumDurationSamples = 3, - this.minimumCompletionTimeSamples = 3, - this.minimumRewardSamples = 3, - this.minimumDifficultySamples = 3, - this.minimumReminderSamples = 3, - }); - - final int minimumDurationSamples; - final int minimumCompletionTimeSamples; - final int minimumRewardSamples; - final int minimumDifficultySamples; - final int minimumReminderSamples; -} - -/// One optional learned suggestion with provenance. -class ProjectSuggestion { - const ProjectSuggestion({ - required this.type, - required this.value, - required this.sampleSize, - required this.supportingSampleCount, - required this.minimumSampleSize, - required this.confidence, - }); - - /// Suggestion category. - final ProjectSuggestionType type; - - /// Suggested value. - final T value; - - /// Number of meaningful observations considered. - final int sampleSize; - - /// Observations that support [value]. - final int supportingSampleCount; - - /// Minimum sample threshold used before producing the suggestion. - final int minimumSampleSize; - - /// Concentration of supporting observations. - final ProjectSuggestionConfidence confidence; -} - -/// Optional suggestion set derived from one project's observations. -class ProjectSuggestionSet { - const ProjectSuggestionSet({ - required this.statistics, - this.durationMinutes, - this.completionTimeBucket, - this.reward, - this.difficulty, - this.reminderProfile, - }); - - final ProjectStatistics statistics; - final ProjectSuggestion? durationMinutes; - final ProjectSuggestion? completionTimeBucket; - final ProjectSuggestion? reward; - final ProjectSuggestion? difficulty; - final ProjectSuggestion? reminderProfile; -} - -/// Configured project defaults plus optional suggestions kept separate. -class ProjectDefaultResolution { - const ProjectDefaultResolution({ - required this.project, - required this.suggestions, - }); - - /// Authoritative configured project profile. - final ProjectProfile project; - - /// Optional learned suggestions that UI can present explicitly. - final ProjectSuggestionSet suggestions; - - int? get configuredDurationMinutes => project.defaultDurationMinutes; - RewardLevel get configuredReward => project.defaultReward; - DifficultyLevel get configuredDifficulty => project.defaultDifficulty; - ReminderProfile get configuredReminderProfile => - project.defaultReminderProfile; -} - -/// Derives deterministic project suggestions from aggregate observations. -class ProjectSuggestionService { - const ProjectSuggestionService({ - this.policy = const ProjectSuggestionPolicy(), - }); - - final ProjectSuggestionPolicy policy; - - /// Build optional suggestions for [statistics]. - ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) { - return ProjectSuggestionSet( - statistics: statistics, - durationMinutes: _durationSuggestion(statistics), - completionTimeBucket: _modeSuggestion( - type: ProjectSuggestionType.completionTimeBucket, - counts: statistics.completionTimeBucketCounts, - minimumSampleSize: policy.minimumCompletionTimeSamples, - ), - reward: _modeSuggestion( - type: ProjectSuggestionType.reward, - counts: _withoutNotSetReward(statistics.rewardCounts), - minimumSampleSize: policy.minimumRewardSamples, - ), - difficulty: _modeSuggestion( - type: ProjectSuggestionType.difficulty, - counts: _withoutNotSetDifficulty(statistics.difficultyCounts), - minimumSampleSize: policy.minimumDifficultySamples, - ), - reminderProfile: _modeSuggestion( - type: ProjectSuggestionType.reminderProfile, - counts: statistics.reminderProfileCounts, - minimumSampleSize: policy.minimumReminderSamples, - ), - ); - } - - /// Return configured defaults and optional suggestions without merging them. - ProjectDefaultResolution resolveDefaults({ - required ProjectProfile project, - required ProjectStatistics statistics, - }) { - return ProjectDefaultResolution( - project: project, - suggestions: suggestionsFor(statistics), - ); - } - - ProjectSuggestion? _durationSuggestion(ProjectStatistics statistics) { - final sampleSize = statistics.knownDurationSampleCount; - if (sampleSize < policy.minimumDurationSamples) { - return null; - } - - final value = _weightedLowerMedian(statistics.durationMinuteCounts); - if (value == null) { - return null; - } - - return ProjectSuggestion( - type: ProjectSuggestionType.durationMinutes, - value: value, - sampleSize: sampleSize, - supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0, - minimumSampleSize: policy.minimumDurationSamples, - confidence: _confidence( - statistics.durationMinuteCounts[value] ?? 0, - sampleSize, - ), - ); - } - - ProjectSuggestion? _modeSuggestion({ - required ProjectSuggestionType type, - required Map counts, - required int minimumSampleSize, - }) { - final sampleSize = _countTotal(counts); - if (sampleSize < minimumSampleSize) { - return null; - } - - final mode = _uniqueMode(counts); - if (mode == null) { - return null; - } - - final support = counts[mode] ?? 0; - return ProjectSuggestion( - type: type, - value: mode, - sampleSize: sampleSize, - supportingSampleCount: support, - minimumSampleSize: minimumSampleSize, - confidence: _confidence(support, sampleSize), - ); - } -} - -Map _incrementCount(Map counts, K key) { - return { - ...counts, - key: (counts[key] ?? 0) + 1, - }; -} - -Map _positiveIntKeyCounts(Map counts, String name) { - final normalized = {}; - for (final entry in counts.entries) { - if (entry.key <= 0) { - throw ArgumentError.value(entry.key, name, 'Key must be positive.'); - } - _validateNonNegative(entry.value, name); - if (entry.value > 0) { - normalized[entry.key] = entry.value; - } - } - return normalized; -} - -Map _enumCounts(Map counts, String name) { - final normalized = {}; - for (final entry in counts.entries) { - _validateNonNegative(entry.value, name); - if (entry.value > 0) { - normalized[entry.key] = entry.value; - } - } - return normalized; -} - -void _validateNonNegative(int value, String name) { - if (value < 0) { - throw ArgumentError.value(value, name, 'Count cannot be negative.'); - } -} - -int _countTotal(Map counts) { - var total = 0; - for (final value in counts.values) { - total += value; - } - return total; -} - -int? _weightedLowerMedian(Map counts) { - if (counts.isEmpty) { - return null; - } - final sampleSize = _countTotal(counts); - final target = (sampleSize + 1) ~/ 2; - var seen = 0; - final sortedMinutes = counts.keys.toList()..sort(); - for (final minutes in sortedMinutes) { - seen += counts[minutes] ?? 0; - if (seen >= target) { - return minutes; - } - } - return null; -} - -T? _uniqueMode(Map counts) { - T? bestKey; - var bestCount = 0; - var tied = false; - - for (final entry in counts.entries) { - if (entry.value > bestCount) { - bestKey = entry.key; - bestCount = entry.value; - tied = false; - continue; - } - if (entry.value == bestCount && bestCount > 0) { - tied = true; - } - } - - return tied ? null : bestKey; -} - -ProjectSuggestionConfidence _confidence(int support, int sampleSize) { - if (sampleSize <= 0) { - return ProjectSuggestionConfidence.low; - } - if (support * 3 >= sampleSize * 2) { - return ProjectSuggestionConfidence.high; - } - if (support * 2 >= sampleSize) { - return ProjectSuggestionConfidence.medium; - } - return ProjectSuggestionConfidence.low; -} - -Map _withoutNotSetReward(Map counts) { - return { - for (final entry in counts.entries) - if (entry.key != RewardLevel.notSet) entry.key: entry.value, - }; -} - -Map _withoutNotSetDifficulty( - Map counts, -) { - return { - for (final entry in counts.entries) - if (entry.key != DifficultyLevel.notSet) entry.key: entry.value, - }; -} - -DateTime? _dateTimeMetadata(Object? value) { - if (value is DateTime) { - return value; - } - if (value is String) { - return DateTime.tryParse(value)?.toUtc(); - } - return null; -} - -int? _intMetadata(Object? value) { - return value is int && value >= 0 ? value : null; -} - -int? _durationMinutes(TaskActivity activity, Task? task) { - final metadataDuration = _intMetadata(activity.metadata['durationMinutes']); - if (metadataDuration != null && metadataDuration > 0) { - return metadataDuration; - } - - final actualStart = - _dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart; - final actualEnd = - _dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd; - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - final minutes = actualEnd.difference(actualStart).inMinutes; - if (minutes > 0) { - return minutes; - } - } - - return task?.durationMinutes; -} - -int _pushesBeforeCompletion(Task? task) { - if (task == null) { - return 0; - } - return task.stats.manuallyPushedCount + task.stats.autoPushedCount; -} - -RewardLevel? _rewardLevel(Object? value) { - if (value is RewardLevel) { - return value; - } - if (value is String) { - return _enumByName(RewardLevel.values, value); - } - return null; -} - -DifficultyLevel? _difficultyLevel(Object? value) { - if (value is DifficultyLevel) { - return value; - } - if (value is String) { - return _enumByName(DifficultyLevel.values, value); - } - return null; -} - -ReminderProfile? _reminderProfile(Object? value) { - if (value is ReminderProfile) { - return value; - } - if (value is String) { - return _enumByName(ReminderProfile.values, value); - } - return null; -} - -T? _enumByName(Iterable values, String name) { - for (final value in values) { - if (value.name == name) { - return value; - } - } - return null; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} diff --git a/packages/scheduler_core/lib/src/repositories.dart b/packages/scheduler_core/lib/src/repositories.dart deleted file mode 100644 index 882a031..0000000 --- a/packages/scheduler_core/lib/src/repositories.dart +++ /dev/null @@ -1,1211 +0,0 @@ -/// Implements Repositories behavior for the Scheduler Core package. -library; - -// Persistence boundary interfaces for the scheduling core. -// -// These interfaces are intentionally database-client-free. SQLite, in-memory, -// and future adapters implement the same pure Dart contracts. - -import 'locked_time.dart'; -import 'models.dart'; -import 'scheduling_engine.dart'; -import 'time_contracts.dart'; - -/// Stable repository mutation failure categories. -enum RepositoryFailureCode { - notFound, - staleRevision, - invalidRevision, - ownerMismatch, - duplicateId, - duplicateOperation, -} - -/// Typed repository failure for optimistic writes and uniqueness checks. -class RepositoryFailure { - const RepositoryFailure({ - required this.code, - this.entityId, - this.expectedRevision, - this.actualRevision, - }); - - final RepositoryFailureCode code; - final String? entityId; - final int? expectedRevision; - final int? actualRevision; -} - -/// Mutation result that avoids throwing for expected repository conflicts. -class RepositoryMutationResult { - const RepositoryMutationResult._({ - this.revision, - this.failure, - }); - - factory RepositoryMutationResult.success({required int revision}) { - return RepositoryMutationResult._(revision: revision); - } - - factory RepositoryMutationResult.failure(RepositoryFailure failure) { - return RepositoryMutationResult._(failure: failure); - } - - final int? revision; - final RepositoryFailure? failure; - - bool get isSuccess => failure == null; -} - -/// Repository value plus adapter-neutral metadata. -class RepositoryRecord { - const RepositoryRecord({ - required this.value, - required this.ownerId, - required this.revision, - this.archivedAt, - }); - - final T value; - final String ownerId; - final int revision; - final DateTime? archivedAt; - - bool get isArchived => archivedAt != null; -} - -/// Cursor contract for bounded repository queries. -class RepositoryPageRequest { - const RepositoryPageRequest({ - this.cursor, - this.limit = 100, - }) : assert(limit > 0); - - /// Adapter-neutral cursor. In-memory repositories use an integer offset. - final String? cursor; - - /// Maximum number of records to return. - final int limit; -} - -/// One bounded page of repository query results. -class RepositoryPage { - const RepositoryPage({ - required this.items, - this.nextCursor, - }); - - final List items; - final String? nextCursor; - - bool get hasMore => nextCursor != null; -} - -/// Status/project filters for backlog candidate queries. -class BacklogCandidateQuery { - const BacklogCandidateQuery({ - required this.ownerId, - this.projectId, - this.tags = const {}, - this.page = const RepositoryPageRequest(), - }); - - final String ownerId; - final String? projectId; - final Set tags; - final RepositoryPageRequest page; -} - -/// Repository contract for task documents. -abstract interface class TaskRepository { - /// Return a task by stable id, or null when it does not exist. - Future findById(String id); - - /// Return a task plus owner/revision metadata. - Future?> findRecordById(String id); - - /// Return all tasks currently known to the repository. - Future> findAll(); - - /// Return tasks with a lifecycle status. - Future> findByStatus(TaskStatus status); - - /// Return owner-scoped tasks in deterministic id order. - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return tasks assigned to [projectId]. - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return direct children owned by [parentTaskId]. - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return backlog candidates ordered by backlog age, then id. - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ); - - /// Return tasks that overlap [window] by scheduled start/end. - Future> findScheduledInWindow(SchedulingWindow window); - - /// Return owner-scoped scheduled tasks that overlap [window]. - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return tasks in a local-day window. Civil date is supplied for adapters - /// that store date-derived keys; in-memory filtering uses [window]. - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [task] by stable id. - Future save(Task task); - - /// Save or replace every task by stable id. - Future saveAll(Iterable tasks); - - /// Insert [task] if its id is unused. - Future insert({ - required Task task, - required String ownerId, - }); - - /// Compare-and-set save by expected repository revision. - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }); -} - -/// Repository contract for project profile documents. -abstract interface class ProjectRepository { - /// Return a project by stable id, or null when it does not exist. - Future findById(String id); - - /// Return a project plus owner/revision metadata. - Future?> findRecordById(String id); - - /// Return all projects currently known to the repository. - Future> findAll(); - - /// Return owner-scoped projects, optionally including archived profiles. - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [project] by stable id. - Future save(ProjectProfile project); - - /// Insert [project] if its id is unused. - Future insert({ - required ProjectProfile project, - required String ownerId, - }); - - /// Compare-and-set save by expected repository revision. - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }); - - /// Archive a project without deleting task history. - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }); -} - -/// Repository contract for locked-block and one-day override documents. -abstract interface class LockedBlockRepository { - /// Return a locked block by stable id, or null when it does not exist. - Future findBlockById(String id); - - /// Return a locked block plus owner/revision metadata. - Future?> findBlockRecordById(String id); - - /// Return all locked block definitions. - Future> findAllBlocks(); - - /// Return owner-scoped locked blocks, optionally including archived blocks. - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Return an override by stable id, or null when it does not exist. - Future findOverrideById(String id); - - /// Return an override plus owner/revision metadata. - Future?> findOverrideRecordById( - String id, - ); - - /// Return all one-day overrides. - Future> findAllOverrides(); - - /// Return owner-scoped one-day overrides for [date]. - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }); - - /// Save or replace [block] by stable id. - Future saveBlock(LockedBlock block); - - /// Save or replace [override] by stable id. - Future saveOverride(LockedBlockOverride override); - - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }); - - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }); - - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }); - - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }); - - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }); -} - -/// Snapshot of one scheduling operation or persisted planning state. -class SchedulingStateSnapshot { - SchedulingStateSnapshot({ - required this.id, - required this.capturedAt, - required this.window, - required List tasks, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - List notices = const [], - List changes = const [], - List overlaps = const [], - this.operationName, - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals), - requiredVisibleIntervals = - List.unmodifiable(requiredVisibleIntervals), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps); - - /// Stable snapshot id. - final String id; - - /// Time this snapshot was created. - final DateTime capturedAt; - - /// Planning window represented by the snapshot. - final SchedulingWindow window; - - /// Task documents visible to the operation. - final List tasks; - - /// Locked intervals supplied to the scheduler. - final List lockedIntervals; - - /// Extra visible required intervals supplied to the scheduler. - final List requiredVisibleIntervals; - - /// Human-readable scheduling notices. - final List notices; - - /// Machine-readable task movements. - final List changes; - - /// Analysis-only overlap findings. - final List overlaps; - - /// Optional operation label, such as `push` or `rollover`. - final String? operationName; - - /// Rebuild scheduler input from the persisted state shape. - SchedulingInput get input { - return SchedulingInput( - tasks: List.unmodifiable(tasks), - window: window, - lockedIntervals: List.unmodifiable(lockedIntervals), - requiredVisibleIntervals: - List.unmodifiable(requiredVisibleIntervals), - ); - } - - /// Rebuild scheduler result details from the persisted state shape. - SchedulingResult get result { - return SchedulingResult( - tasks: List.unmodifiable(tasks), - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - overlaps: List.unmodifiable(overlaps), - ); - } -} - -/// Repository contract for scheduling operation/state snapshot documents. -abstract interface class SchedulingSnapshotRepository { - /// Return a snapshot by stable id, or null when it does not exist. - Future findById(String id); - - /// Return all snapshots overlapping [window]. - Future> findInWindow(SchedulingWindow window); - - /// Save or replace [snapshot] by stable id. - Future save(SchedulingStateSnapshot snapshot); -} - -/// In-memory task repository useful for tests and early application wiring. -class InMemoryTaskRepository implements TaskRepository { - InMemoryTaskRepository([ - Iterable initialTasks = const [], - this.defaultOwnerId = 'owner-1', - ]) : _tasksById = { - for (final task in initialTasks) task.id: task, - }, - _ownersById = { - for (final task in initialTasks) task.id: defaultOwnerId, - }, - _revisionsById = { - for (final task in initialTasks) task.id: 1, - }; - - final Map _tasksById; - final Map _ownersById; - final Map _revisionsById; - final String defaultOwnerId; - - @override - Future findById(String id) async { - return _tasksById[id]; - } - - @override - Future?> findRecordById(String id) async { - final task = _tasksById[id]; - if (task == null) { - return null; - } - return RepositoryRecord( - value: task, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_tasksById.values); - } - - @override - Future> findByStatus(TaskStatus status) async { - return List.unmodifiable( - _tasksById.values.where((task) => task.status == status), - ); - } - - @override - Future> findByOwner({ - required String ownerId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where((task) => _ownerFor(task.id) == ownerId) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByProjectId({ - required String ownerId, - required String projectId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && task.projectId == projectId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findByParentTaskId({ - required String ownerId, - required String parentTaskId, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - task.parentTaskId == parentTaskId, - ) - .toList(growable: false) - ..sort(_compareTaskIds); - return _page(tasks, page); - } - - @override - Future> findBacklogCandidates( - BacklogCandidateQuery query, - ) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == query.ownerId && - task.status == TaskStatus.backlog && - (query.projectId == null || task.projectId == query.projectId) && - query.tags.every(task.backlogTags.contains), - ) - .toList(growable: false) - ..sort(_compareBacklogCandidates); - return _page(tasks, query.page); - } - - @override - Future> findScheduledInWindow(SchedulingWindow window) async { - return List.unmodifiable( - _tasksById.values.where((task) { - return _taskOverlapsWindow(task, window); - }), - ); - } - - @override - Future> findScheduledInWindowForOwner({ - required String ownerId, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final tasks = _tasksById.values - .where( - (task) => - _ownerFor(task.id) == ownerId && - _taskOverlapsWindow( - task, - window, - ), - ) - .toList(growable: false) - ..sort(_compareScheduledTasks); - return _page(tasks, page); - } - - @override - Future> findForLocalDay({ - required String ownerId, - required CivilDate localDate, - required SchedulingWindow window, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) { - return findScheduledInWindowForOwner( - ownerId: ownerId, - window: window, - page: page, - ); - } - - @override - Future save(Task task) async { - _tasksById[task.id] = task; - _ownersById.putIfAbsent(task.id, () => defaultOwnerId); - _revisionsById[task.id] = _revisionFor(task.id) + 1; - } - - @override - Future saveAll(Iterable tasks) async { - for (final task in tasks) { - await save(task); - } - } - - @override - Future insert({ - required Task task, - required String ownerId, - }) async { - if (_tasksById.containsKey(task.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: task.id, - ), - ); - } - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required Task task, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: task.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _tasksById[task.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: task.id, - ), - ); - } - if (_ownerFor(task.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: task.id, - ), - ); - } - final actualRevision = _revisionFor(task.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: task.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _tasksById[task.id] = task; - _ownersById[task.id] = ownerId; - _revisionsById[task.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -/// In-memory project repository useful for tests and early application wiring. -class InMemoryProjectRepository implements ProjectRepository { - InMemoryProjectRepository( - [Iterable initialProjects = const [], - this.defaultOwnerId = 'owner-1']) - : _projectsById = { - for (final project in initialProjects) project.id: project, - }, - _ownersById = { - for (final project in initialProjects) project.id: defaultOwnerId, - }, - _revisionsById = { - for (final project in initialProjects) project.id: 1, - }; - - final Map _projectsById; - final Map _ownersById; - final Map _revisionsById; - final String defaultOwnerId; - - @override - Future findById(String id) async { - return _projectsById[id]; - } - - @override - Future?> findRecordById(String id) async { - final project = _projectsById[id]; - if (project == null) { - return null; - } - return RepositoryRecord( - value: project, - ownerId: _ownerFor(id), - revision: _revisionFor(id), - archivedAt: project.archivedAt, - ); - } - - @override - Future> findAll() async { - return List.unmodifiable(_projectsById.values); - } - - @override - Future> findByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final projects = _projectsById.values - .where( - (project) => - _ownerFor(project.id) == ownerId && - (includeArchived || !project.isArchived), - ) - .toList(growable: false) - ..sort(_compareProjects); - return _page(projects, page); - } - - @override - Future save(ProjectProfile project) async { - _projectsById[project.id] = project; - _ownersById.putIfAbsent(project.id, () => defaultOwnerId); - _revisionsById[project.id] = _revisionFor(project.id) + 1; - } - - @override - Future insert({ - required ProjectProfile project, - required String ownerId, - }) async { - if (_projectsById.containsKey(project.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: project.id, - ), - ); - } - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveIfRevision({ - required ProjectProfile project, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: project.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _projectsById[project.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: project.id, - ), - ); - } - if (_ownerFor(project.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: project.id, - ), - ); - } - final actualRevision = _revisionFor(project.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: project.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _projectsById[project.id] = project; - _ownersById[project.id] = ownerId; - _revisionsById[project.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archive({ - required String projectId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final project = _projectsById[projectId]; - if (project == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: projectId, - ), - ); - } - return saveIfRevision( - project: project.copyWith(archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; - - int _revisionFor(String id) => _revisionsById[id] ?? 1; -} - -/// In-memory locked-time repository useful for tests and early wiring. -class InMemoryLockedBlockRepository implements LockedBlockRepository { - InMemoryLockedBlockRepository({ - Iterable initialBlocks = const [], - Iterable initialOverrides = const [], - this.defaultOwnerId = 'owner-1', - }) : _blocksById = { - for (final block in initialBlocks) block.id: block, - }, - _blockOwnersById = { - for (final block in initialBlocks) block.id: defaultOwnerId, - }, - _blockRevisionsById = { - for (final block in initialBlocks) block.id: 1, - }, - _overridesById = { - for (final override in initialOverrides) override.id: override, - }, - _overrideOwnersById = { - for (final override in initialOverrides) override.id: defaultOwnerId, - }, - _overrideRevisionsById = { - for (final override in initialOverrides) override.id: 1, - }; - - final Map _blocksById; - final Map _blockOwnersById; - final Map _blockRevisionsById; - final Map _overridesById; - final Map _overrideOwnersById; - final Map _overrideRevisionsById; - final String defaultOwnerId; - - @override - Future findBlockById(String id) async { - return _blocksById[id]; - } - - @override - Future?> findBlockRecordById(String id) async { - final block = _blocksById[id]; - if (block == null) { - return null; - } - return RepositoryRecord( - value: block, - ownerId: _blockOwnerFor(id), - revision: _blockRevisionFor(id), - archivedAt: block.archivedAt, - ); - } - - @override - Future> findAllBlocks() async { - return List.unmodifiable(_blocksById.values); - } - - @override - Future> findBlocksByOwner({ - required String ownerId, - bool includeArchived = false, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final blocks = _blocksById.values - .where( - (block) => - _blockOwnerFor(block.id) == ownerId && - (includeArchived || !block.isArchived), - ) - .toList(growable: false) - ..sort(_compareLockedBlocks); - return _page(blocks, page); - } - - @override - Future findOverrideById(String id) async { - return _overridesById[id]; - } - - @override - Future?> findOverrideRecordById( - String id, - ) async { - final override = _overridesById[id]; - if (override == null) { - return null; - } - return RepositoryRecord( - value: override, - ownerId: _overrideOwnerFor(id), - revision: _overrideRevisionFor(id), - ); - } - - @override - Future> findAllOverrides() async { - return List.unmodifiable(_overridesById.values); - } - - @override - Future> findOverridesForDate({ - required String ownerId, - required CivilDate date, - RepositoryPageRequest page = const RepositoryPageRequest(), - }) async { - final overrides = _overridesById.values - .where( - (override) => - _overrideOwnerFor(override.id) == ownerId && - override.date == date, - ) - .toList(growable: false) - ..sort(_compareLockedOverrides); - return _page(overrides, page); - } - - @override - Future saveBlock(LockedBlock block) async { - _blocksById[block.id] = block; - _blockOwnersById.putIfAbsent(block.id, () => defaultOwnerId); - _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; - } - - @override - Future saveOverride(LockedBlockOverride override) async { - _overridesById[override.id] = override; - _overrideOwnersById.putIfAbsent(override.id, () => defaultOwnerId); - _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; - } - - @override - Future insertBlock({ - required LockedBlock block, - required String ownerId, - }) async { - if (_blocksById.containsKey(block.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: block.id, - ), - ); - } - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveBlockIfRevision({ - required LockedBlock block, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: block.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _blocksById[block.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: block.id, - ), - ); - } - if (_blockOwnerFor(block.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: block.id, - ), - ); - } - final actualRevision = _blockRevisionFor(block.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: block.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _blocksById[block.id] = block; - _blockOwnersById[block.id] = ownerId; - _blockRevisionsById[block.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - @override - Future archiveBlock({ - required String blockId, - required String ownerId, - required int expectedRevision, - required DateTime archivedAt, - }) async { - final block = _blocksById[blockId]; - if (block == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: blockId, - ), - ); - } - return saveBlockIfRevision( - block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - } - - @override - Future insertOverride({ - required LockedBlockOverride override, - required String ownerId, - }) async { - if (_overridesById.containsKey(override.id)) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.duplicateId, - entityId: override.id, - ), - ); - } - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = 1; - return RepositoryMutationResult.success(revision: 1); - } - - @override - Future saveOverrideIfRevision({ - required LockedBlockOverride override, - required String ownerId, - required int expectedRevision, - }) async { - if (expectedRevision <= 0) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.invalidRevision, - entityId: override.id, - expectedRevision: expectedRevision, - ), - ); - } - final current = _overridesById[override.id]; - if (current == null) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.notFound, - entityId: override.id, - ), - ); - } - if (_overrideOwnerFor(override.id) != ownerId) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.ownerMismatch, - entityId: override.id, - ), - ); - } - final actualRevision = _overrideRevisionFor(override.id); - if (actualRevision != expectedRevision) { - return RepositoryMutationResult.failure( - RepositoryFailure( - code: RepositoryFailureCode.staleRevision, - entityId: override.id, - expectedRevision: expectedRevision, - actualRevision: actualRevision, - ), - ); - } - final nextRevision = actualRevision + 1; - _overridesById[override.id] = override; - _overrideOwnersById[override.id] = ownerId; - _overrideRevisionsById[override.id] = nextRevision; - return RepositoryMutationResult.success(revision: nextRevision); - } - - String _blockOwnerFor(String id) => _blockOwnersById[id] ?? defaultOwnerId; - - int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; - - String _overrideOwnerFor(String id) => - _overrideOwnersById[id] ?? defaultOwnerId; - - int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; -} - -/// In-memory snapshot repository useful for tests and early application wiring. -class InMemorySchedulingSnapshotRepository - implements SchedulingSnapshotRepository { - InMemorySchedulingSnapshotRepository([ - Iterable initialSnapshots = const [], - ]) : _snapshotsById = { - for (final snapshot in initialSnapshots) snapshot.id: snapshot, - }; - - final Map _snapshotsById; - - @override - Future findById(String id) async { - return _snapshotsById[id]; - } - - @override - Future> findInWindow( - SchedulingWindow window, - ) async { - return List.unmodifiable( - _snapshotsById.values.where( - (snapshot) => snapshot.window.interval.overlaps(window.interval), - ), - ); - } - - @override - Future save(SchedulingStateSnapshot snapshot) async { - _snapshotsById[snapshot.id] = snapshot; - } -} - -RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { - final offset = int.tryParse(request.cursor ?? '') ?? 0; - final safeOffset = offset.clamp(0, sortedItems.length); - final end = (safeOffset + request.limit).clamp(0, sortedItems.length); - final items = sortedItems.sublist(safeOffset, end); - final nextCursor = end < sortedItems.length ? end.toString() : null; - return RepositoryPage( - items: List.unmodifiable(items), - nextCursor: nextCursor, - ); -} - -bool _taskOverlapsWindow(Task task, SchedulingWindow window) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return TimeInterval(start: start, end: end).overlaps(window.interval); -} - -int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); - -int _compareScheduledTasks(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart != null && rightStart != null) { - final startComparison = leftStart.compareTo(rightStart); - if (startComparison != 0) { - return startComparison; - } - } - return left.id.compareTo(right.id); -} - -int _compareBacklogCandidates(Task left, Task right) { - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} - -int _compareProjects(ProjectProfile left, ProjectProfile right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedBlocks(LockedBlock left, LockedBlock right) { - final nameComparison = left.name.compareTo(right.name); - if (nameComparison != 0) { - return nameComparison; - } - return left.id.compareTo(right.id); -} - -int _compareLockedOverrides( - LockedBlockOverride left, - LockedBlockOverride right, -) { - final dateComparison = left.date.compareTo(right.date); - if (dateComparison != 0) { - return dateComparison; - } - final createdComparison = left.createdAt.compareTo(right.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - return left.id.compareTo(right.id); -} diff --git a/packages/scheduler_core/lib/src/repository_values.dart b/packages/scheduler_core/lib/src/repository_values.dart deleted file mode 100644 index c86ca60..0000000 --- a/packages/scheduler_core/lib/src/repository_values.dart +++ /dev/null @@ -1,92 +0,0 @@ -/// Shared repository value objects used by persistence adapters. -/// -/// These types are deliberately small and database-agnostic. They live in the -/// core package so every persistence adapter can use the same owner, revision, -/// and paging contracts without importing a storage implementation. -library; - -/// Stable owner scope for repository operations. -class OwnerId { - OwnerId(String value) : value = _requiredTrimmed(value, 'ownerId'); - - /// Raw owner identifier. - final String value; - - @override - String toString() => value; - - @override - bool operator ==(Object other) { - return other is OwnerId && other.value == value; - } - - @override - int get hashCode => value.hashCode; -} - -/// Optimistic concurrency revision for mutable repository records. -class Revision implements Comparable { - const Revision(this.value) : assert(value >= 0); - - /// First revision assigned to a newly inserted record. - static const initial = Revision(1); - - /// Numeric revision value. - final int value; - - /// Return the next monotonically increasing revision. - Revision next() => Revision(value + 1); - - @override - int compareTo(Revision other) => value.compareTo(other.value); - - @override - String toString() => value.toString(); - - @override - bool operator ==(Object other) { - return other is Revision && other.value == value; - } - - @override - int get hashCode => value.hashCode; -} - -/// Cursor contract for bounded repository queries. -class PageRequest { - const PageRequest({ - this.cursor, - this.limit = 100, - }) : assert(limit > 0); - - /// Adapter-neutral cursor. Implementations choose the cursor format. - final String? cursor; - - /// Maximum number of records to return. - final int limit; -} - -/// One bounded page of repository query results. -class Page { - Page({ - required Iterable items, - this.nextCursor, - }) : items = List.unmodifiable(items); - - /// Items returned for this page. - final List items; - - /// Cursor for the next page, or null when this is the last page. - final String? nextCursor; - - /// Whether another page is available. - bool get hasMore => nextCursor != null; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog.dart b/packages/scheduler_core/lib/src/scheduling/backlog.dart new file mode 100644 index 0000000..738cf2a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Backlog behavior for the Scheduler Core package. +library; + +// Backlog query helpers for the ADHD scheduling core. +// +// The backlog is not a separate database table in this starter model. It is a +// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps +// display-oriented backlog filtering, sorting, and age markers out of the main +// scheduler so the engine can focus on moving tasks through time. + +import '../domain/models.dart'; +part 'backlog/queries/backlog_filter.dart'; +part 'backlog/queries/backlog_sort_key.dart'; +part 'backlog/staleness/backlog_staleness_marker.dart'; +part 'backlog/staleness/backlog_staleness_settings.dart'; +part 'backlog/views/backlog_view.dart'; +part 'backlog/views/indexed_task.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_filter.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_filter.dart new file mode 100644 index 0000000..d49f579 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_filter.dart @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Derived backlog filters for a unified backlog list. +/// +/// These filters do not store separate task collections. They are projections +/// over the same master task list. That is important because a task can move +/// between today's timeline and backlog by changing [Task.status], without +/// needing to copy it between separate stores. +enum BacklogFilter { + /// Uncategorized captured tasks in the default inbox project. + inbox, + + /// Tasks that have been manually or automatically pushed at least once. + pushed, + + /// Critical tasks that have missed at least once and need recovery attention. + criticalMissed, + + /// Someday/maybe tasks that are intentionally kept out of normal pressure. + wishlist, + + /// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold. + stale, + + /// Tasks still missing a reward estimate. Useful during cleanup/review. + noRewardSet, +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart new file mode 100644 index 0000000..7fbdd39 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Sort options for a unified backlog list. +/// +/// Sort keys are intentionally product-facing rather than database-facing. For +/// example, `rewardVsEffort` maps to a simple derived score instead of a stored +/// field. Persistence can later index the underlying fields if needed. +enum BacklogSortKey { + /// Highest priority first. + priority, + + /// Best simple reward-minus-difficulty score first. + rewardVsEffort, + + /// Oldest created task first. + age, + + /// Lexicographic project id grouping. Future UI can replace this with project + /// display order while keeping the same public key. + project, + + /// Most frequently pushed tasks first. + timesPushed, +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_marker.dart new file mode 100644 index 0000000..e0a837d --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_marker.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Visual age bucket for backlog display. +/// +/// This supports the design rule that old backlog items should visually age +/// from green to blue to purple. The enum names describe semantic buckets; UI +/// code should translate them into actual theme colors. +enum BacklogStalenessMarker { + /// Fresh backlog item. Default: created within seven days. + green, + + /// Aging backlog item. Default: created within thirty days. + blue, + + /// Old/stale backlog item. Default: created more than thirty days ago. + purple, +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart new file mode 100644 index 0000000..f100a3a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Configurable thresholds for backlog age markers. +/// +/// The defaults match the current design spec: less than a week is fresh, less +/// than a month is aging, and anything older is stale. Keeping the thresholds in +/// a value object makes future settings/preferences easy to inject in tests or +/// user configuration. +class BacklogStalenessSettings { + /// Creates a `BacklogStalenessSettings` 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 BacklogStalenessSettings({ + this.greenMaxAge = const Duration(days: 7), + this.blueMaxAge = const Duration(days: 30), + }); + + /// Maximum age that still counts as fresh/green. + final Duration greenMaxAge; + + /// Maximum age that still counts as aging/blue. Anything older is purple. + final Duration blueMaxAge; + + /// Return the visual age marker for [task] relative to [now]. + /// + /// This uses [Task.createdAt], not [Task.updatedAt], because the marker is + /// meant to show how long the idea has existed in the system. A task edited + /// yesterday but created two months ago should still feel old in the backlog. + BacklogStalenessMarker markerFor({ + required Task task, + required DateTime now, + }) { + final age = now.difference(task.createdAt); + + if (age <= greenMaxAge) { + return BacklogStalenessMarker.green; + } + + if (age <= blueMaxAge) { + return BacklogStalenessMarker.blue; + } + + return BacklogStalenessMarker.purple; + } +} diff --git a/packages/scheduler_core/lib/src/backlog.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart similarity index 60% rename from packages/scheduler_core/lib/src/backlog.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart index fbda319..e19cecf 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart @@ -1,120 +1,7 @@ -/// Implements Backlog behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Backlog query helpers for the ADHD scheduling core. -// -// The backlog is not a separate database table in this starter model. It is a -// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps -// display-oriented backlog filtering, sorting, and age markers out of the main -// scheduler so the engine can focus on moving tasks through time. - -import 'models.dart'; - -/// Derived backlog filters for a unified backlog list. -/// -/// These filters do not store separate task collections. They are projections -/// over the same master task list. That is important because a task can move -/// between today's timeline and backlog by changing [Task.status], without -/// needing to copy it between separate stores. -enum BacklogFilter { - /// Uncategorized captured tasks in the default inbox project. - inbox, - - /// Tasks that have been manually or automatically pushed at least once. - pushed, - - /// Critical tasks that have missed at least once and need recovery attention. - criticalMissed, - - /// Someday/maybe tasks that are intentionally kept out of normal pressure. - wishlist, - - /// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold. - stale, - - /// Tasks still missing a reward estimate. Useful during cleanup/review. - noRewardSet, -} - -/// Sort options for a unified backlog list. -/// -/// Sort keys are intentionally product-facing rather than database-facing. For -/// example, `rewardVsEffort` maps to a simple derived score instead of a stored -/// field. Persistence can later index the underlying fields if needed. -enum BacklogSortKey { - /// Highest priority first. - priority, - - /// Best simple reward-minus-difficulty score first. - rewardVsEffort, - - /// Oldest created task first. - age, - - /// Lexicographic project id grouping. Future UI can replace this with project - /// display order while keeping the same public key. - project, - - /// Most frequently pushed tasks first. - timesPushed, -} - -/// Visual age bucket for backlog display. -/// -/// This supports the design rule that old backlog items should visually age -/// from green to blue to purple. The enum names describe semantic buckets; UI -/// code should translate them into actual theme colors. -enum BacklogStalenessMarker { - /// Fresh backlog item. Default: created within seven days. - green, - - /// Aging backlog item. Default: created within thirty days. - blue, - - /// Old/stale backlog item. Default: created more than thirty days ago. - purple, -} - -/// Configurable thresholds for backlog age markers. -/// -/// The defaults match the current design spec: less than a week is fresh, less -/// than a month is aging, and anything older is stale. Keeping the thresholds in -/// a value object makes future settings/preferences easy to inject in tests or -/// user configuration. -class BacklogStalenessSettings { - const BacklogStalenessSettings({ - this.greenMaxAge = const Duration(days: 7), - this.blueMaxAge = const Duration(days: 30), - }); - - /// Maximum age that still counts as fresh/green. - final Duration greenMaxAge; - - /// Maximum age that still counts as aging/blue. Anything older is purple. - final Duration blueMaxAge; - - /// Return the visual age marker for [task] relative to [now]. - /// - /// This uses [Task.createdAt], not [Task.updatedAt], because the marker is - /// meant to show how long the idea has existed in the system. A task edited - /// yesterday but created two months ago should still feel old in the backlog. - BacklogStalenessMarker markerFor({ - required Task task, - required DateTime now, - }) { - final age = now.difference(task.createdAt); - - if (age <= greenMaxAge) { - return BacklogStalenessMarker.green; - } - - if (age <= blueMaxAge) { - return BacklogStalenessMarker.blue; - } - - return BacklogStalenessMarker.purple; - } -} +part of '../../backlog.dart'; /// Read-only backlog projection over the unified task list. /// @@ -123,6 +10,8 @@ class BacklogStalenessSettings { /// That keeps backlog display logic out of widgets and avoids duplicating the /// same filtering rules in multiple screens. class BacklogView { + /// Creates a `BacklogView` 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. BacklogView({ required List tasks, required this.now, @@ -286,14 +175,3 @@ int _rewardVsEffortScore(Task task) { int _timesPushed(Task task) { return task.stats.manuallyPushedCount + task.stats.autoPushedCount; } - -/// Backlog task paired with its source-list position for stable sorting. -class _IndexedTask { - const _IndexedTask({ - required this.task, - required this.originalIndex, - }); - - final Task task; - final int originalIndex; -} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/views/indexed_task.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/indexed_task.dart new file mode 100644 index 0000000..c538c6f --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/indexed_task.dart @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Backlog task paired with its source-list position for stable sorting. +class _IndexedTask { + /// Creates a `_IndexedTask` 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 _IndexedTask({ + required this.task, + required this.originalIndex, + }); + + /// Stores the `task` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Task task; + + /// Stores the `originalIndex` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int originalIndex; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart new file mode 100644 index 0000000..f0abef4 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Child Tasks behavior for the Scheduler Core package. +library; + +// Parent/child task ownership helpers. +// +// Child tasks are owned by one parent through `Task.parentTaskId`. This file +// keeps that relationship queryable without adding dependency scheduling, DAG +// traversal, or UI-specific state. + +import '../domain/models.dart'; +import 'task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'child_tasks/models/child_task_entry.dart'; +part 'child_tasks/requests/child_task_break_up_request.dart'; +part 'child_tasks/results/child_task_break_up_result.dart'; +part 'child_tasks/services/child_task_break_up_service.dart'; +part 'child_tasks/models/child_task_view.dart'; +part 'child_tasks/results/child_task_completion_result.dart'; +part 'child_tasks/services/child_task_completion_service.dart'; +part 'child_tasks/models/child_task_summary.dart'; +part 'child_tasks/indexing/indexed_child.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/indexing/indexed_child.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/indexing/indexed_child.dart new file mode 100644 index 0000000..62b03ca --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/indexing/indexed_child.dart @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Private implementation type for `_IndexedChild` 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 _IndexedChild { + /// Creates a `_IndexedChild` 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 _IndexedChild({ + required this.task, + required this.originalIndex, + }); + + /// Stores the `task` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Task task; + + /// Stores the `originalIndex` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final int originalIndex; +} + +/// Top-level helper that performs the `_priorityRank` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + null => -1, + }; +} + +/// Top-level helper that performs the `_taskById` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Top-level helper that performs the `_replaceTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _replaceTask(List tasks, Task replacement) { + return tasks + .map((task) => task.id == replacement.id ? replacement : task) + .toList(growable: false); +} + +/// Top-level helper that performs the `_defaultOperationId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _defaultOperationId(String actionName, String taskId, DateTime at) { + return '$actionName:$taskId:${at.toIso8601String()}'; +} + +/// Top-level helper that performs the `_activityId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_entry.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_entry.dart new file mode 100644 index 0000000..0820410 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_entry.dart @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Row-style input for creating one child task. +/// +/// Priority is nullable on purpose. A null priority means the user did not set +/// one, so callers should preserve row insertion order instead of treating the +/// child as medium priority. +class ChildTaskEntry { + /// Creates a `ChildTaskEntry` 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 ChildTaskEntry({ + required this.id, + required this.title, + required this.createdAt, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.durationMinutes, + this.projectId, + this.updatedAt, + }); + + /// Caller-generated child id. + final String id; + + /// User-entered child title. + final String title; + + /// Creation timestamp supplied by the caller. + final DateTime createdAt; + + /// Optional explicit child priority. + final PriorityLevel? priority; + + /// Optional explicit child reward. Missing reward stays `notSet`. + final RewardLevel reward; + + /// Optional explicit child difficulty. + final DifficultyLevel difficulty; + + /// Optional child duration estimate. + final int? durationMinutes; + + /// Optional project override. Null means inherit the parent project. + final String? projectId; + + /// Optional update timestamp. + final DateTime? updatedAt; + + /// Convert this entry into a child [Task] owned by [parent]. + Task toTask({ + required Task parent, + }) { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'Title is required.'); + } + + return Task( + id: id, + title: trimmedTitle, + projectId: projectId ?? parent.projectId, + type: TaskType.flexible, + status: TaskStatus.backlog, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + parentTaskId: parent.id, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_summary.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_summary.dart new file mode 100644 index 0000000..94df154 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_summary.dart @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Status counts for a parent's direct children. +class ChildTaskSummary { + /// Creates a `ChildTaskSummary` 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 ChildTaskSummary({ + required this.totalCount, + required this.plannedCount, + required this.activeCount, + required this.completedCount, + required this.missedCount, + required this.cancelledCount, + required this.noLongerRelevantCount, + required this.backlogCount, + }); + + /// Build counts from [children]. + factory ChildTaskSummary.fromChildren(List children) { + var plannedCount = 0; + var activeCount = 0; + var completedCount = 0; + var missedCount = 0; + var cancelledCount = 0; + var noLongerRelevantCount = 0; + var backlogCount = 0; + + for (final child in children) { + switch (child.status) { + case TaskStatus.planned: + plannedCount += 1; + case TaskStatus.active: + activeCount += 1; + case TaskStatus.completed: + completedCount += 1; + case TaskStatus.missed: + missedCount += 1; + case TaskStatus.cancelled: + cancelledCount += 1; + case TaskStatus.noLongerRelevant: + noLongerRelevantCount += 1; + case TaskStatus.backlog: + backlogCount += 1; + } + } + + return ChildTaskSummary( + totalCount: children.length, + plannedCount: plannedCount, + activeCount: activeCount, + completedCount: completedCount, + missedCount: missedCount, + cancelledCount: cancelledCount, + noLongerRelevantCount: noLongerRelevantCount, + backlogCount: backlogCount, + ); + } + + /// Total direct children counted. + final int totalCount; + + /// Children in planned status. + final int plannedCount; + + /// Children in active status. + final int activeCount; + + /// Children in completed status. + final int completedCount; + + /// Children in missed status. + final int missedCount; + + /// Children in cancelled status. + final int cancelledCount; + + /// Children marked no longer relevant. + final int noLongerRelevantCount; + + /// Children currently in backlog. + final int backlogCount; + + /// Whether at least one direct child exists. + bool get hasChildren => totalCount > 0; + + /// Whether every direct child is completed. + bool get allChildrenCompleted => hasChildren && completedCount == totalCount; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_view.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_view.dart new file mode 100644 index 0000000..6502920 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_view.dart @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Read-only parent/child projection over a task list. +/// +/// This is intentionally not a scheduler. It answers ownership questions such +/// as "which tasks belong to this parent?" while leaving task placement and +/// completion rules to other domain services. +class ChildTaskView { + /// Creates a `ChildTaskView` 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. + ChildTaskView({ + required List tasks, + }) : tasks = List.unmodifiable(tasks); + + /// Source task list supplied by the caller. + final List tasks; + + /// Direct children owned by [parent], preserving source-list order. + List childrenOf(Task parent) { + return List.unmodifiable( + tasks.where((task) => task.parentTaskId == parent.id), + ); + } + + /// Direct children sorted by explicit priority while preserving row order ties. + /// + /// Children without priority sort after explicit priorities. Within each + /// priority group, including the no-priority group, source-list order is kept. + List childrenOfSortedByPriority(Task parent) { + final indexedChildren = childrenOf(parent) + .asMap() + .entries + .map( + (entry) => _IndexedChild( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + + indexedChildren.sort((a, b) { + final priorityComparison = _priorityRank(b.task.priority) + .compareTo(_priorityRank(a.task.priority)); + if (priorityComparison != 0) { + return priorityComparison; + } + + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + indexedChildren.map((child) => child.task), + ); + } + + /// Parent task for [child], or null when the parent is not in [tasks]. + Task? parentOf(Task child) { + final parentId = child.parentTaskId; + if (parentId == null) { + return null; + } + + for (final task in tasks) { + if (task.id == parentId) { + return task; + } + } + + return null; + } + + /// Whether [child] is directly owned by [parent]. + bool isChildOf({ + required Task child, + required Task parent, + }) { + return child.parentTaskId == parent.id; + } + + /// Aggregate direct child status counts for [parent]. + ChildTaskSummary summaryFor(Task parent) { + return ChildTaskSummary.fromChildren(childrenOf(parent)); + } + + /// Whether this helper would auto-complete [parent]. + bool parentShouldAutoComplete(Task parent) { + return summaryFor(parent).allChildrenCompleted; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/requests/child_task_break_up_request.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/requests/child_task_break_up_request.dart new file mode 100644 index 0000000..3ed8538 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/requests/child_task_break_up_request.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Command input for breaking one parent task into direct children. +class ChildTaskBreakUpRequest { + /// Creates a `ChildTaskBreakUpRequest` 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. + ChildTaskBreakUpRequest({ + required String parentTaskId, + required List entries, + required String operationId, + }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), + entries = List.unmodifiable(entries), + operationId = _requiredTrimmed(operationId, 'operationId'); + + /// Parent task that will own every created child. + final String parentTaskId; + + /// Ordered child rows supplied by the caller. + final List entries; + + /// Idempotency key for the application command that creates the children. + final String operationId; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_break_up_result.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_break_up_result.dart new file mode 100644 index 0000000..3176a06 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_break_up_result.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Result from breaking a parent task into direct children. +class ChildTaskBreakUpResult { + /// Creates a `ChildTaskBreakUpResult` 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. + ChildTaskBreakUpResult({ + required List tasks, + required this.parentTask, + required List childTasks, + required this.operationId, + List createdChildTaskIds = const [], + List activities = const [], + }) : tasks = List.unmodifiable(tasks), + childTasks = List.unmodifiable(childTasks), + createdChildTaskIds = List.unmodifiable(createdChildTaskIds), + activities = List.unmodifiable(activities); + + /// Replacement task list including newly created children. + final List tasks; + + /// Parent that owns the created children. + final Task parentTask; + + /// Created children in request order. + final List childTasks; + + /// Idempotency key for the command. + final String operationId; + + /// Child ids created by this command. + final List createdChildTaskIds; + + /// Activity records produced by this command. + /// + /// V1 child creation is represented by task inserts, so this is currently + /// empty while still giving application use cases one atomic mutation shape. + final List activities; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_completion_result.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_completion_result.dart new file mode 100644 index 0000000..2fde7ea --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_completion_result.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Result from child/parent completion propagation. +class ChildTaskCompletionResult { + /// Creates a `ChildTaskCompletionResult` 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. + ChildTaskCompletionResult({ + required List tasks, + required List changedTaskIds, + this.completedChildId, + this.completedParentId, + List forceCompletedChildIds = const [], + List activities = const [], + List transitionOutcomeCodes = + const [], + this.autoCompletedParent = false, + this.canCompleteParentExplicitly = false, + }) : tasks = List.unmodifiable(tasks), + changedTaskIds = List.unmodifiable(changedTaskIds), + forceCompletedChildIds = + List.unmodifiable(forceCompletedChildIds), + activities = List.unmodifiable(activities), + transitionOutcomeCodes = List.unmodifiable( + transitionOutcomeCodes); + + /// Replacement task list after the completion action. + final List tasks; + + /// Task ids whose completion state changed. + final List changedTaskIds; + + /// Child id completed by the initial child-complete action, when applicable. + final String? completedChildId; + + /// Parent id completed by the action, when applicable. + final String? completedParentId; + + /// Direct child ids completed by explicit parent-complete behavior. + final List forceCompletedChildIds; + + /// Internal activities produced by parent/child completion propagation. + final List activities; + + /// Transition outcomes returned while applying completion propagation. + final List transitionOutcomeCodes; + + /// Whether the parent was completed because all direct children are complete. + final bool autoCompletedParent; + + /// Whether callers may offer an explicit parent-complete action. + final bool canCompleteParentExplicitly; +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart new file mode 100644 index 0000000..23fa5ce --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Creates direct child tasks from ordered child-entry rows. +class ChildTaskBreakUpService { + /// Creates a `ChildTaskBreakUpService` 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 ChildTaskBreakUpService(); + + /// Break `request.parentTaskId` into direct child tasks. + /// + /// This service validates the full requested set before returning any + /// mutations. Persistence and transaction wiring are handled by later blocks. + ChildTaskBreakUpResult breakUp({ + required List tasks, + required ChildTaskBreakUpRequest request, + }) { + final parent = _taskById(tasks, request.parentTaskId); + if (parent == null) { + throw ArgumentError.value( + request.parentTaskId, + 'parentTaskId', + 'Parent not found.', + ); + } + if (request.entries.isEmpty) { + throw ArgumentError.value( + request.entries, + 'entries', + 'At least one child task is required.', + ); + } + + final existingIds = tasks.map((task) => task.id).toSet(); + final requestedIds = {}; + for (final entry in request.entries) { + final childId = entry.id.trim(); + if (childId.isEmpty) { + throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); + } + if (childId == parent.id) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id cannot match the parent id.', + ); + } + if (!requestedIds.add(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child ids must be unique within one break-up command.', + ); + } + if (existingIds.contains(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id is already used by another task.', + ); + } + } + + final childTasks = request.entries + .map((entry) => entry.toTask(parent: parent)) + .toList(growable: false); + + return ChildTaskBreakUpResult( + tasks: List.unmodifiable([...tasks, ...childTasks]), + parentTask: parent, + childTasks: childTasks, + operationId: request.operationId, + createdChildTaskIds: + childTasks.map((child) => child.id).toList(growable: false), + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart new file mode 100644 index 0000000..9b8509a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../child_tasks.dart'; + +/// Applies direct parent/child completion rules. +/// +/// This service intentionally handles only one ownership level. It does not walk +/// dependency graphs, and it only completes sibling children when the caller +/// explicitly completes the parent. +class ChildTaskCompletionService { + /// Creates a `ChildTaskCompletionService` 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 ChildTaskCompletionService({ + this.clock = const SystemClock(), + this.transitionService = const TaskTransitionService(), + }); + + /// Clock boundary used when a completion timestamp is not supplied. + final Clock clock; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Complete one child and auto-complete its parent only when all children are complete. + ChildTaskCompletionResult completeChild({ + required List tasks, + required String childTaskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final now = updatedAt ?? clock.now(); + final child = _taskById(tasks, childTaskId); + if (child == null) { + throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); + } + + final parentId = child.parentTaskId; + if (parentId == null) { + throw ArgumentError.value( + childTaskId, 'childTaskId', 'Task is not a child.'); + } + + final parent = _taskById(tasks, parentId); + if (parent == null) { + throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); + } + + final opId = + operationId ?? _defaultOperationId('complete-child', child.id, now); + final childTransition = _completeWithTransition( + task: child, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'child', + 'parentTaskId': parent.id, + }, + ); + final withChildCompleted = _replaceTask(tasks, childTransition.task); + final view = ChildTaskView(tasks: withChildCompleted); + final shouldCompleteParent = parent.status != TaskStatus.completed && + view.parentShouldAutoComplete(parent); + final activities = [...childTransition.activities]; + final outcomes = [childTransition.outcomeCode]; + + if (!shouldCompleteParent) { + return ChildTaskCompletionResult( + tasks: List.unmodifiable(withChildCompleted), + changedTaskIds: childTransition.applied ? [child.id] : const [], + completedChildId: child.id, + canCompleteParentExplicitly: parent.status != TaskStatus.completed, + activities: activities, + transitionOutcomeCodes: outcomes, + ); + } + + final parentTransition = _completeWithTransition( + task: parent, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'autoParentAfterLastChild', + 'completedChildTaskId': child.id, + }, + ); + activities.addAll(parentTransition.activities); + outcomes.add(parentTransition.outcomeCode); + + final updatedParent = parentTransition.task; + final completedTasks = _replaceTask(withChildCompleted, updatedParent); + final changedTaskIds = [ + if (childTransition.applied) child.id, + if (parentTransition.applied) parent.id, + ]; + + return ChildTaskCompletionResult( + tasks: List.unmodifiable(completedTasks), + changedTaskIds: List.unmodifiable(changedTaskIds), + completedChildId: child.id, + completedParentId: parent.id, + autoCompletedParent: true, + activities: activities, + transitionOutcomeCodes: outcomes, + ); + } + + /// Complete a parent from one of its direct children and force-complete siblings. + ChildTaskCompletionResult completeParentFromChild({ + required List tasks, + required String childTaskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final child = _taskById(tasks, childTaskId); + if (child == null) { + throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); + } + final parentId = child.parentTaskId; + if (parentId == null) { + throw ArgumentError.value( + childTaskId, + 'childTaskId', + 'Task is not a child.', + ); + } + + return completeParent( + tasks: tasks, + parentTaskId: parentId, + updatedAt: updatedAt, + operationId: operationId, + initiatingChildTaskId: child.id, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + ); + } + + /// Explicitly complete a parent and any direct children not already completed. + ChildTaskCompletionResult completeParent({ + required List tasks, + required String parentTaskId, + DateTime? updatedAt, + String? operationId, + String? initiatingChildTaskId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + final now = updatedAt ?? clock.now(); + final parent = _taskById(tasks, parentTaskId); + if (parent == null) { + throw ArgumentError.value( + parentTaskId, 'parentTaskId', 'Parent not found.'); + } + + final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); + if (initiatingChildTaskId != null && + !directChildren.any((child) => child.id == initiatingChildTaskId)) { + throw ArgumentError.value( + initiatingChildTaskId, + 'initiatingChildTaskId', + 'Initiating task must be a direct child of the parent.', + ); + } + + final opId = + operationId ?? _defaultOperationId('complete-parent', parent.id, now); + final forceCompletedChildIds = []; + final changedTaskIds = []; + final activities = []; + final outcomes = []; + final updatedTasks = []; + + for (final task in tasks) { + if (task.id == parent.id) { + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': + initiatingChildTaskId == null ? 'parent' : 'parentFromChild', + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + final completedParent = transition.task; + updatedTasks.add(completedParent); + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { + changedTaskIds.add(task.id); + } + continue; + } + + final isDirectChild = directChildren.any((child) => child.id == task.id); + if (isDirectChild && task.status != TaskStatus.completed) { + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': initiatingChildTaskId == null + ? 'parentForce' + : 'childForceParent', + 'parentTaskId': parent.id, + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + updatedTasks.add(transition.task); + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { + forceCompletedChildIds.add(task.id); + changedTaskIds.add(task.id); + } + continue; + } + + updatedTasks.add(task); + } + + return ChildTaskCompletionResult( + tasks: List.unmodifiable(updatedTasks), + changedTaskIds: List.unmodifiable(changedTaskIds), + completedParentId: parent.id, + forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), + activities: List.unmodifiable(activities), + transitionOutcomeCodes: List.unmodifiable( + outcomes, + ), + ); + } + + /// Runs the `_completeWithTransition` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + TaskTransitionResult _completeWithTransition({ + required Task task, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, + required Iterable appliedActivityIds, + required Map metadata, + }) { + return transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: operationId, + activityId: _activityId( + operationId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: occurredAt, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots.dart b/packages/scheduler_core/lib/src/scheduling/free_slots.dart new file mode 100644 index 0000000..7278c70 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/free_slots.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Free Slots behavior for the Scheduler Core package. +library; + +// Protected Free Slot helpers. +// +// Free slots are intentional rest, not empty capacity. They are represented as +// scheduled Task records so they can appear in read models, but scheduling +// treats them as protected occupancy. + +import '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; +import 'scheduling_engine.dart'; +part 'free_slots/required_commitment_schedule_status.dart'; +part 'free_slots/protected_free_slot_conflict.dart'; +part 'free_slots/required_commitment_schedule_result.dart'; +part 'free_slots/free_slot_service.dart'; diff --git a/packages/scheduler_core/lib/src/free_slots.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart similarity index 76% rename from packages/scheduler_core/lib/src/free_slots.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart index 4d681ea..6e4b32f 100644 --- a/packages/scheduler_core/lib/src/free_slots.dart +++ b/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart @@ -1,81 +1,12 @@ -/// Implements Free Slots behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Protected Free Slot helpers. -// -// Free slots are intentional rest, not empty capacity. They are represented as -// scheduled Task records so they can appear in read models, but scheduling -// treats them as protected occupancy. - -import 'models.dart'; -import 'occupancy_policy.dart'; -import 'scheduling_engine.dart'; - -/// Typed outcome for explicitly placing a required visible commitment. -enum RequiredCommitmentScheduleStatus { - /// Required task was scheduled without protected rest conflicts. - scheduled, - - /// Required task was scheduled and overlaps protected rest. - scheduledWithProtectedFreeSlotConflict, - - /// No task with the requested id was present. - taskNotFound, - - /// The task is not critical or inflexible. - invalidTaskType, -} - -/// Typed overlap between an explicitly placed required commitment and rest. -class ProtectedFreeSlotConflict { - const ProtectedFreeSlotConflict({ - required this.requiredTaskId, - required this.freeSlotTaskId, - required this.requiredInterval, - required this.freeSlotInterval, - }); - - /// Critical or inflexible task that was explicitly placed. - final String requiredTaskId; - - /// Protected Free Slot task that was interrupted. - final String freeSlotTaskId; - - /// Required commitment interval. - final TimeInterval requiredInterval; - - /// Protected rest interval. - final TimeInterval freeSlotInterval; -} - -/// Result from explicitly scheduling a required visible commitment. -class RequiredCommitmentScheduleResult { - RequiredCommitmentScheduleResult({ - required this.status, - required this.schedulingResult, - required List protectedFreeSlotConflicts, - }) : protectedFreeSlotConflicts = - List.unmodifiable( - protectedFreeSlotConflicts, - ); - - /// High-level typed outcome. - final RequiredCommitmentScheduleStatus status; - - /// Replacement task list, notices, changes, and overlap details. - final SchedulingResult schedulingResult; - - /// Protected rest interruptions caused by the explicit placement. - final List protectedFreeSlotConflicts; - - /// Whether this placement interrupted protected rest. - bool get hasProtectedFreeSlotConflict { - return protectedFreeSlotConflicts.isNotEmpty; - } -} +part of '../free_slots.dart'; /// Creates and updates protected Free Slot task records. class FreeSlotService { + /// Creates a `FreeSlotService` 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 FreeSlotService(); /// Create a scheduled Free Slot record. @@ -260,6 +191,8 @@ class FreeSlotService { } } +/// Top-level helper that performs the `_unchangedRequiredResult` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. RequiredCommitmentScheduleResult _unchangedRequiredResult({ required SchedulingInput input, required RequiredCommitmentScheduleStatus status, @@ -278,6 +211,8 @@ RequiredCommitmentScheduleResult _unchangedRequiredResult({ ); } +/// Top-level helper that performs the `_protectedFreeSlotConflicts` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. List _protectedFreeSlotConflicts({ required SchedulingInput input, required String requiredTaskId, @@ -311,6 +246,8 @@ List _protectedFreeSlotConflicts({ return List.unmodifiable(conflicts); } +/// Top-level helper that performs the `_taskById` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Task? _taskById(List tasks, String taskId) { for (final task in tasks) { if (task.id == taskId) { @@ -321,6 +258,8 @@ Task? _taskById(List tasks, String taskId) { return null; } +/// Top-level helper that performs the `_sameDateTime` 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 _sameDateTime(DateTime? first, DateTime? second) { if (first == null || second == null) { return first == null && second == null; diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots/protected_free_slot_conflict.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/protected_free_slot_conflict.dart new file mode 100644 index 0000000..467cef2 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/free_slots/protected_free_slot_conflict.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../free_slots.dart'; + +/// Typed overlap between an explicitly placed required commitment and rest. +class ProtectedFreeSlotConflict { + /// Creates a `ProtectedFreeSlotConflict` 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 ProtectedFreeSlotConflict({ + required this.requiredTaskId, + required this.freeSlotTaskId, + required this.requiredInterval, + required this.freeSlotInterval, + }); + + /// Critical or inflexible task that was explicitly placed. + final String requiredTaskId; + + /// Protected Free Slot task that was interrupted. + final String freeSlotTaskId; + + /// Required commitment interval. + final TimeInterval requiredInterval; + + /// Protected rest interval. + final TimeInterval freeSlotInterval; +} diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_result.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_result.dart new file mode 100644 index 0000000..8b7c61b --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_result.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../free_slots.dart'; + +/// Result from explicitly scheduling a required visible commitment. +class RequiredCommitmentScheduleResult { + /// Creates a `RequiredCommitmentScheduleResult` 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. + RequiredCommitmentScheduleResult({ + required this.status, + required this.schedulingResult, + required List protectedFreeSlotConflicts, + }) : protectedFreeSlotConflicts = + List.unmodifiable( + protectedFreeSlotConflicts, + ); + + /// High-level typed outcome. + final RequiredCommitmentScheduleStatus status; + + /// Replacement task list, notices, changes, and overlap details. + final SchedulingResult schedulingResult; + + /// Protected rest interruptions caused by the explicit placement. + final List protectedFreeSlotConflicts; + + /// Whether this placement interrupted protected rest. + bool get hasProtectedFreeSlotConflict { + return protectedFreeSlotConflicts.isNotEmpty; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_status.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_status.dart new file mode 100644 index 0000000..1d6d84b --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_status.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../free_slots.dart'; + +/// Typed outcome for explicitly placing a required visible commitment. +enum RequiredCommitmentScheduleStatus { + /// Required task was scheduled without protected rest conflicts. + scheduled, + + /// Required task was scheduled and overlaps protected rest. + scheduledWithProtectedFreeSlotConflict, + + /// No task with the requested id was present. + taskNotFound, + + /// The task is not critical or inflexible. + invalidTaskType, +} diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart new file mode 100644 index 0000000..8c04100 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Quick Capture behavior for the Scheduler Core package. +library; + +// Low-friction capture flow. +// +// Quick capture is the "dump the thought before it disappears" path. The goal +// is to accept minimal data, preserve the user's input, and only ask for more +// structure when the user wants immediate scheduling. + +import '../domain/models.dart'; +import 'scheduling_engine.dart'; +import '../domain/time_contracts.dart'; +part 'quick_capture/quick_capture_status.dart'; +part 'quick_capture/quick_capture_request.dart'; +part 'quick_capture/quick_capture_result.dart'; +part 'quick_capture/quick_capture_service.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_request.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_request.dart new file mode 100644 index 0000000..d6af0c0 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_request.dart @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../quick_capture.dart'; + +/// Input for low-friction task capture. +/// +/// This object represents what the UI knows at the moment of capture. It mirrors +/// the product goal: adding a thought should require as little structure as +/// possible, but the user can optionally provide enough detail to immediately +/// schedule it into the next open flexible slot. +class QuickCaptureRequest { + /// Creates a `QuickCaptureRequest` 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. + QuickCaptureRequest({ + required this.id, + required this.title, + required this.createdAt, + this.addToNextAvailableSlot = false, + this.projectId = 'inbox', + this.priority = PriorityLevel.medium, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.type = TaskType.flexible, + this.durationMinutes, + Set backlogTags = const {}, + }) : backlogTags = Set.unmodifiable(backlogTags); + + /// Caller-generated id. Keeping id generation outside this service makes the + /// domain layer independent from persistence/database choices. + final String id; + + /// Raw user-entered title. The [Task.quickCapture] factory trims it. + final String title; + + /// Capture timestamp supplied by the caller for testability. + final DateTime createdAt; + + /// Whether capture should attempt immediate timeline placement. + final bool addToNextAvailableSlot; + + /// Project id to assign; defaults to the inbox for uncategorized thoughts. + final String projectId; + + /// Initial priority used by backlog/scheduler heuristics. + final PriorityLevel priority; + + /// Initial reward estimate. + final RewardLevel reward; + + /// Initial difficulty estimate. + final DifficultyLevel difficulty; + + /// Captured task type. Immediate scheduling currently requires flexible tasks. + final TaskType type; + + /// Optional duration estimate. Required only when scheduling immediately. + final int? durationMinutes; + + /// Optional backlog flags such as wishlist/someday. + final Set backlogTags; +} diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_result.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_result.dart new file mode 100644 index 0000000..c380746 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_result.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../quick_capture.dart'; + +/// Result of a quick-capture request. +/// +/// The result always carries a [task], even on validation failure, so the UI can +/// preserve the user's input and show what needs to be fixed. When scheduling +/// was attempted, [schedulingResult] exposes the lower-level engine notices and +/// changes for debugging or timeline updates. +class QuickCaptureResult { + /// Creates a `QuickCaptureResult` 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. + QuickCaptureResult({ + required this.task, + required this.status, + this.schedulingResult, + List messages = const [], + }) : messages = List.unmodifiable(messages); + + /// Captured task, scheduled or unscheduled depending on [status]. + final Task task; + + /// High-level outcome of the capture attempt. + final QuickCaptureStatus status; + + /// Detailed scheduling output when immediate placement was attempted. + final SchedulingResult? schedulingResult; + + /// Human-readable validation or scheduling messages. + final List messages; + + /// Convenience check for UI branches that only care whether capture succeeded. + bool get isValid => status != QuickCaptureStatus.validationError; +} diff --git a/packages/scheduler_core/lib/src/quick_capture.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart similarity index 58% rename from packages/scheduler_core/lib/src/quick_capture.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart index c9ccdbc..6e205fc 100644 --- a/packages/scheduler_core/lib/src/quick_capture.dart +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart @@ -1,118 +1,7 @@ -/// Implements Quick Capture behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Low-friction capture flow. -// -// Quick capture is the "dump the thought before it disappears" path. The goal -// is to accept minimal data, preserve the user's input, and only ask for more -// structure when the user wants immediate scheduling. - -import 'models.dart'; -import 'scheduling_engine.dart'; -import 'time_contracts.dart'; - -/// Outcome of a quick-capture request. -/// -/// The UI can use this status to decide whether to show a passive success, draw -/// a scheduled card on the timeline, or display validation messages. It is not -/// an exception-based flow because quick capture should fail gently and keep the -/// user's typed task available. -enum QuickCaptureStatus { - /// Capture succeeded and the task remains unscheduled in backlog. - addedToBacklog, - - /// Capture succeeded and the task was placed on the timeline. - scheduled, - - /// Capture could not complete the requested flow; see result messages. - validationError, -} - -/// Input for low-friction task capture. -/// -/// This object represents what the UI knows at the moment of capture. It mirrors -/// the product goal: adding a thought should require as little structure as -/// possible, but the user can optionally provide enough detail to immediately -/// schedule it into the next open flexible slot. -class QuickCaptureRequest { - QuickCaptureRequest({ - required this.id, - required this.title, - required this.createdAt, - this.addToNextAvailableSlot = false, - this.projectId = 'inbox', - this.priority = PriorityLevel.medium, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - this.type = TaskType.flexible, - this.durationMinutes, - Set backlogTags = const {}, - }) : backlogTags = Set.unmodifiable(backlogTags); - - /// Caller-generated id. Keeping id generation outside this service makes the - /// domain layer independent from persistence/database choices. - final String id; - - /// Raw user-entered title. The [Task.quickCapture] factory trims it. - final String title; - - /// Capture timestamp supplied by the caller for testability. - final DateTime createdAt; - - /// Whether capture should attempt immediate timeline placement. - final bool addToNextAvailableSlot; - - /// Project id to assign; defaults to the inbox for uncategorized thoughts. - final String projectId; - - /// Initial priority used by backlog/scheduler heuristics. - final PriorityLevel priority; - - /// Initial reward estimate. - final RewardLevel reward; - - /// Initial difficulty estimate. - final DifficultyLevel difficulty; - - /// Captured task type. Immediate scheduling currently requires flexible tasks. - final TaskType type; - - /// Optional duration estimate. Required only when scheduling immediately. - final int? durationMinutes; - - /// Optional backlog flags such as wishlist/someday. - final Set backlogTags; -} - -/// Result of a quick-capture request. -/// -/// The result always carries a [task], even on validation failure, so the UI can -/// preserve the user's input and show what needs to be fixed. When scheduling -/// was attempted, [schedulingResult] exposes the lower-level engine notices and -/// changes for debugging or timeline updates. -class QuickCaptureResult { - QuickCaptureResult({ - required this.task, - required this.status, - this.schedulingResult, - List messages = const [], - }) : messages = List.unmodifiable(messages); - - /// Captured task, scheduled or unscheduled depending on [status]. - final Task task; - - /// High-level outcome of the capture attempt. - final QuickCaptureStatus status; - - /// Detailed scheduling output when immediate placement was attempted. - final SchedulingResult? schedulingResult; - - /// Human-readable validation or scheduling messages. - final List messages; - - /// Convenience check for UI branches that only care whether capture succeeded. - bool get isValid => status != QuickCaptureStatus.validationError; -} +part of '../quick_capture.dart'; /// Coordinates quick capture defaults and optional scheduling. /// @@ -121,6 +10,8 @@ class QuickCaptureResult { /// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand /// every scheduler precondition. class QuickCaptureService { + /// Creates a `QuickCaptureService` 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 QuickCaptureService({ this.engine = const SchedulingEngine(), this.clock = const SystemClock(), diff --git a/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_status.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_status.dart new file mode 100644 index 0000000..908ce20 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_status.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../quick_capture.dart'; + +/// Outcome of a quick-capture request. +/// +/// The UI can use this status to decide whether to show a passive success, draw +/// a scheduled card on the timeline, or display validation messages. It is not +/// an exception-based flow because quick capture should fail gently and keep the +/// user's typed task available. +enum QuickCaptureStatus { + /// Capture succeeded and the task remains unscheduled in backlog. + addedToBacklog, + + /// Capture succeeded and the task was placed on the timeline. + scheduled, + + /// Capture could not complete the requested flow; see result messages. + validationError, +} diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart new file mode 100644 index 0000000..584b54c --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Reminder Policy behavior for the Scheduler Core package. +library; + +// UI/platform-independent reminder policy. +// +// This file decides whether the core would deliver, suppress, defer, or require +// acknowledgement for a reminder at a specific instant. It does not schedule OS +// notifications, run background timers, or move tasks. + +import '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; +part 'reminder_policy/directives/reminder_directive_action.dart'; +part 'reminder_policy/directives/reminder_directive_reason.dart'; +part 'reminder_policy/profiles/effective_reminder_profile.dart'; +part 'reminder_policy/profiles/effective_reminder_profile_source.dart'; +part 'reminder_policy/directives/reminder_directive.dart'; +part 'reminder_policy/services/reminder_policy_service.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive.dart new file mode 100644 index 0000000..801e3c2 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../reminder_policy.dart'; + +/// UI/platform-independent directive for one task reminder check. +class ReminderDirective { + /// Creates a `ReminderDirective` 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 ReminderDirective({ + required this.taskId, + required this.action, + required this.reason, + required this.effectiveProfile, + required this.effectiveProfileSource, + required this.evaluatedAt, + this.scheduledStart, + this.scheduledEnd, + this.protectedFreeSlotTaskId, + this.nextEligibleAt, + }); + + /// Task evaluated for reminder behavior. + final String taskId; + + /// What an application layer may do. + final ReminderDirectiveAction action; + + /// Stable reason code for tests, telemetry, and later UI copy. + final ReminderDirectiveReason reason; + + /// Effective reminder profile used for the decision. + final ReminderProfile effectiveProfile; + + /// Source of [effectiveProfile]. + final EffectiveReminderProfileSource effectiveProfileSource; + + /// Instant used to evaluate the directive. + final DateTime evaluatedAt; + + /// Task scheduled start, when present. + final DateTime? scheduledStart; + + /// Task scheduled end, when present. + final DateTime? scheduledEnd; + + /// Active protected Free Slot that affected the decision, when any. + final String? protectedFreeSlotTaskId; + + /// Next known time the application may re-check, when deterministic. + final DateTime? nextEligibleAt; +} diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_action.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_action.dart new file mode 100644 index 0000000..4a93115 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_action.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../reminder_policy.dart'; + +/// High-level reminder directive for application/platform layers. +enum ReminderDirectiveAction { + /// Selects the `deliver` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + deliver, + + /// Selects the `suppress` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppress, + + /// Selects the `defer` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + defer, + + /// Selects the `requireAcknowledgement` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + requireAcknowledgement, +} diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_reason.dart new file mode 100644 index 0000000..2c7cd82 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_reason.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../reminder_policy.dart'; + +/// Stable reason codes for reminder directives. +enum ReminderDirectiveReason { + /// Selects the `deliverNormal` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + deliverNormal, + + /// Selects the `requireRequiredAcknowledgement` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + requireRequiredAcknowledgement, + + /// Selects the `deferUntilTaskWindow` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + deferUntilTaskWindow, + + /// Selects the `deferRequiredDuringProtectedRest` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + deferRequiredDuringProtectedRest, + + /// Selects the `suppressSilentProfile` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressSilentProfile, + + /// Selects the `suppressProtectedRest` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressProtectedRest, + + /// Selects the `suppressNoScheduledWindow` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressNoScheduledWindow, + + /// Selects the `suppressUnsupportedTaskType` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressUnsupportedTaskType, + + /// Selects the `suppressInactiveStatus` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressInactiveStatus, + + /// Selects the `suppressHiddenLockedTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressHiddenLockedTime, + + /// Selects the `suppressFreeSlotRecord` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + suppressFreeSlotRecord, +} diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile.dart new file mode 100644 index 0000000..a509204 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../reminder_policy.dart'; + +/// Result of resolving the effective reminder profile for one task. +class EffectiveReminderProfile { + /// Creates a `EffectiveReminderProfile` 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 EffectiveReminderProfile({ + required this.profile, + required this.source, + }); + + /// Resolved reminder profile. + final ReminderProfile profile; + + /// Where [profile] came from. + final EffectiveReminderProfileSource source; +} diff --git a/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile_source.dart new file mode 100644 index 0000000..d9754c6 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile_source.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../reminder_policy.dart'; + +/// Source for an effective reminder profile. +enum EffectiveReminderProfileSource { + /// Selects the `taskOverride` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + taskOverride, + + /// Selects the `projectDefault` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + projectDefault, + + /// Selects the `fallback` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + fallback, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart similarity index 71% rename from packages/scheduler_core/lib/src/reminder_policy.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart index 0e9e380..f60bf11 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart @@ -1,107 +1,12 @@ -/// Implements Reminder Policy behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// UI/platform-independent reminder policy. -// -// This file decides whether the core would deliver, suppress, defer, or require -// acknowledgement for a reminder at a specific instant. It does not schedule OS -// notifications, run background timers, or move tasks. - -import 'models.dart'; -import 'occupancy_policy.dart'; - -/// High-level reminder directive for application/platform layers. -enum ReminderDirectiveAction { - deliver, - suppress, - defer, - requireAcknowledgement, -} - -/// Stable reason codes for reminder directives. -enum ReminderDirectiveReason { - deliverNormal, - requireRequiredAcknowledgement, - deferUntilTaskWindow, - deferRequiredDuringProtectedRest, - suppressSilentProfile, - suppressProtectedRest, - suppressNoScheduledWindow, - suppressUnsupportedTaskType, - suppressInactiveStatus, - suppressHiddenLockedTime, - suppressFreeSlotRecord, -} - -/// Result of resolving the effective reminder profile for one task. -class EffectiveReminderProfile { - const EffectiveReminderProfile({ - required this.profile, - required this.source, - }); - - /// Resolved reminder profile. - final ReminderProfile profile; - - /// Where [profile] came from. - final EffectiveReminderProfileSource source; -} - -/// Source for an effective reminder profile. -enum EffectiveReminderProfileSource { - taskOverride, - projectDefault, - fallback, -} - -/// UI/platform-independent directive for one task reminder check. -class ReminderDirective { - const ReminderDirective({ - required this.taskId, - required this.action, - required this.reason, - required this.effectiveProfile, - required this.effectiveProfileSource, - required this.evaluatedAt, - this.scheduledStart, - this.scheduledEnd, - this.protectedFreeSlotTaskId, - this.nextEligibleAt, - }); - - /// Task evaluated for reminder behavior. - final String taskId; - - /// What an application layer may do. - final ReminderDirectiveAction action; - - /// Stable reason code for tests, telemetry, and later UI copy. - final ReminderDirectiveReason reason; - - /// Effective reminder profile used for the decision. - final ReminderProfile effectiveProfile; - - /// Source of [effectiveProfile]. - final EffectiveReminderProfileSource effectiveProfileSource; - - /// Instant used to evaluate the directive. - final DateTime evaluatedAt; - - /// Task scheduled start, when present. - final DateTime? scheduledStart; - - /// Task scheduled end, when present. - final DateTime? scheduledEnd; - - /// Active protected Free Slot that affected the decision, when any. - final String? protectedFreeSlotTaskId; - - /// Next known time the application may re-check, when deterministic. - final DateTime? nextEligibleAt; -} +part of '../../reminder_policy.dart'; /// Resolves reminder profiles and reminder directives. class ReminderPolicyService { + /// Creates a `ReminderPolicyService` 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 ReminderPolicyService({ this.fallbackProfile = ReminderProfile.gentle, this.occupancyPolicy = const OccupancyPolicy(), @@ -160,6 +65,8 @@ class ReminderPolicyService { ); } + /// Runs the `_directiveForTask` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. ReminderDirective _directiveForTask({ required Task task, required DateTime now, @@ -270,6 +177,8 @@ class ReminderPolicyService { ); } + /// Runs the `_requiredDuringProtectedRest` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. ReminderDirective _requiredDuringProtectedRest({ required Task task, required EffectiveReminderProfile effective, @@ -303,6 +212,8 @@ class ReminderPolicyService { ); } + /// Runs the `_activeProtectedFreeSlot` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. OccupancyEntry? _activeProtectedFreeSlot({ required DateTime now, required Iterable contextTasks, @@ -326,10 +237,14 @@ class ReminderPolicyService { } } +/// Top-level helper that performs the `_isReminderEligibleStatus` 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 _isReminderEligibleStatus(TaskStatus status) { return status == TaskStatus.planned || status == TaskStatus.active; } +/// Top-level helper that performs the `_isReminderEligibleType` 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 _isReminderEligibleType(TaskType type) { return type == TaskType.flexible || type == TaskType.critical || diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart new file mode 100644 index 0000000..3309442 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Scheduling Engine behavior for the Scheduler Core package. +library; + +// Scheduling engine for the ADHD scheduling starter project. +// +// This file is the core timeline manipulation layer. It takes task data plus a +// planning window and returns a new task list, notices, changes, and analysis +// findings. The implementation is deliberately side-effect free so a first-time +// reader can trace each operation from input validation, to placement planning, +// to application of the plan. +// +// Human reading map: +// 1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes. +// 2. Public engine methods: the operations UI/actions can call. +// 3. Private planning helpers: calculate intervals without changing tasks. +// 4. Private apply helpers: convert plans into updated tasks and notices. + +import '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; +import 'task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'scheduling_engine/codes/notices/scheduling_notice_type.dart'; +part 'scheduling_engine/codes/operations/scheduling_operation_code.dart'; +part 'scheduling_engine/codes/operations/scheduling_outcome_code.dart'; +part 'scheduling_engine/codes/notices/scheduling_issue_code.dart'; +part 'scheduling_engine/codes/operations/scheduling_movement_code.dart'; +part 'scheduling_engine/codes/conflicts/scheduling_conflict_code.dart'; +part 'scheduling_engine/models/inputs/scheduling_window.dart'; +part 'scheduling_engine/models/inputs/scheduling_input.dart'; +part 'scheduling_engine/models/changes/scheduling_change.dart'; +part 'scheduling_engine/models/changes/scheduling_overlap.dart'; +part 'scheduling_engine/models/notices/scheduling_notice.dart'; +part 'scheduling_engine/models/results/scheduling_result.dart'; +part 'scheduling_engine/engine/scheduling_engine.dart'; +part 'scheduling_engine/planning/placement_item.dart'; +part 'scheduling_engine/planning/backlog_insertion_plan.dart'; + +/// Private file-level value for `_occupancyPolicy`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); + +/// Private file-level value for `_transitionService`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const TaskTransitionService _transitionService = TaskTransitionService(); + +/// Private file-level value for `_activityAccountingService`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. +const TaskActivityAccountingService _activityAccountingService = + TaskActivityAccountingService(); diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart new file mode 100644 index 0000000..f9d41ae --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Stable conflict identifiers for overlap/interrupt notices. +enum SchedulingConflictCode { + /// Selects the `flexibleTaskOverlapsBlockedTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexibleTaskOverlapsBlockedTime, + + /// Selects the `surpriseTaskOverlapsRequiredVisibleTime` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + surpriseTaskOverlapsRequiredVisibleTime, + + /// Selects the `requiredCommitmentOverlapsProtectedFreeSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + requiredCommitmentOverlapsProtectedFreeSlot, +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart new file mode 100644 index 0000000..021c4d6 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Stable issue identifiers for validation, no-slot, and no-op notices. +enum SchedulingIssueCode { + /// Selects the `taskNotFound` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + taskNotFound, + + /// Selects the `invalidTaskState` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidTaskState, + + /// Selects the `missingDuration` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + missingDuration, + + /// Selects the `missingScheduledSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + missingScheduledSlot, + + /// Selects the `nonPositiveDuration` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + nonPositiveDuration, + + /// Selects the `noAvailableSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noAvailableSlot, + + /// Selects the `unfinishedTasksCouldNotFit` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + unfinishedTasksCouldNotFit, + + /// Selects the `noUnfinishedFlexibleTasks` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noUnfinishedFlexibleTasks, + + /// Selects the `duplicateSurpriseLog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + duplicateSurpriseLog, +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_notice_type.dart new file mode 100644 index 0000000..14041bd --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_notice_type.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Category for scheduler notices. +/// +/// Notices are human-readable summaries attached to a [SchedulingResult]. They +/// are not exceptions. The scheduler returns them alongside the task list so UI +/// can explain what happened without losing the successfully computed output. +enum SchedulingNoticeType { + /// General informational notice. + info, + + /// A task was moved by a scheduling operation. + moved, + + /// A scheduled task overlaps blocked time. + overlap, + + /// A task could not fit in the requested window. + noFit, + + /// A task would need to move outside the requested window. + overflow, +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_movement_code.dart new file mode 100644 index 0000000..852bc61 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_movement_code.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Stable movement identifiers for task placement changes. +enum SchedulingMovementCode { + /// Selects the `backlogTaskInserted` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + backlogTaskInserted, + + /// Selects the `flexibleTaskMovedToMakeRoom` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexibleTaskMovedToMakeRoom, + + /// Selects the `flexibleTaskPushedToNextAvailableSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexibleTaskPushedToNextAvailableSlot, + + /// Selects the `flexibleTaskMovedToTomorrow` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexibleTaskMovedToTomorrow, + + /// Selects the `unfinishedFlexibleTasksRolledOver` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + unfinishedFlexibleTasksRolledOver, + + /// Selects the `flexibleTaskMovedToBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexibleTaskMovedToBacklog, + + /// Selects the `requiredCommitmentScheduled` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + requiredCommitmentScheduled, +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart new file mode 100644 index 0000000..fa3b4e8 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Stable scheduler operation identifiers. +enum SchedulingOperationCode { + /// Operation was built outside the core scheduler or is not specified. + unspecified, + + /// Insert a backlog task into the next available slot. + insertBacklogTaskIntoNextAvailableSlot, + + /// Push a flexible task later in the current window. + pushFlexibleTaskToNextAvailableSlot, + + /// Move a flexible task to the top of a future/tomorrow queue. + pushFlexibleTaskToTomorrowTopOfQueue, + + /// Roll unfinished flexible tasks into a target window. + rollOverUnfinishedFlexibleTasks, + + /// Analyze overlaps without moving tasks. + analyzeSchedule, + + /// Move a flexible task to backlog through the action service. + moveFlexibleTaskToBacklog, + + /// Log completed surprise work. + logSurpriseTask, + + /// Explicitly schedule a critical or inflexible commitment. + scheduleRequiredCommitment, +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_outcome_code.dart new file mode 100644 index 0000000..e12a4b1 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_outcome_code.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Stable high-level outcome categories for scheduling operations. +enum SchedulingOutcomeCode { + /// Operation completed without conflicts. + success, + + /// Operation intentionally made no changes. + noOp, + + /// Requested record was not found. + notFound, + + /// Requested operation does not apply to the current state. + invalidState, + + /// No placement slot exists for the requested item. + noSlot, + + /// The requested queue would extend beyond the planning window. + overflow, + + /// Operation completed or analyzed with a conflict. + conflict, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart similarity index 73% rename from packages/scheduler_core/lib/src/scheduling_engine.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart index 287239d..abb5d5b 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart @@ -1,424 +1,7 @@ -/// Implements Scheduling Engine behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Scheduling engine for the ADHD scheduling starter project. -// -// This file is the core timeline manipulation layer. It takes task data plus a -// planning window and returns a new task list, notices, changes, and analysis -// findings. The implementation is deliberately side-effect free so a first-time -// reader can trace each operation from input validation, to placement planning, -// to application of the plan. -// -// Human reading map: -// 1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes. -// 2. Public engine methods: the operations UI/actions can call. -// 3. Private planning helpers: calculate intervals without changing tasks. -// 4. Private apply helpers: convert plans into updated tasks and notices. - -import 'models.dart'; -import 'occupancy_policy.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); -const TaskTransitionService _transitionService = TaskTransitionService(); -const TaskActivityAccountingService _activityAccountingService = - TaskActivityAccountingService(); - -/// Category for scheduler notices. -/// -/// Notices are human-readable summaries attached to a [SchedulingResult]. They -/// are not exceptions. The scheduler returns them alongside the task list so UI -/// can explain what happened without losing the successfully computed output. -enum SchedulingNoticeType { - /// General informational notice. - info, - - /// A task was moved by a scheduling operation. - moved, - - /// A scheduled task overlaps blocked time. - overlap, - - /// A task could not fit in the requested window. - noFit, - - /// A task would need to move outside the requested window. - overflow, -} - -/// Stable scheduler operation identifiers. -enum SchedulingOperationCode { - /// Operation was built outside the core scheduler or is not specified. - unspecified, - - /// Insert a backlog task into the next available slot. - insertBacklogTaskIntoNextAvailableSlot, - - /// Push a flexible task later in the current window. - pushFlexibleTaskToNextAvailableSlot, - - /// Move a flexible task to the top of a future/tomorrow queue. - pushFlexibleTaskToTomorrowTopOfQueue, - - /// Roll unfinished flexible tasks into a target window. - rollOverUnfinishedFlexibleTasks, - - /// Analyze overlaps without moving tasks. - analyzeSchedule, - - /// Move a flexible task to backlog through the action service. - moveFlexibleTaskToBacklog, - - /// Log completed surprise work. - logSurpriseTask, - - /// Explicitly schedule a critical or inflexible commitment. - scheduleRequiredCommitment, -} - -/// Stable high-level outcome categories for scheduling operations. -enum SchedulingOutcomeCode { - /// Operation completed without conflicts. - success, - - /// Operation intentionally made no changes. - noOp, - - /// Requested record was not found. - notFound, - - /// Requested operation does not apply to the current state. - invalidState, - - /// No placement slot exists for the requested item. - noSlot, - - /// The requested queue would extend beyond the planning window. - overflow, - - /// Operation completed or analyzed with a conflict. - conflict, -} - -/// Stable issue identifiers for validation, no-slot, and no-op notices. -enum SchedulingIssueCode { - taskNotFound, - invalidTaskState, - missingDuration, - missingScheduledSlot, - nonPositiveDuration, - noAvailableSlot, - unfinishedTasksCouldNotFit, - noUnfinishedFlexibleTasks, - duplicateSurpriseLog, -} - -/// Stable movement identifiers for task placement changes. -enum SchedulingMovementCode { - backlogTaskInserted, - flexibleTaskMovedToMakeRoom, - flexibleTaskPushedToNextAvailableSlot, - flexibleTaskMovedToTomorrow, - unfinishedFlexibleTasksRolledOver, - flexibleTaskMovedToBacklog, - requiredCommitmentScheduled, -} - -/// Stable conflict identifiers for overlap/interrupt notices. -enum SchedulingConflictCode { - flexibleTaskOverlapsBlockedTime, - surpriseTaskOverlapsRequiredVisibleTime, - requiredCommitmentOverlapsProtectedFreeSlot, -} - -/// Window of time available to a scheduling operation. -/// -/// Most engine methods operate on one planning window: "today", "tomorrow", -/// or any other bounded range supplied by the caller. The window constrains where -/// flexible tasks can be placed. Anything outside this range is treated as out of -/// scope for the operation. -class SchedulingWindow { - SchedulingWindow({ - required this.start, - required this.end, - }) { - if (!start.isBefore(end)) { - throw DomainValidationException( - code: DomainValidationCode.invalidSchedulingWindow, - invalidValue: {'start': start, 'end': end}, - name: 'SchedulingWindow', - message: 'Scheduling window end must be after start.', - ); - } - } - - /// Inclusive beginning of the scheduling range. - final DateTime start; - - /// Exclusive ending of the scheduling range. - final DateTime end; - - /// The window as a [TimeInterval], useful for overlap checks. - TimeInterval get interval => TimeInterval(start: start, end: end); - - /// Whether [interval] is completely inside this window. - /// - /// A task that starts before the window or ends after the window is considered - /// outside the operation. The engine may treat such tasks as fixed blocks - /// instead of moving them. - bool contains(TimeInterval interval) { - final startsInWindow = - interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start); - final endsInWindow = - interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end); - - return startsInWindow && endsInWindow; - } -} - -/// In-memory input for scheduling operations. -/// -/// This is the complete snapshot the pure scheduling engine needs. It contains -/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does -/// not know where the data came from: UI state, a database, tests, or generated -/// examples can all build this same object. -class SchedulingInput { - SchedulingInput({ - required List tasks, - required this.window, - List lockedIntervals = const [], - List requiredVisibleIntervals = const [], - }) : tasks = List.unmodifiable(tasks), - lockedIntervals = List.unmodifiable(lockedIntervals), - requiredVisibleIntervals = - List.unmodifiable(requiredVisibleIntervals); - - /// All tasks available to this operation. The scheduler returns a replacement - /// list rather than mutating this one. - final List tasks; - - /// Date/time range that the operation is allowed to plan inside. - final SchedulingWindow window; - - /// External locked time intervals, usually produced by `locked_time.dart`. - final List lockedIntervals; - - /// Extra fixed visible intervals supplied by the caller. This lets UI/backend - /// code reserve required time even when that time is not represented as a - /// [Task] in the current list. - final List requiredVisibleIntervals; - - /// Tasks that the flexible movement algorithms are allowed to consider. - List get flexibleTasks { - return tasks.where((task) => task.isFlexible).toList(growable: false); - } - - /// Central occupancy classification for this scheduling snapshot. - List get occupancyEntries { - return _occupancyPolicy.classify( - tasks: tasks, - lockedIntervals: lockedIntervals, - requiredVisibleIntervals: requiredVisibleIntervals, - ); - } - - /// Planned flexible task records that automatic scheduling may move. - List get movablePlannedFlexibleTasks { - return occupancyEntries - .where((entry) => entry.isMovablePlannedFlexible) - .map((entry) => entry.task) - .whereType() - .toList(growable: false); - } - - /// Locked task records in [tasks], if the caller represents locked time as - /// tasks instead of only passing [lockedIntervals]. - List get lockedTasks { - return tasks.where((task) => task.isLocked).toList(growable: false); - } - - /// Critical and inflexible task records that should block flexible placement. - List get requiredVisibleTasks { - return tasks - .where((task) => task.isRequiredVisible) - .toList(growable: false); - } - - /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging. - List get flexibleIntervals { - return _scheduledIntervalsFor(flexibleTasks); - } - - /// All intervals that flexible scheduling must avoid. - /// - /// This is derived from the central occupancy policy rather than from UI card - /// categories. The result is sorted to make interval scanning deterministic. - List get blockedIntervals { - final intervals = occupancyEntries - .where((entry) => entry.blocksAutomaticFlexiblePlacement) - .map((entry) => entry.interval) - .whereType() - .toList(growable: false) - ..sort((a, b) => a.start.compareTo(b.start)); - - return List.unmodifiable(intervals); - } - - /// Occupancy entries that should be surfaced as conflicts when overlapped. - List get conflictReportingOccupancies { - return occupancyEntries - .where((entry) => entry.reportsConflict) - .toList(growable: false); - } -} - -/// Exact placement change made by a scheduling operation. -/// -/// Changes are machine-readable before/after records. UI can use notices for -/// display text, but persistence, undo, analytics, or tests should inspect these -/// fields to know exactly which task moved and where. -class SchedulingChange { - const SchedulingChange({ - required this.taskId, - required this.previousStart, - required this.previousEnd, - required this.nextStart, - required this.nextEnd, - }); - - /// Task that moved or had its schedule cleared. - final String taskId; - - /// Previous scheduled start, or null if the task was previously unplaced. - final DateTime? previousStart; - - /// Previous scheduled end, or null if the task was previously unplaced. - final DateTime? previousEnd; - - /// New scheduled start, or null if the task was moved out of the timeline. - final DateTime? nextStart; - - /// New scheduled end, or null if the task was moved out of the timeline. - final DateTime? nextEnd; -} - -/// Overlap between a scheduled task and blocked time. -/// -/// Analysis uses this to report problems without moving anything. This is useful -/// when loading persisted data, debugging imports, or validating a day before the -/// UI presents it as clean. -class SchedulingOverlap { - const SchedulingOverlap({ - required this.taskId, - required this.taskInterval, - required this.blockedInterval, - }); - - /// Flexible task that overlaps blocked time. - final String taskId; - - /// The task's scheduled interval. - final TimeInterval taskInterval; - - /// The blocked interval it overlaps. - final TimeInterval blockedInterval; -} - -/// Starter notice type returned by scheduling operations. -/// -/// A notice is presentation-friendly context about an operation. It intentionally -/// carries both text and a structured [type] so the UI can decide whether to show -/// it as neutral info, movement, overlap, or failure. -class SchedulingNotice { - SchedulingNotice( - this.message, { - this.type = SchedulingNoticeType.info, - this.taskId, - this.issueCode, - this.movementCode, - this.conflictCode, - Map parameters = const {}, - }) : parameters = Map.unmodifiable(parameters); - - /// Human-readable message safe to surface in UI or logs. - final String message; - - /// Structured category for UI styling and tests. - final SchedulingNoticeType type; - - /// Optional task related to this notice. Null means the notice applies to the - /// whole operation. - final String? taskId; - - /// Stable issue code for validation/no-slot/no-op outcomes. - final SchedulingIssueCode? issueCode; - - /// Stable movement code for placement changes. - final SchedulingMovementCode? movementCode; - - /// Stable conflict code for overlap outcomes. - final SchedulingConflictCode? conflictCode; - - /// Structured notice parameters for callers that need more context. - final Map parameters; -} - -/// Starter result wrapper for scheduling operations. -/// -/// Every engine operation returns a [SchedulingResult], even when nothing moved. -/// This keeps the call pattern predictable: always inspect `tasks`, then surface -/// any `notices`, `changes`, or `overlaps` relevant to the UI. -class SchedulingResult { - SchedulingResult({ - required List tasks, - this.operationCode = SchedulingOperationCode.unspecified, - this.outcomeCode = SchedulingOutcomeCode.success, - List notices = const [], - List changes = const [], - List overlaps = const [], - }) : tasks = List.unmodifiable(tasks), - assert( - _noticeCodesAreSinglePurpose(notices), - 'Each scheduling notice may have only one issue/movement/conflict code.', - ), - notices = List.unmodifiable(notices), - changes = List.unmodifiable(changes), - overlaps = List.unmodifiable(overlaps); - - /// Replacement task list after the operation. - final List tasks; - - /// Stable operation identifier. - final SchedulingOperationCode operationCode; - - /// Stable high-level outcome category. - final SchedulingOutcomeCode outcomeCode; - - /// Human-readable operation messages. - final List notices; - - /// Machine-readable movements or schedule clears. - final List changes; - - /// Analysis-only overlap findings. - final List overlaps; -} - -bool _noticeCodesAreSinglePurpose(Iterable notices) { - for (final notice in notices) { - final codeCount = [ - notice.issueCode, - notice.movementCode, - notice.conflictCode, - ].where((code) => code != null).length; - if (codeCount > 1) { - return false; - } - } - - return true; -} +part of '../../scheduling_engine.dart'; /// Starter scheduling engine. /// @@ -439,6 +22,8 @@ bool _noticeCodesAreSinglePurpose(Iterable notices) { /// - `queue` is the ordered set of flexible tasks that may be placed or shifted. /// - `placement` is a map from task id to the interval chosen by the planner. class SchedulingEngine { + /// Creates a `SchedulingEngine` 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 SchedulingEngine({ this.clock = const SystemClock(), }); @@ -1647,6 +1232,8 @@ bool _sameDateTime(DateTime? first, DateTime? second) { return first.isAtSameMomentAs(second); } +/// Top-level helper that performs the `_applySchedulingActivity` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Task _applySchedulingActivity({ required Task originalTask, required Task updatedTask, @@ -1727,37 +1314,3 @@ TimeInterval? _firstOpenIntervalFrom({ cursor = overlappingBlock.end; } } - -/// One item in a placement queue. -/// -/// [earliestStart] preserves a task's natural ordering constraint. For existing -/// scheduled tasks, this is usually their current start; for a pushed task, it is -/// the earliest time the push operation allows. -class _PlacementItem { - const _PlacementItem({ - required this.task, - required this.duration, - required this.earliestStart, - }); - - /// Task represented by this queue entry. - final Task task; - - /// Duration the planner must reserve. - final Duration duration; - - /// Earliest allowed start time for this item. - final DateTime earliestStart; -} - -/// Planned task intervals keyed by task id. -/// -/// The name is historical from the first insertion feature; it now also supports -/// push and rollover placement plans. It remains private so it can be renamed or -/// expanded later without affecting callers. -class _BacklogInsertionPlan { - const _BacklogInsertionPlan({required this.placements}); - - /// Chosen interval for each task that should be scheduled or moved. - final Map placements; -} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_change.dart new file mode 100644 index 0000000..0b5e536 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_change.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Exact placement change made by a scheduling operation. +/// +/// Changes are machine-readable before/after records. UI can use notices for +/// display text, but persistence, undo, analytics, or tests should inspect these +/// fields to know exactly which task moved and where. +class SchedulingChange { + /// Creates a `SchedulingChange` 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 SchedulingChange({ + required this.taskId, + required this.previousStart, + required this.previousEnd, + required this.nextStart, + required this.nextEnd, + }); + + /// Task that moved or had its schedule cleared. + final String taskId; + + /// Previous scheduled start, or null if the task was previously unplaced. + final DateTime? previousStart; + + /// Previous scheduled end, or null if the task was previously unplaced. + final DateTime? previousEnd; + + /// New scheduled start, or null if the task was moved out of the timeline. + final DateTime? nextStart; + + /// New scheduled end, or null if the task was moved out of the timeline. + final DateTime? nextEnd; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_overlap.dart new file mode 100644 index 0000000..2d843ff --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_overlap.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Overlap between a scheduled task and blocked time. +/// +/// Analysis uses this to report problems without moving anything. This is useful +/// when loading persisted data, debugging imports, or validating a day before the +/// UI presents it as clean. +class SchedulingOverlap { + /// Creates a `SchedulingOverlap` 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 SchedulingOverlap({ + required this.taskId, + required this.taskInterval, + required this.blockedInterval, + }); + + /// Flexible task that overlaps blocked time. + final String taskId; + + /// The task's scheduled interval. + final TimeInterval taskInterval; + + /// The blocked interval it overlaps. + final TimeInterval blockedInterval; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_input.dart new file mode 100644 index 0000000..98173a0 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_input.dart @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// In-memory input for scheduling operations. +/// +/// This is the complete snapshot the pure scheduling engine needs. It contains +/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does +/// not know where the data came from: UI state, a database, tests, or generated +/// examples can all build this same object. +class SchedulingInput { + /// Creates a `SchedulingInput` 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. + SchedulingInput({ + required List tasks, + required this.window, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals), + requiredVisibleIntervals = + List.unmodifiable(requiredVisibleIntervals); + + /// All tasks available to this operation. The scheduler returns a replacement + /// list rather than mutating this one. + final List tasks; + + /// Date/time range that the operation is allowed to plan inside. + final SchedulingWindow window; + + /// External locked time intervals, usually produced by `locked_time.dart`. + final List lockedIntervals; + + /// Extra fixed visible intervals supplied by the caller. This lets UI/backend + /// code reserve required time even when that time is not represented as a + /// [Task] in the current list. + final List requiredVisibleIntervals; + + /// Tasks that the flexible movement algorithms are allowed to consider. + List get flexibleTasks { + return tasks.where((task) => task.isFlexible).toList(growable: false); + } + + /// Central occupancy classification for this scheduling snapshot. + List get occupancyEntries { + return _occupancyPolicy.classify( + tasks: tasks, + lockedIntervals: lockedIntervals, + requiredVisibleIntervals: requiredVisibleIntervals, + ); + } + + /// Planned flexible task records that automatic scheduling may move. + List get movablePlannedFlexibleTasks { + return occupancyEntries + .where((entry) => entry.isMovablePlannedFlexible) + .map((entry) => entry.task) + .whereType() + .toList(growable: false); + } + + /// Locked task records in [tasks], if the caller represents locked time as + /// tasks instead of only passing [lockedIntervals]. + List get lockedTasks { + return tasks.where((task) => task.isLocked).toList(growable: false); + } + + /// Critical and inflexible task records that should block flexible placement. + List get requiredVisibleTasks { + return tasks + .where((task) => task.isRequiredVisible) + .toList(growable: false); + } + + /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging. + List get flexibleIntervals { + return _scheduledIntervalsFor(flexibleTasks); + } + + /// All intervals that flexible scheduling must avoid. + /// + /// This is derived from the central occupancy policy rather than from UI card + /// categories. The result is sorted to make interval scanning deterministic. + List get blockedIntervals { + final intervals = occupancyEntries + .where((entry) => entry.blocksAutomaticFlexiblePlacement) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false) + ..sort((a, b) => a.start.compareTo(b.start)); + + return List.unmodifiable(intervals); + } + + /// Occupancy entries that should be surfaced as conflicts when overlapped. + List get conflictReportingOccupancies { + return occupancyEntries + .where((entry) => entry.reportsConflict) + .toList(growable: false); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_window.dart new file mode 100644 index 0000000..76c09dd --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_window.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Window of time available to a scheduling operation. +/// +/// Most engine methods operate on one planning window: "today", "tomorrow", +/// or any other bounded range supplied by the caller. The window constrains where +/// flexible tasks can be placed. Anything outside this range is treated as out of +/// scope for the operation. +class SchedulingWindow { + /// Creates a `SchedulingWindow` 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. + SchedulingWindow({ + required this.start, + required this.end, + }) { + if (!start.isBefore(end)) { + throw DomainValidationException( + code: DomainValidationCode.invalidSchedulingWindow, + invalidValue: {'start': start, 'end': end}, + name: 'SchedulingWindow', + message: 'Scheduling window end must be after start.', + ); + } + } + + /// Inclusive beginning of the scheduling range. + final DateTime start; + + /// Exclusive ending of the scheduling range. + final DateTime end; + + /// The window as a [TimeInterval], useful for overlap checks. + TimeInterval get interval => TimeInterval(start: start, end: end); + + /// Whether [interval] is completely inside this window. + /// + /// A task that starts before the window or ends after the window is considered + /// outside the operation. The engine may treat such tasks as fixed blocks + /// instead of moving them. + bool contains(TimeInterval interval) { + final startsInWindow = + interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start); + final endsInWindow = + interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end); + + return startsInWindow && endsInWindow; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/notices/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/notices/scheduling_notice.dart new file mode 100644 index 0000000..6f2fa1a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/notices/scheduling_notice.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Starter notice type returned by scheduling operations. +/// +/// A notice is presentation-friendly context about an operation. It intentionally +/// carries both text and a structured [type] so the UI can decide whether to show +/// it as neutral info, movement, overlap, or failure. +class SchedulingNotice { + /// Creates a `SchedulingNotice` 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. + SchedulingNotice( + this.message, { + this.type = SchedulingNoticeType.info, + this.taskId, + this.issueCode, + this.movementCode, + this.conflictCode, + Map parameters = const {}, + }) : parameters = Map.unmodifiable(parameters); + + /// Human-readable message safe to surface in UI or logs. + final String message; + + /// Structured category for UI styling and tests. + final SchedulingNoticeType type; + + /// Optional task related to this notice. Null means the notice applies to the + /// whole operation. + final String? taskId; + + /// Stable issue code for validation/no-slot/no-op outcomes. + final SchedulingIssueCode? issueCode; + + /// Stable movement code for placement changes. + final SchedulingMovementCode? movementCode; + + /// Stable conflict code for overlap outcomes. + final SchedulingConflictCode? conflictCode; + + /// Structured notice parameters for callers that need more context. + final Map parameters; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/results/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/results/scheduling_result.dart new file mode 100644 index 0000000..b133259 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/results/scheduling_result.dart @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduling_engine.dart'; + +/// Starter result wrapper for scheduling operations. +/// +/// Every engine operation returns a [SchedulingResult], even when nothing moved. +/// This keeps the call pattern predictable: always inspect `tasks`, then surface +/// any `notices`, `changes`, or `overlaps` relevant to the UI. +class SchedulingResult { + /// Creates a `SchedulingResult` 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. + SchedulingResult({ + required List tasks, + this.operationCode = SchedulingOperationCode.unspecified, + this.outcomeCode = SchedulingOutcomeCode.success, + List notices = const [], + List changes = const [], + List overlaps = const [], + }) : tasks = List.unmodifiable(tasks), + assert( + _noticeCodesAreSinglePurpose(notices), + 'Each scheduling notice may have only one issue/movement/conflict code.', + ), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps); + + /// Replacement task list after the operation. + final List tasks; + + /// Stable operation identifier. + final SchedulingOperationCode operationCode; + + /// Stable high-level outcome category. + final SchedulingOutcomeCode outcomeCode; + + /// Human-readable operation messages. + final List notices; + + /// Machine-readable movements or schedule clears. + final List changes; + + /// Analysis-only overlap findings. + final List overlaps; +} + +/// Top-level helper that performs the `_noticeCodesAreSinglePurpose` 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 _noticeCodesAreSinglePurpose(Iterable notices) { + for (final notice in notices) { + final codeCount = [ + notice.issueCode, + notice.movementCode, + notice.conflictCode, + ].where((code) => code != null).length; + if (codeCount > 1) { + return false; + } + } + + return true; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/backlog_insertion_plan.dart new file mode 100644 index 0000000..f5c1fa7 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/backlog_insertion_plan.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduling_engine.dart'; + +/// Planned task intervals keyed by task id. +/// +/// The name is historical from the first insertion feature; it now also supports +/// push and rollover placement plans. It remains private so it can be renamed or +/// expanded later without affecting callers. +class _BacklogInsertionPlan { + /// Creates a `_BacklogInsertionPlan` 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 _BacklogInsertionPlan({required this.placements}); + + /// Chosen interval for each task that should be scheduled or moved. + final Map placements; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/placement_item.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/placement_item.dart new file mode 100644 index 0000000..702ac2c --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/placement_item.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduling_engine.dart'; + +/// One item in a placement queue. +/// +/// [earliestStart] preserves a task's natural ordering constraint. For existing +/// scheduled tasks, this is usually their current start; for a pushed task, it is +/// the earliest time the push operation allows. +class _PlacementItem { + /// Creates a `_PlacementItem` 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 _PlacementItem({ + required this.task, + required this.duration, + required this.earliestStart, + }); + + /// Task represented by this queue entry. + final Task task; + + /// Duration the planner must reserve. + final Duration duration; + + /// Earliest allowed start time for this item. + final DateTime earliestStart; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions.dart b/packages/scheduler_core/lib/src/scheduling/task_actions.dart new file mode 100644 index 0000000..d06a096 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions.dart @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Task Actions behavior for the Scheduler Core package. +library; + +// Flexible task card actions. +// +// The main scheduling engine moves tasks through time. This file models the +// small user actions that appear on a flexible task card, such as done, push, +// backlog, and break-up. Keeping these in a service makes UI button handlers +// thin and keeps task-type safety checks in one place. + +import '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; +import 'scheduling_engine.dart'; +import 'task_lifecycle.dart'; +import '../domain/time_contracts.dart'; +part 'task_actions/actions/flexible_task_quick_action.dart'; +part 'task_actions/actions/required_task_action.dart'; +part 'task_actions/actions/push_destination.dart'; +part 'task_actions/results/flexible_task_action_result.dart'; +part 'task_actions/results/push_destination_result.dart'; +part 'task_actions/results/required_task_action_result.dart'; +part 'task_actions/requests/surprise_task_log_request.dart'; +part 'task_actions/results/surprise_task_log_result.dart'; +part 'task_actions/services/flexible_task_action_service.dart'; +part 'task_actions/services/required_task_action_service.dart'; +part 'task_actions/services/surprise_task_log_service.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/flexible_task_quick_action.dart new file mode 100644 index 0000000..fe5ef3b --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/flexible_task_quick_action.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Quick actions available from a flexible task card. +/// +/// These are the low-friction card controls the UI can expose directly on a +/// planned flexible task. The service below translates each button into either a +/// direct task update, a scheduling operation, or a follow-up flow. +enum FlexibleTaskQuickAction { + /// Mark the task completed. + done, + + /// Ask the user where the task should be pushed. + push, + + /// Move the task out of today's plan and into backlog. + backlog, + + /// Start a flow that splits the task into child tasks. + breakUp, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/actions/push_destination.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/push_destination.dart new file mode 100644 index 0000000..1131bac --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/push_destination.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Explicit push destinations shown after choosing the push quick action. +/// +/// Push starts as a simple quick action, but the actual destination requires one +/// more choice. Keeping destinations as a separate enum prevents the initial card +/// action list from becoming too crowded. +enum PushDestination { + /// Move the task later within the current planning window. + nextAvailableSlot, + + /// Move the task to the beginning of the supplied tomorrow/future window. + tomorrowTopOfQueue, + + /// Remove the task from the active timeline and store it for later. + backlog, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/actions/required_task_action.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/required_task_action.dart new file mode 100644 index 0000000..7c565b8 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/required_task_action.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// State transitions available from required visible task cards. +/// +/// Required visible tasks are critical or inflexible items. They are not moved by +/// flexible scheduling, but the user still needs simple lifecycle actions for +/// what happened to the commitment. +enum RequiredTaskAction { + /// Mark the required task completed. + done, + + /// Mark that the required task did not happen. + missed, + + /// Intentionally remove it from the active plan. + cancel, + + /// Dismiss it because it no longer applies, distinct from cancellation. + noLongerRelevant, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/requests/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/requests/surprise_task_log_request.dart new file mode 100644 index 0000000..dcdabfc --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/requests/surprise_task_log_request.dart @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Input for logging work the user already did outside the plan. +/// +/// The user may know only a title, or may also know when it happened and how +/// long it took. When time data is present, the resulting surprise task occupies +/// that interval and flexible work overlapping it is pushed out of the way. +class SurpriseTaskLogRequest { + /// Creates a `SurpriseTaskLogRequest` 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 SurpriseTaskLogRequest({ + required this.id, + required this.title, + required this.createdAt, + this.startedAt, + this.timeUsedMinutes, + this.projectId = 'inbox', + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + }); + + /// Caller-generated id for the new surprise task. + /// + /// This id is also the idempotency key for retrying the same log operation. + final String id; + + /// User-entered title. + final String title; + + /// Creation/log timestamp supplied by the caller for testability. + final DateTime createdAt; + + /// When the surprise work started, if known. + final DateTime? startedAt; + + /// Minutes spent on the surprise work, if known. + final int? timeUsedMinutes; + + /// Project id to assign; defaults to inbox when omitted. + final String projectId; + + /// Optional priority. Null means the user did not set one. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/results/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/flexible_task_action_result.dart new file mode 100644 index 0000000..0bcc94f --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/results/flexible_task_action_result.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Domain result for a flexible task quick action. +/// +/// This result deliberately supports three outcomes: the task changed, the user +/// must choose a push destination, or the UI should start the child-task flow. +/// That keeps card code from guessing how to interpret each action. +class FlexibleTaskActionResult { + /// Creates a `FlexibleTaskActionResult` 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. + FlexibleTaskActionResult({ + required this.action, + required this.task, + this.pushDestinations = const [], + this.startsChildTaskFlow = false, + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); + + /// Action the user selected. + final FlexibleTaskQuickAction action; + + /// Current or updated task, depending on the action. + final Task task; + + /// Destination choices to show after `push`; empty for direct actions. + final List pushDestinations; + + /// Whether the UI should open a child-task creation flow. + final bool startsChildTaskFlow; + + /// Internal activities produced by direct lifecycle actions. + final List activities; + + /// Typed transition outcome for direct lifecycle actions. + final TaskTransitionOutcomeCode? transitionOutcomeCode; + + /// True when the action directly produced an updated [task]. + bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/results/push_destination_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/push_destination_result.dart new file mode 100644 index 0000000..557cda9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/results/push_destination_result.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Result from applying a selected push destination. +/// +/// The selected destination is included next to the [SchedulingResult] so UI and +/// tests can distinguish "moved later today" from "moved to tomorrow" even if +/// the low-level scheduling change shape is similar. +class PushDestinationResult { + /// Creates a `PushDestinationResult` 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. + PushDestinationResult({ + required this.destination, + required this.schedulingResult, + List activities = const [], + }) : activities = List.unmodifiable(activities); + + /// Destination that was applied. + final PushDestination destination; + + /// Full scheduler output: updated tasks, notices, changes, and overlaps. + final SchedulingResult schedulingResult; + + /// Internal activities produced by applying the selected destination. + final List activities; + + /// Convenience flag for UI copy or persistence behavior that cares about the + /// tomorrow queue specifically. + bool get placesAtTomorrowTopOfQueue { + return destination == PushDestination.tomorrowTopOfQueue; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/results/required_task_action_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/required_task_action_result.dart new file mode 100644 index 0000000..c24e7e4 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/results/required_task_action_result.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Result from applying a required task state transition. +class RequiredTaskActionResult { + /// Creates a `RequiredTaskActionResult` 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. + RequiredTaskActionResult({ + required this.action, + required this.task, + required this.removedFromActivePlan, + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); + + /// Action the user selected. + final RequiredTaskAction action; + + /// Updated task after the transition. + final Task task; + + /// Whether the transition removes the task from the active timeline. + /// + /// Critical missed tasks return true because they move to backlog. Cancelled + /// and no-longer-relevant tasks also return true because they are no longer + /// active planned work. + final bool removedFromActivePlan; + + /// Internal activities produced by the transition. + final List activities; + + /// Typed transition outcome. + final TaskTransitionOutcomeCode? transitionOutcomeCode; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/results/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/surprise_task_log_result.dart new file mode 100644 index 0000000..1b73a28 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/results/surprise_task_log_result.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Result from logging a surprise task. +class SurpriseTaskLogResult { + /// Creates a `SurpriseTaskLogResult` 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 SurpriseTaskLogResult({ + required this.surpriseTask, + required this.schedulingResult, + this.activities = const [], + this.requiredOverlaps = const [], + this.lockedOverlaps = const [], + this.usedNormalPushRules = false, + }); + + /// Newly created completed surprise task. + final Task surpriseTask; + + /// Updated task list, movement notices, changes, and visible overlaps. + final SchedulingResult schedulingResult; + + /// Internal activities produced by logging the surprise task. + final List activities; + + /// Critical/inflexible overlaps with the surprise interval. + final List requiredOverlaps; + + /// Locked overlaps tracked separately so they stay hidden by default. + final List lockedOverlaps; + + /// Whether flexible movement was delegated to normal push behavior. + final bool usedNormalPushRules; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart new file mode 100644 index 0000000..12870b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Applies low-friction quick actions for flexible task cards. +/// +/// This service is the adapter between small UI button presses and domain logic. +/// It intentionally only accepts flexible tasks; required/locked/surprise items +/// should have their own action rules so the UI cannot accidentally apply a +/// flexible-only behavior to a fixed commitment. +class FlexibleTaskActionService { + /// Creates a `FlexibleTaskActionService` 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 FlexibleTaskActionService({ + this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), + this.clock = const SystemClock(), + }); + + /// Scheduling dependency used for actions that need timeline changes. + final SchedulingEngine schedulingEngine; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Clock boundary used when an action timestamp is not supplied. + final Clock clock; + + /// Apply the first-stage quick action. + /// + /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the + /// list of destinations the UI should present. `breakUp` signals that the UI + /// should start a child-task flow rather than changing the task immediately. + FlexibleTaskActionResult apply({ + required Task task, + required FlexibleTaskQuickAction action, + DateTime? updatedAt, + String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], + Iterable existingActivities = const [], + }) { + if (!task.isFlexible) { + throw ArgumentError.value( + task.type, 'task.type', 'Task must be flexible.'); + } + + switch (action) { + case FlexibleTaskQuickAction.done: + final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, + existingActivities: existingActivities, + ); + return FlexibleTaskActionResult( + action: action, + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + case FlexibleTaskQuickAction.push: + return FlexibleTaskActionResult( + action: action, + task: task, + pushDestinations: const [ + PushDestination.nextAvailableSlot, + PushDestination.tomorrowTopOfQueue, + PushDestination.backlog, + ], + ); + case FlexibleTaskQuickAction.backlog: + final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.movedToBacklog, + ), + occurredAt: now, + existingActivities: existingActivities, + ); + return FlexibleTaskActionResult( + action: action, + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + case FlexibleTaskQuickAction.breakUp: + return FlexibleTaskActionResult( + action: action, + task: task, + startsChildTaskFlow: true, + ); + } + } + + /// Apply the second-stage destination selected after the `push` action. + /// + /// This needs the full [SchedulingInput] because pushing can shift other + /// flexible tasks and must avoid locked/required intervals. + PushDestinationResult applyPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + }) { + final now = updatedAt ?? clock.now(); + final opId = operationId ?? + _defaultOperationId('push-${destination.name}', taskId, now); + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: opId, + taskId: taskId, + )) { + return PushDestinationResult( + destination: destination, + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: _operationCodeForPushDestination(destination), + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice('Push operation already applied.'), + ], + ), + ); + } + + final result = switch (destination) { + PushDestination.nextAvailableSlot => + schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: input, + taskId: taskId, + updatedAt: now, + ), + PushDestination.tomorrowTopOfQueue => + schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( + input: input, + taskId: taskId, + updatedAt: now, + ), + PushDestination.backlog => _moveTaskToBacklog( + input: input, + taskId: taskId, + updatedAt: now, + ), + }; + + return PushDestinationResult( + destination: destination, + schedulingResult: result, + activities: _activitiesForPushDestination( + destination: destination, + input: input, + result: result, + taskId: taskId, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + ), + ); + } + + /// Move one planned flexible task to backlog inside a scheduling result. + /// + /// This mirrors the shape of other push destination results so callers can + /// handle every destination through the same `SchedulingResult` interface. + SchedulingResult _moveTaskToBacklog({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.notFound, + notices: [ + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + ], + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.invalidState, + notices: [ + SchedulingNotice( + 'Only planned flexible tasks can be moved to backlog.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + ], + ); + } + + final movedTask = schedulingEngine.moveToBacklog( + task, + updatedAt: updatedAt, + ); + + return SchedulingResult( + tasks: List.unmodifiable( + input.tasks.map((current) { + if (current.id == task.id) { + return movedTask; + } + + return current; + }), + ), + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.success, + notices: [ + SchedulingNotice( + 'Flexible task moved to backlog.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': null, + 'nextEnd': null, + }, + ), + ], + changes: [ + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: null, + nextEnd: null, + ), + ], + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart new file mode 100644 index 0000000..35874f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Applies lifecycle actions for required visible task cards. +/// +/// This service only handles critical and inflexible tasks. Locked blocks are +/// scheduling constraints, not normal task cards, and flexible tasks use +/// [FlexibleTaskActionService]. +class RequiredTaskActionService { + /// Creates a `RequiredTaskActionService` 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 RequiredTaskActionService({ + this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), + this.clock = const SystemClock(), + }); + + /// Scheduling dependency used for required missed behavior. + final SchedulingEngine schedulingEngine; + + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + + /// Clock boundary used when an action timestamp is not supplied. + final Clock clock; + + /// Apply one required-card state transition. + RequiredTaskActionResult apply({ + required Task task, + required RequiredTaskAction action, + DateTime? updatedAt, + String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], + Iterable existingActivities = const [], + }) { + if (!task.isRequiredVisible) { + throw ArgumentError.value( + task.type, + 'task.type', + 'Task must be critical or inflexible.', + ); + } + + final now = updatedAt ?? clock.now(); + + final transitionCode = switch (action) { + RequiredTaskAction.done => TaskTransitionCode.complete, + RequiredTaskAction.missed => TaskTransitionCode.miss, + RequiredTaskAction.cancel => TaskTransitionCode.cancel, + RequiredTaskAction.noLongerRelevant => + TaskTransitionCode.noLongerRelevant, + }; + final activityCode = switch (action) { + RequiredTaskAction.done => TaskActivityCode.completed, + RequiredTaskAction.missed => TaskActivityCode.missed, + RequiredTaskAction.cancel => TaskActivityCode.cancelled, + RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, + }; + final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: transitionCode, + operationId: opId, + activityId: _activityId(opId, task.id, activityCode), + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, + existingActivities: existingActivities, + ); + + return RequiredTaskActionResult( + action: action, + task: transition.task, + removedFromActivePlan: transition.task.status == TaskStatus.backlog || + transition.task.status == TaskStatus.cancelled || + transition.task.status == TaskStatus.noLongerRelevant, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart new file mode 100644 index 0000000..7af04ec --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_actions.dart'; + +/// Logs surprise completed work and repairs affected flexible tasks. +/// +/// Surprise work is already done, so the new task is created as completed. When +/// it has a concrete interval, overlapping planned flexible tasks are moved by +/// the existing push scheduler with the surprise interval treated as blocked +/// time. Required visible and locked time is never moved. +class SurpriseTaskLogService { + /// Creates a `SurpriseTaskLogService` 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 SurpriseTaskLogService({ + this.schedulingEngine = const SchedulingEngine(), + this.accountingService = const TaskActivityAccountingService(), + this.clock = const SystemClock(), + this.idGenerator, + }); + + /// Scheduling dependency used to move overlapping flexible tasks. + final SchedulingEngine schedulingEngine; + + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + + /// Clock boundary used when a log timestamp is not supplied. + final Clock clock; + + /// Optional ID boundary for outer convenience entry points. + final IdGenerator? idGenerator; + + /// Log one surprise task against a scheduling snapshot. + SurpriseTaskLogResult log({ + required SurpriseTaskLogRequest request, + required SchedulingInput input, + DateTime? updatedAt, + bool revealHiddenLockedDetails = false, + }) { + final now = updatedAt ?? clock.now(); + final existingTask = _taskById(input.tasks, request.id); + if (existingTask != null) { + if (existingTask.type != TaskType.surprise) { + throw ArgumentError.value( + request.id, + 'request.id', + 'Surprise task id is already used by another task.', + ); + } + + return SurpriseTaskLogResult( + surpriseTask: existingTask, + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'Surprise task already logged.', + issueCode: SchedulingIssueCode.duplicateSurpriseLog, + taskId: existingTask.id, + ), + ], + ), + ); + } + + final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now); + final completedActivity = _surpriseCompletedActivity( + task: rawSurpriseTask, + operationId: request.id, + occurredAt: now, + ); + final accountedSurprise = accountingService.apply( + task: rawSurpriseTask, + activities: [completedActivity], + lockedIntervals: _lockedIntervalsForAccounting(input), + ); + final surpriseTask = accountedSurprise.task; + final surpriseInterval = _occupyingIntervalForTask(surpriseTask); + var currentTasks = [surpriseTask, ...input.tasks]; + + if (surpriseInterval == null) { + return SurpriseTaskLogResult( + surpriseTask: surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.success, + notices: [ + SchedulingNotice('Surprise task logged.'), + ], + ), + activities: [completedActivity], + ); + } + + final requiredOverlaps = _requiredOverlaps( + input: input, + surpriseInterval: surpriseInterval, + ); + final lockedOverlaps = _lockedOverlaps( + input: input, + surpriseInterval: surpriseInterval, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + final flexibleTaskIds = _overlappingFlexibleTaskIds( + input.movablePlannedFlexibleTasks, + surpriseInterval, + ); + final changes = []; + final movementNotices = []; + var usedNormalPushRules = false; + + for (final taskId in flexibleTaskIds) { + final currentTask = _taskById(currentTasks, taskId); + final currentInterval = + currentTask == null ? null : _scheduledIntervalForTask(currentTask); + if (currentTask == null || + currentInterval == null || + !currentInterval.overlaps(surpriseInterval)) { + continue; + } + + final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: currentTasks, + window: input.window, + lockedIntervals: input.lockedIntervals, + requiredVisibleIntervals: input.requiredVisibleIntervals, + ), + taskId: taskId, + updatedAt: now, + countAsManualPush: false, + ); + + usedNormalPushRules = true; + currentTasks = pushResult.tasks; + changes.addAll(pushResult.changes); + movementNotices.addAll(pushResult.notices); + } + + final overlapNotices = requiredOverlaps + .map( + (overlap) => SchedulingNotice( + 'Surprise task overlaps required visible time.', + type: SchedulingNoticeType.overlap, + taskId: overlap.taskId, + conflictCode: + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + parameters: { + 'blockedStart': overlap.blockedInterval.start, + 'blockedEnd': overlap.blockedInterval.end, + }, + ), + ) + .toList(growable: false); + + return SurpriseTaskLogResult( + surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, + schedulingResult: SchedulingResult( + tasks: List.unmodifiable(currentTasks), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: requiredOverlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: List.unmodifiable([ + SchedulingNotice('Surprise task logged.'), + ...movementNotices, + ...overlapNotices, + ]), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(requiredOverlaps), + ), + requiredOverlaps: List.unmodifiable(requiredOverlaps), + lockedOverlaps: List.unmodifiable(lockedOverlaps), + activities: [completedActivity], + usedNormalPushRules: usedNormalPushRules, + ); + } + + /// Convenience outer-boundary wrapper that supplies id and creation time. + SurpriseTaskLogResult logNew({ + required String title, + required SchedulingInput input, + DateTime? startedAt, + int? timeUsedMinutes, + String projectId = 'inbox', + PriorityLevel? priority, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + DateTime? createdAt, + DateTime? updatedAt, + bool revealHiddenLockedDetails = false, + }) { + final generator = idGenerator; + if (generator == null) { + throw StateError('Surprise task logging needs an id generator.'); + } + + final now = createdAt ?? updatedAt ?? clock.now(); + + return log( + request: SurpriseTaskLogRequest( + id: generator.nextId(), + title: title, + createdAt: now, + startedAt: startedAt, + timeUsedMinutes: timeUsedMinutes, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + ), + input: input, + updatedAt: updatedAt ?? now, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + } + + /// Runs the `_createSurpriseTask` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + Task _createSurpriseTask( + SurpriseTaskLogRequest request, { + required DateTime updatedAt, + }) { + final trimmedTitle = request.title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(request.title, 'title', 'Title is required.'); + } + + final duration = request.timeUsedMinutes; + final start = request.startedAt; + final hasScheduledTime = start != null && duration != null && duration > 0; + + return Task( + id: request.id, + title: trimmedTitle, + projectId: request.projectId, + type: TaskType.surprise, + status: TaskStatus.completed, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + durationMinutes: duration != null && duration > 0 ? duration : null, + scheduledStart: hasScheduledTime ? start : null, + scheduledEnd: + hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + actualStart: hasScheduledTime ? start : null, + actualEnd: + hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + completedAt: updatedAt, + createdAt: request.createdAt, + updatedAt: updatedAt, + ); + } +} + +/// Top-level helper that performs the `_activitiesForPushDestination` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _activitiesForPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required SchedulingResult result, + required String taskId, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: operationId, + taskId: change.taskId, + )) { + continue; + } + + final task = _taskById(input.tasks, change.taskId); + if (task == null) { + continue; + } + + final code = _activityCodeForPushChange( + destination: destination, + isSelectedTask: change.taskId == taskId, + ); + activities.add( + TaskActivity( + id: _activityId(operationId, change.taskId, code), + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': task.status.name, + 'nextStatus': code == TaskActivityCode.movedToBacklog + ? TaskStatus.backlog.name + : task.status.name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + 'destination': destination.name, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +/// Top-level helper that performs the `_activityCodeForPushChange` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TaskActivityCode _activityCodeForPushChange({ + required PushDestination destination, + required bool isSelectedTask, +}) { + if (destination == PushDestination.backlog) { + return TaskActivityCode.movedToBacklog; + } + + return isSelectedTask + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed; +} + +/// Top-level helper that performs the `_operationCodeForPushDestination` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +SchedulingOperationCode _operationCodeForPushDestination( + PushDestination destination, +) { + return switch (destination) { + PushDestination.nextAvailableSlot => + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + PushDestination.tomorrowTopOfQueue => + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + PushDestination.backlog => + SchedulingOperationCode.moveFlexibleTaskToBacklog, + }; +} + +/// Top-level helper that performs the `_surpriseCompletedActivity` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TaskActivity _surpriseCompletedActivity({ + required Task task, + required String operationId, + required DateTime occurredAt, +}) { + return TaskActivity( + id: _activityId(operationId, task.id, TaskActivityCode.completed), + operationId: operationId, + code: TaskActivityCode.completed, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'nextStatus': TaskStatus.completed.name, + 'completedAt': occurredAt, + 'actualStart': task.actualStart, + 'actualEnd': task.actualEnd, + 'durationMinutes': task.durationMinutes, + 'reward': task.reward.name, + 'difficulty': task.difficulty.name, + if (task.reminderOverride != null) + 'reminderProfile': task.reminderOverride!.name, + 'pushesBeforeCompletion': 0, + 'source': 'surpriseLog', + }, + ); +} + +/// Top-level helper that performs the `_lockedIntervalsForAccounting` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _lockedIntervalsForAccounting(SchedulingInput input) { + return input.occupancyEntries + .where( + (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false); +} + +/// Top-level helper that performs the `_hasDuplicateActivity` 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 _hasDuplicateActivity({ + required Iterable existingActivities, + required String operationId, + required String taskId, +}) { + for (final activity in existingActivities) { + if (activity.operationId == operationId && activity.taskId == taskId) { + return true; + } + } + + return false; +} + +/// Top-level helper that performs the `_defaultOperationId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _defaultOperationId( + String actionName, String taskId, DateTime occurredAt) { + return '$actionName:$taskId:${occurredAt.toIso8601String()}'; +} + +/// Top-level helper that performs the `_activityId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + +/// Find one task by id in a list. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Build an interval for a task, or null when it has no usable placement. +TimeInterval? _scheduledIntervalForTask(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null || !start.isBefore(end)) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Build an actual-first occupancy interval for completed/active task facts. +TimeInterval? _occupyingIntervalForTask(Task task) { + final actualStart = task.actualStart; + final actualEnd = task.actualEnd; + + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + return TimeInterval(start: actualStart, end: actualEnd, label: task.id); + } + + return _scheduledIntervalForTask(task); +} + +/// Required visible overlaps with a surprise interval. +List _requiredOverlaps({ + required SchedulingInput input, + required TimeInterval surpriseInterval, +}) { + final overlaps = []; + + for (final occupancy in input.occupancyEntries) { + final task = occupancy.task; + final interval = occupancy.interval; + final isRequiredOccupancy = + occupancy.category == OccupancyCategory.requiredVisibleCommitment || + (occupancy.category == OccupancyCategory.activeWork && + task != null && + task.isRequiredVisible); + + if (task == null || + !isRequiredOccupancy || + interval == null || + !interval.overlaps(surpriseInterval)) { + continue; + } + + overlaps.add( + SchedulingOverlap( + taskId: task.id, + taskInterval: interval, + blockedInterval: surpriseInterval, + ), + ); + } + + return overlaps; +} + +/// Locked overlaps with a surprise interval, kept hidden from normal notices. +List _lockedOverlaps({ + required SchedulingInput input, + required TimeInterval surpriseInterval, + required bool revealHiddenLockedDetails, +}) { + return input.occupancyEntries + .where( + (occupancy) => + occupancy.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((occupancy) => occupancy.interval) + .whereType() + .where((interval) => interval.overlaps(surpriseInterval)) + .map((interval) { + if (revealHiddenLockedDetails) { + return interval; + } + return TimeInterval(start: interval.start, end: interval.end); + }).toList(growable: false); +} + +/// Planned flexible task ids overlapping a surprise interval in timeline order. +List _overlappingFlexibleTaskIds( + List tasks, + TimeInterval surpriseInterval, +) { + final overlappingTasks = tasks.where((task) { + if (task.status != TaskStatus.planned) { + return false; + } + + final interval = _scheduledIntervalForTask(task); + return interval != null && interval.overlaps(surpriseInterval); + }).toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? surpriseInterval.end; + final bStart = b.scheduledStart ?? surpriseInterval.end; + + return aStart.compareTo(bStart); + }); + + return overlappingTasks.map((task) => task.id).toList(growable: false); +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart new file mode 100644 index 0000000..f4ba3bd --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Task Lifecycle behavior for the Scheduler Core package. +library; + +// Canonical task lifecycle transitions and internal activity records. +// +// This module keeps task state changes separate from UI buttons and scheduling +// placement. Application use cases can persist the returned task and activities +// atomically in later blocks. + +import '../domain/models.dart'; +import '../domain/task_statistics.dart'; +import '../domain/time_contracts.dart'; +part 'task_lifecycle/codes/task_transition_code.dart'; +part 'task_lifecycle/codes/task_activity_code.dart'; +part 'task_lifecycle/codes/task_transition_outcome_code.dart'; +part 'task_lifecycle/models/task_activity.dart'; +part 'task_lifecycle/models/task_transition_result.dart'; +part 'task_lifecycle/models/task_activity_application_result.dart'; +part 'task_lifecycle/services/task_activity_accounting_service.dart'; +part 'task_lifecycle/services/task_transition_service.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_activity_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_activity_code.dart new file mode 100644 index 0000000..0af7725 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_activity_code.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Stable internal activity categories derived from transitions. +enum TaskActivityCode { + /// Selects the `completed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + completed, + + /// Selects the `missed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + missed, + + /// Selects the `cancelled` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + cancelled, + + /// Selects the `noLongerRelevant` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noLongerRelevant, + + /// Selects the `manuallyPushed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + manuallyPushed, + + /// Selects the `automaticallyPushed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + automaticallyPushed, + + /// Selects the `movedToBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + movedToBacklog, + + /// Selects the `restoredFromBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + restoredFromBacklog, + + /// Selects the `activated` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + activated, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_code.dart new file mode 100644 index 0000000..2a37721 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_code.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Canonical task transition commands. +enum TaskTransitionCode { + /// Selects the `complete` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + complete, + + /// Selects the `miss` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + miss, + + /// Selects the `cancel` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + cancel, + + /// Selects the `noLongerRelevant` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noLongerRelevant, + + /// Selects the `manualPush` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + manualPush, + + /// Selects the `automaticPush` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + automaticPush, + + /// Selects the `moveToBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + moveToBacklog, + + /// Selects the `restoreFromBacklog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + restoreFromBacklog, + + /// Selects the `activate` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + activate, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_outcome_code.dart new file mode 100644 index 0000000..4d3298b --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_outcome_code.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Typed result categories for expected transition states. +enum TaskTransitionOutcomeCode { + /// Selects the `applied` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + applied, + + /// Selects the `noOp` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noOp, + + /// Selects the `duplicateOperation` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + duplicateOperation, + + /// Selects the `invalidState` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + invalidState, +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity.dart new file mode 100644 index 0000000..4937f75 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity.dart @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Immutable internal activity fact. +/// +/// Activities are backend/application data, not a visible per-task history UI. +/// They carry enough stable structure for idempotency, statistics, and future +/// persistence adapters. +class TaskActivity { + /// Creates a `TaskActivity` 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. + TaskActivity({ + required String id, + required String operationId, + required this.code, + required String taskId, + required String projectId, + required this.occurredAt, + Map metadata = const {}, + }) : id = _requiredTrimmed(id, 'id'), + operationId = _requiredTrimmed(operationId, 'operationId'), + taskId = _requiredTrimmed(taskId, 'taskId'), + projectId = _requiredTrimmed(projectId, 'projectId'), + metadata = Map.unmodifiable(metadata); + + /// Stable activity id supplied by the application boundary. + final String id; + + /// Idempotency key for the user/application operation that created this fact. + final String operationId; + + /// Stable activity category. + final TaskActivityCode code; + + /// Task this activity belongs to. + final String taskId; + + /// Project the task belonged to when the activity occurred. + final String projectId; + + /// When the activity happened. + final DateTime occurredAt; + + /// Small structured payload for later statistics and application use cases. + final Map metadata; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity_application_result.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity_application_result.dart new file mode 100644 index 0000000..6c46629 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity_application_result.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Result of applying activity-derived task statistics. +class TaskActivityApplicationResult { + /// Creates a `TaskActivityApplicationResult` 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. + TaskActivityApplicationResult({ + required this.task, + List appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Task after all newly applied activity effects. + final Task task; + + /// Activity ids that changed statistics in this application pass. + final List appliedActivityIds; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_transition_result.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_transition_result.dart new file mode 100644 index 0000000..4d0b409 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_transition_result.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Result of applying one canonical transition. +class TaskTransitionResult { + /// Creates a `TaskTransitionResult` 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. + TaskTransitionResult({ + required this.transitionCode, + required this.outcomeCode, + required this.task, + List activities = const [], + this.duplicateOfActivityId, + }) : activities = List.unmodifiable(activities); + + /// Requested transition. + final TaskTransitionCode transitionCode; + + /// Typed outcome for callers. + final TaskTransitionOutcomeCode outcomeCode; + + /// Original or updated task. + final Task task; + + /// New internal activity records. Empty for no-op, invalid, and duplicates. + final List activities; + + /// Existing activity id matched by a duplicate operation, when available. + final String? duplicateOfActivityId; + + /// Convenience flag for mutation persistence. + bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; +} diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_activity_accounting_service.dart new file mode 100644 index 0000000..68cbe47 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_activity_accounting_service.dart @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../task_lifecycle.dart'; + +/// Applies task-statistic effects from internal activities exactly once. +class TaskActivityAccountingService { + /// Creates a `TaskActivityAccountingService` 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 TaskActivityAccountingService(); + + /// Apply [activities] to [task], skipping ids already present in + /// [alreadyAppliedActivityIds]. + /// + /// Completion accounting uses explicit completion timestamps and actual + /// intervals. If a completion has a timestamp but no actual interval, locked + /// minutes are left at zero rather than inferred from the planned slot. + TaskActivityApplicationResult apply({ + required Task task, + required Iterable activities, + Iterable alreadyAppliedActivityIds = const [], + List lockedIntervals = const [], + }) { + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + var current = task; + + for (final activity in activities) { + if (activity.taskId != task.id || appliedIds.contains(activity.id)) { + continue; + } + + final nextStats = _applyActivityStats( + task: current, + activity: activity, + lockedIntervals: lockedIntervals, + ); + current = current.copyWith(stats: nextStats); + appliedIds.add(activity.id); + newlyAppliedIds.add(activity.id); + } + + return TaskActivityApplicationResult( + task: current, + appliedActivityIds: newlyAppliedIds, + ); + } +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart similarity index 78% rename from packages/scheduler_core/lib/src/task_lifecycle.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart index dbd2c6f..3425815 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart @@ -1,179 +1,12 @@ -/// Implements Task Lifecycle behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Canonical task lifecycle transitions and internal activity records. -// -// This module keeps task state changes separate from UI buttons and scheduling -// placement. Application use cases can persist the returned task and activities -// atomically in later blocks. - -import 'models.dart'; -import 'task_statistics.dart'; -import 'time_contracts.dart'; - -/// Canonical task transition commands. -enum TaskTransitionCode { - complete, - miss, - cancel, - noLongerRelevant, - manualPush, - automaticPush, - moveToBacklog, - restoreFromBacklog, - activate, -} - -/// Stable internal activity categories derived from transitions. -enum TaskActivityCode { - completed, - missed, - cancelled, - noLongerRelevant, - manuallyPushed, - automaticallyPushed, - movedToBacklog, - restoredFromBacklog, - activated, -} - -/// Typed result categories for expected transition states. -enum TaskTransitionOutcomeCode { - applied, - noOp, - duplicateOperation, - invalidState, -} - -/// Immutable internal activity fact. -/// -/// Activities are backend/application data, not a visible per-task history UI. -/// They carry enough stable structure for idempotency, statistics, and future -/// persistence adapters. -class TaskActivity { - TaskActivity({ - required String id, - required String operationId, - required this.code, - required String taskId, - required String projectId, - required this.occurredAt, - Map metadata = const {}, - }) : id = _requiredTrimmed(id, 'id'), - operationId = _requiredTrimmed(operationId, 'operationId'), - taskId = _requiredTrimmed(taskId, 'taskId'), - projectId = _requiredTrimmed(projectId, 'projectId'), - metadata = Map.unmodifiable(metadata); - - /// Stable activity id supplied by the application boundary. - final String id; - - /// Idempotency key for the user/application operation that created this fact. - final String operationId; - - /// Stable activity category. - final TaskActivityCode code; - - /// Task this activity belongs to. - final String taskId; - - /// Project the task belonged to when the activity occurred. - final String projectId; - - /// When the activity happened. - final DateTime occurredAt; - - /// Small structured payload for later statistics and application use cases. - final Map metadata; -} - -/// Result of applying one canonical transition. -class TaskTransitionResult { - TaskTransitionResult({ - required this.transitionCode, - required this.outcomeCode, - required this.task, - List activities = const [], - this.duplicateOfActivityId, - }) : activities = List.unmodifiable(activities); - - /// Requested transition. - final TaskTransitionCode transitionCode; - - /// Typed outcome for callers. - final TaskTransitionOutcomeCode outcomeCode; - - /// Original or updated task. - final Task task; - - /// New internal activity records. Empty for no-op, invalid, and duplicates. - final List activities; - - /// Existing activity id matched by a duplicate operation, when available. - final String? duplicateOfActivityId; - - /// Convenience flag for mutation persistence. - bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; -} - -/// Result of applying activity-derived task statistics. -class TaskActivityApplicationResult { - TaskActivityApplicationResult({ - required this.task, - List appliedActivityIds = const [], - }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); - - /// Task after all newly applied activity effects. - final Task task; - - /// Activity ids that changed statistics in this application pass. - final List appliedActivityIds; -} - -/// Applies task-statistic effects from internal activities exactly once. -class TaskActivityAccountingService { - const TaskActivityAccountingService(); - - /// Apply [activities] to [task], skipping ids already present in - /// [alreadyAppliedActivityIds]. - /// - /// Completion accounting uses explicit completion timestamps and actual - /// intervals. If a completion has a timestamp but no actual interval, locked - /// minutes are left at zero rather than inferred from the planned slot. - TaskActivityApplicationResult apply({ - required Task task, - required Iterable activities, - Iterable alreadyAppliedActivityIds = const [], - List lockedIntervals = const [], - }) { - final appliedIds = alreadyAppliedActivityIds.toSet(); - final newlyAppliedIds = []; - var current = task; - - for (final activity in activities) { - if (activity.taskId != task.id || appliedIds.contains(activity.id)) { - continue; - } - - final nextStats = _applyActivityStats( - task: current, - activity: activity, - lockedIntervals: lockedIntervals, - ); - current = current.copyWith(stats: nextStats); - appliedIds.add(activity.id); - newlyAppliedIds.add(activity.id); - } - - return TaskActivityApplicationResult( - task: current, - appliedActivityIds: newlyAppliedIds, - ); - } -} +part of '../../task_lifecycle.dart'; /// Canonical lifecycle transition service. class TaskTransitionService { + /// Creates a `TaskTransitionService` 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 TaskTransitionService({ this.clock = const SystemClock(), this.accountingService = const TaskActivityAccountingService(), @@ -325,6 +158,8 @@ class TaskTransitionService { }; } + /// Runs the `_complete` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _complete({ required Task task, required String operationId, @@ -388,6 +223,8 @@ class TaskTransitionService { ); } + /// Runs the `_miss` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _miss({ required Task task, required String operationId, @@ -455,6 +292,8 @@ class TaskTransitionService { ); } + /// Runs the `_cancel` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _cancel({ required Task task, required String operationId, @@ -499,6 +338,8 @@ class TaskTransitionService { ); } + /// Runs the `_noLongerRelevant` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _noLongerRelevant({ required Task task, required String operationId, @@ -544,6 +385,8 @@ class TaskTransitionService { ); } + /// Runs the `_movementOnly` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _movementOnly({ required Task task, required TaskTransitionCode transitionCode, @@ -583,6 +426,8 @@ class TaskTransitionService { ); } + /// Runs the `_moveToBacklog` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _moveToBacklog({ required Task task, required String operationId, @@ -626,6 +471,8 @@ class TaskTransitionService { ); } + /// Runs the `_restoreFromBacklog` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _restoreFromBacklog({ required Task task, required String operationId, @@ -667,6 +514,8 @@ class TaskTransitionService { ); } + /// Runs the `_activate` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TaskTransitionResult _activate({ required Task task, required String operationId, @@ -712,6 +561,8 @@ class TaskTransitionService { } } +/// Top-level helper that performs the `_applyActivityStats` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskStatistics _applyActivityStats({ required Task task, required TaskActivity activity, @@ -736,6 +587,8 @@ TaskStatistics _applyActivityStats({ }; } +/// Top-level helper that performs the `_applyCompletionStats` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskStatistics _applyCompletionStats({ required Task task, required TaskActivity activity, @@ -764,6 +617,8 @@ TaskStatistics _applyCompletionStats({ return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); } +/// Top-level helper that performs the `_actualLockedOverlapMinutes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int _actualLockedOverlapMinutes({ required Task task, required List lockedIntervals, @@ -816,10 +671,14 @@ int _actualLockedOverlapMinutes({ return total.inMinutes; } +/// Top-level helper that performs the `_pushesBeforeCompletion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int _pushesBeforeCompletion(Task task) { return task.stats.manuallyPushedCount + task.stats.autoPushedCount; } +/// Top-level helper that performs the `_knownDurationMinutes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int? _knownDurationMinutes( Task task, DateTime? actualStart, @@ -837,18 +696,26 @@ int? _knownDurationMinutes( return task.durationMinutes; } +/// Top-level helper that performs the `_intMetadata` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int? _intMetadata(Object? value) { return value is int ? value : null; } +/// Top-level helper that performs the `_earliest` 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 _earliest(DateTime first, DateTime second) { return first.isBefore(second) ? first : second; } +/// Top-level helper that performs the `_latest` 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 _latest(DateTime first, DateTime second) { return first.isAfter(second) ? first : second; } +/// Top-level helper that performs the `_applied` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskTransitionResult _applied({ required TaskTransitionCode transitionCode, required Task task, @@ -862,6 +729,8 @@ TaskTransitionResult _applied({ ); } +/// Top-level helper that performs the `_invalid` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { return TaskTransitionResult( transitionCode: transitionCode, @@ -870,6 +739,8 @@ TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { ); } +/// Top-level helper that performs the `_noOp` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { return TaskTransitionResult( transitionCode: transitionCode, @@ -878,6 +749,8 @@ TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { ); } +/// Top-level helper that performs the `_activity` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskActivity _activity({ required String id, required String operationId, @@ -902,6 +775,8 @@ TaskActivity _activity({ ); } +/// Top-level helper that performs the `_duplicateActivity` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskActivity? _duplicateActivity({ required Iterable existingActivities, required String taskId, @@ -916,6 +791,8 @@ TaskActivity? _duplicateActivity({ return null; } +/// Top-level helper that performs the `_actualIntervalShapeIsValid` 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 _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { if (actualStart == null && actualEnd == null) { return true; @@ -925,6 +802,8 @@ bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { actualStart.isBefore(actualEnd); } +/// Top-level helper that performs the `_isNoOpTerminalRepeat` 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 _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { return (task.status == TaskStatus.completed && transitionCode == TaskTransitionCode.complete) || @@ -934,6 +813,8 @@ bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { transitionCode == TaskTransitionCode.noLongerRelevant); } +/// Top-level helper that performs the `_isBlockedByTerminalState` 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 _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { if (!_isTerminal(task.status)) { return false; @@ -941,12 +822,16 @@ bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { return !_isNoOpTerminalRepeat(task, transitionCode); } +/// Top-level helper that performs the `_isTerminal` 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 _isTerminal(TaskStatus status) { return status == TaskStatus.completed || status == TaskStatus.cancelled || status == TaskStatus.noLongerRelevant; } +/// Top-level helper that performs the `_canRemoveFromActivePlan` 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 _canRemoveFromActivePlan(Task task) { return task.status == TaskStatus.planned || task.status == TaskStatus.active || @@ -954,6 +839,8 @@ bool _canRemoveFromActivePlan(Task task) { task.status == TaskStatus.missed; } +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _requiredTrimmed(String value, String name) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state.dart new file mode 100644 index 0000000..8c1b5fd --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Timeline State behavior for the Scheduler Core package. +library; + +// UI-independent Today timeline view state. +// +// This file translates domain tasks into small display-ready value objects +// without importing Flutter or choosing visual assets. UI layers can map these +// tokens to colors, icons, text, or widgets later. + +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../domain/time_contracts.dart'; +part 'timeline_state/tokens/timeline_item_category.dart'; +part 'timeline_state/tokens/timeline_background_token.dart'; +part 'timeline_state/tokens/timeline_reward_icon_token.dart'; +part 'timeline_state/tokens/timeline_difficulty_icon_token.dart'; +part 'timeline_state/tokens/timeline_quick_action.dart'; +part 'timeline_state/models/timeline_item.dart'; +part 'timeline_state/models/compact_timeline_state.dart'; +part 'timeline_state/mapping/timeline_item_mapper.dart'; diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart similarity index 68% rename from packages/scheduler_core/lib/src/timeline_state.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart index 1bb5e4f..4d4a927 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart @@ -1,173 +1,12 @@ -/// Implements Timeline State behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// UI-independent Today timeline view state. -// -// This file translates domain tasks into small display-ready value objects -// without importing Flutter or choosing visual assets. UI layers can map these -// tokens to colors, icons, text, or widgets later. - -import 'locked_time.dart'; -import 'models.dart'; -import 'time_contracts.dart'; - -/// Broad display category for an item placed on the Today timeline. -enum TimelineItemCategory { - /// Normal visible task card. - taskCard, - - /// Non-task timeline layer such as locked time. - overlay, -} - -/// Background style token derived from the scheduling behavior type. -enum TimelineBackgroundToken { - flexible, - inflexible, - critical, - locked, - surprise, - freeSlot, -} - -/// Reward icon token for the first timeline card icon. -enum TimelineRewardIconToken { - notSet, - veryLow, - low, - medium, - high, - veryHigh, -} - -/// Difficulty icon token for the second timeline card icon. -enum TimelineDifficultyIconToken { - notSet, - veryEasy, - easy, - medium, - hard, - veryHard, -} - -/// Quick actions the timeline can expose without depending on a UI framework. -enum TimelineQuickAction { - done, - push, - backlog, - breakUp, - missed, - cancel, - noLongerRelevant, -} - -/// Display-ready timeline item derived from a domain [Task]. -class TimelineItem { - TimelineItem({ - required this.id, - required this.displayTitle, - required this.taskType, - required this.projectColorToken, - required this.backgroundToken, - required this.rewardIconToken, - required this.difficultyIconToken, - required this.showsExplicitTime, - required List quickActions, - required this.category, - this.start, - this.end, - this.durationMinutes, - }) : quickActions = List.unmodifiable(quickActions); - - /// Stable id of the source item. - final String id; - - /// Text shown as the item name. - final String displayTitle; - - /// Source scheduling behavior type. - final TaskType taskType; - - /// Border/project color token. This is a key, not a raw color. - final String projectColorToken; - - /// Translucent background token derived from [taskType]. - final TimelineBackgroundToken backgroundToken; - - /// First icon token. - final TimelineRewardIconToken rewardIconToken; - - /// Second icon token. - final TimelineDifficultyIconToken difficultyIconToken; - - /// Whether the item should render explicit start/end time text. - final bool showsExplicitTime; - - /// Timeline placement start, if the source item has one. - final DateTime? start; - - /// Timeline placement end, if the source item has one. - final DateTime? end; - - /// Duration display value for flexible task cards. - final int? durationMinutes; - - /// Quick actions available from this item. - final List quickActions; - - /// Whether this is a normal task card or a non-task overlay. - final TimelineItemCategory category; -} - -/// Reduced Today timeline state for manual compact mode. -class CompactTimelineState { - const CompactTimelineState({ - required this.manualCompactModeEnabled, - required this.fullTimelineExpanded, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }); - - /// User-controlled compact mode flag. - final bool manualCompactModeEnabled; - - /// Whether the full timeline is expanded while compact mode is active. - final bool fullTimelineExpanded; - - /// Current visible task, if one is active or occupies the query time. - final TimelineItem? currentItem; - - /// Next critical or inflexible task after the current moment. - final TimelineItem? nextRequiredItem; - - /// Optional next flexible task after the current moment. - final TimelineItem? nextFlexibleItem; - - /// Whether callers should show the full timeline. - bool get fullTimelineVisible { - return !manualCompactModeEnabled || fullTimelineExpanded; - } - - /// Items selected for the compact view. - List get compactItems { - if (!manualCompactModeEnabled) { - return const []; - } - return [ - if (currentItem != null) currentItem!, - if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) - nextRequiredItem!, - if (nextFlexibleItem != null && - nextFlexibleItem!.id != currentItem?.id && - nextFlexibleItem!.id != nextRequiredItem?.id) - nextFlexibleItem!, - ]; - } -} +part of '../../timeline_state.dart'; /// Converts domain tasks into Today timeline items. class TimelineItemMapper { + /// Creates a `TimelineItemMapper` 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. TimelineItemMapper({ Map projectColorTokensById = const {}, }) : projectColorTokensById = @@ -198,6 +37,7 @@ class TimelineItemMapper { showsExplicitTime: _showsExplicitTime(task.type), start: task.scheduledStart, end: task.scheduledEnd, + completedAt: task.completedAt, durationMinutes: _durationMinutesFor(task), quickActions: _quickActionsFor(task.type), category: task.isLocked @@ -296,6 +136,8 @@ class TimelineItemMapper { ); } + /// Runs the `_canAppearInCompactMode` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. bool _canAppearInCompactMode(Task task) { return !task.isLocked && task.status != TaskStatus.backlog && @@ -306,6 +148,8 @@ class TimelineItemMapper { task.scheduledEnd != null; } + /// Runs the `_containsTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. bool _containsTime(Task task, DateTime now) { final start = task.scheduledStart; final end = task.scheduledEnd; @@ -315,11 +159,15 @@ class TimelineItemMapper { return !now.isBefore(start) && now.isBefore(end); } + /// Runs the `_startsAtOrAfter` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. bool _startsAtOrAfter(Task task, DateTime now) { final start = task.scheduledStart; return start != null && !start.isBefore(now); } + /// Runs the `_compareTasksByStart` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. int _compareTasksByStart(Task left, Task right) { final leftStart = left.scheduledStart; final rightStart = right.scheduledStart; @@ -335,6 +183,8 @@ class TimelineItemMapper { return leftStart.compareTo(rightStart); } + /// Runs the `_lockedOccurrenceId` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _lockedOccurrenceId( LockedBlockOccurrence occurrence, { CivilDate? occurrenceDate, @@ -353,6 +203,8 @@ class TimelineItemMapper { '${occurrence.interval.start.toIso8601String()}'; } + /// Runs the `_projectColorTokenFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _projectColorTokenFor(String? projectId) { if (projectId == null) { return 'locked'; @@ -360,12 +212,16 @@ class TimelineItemMapper { return projectColorTokensById[projectId] ?? projectId; } + /// Runs the `_showsExplicitTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. bool _showsExplicitTime(TaskType type) { return type == TaskType.inflexible || type == TaskType.critical || type == TaskType.locked; } + /// Runs the `_durationMinutesFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. int? _durationMinutesFor(Task task) { if (!task.isFlexible) { return null; @@ -381,6 +237,8 @@ class TimelineItemMapper { return end.difference(start).inMinutes; } + /// Runs the `_quickActionsFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. List _quickActionsFor(TaskType type) { switch (type) { case TaskType.flexible: @@ -405,6 +263,8 @@ class TimelineItemMapper { } } + /// Runs the `_backgroundTokenFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TimelineBackgroundToken _backgroundTokenFor(TaskType type) { switch (type) { case TaskType.flexible: @@ -422,6 +282,8 @@ class TimelineItemMapper { } } + /// Runs the `_rewardIconTokenFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) { switch (reward) { case RewardLevel.notSet: @@ -439,6 +301,8 @@ class TimelineItemMapper { } } + /// Runs the `_difficultyIconTokenFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. TimelineDifficultyIconToken _difficultyIconTokenFor( DifficultyLevel difficulty, ) { diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/compact_timeline_state.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/compact_timeline_state.dart new file mode 100644 index 0000000..d9a6b50 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/compact_timeline_state.dart @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Reduced Today timeline state for manual compact mode. +class CompactTimelineState { + /// Creates a `CompactTimelineState` 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 CompactTimelineState({ + required this.manualCompactModeEnabled, + required this.fullTimelineExpanded, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }); + + /// User-controlled compact mode flag. + final bool manualCompactModeEnabled; + + /// Whether the full timeline is expanded while compact mode is active. + final bool fullTimelineExpanded; + + /// Current visible task, if one is active or occupies the query time. + final TimelineItem? currentItem; + + /// Next critical or inflexible task after the current moment. + final TimelineItem? nextRequiredItem; + + /// Optional next flexible task after the current moment. + final TimelineItem? nextFlexibleItem; + + /// Whether callers should show the full timeline. + bool get fullTimelineVisible { + return !manualCompactModeEnabled || fullTimelineExpanded; + } + + /// Items selected for the compact view. + List get compactItems { + if (!manualCompactModeEnabled) { + return const []; + } + return [ + if (currentItem != null) currentItem!, + if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) + nextRequiredItem!, + if (nextFlexibleItem != null && + nextFlexibleItem!.id != currentItem?.id && + nextFlexibleItem!.id != nextRequiredItem?.id) + nextFlexibleItem!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart new file mode 100644 index 0000000..79913f9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Display-ready timeline item derived from a domain [Task]. +class TimelineItem { + /// Creates a `TimelineItem` 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. + TimelineItem({ + required this.id, + required this.displayTitle, + required this.taskType, + required this.projectColorToken, + required this.backgroundToken, + required this.rewardIconToken, + required this.difficultyIconToken, + required this.showsExplicitTime, + required List quickActions, + required this.category, + this.start, + this.end, + this.completedAt, + this.durationMinutes, + }) : quickActions = List.unmodifiable(quickActions); + + /// Stable id of the source item. + final String id; + + /// Text shown as the item name. + final String displayTitle; + + /// Source scheduling behavior type. + final TaskType taskType; + + /// Border/project color token. This is a key, not a raw color. + final String projectColorToken; + + /// Translucent background token derived from [taskType]. + final TimelineBackgroundToken backgroundToken; + + /// First icon token. + final TimelineRewardIconToken rewardIconToken; + + /// Second icon token. + final TimelineDifficultyIconToken difficultyIconToken; + + /// Whether the item should render explicit start/end time text. + final bool showsExplicitTime; + + /// Timeline placement start, if the source item has one. + final DateTime? start; + + /// Timeline placement end, if the source item has one. + final DateTime? end; + + /// Completion instant, if the source task has been completed. + final DateTime? completedAt; + + /// Duration display value for flexible task cards. + final int? durationMinutes; + + /// Quick actions available from this item. + final List quickActions; + + /// Whether this is a normal task card or a non-task overlay. + final TimelineItemCategory category; +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_background_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_background_token.dart new file mode 100644 index 0000000..f6454ea --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_background_token.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Background style token derived from the scheduling behavior type. +enum TimelineBackgroundToken { + /// Selects the `flexible` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + flexible, + + /// Selects the `inflexible` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + inflexible, + + /// Selects the `critical` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + critical, + + /// Selects the `locked` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + locked, + + /// Selects the `surprise` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + surprise, + + /// Selects the `freeSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + freeSlot, +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_difficulty_icon_token.dart new file mode 100644 index 0000000..9a0c533 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_difficulty_icon_token.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Difficulty icon token for the second timeline card icon. +enum TimelineDifficultyIconToken { + /// Selects the `notSet` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + notSet, + + /// Selects the `veryEasy` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + veryEasy, + + /// Selects the `easy` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + easy, + + /// Selects the `medium` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + medium, + + /// Selects the `hard` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + hard, + + /// Selects the `veryHard` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + veryHard, +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_item_category.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_item_category.dart new file mode 100644 index 0000000..e921eb3 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_item_category.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Broad display category for an item placed on the Today timeline. +enum TimelineItemCategory { + /// Normal visible task card. + taskCard, + + /// Non-task timeline layer such as locked time. + overlay, +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_quick_action.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_quick_action.dart new file mode 100644 index 0000000..d1e0787 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_quick_action.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Quick actions the timeline can expose without depending on a UI framework. +enum TimelineQuickAction { + /// Selects the `done` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + done, + + /// Selects the `push` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + push, + + /// Selects the `backlog` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + backlog, + + /// Selects the `breakUp` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + breakUp, + + /// Selects the `missed` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + missed, + + /// Selects the `cancel` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + cancel, + + /// Selects the `noLongerRelevant` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + noLongerRelevant, +} diff --git a/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_reward_icon_token.dart new file mode 100644 index 0000000..f85bf65 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_reward_icon_token.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../timeline_state.dart'; + +/// Reward icon token for the first timeline card icon. +enum TimelineRewardIconToken { + /// Selects the `notSet` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + notSet, + + /// Selects the `veryLow` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + veryLow, + + /// Selects the `low` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + low, + + /// Selects the `medium` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + medium, + + /// Selects the `high` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + high, + + /// Selects the `veryHigh` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/scheduling/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state.dart new file mode 100644 index 0000000..8b42495 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Implements Today State behavior for the Scheduler Core package. +library; + +// Application-level Today read model. +// +// This module builds UI-independent state for the Today and compact views. It +// is read-only: it does not run rollover, create activities, update statistics, +// or persist operation records. + +import '../application/application_layer.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; +import '../persistence/repositories.dart'; +import 'scheduling_engine.dart'; +import 'timeline_state.dart'; +import '../domain/time_contracts.dart'; +part 'today_state/models/today_timeline_item_source.dart'; +part 'today_state/requests/get_today_state_request.dart'; +part 'today_state/models/today_timeline_item.dart'; +part 'today_state/models/today_compact_state.dart'; +part 'today_state/models/today_pending_notice.dart'; +part 'today_state/models/today_state.dart'; +part 'today_state/queries/get_today_state_query.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/models/today_compact_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_compact_state.dart new file mode 100644 index 0000000..018d9fa --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_compact_state.dart @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// Compact projection derived from the same Today item list. +class TodayCompactState { + /// Creates a `TodayCompactState` 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 TodayCompactState({ + required this.manualCompactModeEnabled, + required this.fullTimelineExpanded, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }); + + /// User-controlled compact mode flag. + final bool manualCompactModeEnabled; + + /// Whether the full timeline is expanded while compact mode is active. + final bool fullTimelineExpanded; + + /// Current visible item, if one occupies the read instant. + final TodayTimelineItem? currentItem; + + /// Next critical or inflexible item. + final TodayTimelineItem? nextRequiredItem; + + /// Optional next flexible item. + final TodayTimelineItem? nextFlexibleItem; + + /// Whether callers should show the full timeline. + bool get fullTimelineVisible { + return !manualCompactModeEnabled || fullTimelineExpanded; + } + + /// Items selected for compact rendering. + List get compactItems { + if (!manualCompactModeEnabled) { + return const []; + } + + return [ + if (currentItem != null) currentItem!, + if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) + nextRequiredItem!, + if (nextFlexibleItem != null && + nextFlexibleItem!.id != currentItem?.id && + nextFlexibleItem!.id != nextRequiredItem?.id) + nextFlexibleItem!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/models/today_pending_notice.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_pending_notice.dart new file mode 100644 index 0000000..3bea855 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_pending_notice.dart @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// Pending notice data surfaced through Today state. +class TodayPendingNotice { + /// Creates a `TodayPendingNotice` 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. + TodayPendingNotice({ + required this.noticeId, + required this.snapshotId, + required this.capturedAt, + required this.message, + required this.type, + required Map parameters, + this.taskId, + this.issueCode, + this.movementCode, + this.conflictCode, + }) : parameters = Map.unmodifiable(parameters); + + /// Stable id for acknowledgement/consume commands. + final String noticeId; + + /// Source snapshot id. + final String snapshotId; + + /// Snapshot capture time. + final DateTime capturedAt; + + /// Existing neutral scheduler message. UI may localize from codes later. + final String message; + + /// Notice category. + final SchedulingNoticeType type; + + /// Related task id, if any. + final String? taskId; + + /// Stable issue code, if any. + final SchedulingIssueCode? issueCode; + + /// Stable movement code, if any. + final SchedulingMovementCode? movementCode; + + /// Stable conflict code, if any. + final SchedulingConflictCode? conflictCode; + + /// Structured notice parameters. + final Map parameters; +} diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/models/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_state.dart new file mode 100644 index 0000000..8b3670b --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_state.dart @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// Complete V1 Today state for one owner-local date. +class TodayState { + /// Creates a `TodayState` 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. + TodayState({ + required this.date, + required this.ownerId, + required this.timeZoneId, + required this.readAt, + required this.dayStart, + required this.dayEnd, + required this.ownerSettings, + required List timelineItems, + required this.compactState, + required List pendingNotices, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }) : timelineItems = List.unmodifiable(timelineItems), + pendingNotices = List.unmodifiable(pendingNotices); + + /// Owner-local date represented by this state. + final CivilDate date; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Time-zone id used for local-day expansion. + final String timeZoneId; + + /// Read instant from the request context. + final DateTime readAt; + + /// Resolved start instant for [date]. + final DateTime dayStart; + + /// Resolved end instant for [date]. + final DateTime dayEnd; + + /// Effective owner settings used by the query. + final OwnerSettings ownerSettings; + + /// Full sorted UI-independent timeline projection. + final List timelineItems; + + /// Current item, if one occupies [readAt]. + final TodayTimelineItem? currentItem; + + /// Next required item after [readAt]. + final TodayTimelineItem? nextRequiredItem; + + /// Optional next flexible item after [readAt]. + final TodayTimelineItem? nextFlexibleItem; + + /// Compact-mode projection from [timelineItems]. + final TodayCompactState compactState; + + /// Pending scheduling notices stored for the day. + final List pendingNotices; +} diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item.dart new file mode 100644 index 0000000..40f00b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item.dart @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// One item in the Today read model, with task status/source metadata. +class TodayTimelineItem { + /// Creates a `TodayTimelineItem` 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. + TodayTimelineItem({ + required this.item, + required this.source, + this.taskStatus, + this.taskId, + this.lockedBlockId, + this.lockedOverrideId, + this.hiddenByDefault = false, + this.isCurrent = false, + this.isNextRequired = false, + this.isNextFlexible = false, + }); + + /// Display-ready framework-neutral item. + final TimelineItem item; + + /// Repository/domain source for this item. + final TodayTimelineItemSource source; + + /// Source task status. Null for locked occurrences. + final TaskStatus? taskStatus; + + /// Source task id, when [source] is [TodayTimelineItemSource.task]. + final String? taskId; + + /// Source locked block id, when this is a locked occurrence. + final String? lockedBlockId; + + /// Source override id, when this occurrence was replaced or added. + final String? lockedOverrideId; + + /// Whether the locked source is normally hidden. + final bool hiddenByDefault; + + /// Whether this item is the current selected item for the read instant. + final bool isCurrent; + + /// Whether this item is the next required item. + final bool isNextRequired; + + /// Whether this item is the optional next flexible item. + final bool isNextFlexible; + + /// Stable item id. + String get id => item.id; + + /// Timeline start. + DateTime? get start => item.start; + + /// Timeline end. + DateTime? get end => item.end; + + /// Source task behavior type. + TaskType get taskType => item.taskType; + + /// Project/border color token. + String get projectColorToken => item.projectColorToken; + + /// Structured quick actions available for this item. + List get quickActions => item.quickActions; + + /// Return a copy with selection flags changed. + TodayTimelineItem copyWith({ + bool? isCurrent, + bool? isNextRequired, + bool? isNextFlexible, + }) { + return TodayTimelineItem( + item: item, + source: source, + taskStatus: taskStatus, + taskId: taskId, + lockedBlockId: lockedBlockId, + lockedOverrideId: lockedOverrideId, + hiddenByDefault: hiddenByDefault, + isCurrent: isCurrent ?? this.isCurrent, + isNextRequired: isNextRequired ?? this.isNextRequired, + isNextFlexible: isNextFlexible ?? this.isNextFlexible, + ); + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item_source.dart new file mode 100644 index 0000000..be845fc --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item_source.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// Source category for one Today timeline item. +enum TodayTimelineItemSource { + /// Selects the `task` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + task, + + /// Selects the `lockedOccurrence` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + lockedOccurrence, +} diff --git a/packages/scheduler_core/lib/src/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart similarity index 58% rename from packages/scheduler_core/lib/src/today_state.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart index 6e25d1e..256b296 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart @@ -1,292 +1,12 @@ -/// Implements Today State behavior for the Scheduler Core package. -library; +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only -// Application-level Today read model. -// -// This module builds UI-independent state for the Today and compact views. It -// is read-only: it does not run rollover, create activities, update statistics, -// or persist operation records. - -import 'application_layer.dart'; -import 'locked_time.dart'; -import 'models.dart'; -import 'repositories.dart'; -import 'scheduling_engine.dart'; -import 'timeline_state.dart'; -import 'time_contracts.dart'; - -/// Source category for one Today timeline item. -enum TodayTimelineItemSource { - task, - lockedOccurrence, -} - -/// Request for the V1 Today read model. -class GetTodayStateRequest { - const GetTodayStateRequest({ - required this.context, - required this.date, - this.revealHiddenLockedBlocks = false, - this.includeNextFlexibleItem = true, - this.fullTimelineExpanded = false, - }); - - /// Operation/read context. [ApplicationOperationContext.now] is the read - /// instant and is never recomputed during the query. - final ApplicationOperationContext context; - - /// Owner-local day to read. - final CivilDate date; - - /// Whether hidden locked-time overlays should be present in the item list. - final bool revealHiddenLockedBlocks; - - /// Whether compact projection should expose a next flexible task. - final bool includeNextFlexibleItem; - - /// Whether compact mode is currently showing the full timeline. - final bool fullTimelineExpanded; -} - -/// One item in the Today read model, with task status/source metadata. -class TodayTimelineItem { - TodayTimelineItem({ - required this.item, - required this.source, - this.taskStatus, - this.taskId, - this.lockedBlockId, - this.lockedOverrideId, - this.hiddenByDefault = false, - this.isCurrent = false, - this.isNextRequired = false, - this.isNextFlexible = false, - }); - - /// Display-ready framework-neutral item. - final TimelineItem item; - - /// Repository/domain source for this item. - final TodayTimelineItemSource source; - - /// Source task status. Null for locked occurrences. - final TaskStatus? taskStatus; - - /// Source task id, when [source] is [TodayTimelineItemSource.task]. - final String? taskId; - - /// Source locked block id, when this is a locked occurrence. - final String? lockedBlockId; - - /// Source override id, when this occurrence was replaced or added. - final String? lockedOverrideId; - - /// Whether the locked source is normally hidden. - final bool hiddenByDefault; - - /// Whether this item is the current selected item for the read instant. - final bool isCurrent; - - /// Whether this item is the next required item. - final bool isNextRequired; - - /// Whether this item is the optional next flexible item. - final bool isNextFlexible; - - /// Stable item id. - String get id => item.id; - - /// Timeline start. - DateTime? get start => item.start; - - /// Timeline end. - DateTime? get end => item.end; - - /// Source task behavior type. - TaskType get taskType => item.taskType; - - /// Project/border color token. - String get projectColorToken => item.projectColorToken; - - /// Structured quick actions available for this item. - List get quickActions => item.quickActions; - - /// Return a copy with selection flags changed. - TodayTimelineItem copyWith({ - bool? isCurrent, - bool? isNextRequired, - bool? isNextFlexible, - }) { - return TodayTimelineItem( - item: item, - source: source, - taskStatus: taskStatus, - taskId: taskId, - lockedBlockId: lockedBlockId, - lockedOverrideId: lockedOverrideId, - hiddenByDefault: hiddenByDefault, - isCurrent: isCurrent ?? this.isCurrent, - isNextRequired: isNextRequired ?? this.isNextRequired, - isNextFlexible: isNextFlexible ?? this.isNextFlexible, - ); - } -} - -/// Compact projection derived from the same Today item list. -class TodayCompactState { - const TodayCompactState({ - required this.manualCompactModeEnabled, - required this.fullTimelineExpanded, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }); - - /// User-controlled compact mode flag. - final bool manualCompactModeEnabled; - - /// Whether the full timeline is expanded while compact mode is active. - final bool fullTimelineExpanded; - - /// Current visible item, if one occupies the read instant. - final TodayTimelineItem? currentItem; - - /// Next critical or inflexible item. - final TodayTimelineItem? nextRequiredItem; - - /// Optional next flexible item. - final TodayTimelineItem? nextFlexibleItem; - - /// Whether callers should show the full timeline. - bool get fullTimelineVisible { - return !manualCompactModeEnabled || fullTimelineExpanded; - } - - /// Items selected for compact rendering. - List get compactItems { - if (!manualCompactModeEnabled) { - return const []; - } - - return [ - if (currentItem != null) currentItem!, - if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id) - nextRequiredItem!, - if (nextFlexibleItem != null && - nextFlexibleItem!.id != currentItem?.id && - nextFlexibleItem!.id != nextRequiredItem?.id) - nextFlexibleItem!, - ]; - } -} - -/// Pending notice data surfaced through Today state. -class TodayPendingNotice { - TodayPendingNotice({ - required this.noticeId, - required this.snapshotId, - required this.capturedAt, - required this.message, - required this.type, - required Map parameters, - this.taskId, - this.issueCode, - this.movementCode, - this.conflictCode, - }) : parameters = Map.unmodifiable(parameters); - - /// Stable id for acknowledgement/consume commands. - final String noticeId; - - /// Source snapshot id. - final String snapshotId; - - /// Snapshot capture time. - final DateTime capturedAt; - - /// Existing neutral scheduler message. UI may localize from codes later. - final String message; - - /// Notice category. - final SchedulingNoticeType type; - - /// Related task id, if any. - final String? taskId; - - /// Stable issue code, if any. - final SchedulingIssueCode? issueCode; - - /// Stable movement code, if any. - final SchedulingMovementCode? movementCode; - - /// Stable conflict code, if any. - final SchedulingConflictCode? conflictCode; - - /// Structured notice parameters. - final Map parameters; -} - -/// Complete V1 Today state for one owner-local date. -class TodayState { - TodayState({ - required this.date, - required this.ownerId, - required this.timeZoneId, - required this.readAt, - required this.dayStart, - required this.dayEnd, - required this.ownerSettings, - required List timelineItems, - required this.compactState, - required List pendingNotices, - this.currentItem, - this.nextRequiredItem, - this.nextFlexibleItem, - }) : timelineItems = List.unmodifiable(timelineItems), - pendingNotices = List.unmodifiable(pendingNotices); - - /// Owner-local date represented by this state. - final CivilDate date; - - /// Authorization-neutral owner scope. - final String ownerId; - - /// Time-zone id used for local-day expansion. - final String timeZoneId; - - /// Read instant from the request context. - final DateTime readAt; - - /// Resolved start instant for [date]. - final DateTime dayStart; - - /// Resolved end instant for [date]. - final DateTime dayEnd; - - /// Effective owner settings used by the query. - final OwnerSettings ownerSettings; - - /// Full sorted UI-independent timeline projection. - final List timelineItems; - - /// Current item, if one occupies [readAt]. - final TodayTimelineItem? currentItem; - - /// Next required item after [readAt]. - final TodayTimelineItem? nextRequiredItem; - - /// Optional next flexible item after [readAt]. - final TodayTimelineItem? nextFlexibleItem; - - /// Compact-mode projection from [timelineItems]. - final TodayCompactState compactState; - - /// Pending scheduling notices stored for the day. - final List pendingNotices; -} +part of '../../today_state.dart'; /// Builds Today state from application repositories. class GetTodayStateQuery { + /// Creates a `GetTodayStateQuery` 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 GetTodayStateQuery({ required this.applicationStore, required this.timeZoneResolver, @@ -446,6 +166,8 @@ class GetTodayStateQuery { ); } + /// Runs the `_resolve` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. DateTime _resolve({ required CivilDate date, required WallTime wallTime, @@ -461,6 +183,8 @@ class GetTodayStateQuery { .instant; } + /// Runs the `_taskAppearsInToday` 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 bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) { if (task.status == TaskStatus.backlog || task.status == TaskStatus.cancelled || @@ -474,6 +198,8 @@ class GetTodayStateQuery { return task.scheduledStart != null && task.scheduledEnd != null; } + /// Runs the `_currentItem` 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 TodayTimelineItem? _currentItem( List items, DateTime now, @@ -497,6 +223,8 @@ class GetTodayStateQuery { ); } + /// Runs the `_nextRequiredItem` 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 TodayTimelineItem? _nextRequiredItem( List items, DateTime now, @@ -511,6 +239,8 @@ class GetTodayStateQuery { ); } + /// Runs the `_nextFlexibleItem` 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 TodayTimelineItem? _nextFlexibleItem( List items, DateTime now, { @@ -527,14 +257,20 @@ class GetTodayStateQuery { ); } + /// Runs the `_isActionable` 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 bool _isActionable(TaskStatus? status) { return status == TaskStatus.planned || status == TaskStatus.active; } + /// Runs the `_isRequired` 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 bool _isRequired(TaskType type) { return type == TaskType.inflexible || type == TaskType.critical; } + /// Runs the `_containsTime` 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 bool _containsTime(TodayTimelineItem item, DateTime now) { final start = item.start; final end = item.end; @@ -545,11 +281,15 @@ class GetTodayStateQuery { return !now.isBefore(start) && now.isBefore(end); } + /// Runs the `_startsAtOrAfter` 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 bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) { final start = item.start; return start != null && !start.isBefore(now); } + /// Runs the `_findById` 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 TodayTimelineItem? _findById( List items, String? id, @@ -561,6 +301,8 @@ class GetTodayStateQuery { return _firstOrNull(items.where((item) => item.id == id)); } + /// Runs the `_pendingNoticesFromSnapshots` 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 List _pendingNoticesFromSnapshots( List snapshots, { Set acknowledgedNoticeIds = const {}, @@ -603,6 +345,8 @@ class GetTodayStateQuery { } } +/// Runs the `schedulingNoticeId` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. String schedulingNoticeId({ required String snapshotId, required int noticeIndex, @@ -610,10 +354,14 @@ String schedulingNoticeId({ return _noticeId(snapshotId, noticeIndex); } +/// Top-level helper that performs the `_noticeId` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _noticeId(String snapshotId, int noticeIndex) { return '$snapshotId:$noticeIndex'; } +/// Top-level helper that performs the `_compareTodayItems` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { final startComparison = _compareNullableInstants(left.start, right.start); if (startComparison != 0) { @@ -633,6 +381,8 @@ int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { return left.id.compareTo(right.id); } +/// Top-level helper that performs the `_compareNullableInstants` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. int _compareNullableInstants(DateTime? left, DateTime? right) { if (left == null && right == null) { return 0; @@ -647,6 +397,8 @@ int _compareNullableInstants(DateTime? left, DateTime? right) { return left.compareTo(right); } +/// Top-level helper that performs the `_firstOrNull` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. T? _firstOrNull(Iterable values) { final iterator = values.iterator; if (!iterator.moveNext()) { diff --git a/packages/scheduler_core/lib/src/scheduling/today_state/requests/get_today_state_request.dart b/packages/scheduler_core/lib/src/scheduling/today_state/requests/get_today_state_request.dart new file mode 100644 index 0000000..27afd0e --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/today_state/requests/get_today_state_request.dart @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../today_state.dart'; + +/// Request for the V1 Today read model. +class GetTodayStateRequest { + /// Creates a `GetTodayStateRequest` 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 GetTodayStateRequest({ + required this.context, + required this.date, + this.revealHiddenLockedBlocks = false, + this.includeNextFlexibleItem = true, + this.fullTimelineExpanded = false, + }); + + /// Operation/read context. [ApplicationOperationContext.now] is the read + /// instant and is never recomputed during the query. + final ApplicationOperationContext context; + + /// Owner-local day to read. + final CivilDate date; + + /// Whether hidden locked-time overlays should be present in the item list. + final bool revealHiddenLockedBlocks; + + /// Whether compact projection should expose a next flexible task. + final bool includeNextFlexibleItem; + + /// Whether compact mode is currently showing the full timeline. + final bool fullTimelineExpanded; +} diff --git a/packages/scheduler_core/lib/src/task_actions.dart b/packages/scheduler_core/lib/src/task_actions.dart deleted file mode 100644 index 2cf0a4f..0000000 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ /dev/null @@ -1,1101 +0,0 @@ -/// Implements Task Actions behavior for the Scheduler Core package. -library; - -// Flexible task card actions. -// -// The main scheduling engine moves tasks through time. This file models the -// small user actions that appear on a flexible task card, such as done, push, -// backlog, and break-up. Keeping these in a service makes UI button handlers -// thin and keeps task-type safety checks in one place. - -import 'models.dart'; -import 'occupancy_policy.dart'; -import 'scheduling_engine.dart'; -import 'task_lifecycle.dart'; -import 'time_contracts.dart'; - -/// Quick actions available from a flexible task card. -/// -/// These are the low-friction card controls the UI can expose directly on a -/// planned flexible task. The service below translates each button into either a -/// direct task update, a scheduling operation, or a follow-up flow. -enum FlexibleTaskQuickAction { - /// Mark the task completed. - done, - - /// Ask the user where the task should be pushed. - push, - - /// Move the task out of today's plan and into backlog. - backlog, - - /// Start a flow that splits the task into child tasks. - breakUp, -} - -/// State transitions available from required visible task cards. -/// -/// Required visible tasks are critical or inflexible items. They are not moved by -/// flexible scheduling, but the user still needs simple lifecycle actions for -/// what happened to the commitment. -enum RequiredTaskAction { - /// Mark the required task completed. - done, - - /// Mark that the required task did not happen. - missed, - - /// Intentionally remove it from the active plan. - cancel, - - /// Dismiss it because it no longer applies, distinct from cancellation. - noLongerRelevant, -} - -/// Explicit push destinations shown after choosing the push quick action. -/// -/// Push starts as a simple quick action, but the actual destination requires one -/// more choice. Keeping destinations as a separate enum prevents the initial card -/// action list from becoming too crowded. -enum PushDestination { - /// Move the task later within the current planning window. - nextAvailableSlot, - - /// Move the task to the beginning of the supplied tomorrow/future window. - tomorrowTopOfQueue, - - /// Remove the task from the active timeline and store it for later. - backlog, -} - -/// Domain result for a flexible task quick action. -/// -/// This result deliberately supports three outcomes: the task changed, the user -/// must choose a push destination, or the UI should start the child-task flow. -/// That keeps card code from guessing how to interpret each action. -class FlexibleTaskActionResult { - FlexibleTaskActionResult({ - required this.action, - required this.task, - this.pushDestinations = const [], - this.startsChildTaskFlow = false, - List activities = const [], - this.transitionOutcomeCode, - }) : activities = List.unmodifiable(activities); - - /// Action the user selected. - final FlexibleTaskQuickAction action; - - /// Current or updated task, depending on the action. - final Task task; - - /// Destination choices to show after `push`; empty for direct actions. - final List pushDestinations; - - /// Whether the UI should open a child-task creation flow. - final bool startsChildTaskFlow; - - /// Internal activities produced by direct lifecycle actions. - final List activities; - - /// Typed transition outcome for direct lifecycle actions. - final TaskTransitionOutcomeCode? transitionOutcomeCode; - - /// True when the action directly produced an updated [task]. - bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; -} - -/// Result from applying a selected push destination. -/// -/// The selected destination is included next to the [SchedulingResult] so UI and -/// tests can distinguish "moved later today" from "moved to tomorrow" even if -/// the low-level scheduling change shape is similar. -class PushDestinationResult { - PushDestinationResult({ - required this.destination, - required this.schedulingResult, - List activities = const [], - }) : activities = List.unmodifiable(activities); - - /// Destination that was applied. - final PushDestination destination; - - /// Full scheduler output: updated tasks, notices, changes, and overlaps. - final SchedulingResult schedulingResult; - - /// Internal activities produced by applying the selected destination. - final List activities; - - /// Convenience flag for UI copy or persistence behavior that cares about the - /// tomorrow queue specifically. - bool get placesAtTomorrowTopOfQueue { - return destination == PushDestination.tomorrowTopOfQueue; - } -} - -/// Result from applying a required task state transition. -class RequiredTaskActionResult { - RequiredTaskActionResult({ - required this.action, - required this.task, - required this.removedFromActivePlan, - List activities = const [], - this.transitionOutcomeCode, - }) : activities = List.unmodifiable(activities); - - /// Action the user selected. - final RequiredTaskAction action; - - /// Updated task after the transition. - final Task task; - - /// Whether the transition removes the task from the active timeline. - /// - /// Critical missed tasks return true because they move to backlog. Cancelled - /// and no-longer-relevant tasks also return true because they are no longer - /// active planned work. - final bool removedFromActivePlan; - - /// Internal activities produced by the transition. - final List activities; - - /// Typed transition outcome. - final TaskTransitionOutcomeCode? transitionOutcomeCode; -} - -/// Input for logging work the user already did outside the plan. -/// -/// The user may know only a title, or may also know when it happened and how -/// long it took. When time data is present, the resulting surprise task occupies -/// that interval and flexible work overlapping it is pushed out of the way. -class SurpriseTaskLogRequest { - const SurpriseTaskLogRequest({ - required this.id, - required this.title, - required this.createdAt, - this.startedAt, - this.timeUsedMinutes, - this.projectId = 'inbox', - this.priority, - this.reward = RewardLevel.notSet, - this.difficulty = DifficultyLevel.notSet, - }); - - /// Caller-generated id for the new surprise task. - /// - /// This id is also the idempotency key for retrying the same log operation. - final String id; - - /// User-entered title. - final String title; - - /// Creation/log timestamp supplied by the caller for testability. - final DateTime createdAt; - - /// When the surprise work started, if known. - final DateTime? startedAt; - - /// Minutes spent on the surprise work, if known. - final int? timeUsedMinutes; - - /// Project id to assign; defaults to inbox when omitted. - final String projectId; - - /// Optional priority. Null means the user did not set one. - final PriorityLevel? priority; - - /// Optional reward estimate. - final RewardLevel reward; - - /// Optional difficulty estimate. - final DifficultyLevel difficulty; -} - -/// Result from logging a surprise task. -class SurpriseTaskLogResult { - const SurpriseTaskLogResult({ - required this.surpriseTask, - required this.schedulingResult, - this.activities = const [], - this.requiredOverlaps = const [], - this.lockedOverlaps = const [], - this.usedNormalPushRules = false, - }); - - /// Newly created completed surprise task. - final Task surpriseTask; - - /// Updated task list, movement notices, changes, and visible overlaps. - final SchedulingResult schedulingResult; - - /// Internal activities produced by logging the surprise task. - final List activities; - - /// Critical/inflexible overlaps with the surprise interval. - final List requiredOverlaps; - - /// Locked overlaps tracked separately so they stay hidden by default. - final List lockedOverlaps; - - /// Whether flexible movement was delegated to normal push behavior. - final bool usedNormalPushRules; -} - -/// Applies low-friction quick actions for flexible task cards. -/// -/// This service is the adapter between small UI button presses and domain logic. -/// It intentionally only accepts flexible tasks; required/locked/surprise items -/// should have their own action rules so the UI cannot accidentally apply a -/// flexible-only behavior to a fixed commitment. -class FlexibleTaskActionService { - const FlexibleTaskActionService({ - this.schedulingEngine = const SchedulingEngine(), - this.transitionService = const TaskTransitionService(), - this.clock = const SystemClock(), - }); - - /// Scheduling dependency used for actions that need timeline changes. - final SchedulingEngine schedulingEngine; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Clock boundary used when an action timestamp is not supplied. - final Clock clock; - - /// Apply the first-stage quick action. - /// - /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the - /// list of destinations the UI should present. `breakUp` signals that the UI - /// should start a child-task flow rather than changing the task immediately. - FlexibleTaskActionResult apply({ - required Task task, - required FlexibleTaskQuickAction action, - DateTime? updatedAt, - String? operationId, - DateTime? actualStart, - DateTime? actualEnd, - List lockedIntervals = const [], - Iterable existingActivities = const [], - }) { - if (!task.isFlexible) { - throw ArgumentError.value( - task.type, 'task.type', 'Task must be flexible.'); - } - - switch (action) { - case FlexibleTaskQuickAction.done: - final now = updatedAt ?? clock.now(); - final opId = - operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.complete, - operationId: opId, - activityId: _activityId( - opId, - task.id, - TaskActivityCode.completed, - ), - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: lockedIntervals, - existingActivities: existingActivities, - ); - return FlexibleTaskActionResult( - action: action, - task: transition.task, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - case FlexibleTaskQuickAction.push: - return FlexibleTaskActionResult( - action: action, - task: task, - pushDestinations: const [ - PushDestination.nextAvailableSlot, - PushDestination.tomorrowTopOfQueue, - PushDestination.backlog, - ], - ); - case FlexibleTaskQuickAction.backlog: - final now = updatedAt ?? clock.now(); - final opId = - operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.moveToBacklog, - operationId: opId, - activityId: _activityId( - opId, - task.id, - TaskActivityCode.movedToBacklog, - ), - occurredAt: now, - existingActivities: existingActivities, - ); - return FlexibleTaskActionResult( - action: action, - task: transition.task, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - case FlexibleTaskQuickAction.breakUp: - return FlexibleTaskActionResult( - action: action, - task: task, - startsChildTaskFlow: true, - ); - } - } - - /// Apply the second-stage destination selected after the `push` action. - /// - /// This needs the full [SchedulingInput] because pushing can shift other - /// flexible tasks and must avoid locked/required intervals. - PushDestinationResult applyPushDestination({ - required PushDestination destination, - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - String? operationId, - Iterable existingActivities = const [], - }) { - final now = updatedAt ?? clock.now(); - final opId = operationId ?? - _defaultOperationId('push-${destination.name}', taskId, now); - if (_hasDuplicateActivity( - existingActivities: existingActivities, - operationId: opId, - taskId: taskId, - )) { - return PushDestinationResult( - destination: destination, - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: _operationCodeForPushDestination(destination), - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice('Push operation already applied.'), - ], - ), - ); - } - - final result = switch (destination) { - PushDestination.nextAvailableSlot => - schedulingEngine.pushFlexibleTaskToNextAvailableSlot( - input: input, - taskId: taskId, - updatedAt: now, - ), - PushDestination.tomorrowTopOfQueue => - schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( - input: input, - taskId: taskId, - updatedAt: now, - ), - PushDestination.backlog => _moveTaskToBacklog( - input: input, - taskId: taskId, - updatedAt: now, - ), - }; - - return PushDestinationResult( - destination: destination, - schedulingResult: result, - activities: _activitiesForPushDestination( - destination: destination, - input: input, - result: result, - taskId: taskId, - operationId: opId, - occurredAt: now, - existingActivities: existingActivities, - ), - ); - } - - /// Move one planned flexible task to backlog inside a scheduling result. - /// - /// This mirrors the shape of other push destination results so callers can - /// handle every destination through the same `SchedulingResult` interface. - SchedulingResult _moveTaskToBacklog({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.notFound, - notices: [ - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - ], - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.invalidState, - notices: [ - SchedulingNotice( - 'Only planned flexible tasks can be moved to backlog.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - ], - ); - } - - final movedTask = schedulingEngine.moveToBacklog( - task, - updatedAt: updatedAt, - ); - - return SchedulingResult( - tasks: List.unmodifiable( - input.tasks.map((current) { - if (current.id == task.id) { - return movedTask; - } - - return current; - }), - ), - operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, - outcomeCode: SchedulingOutcomeCode.success, - notices: [ - SchedulingNotice( - 'Flexible task moved to backlog.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': null, - 'nextEnd': null, - }, - ), - ], - changes: [ - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: null, - nextEnd: null, - ), - ], - ); - } -} - -/// Applies lifecycle actions for required visible task cards. -/// -/// This service only handles critical and inflexible tasks. Locked blocks are -/// scheduling constraints, not normal task cards, and flexible tasks use -/// [FlexibleTaskActionService]. -class RequiredTaskActionService { - const RequiredTaskActionService({ - this.schedulingEngine = const SchedulingEngine(), - this.transitionService = const TaskTransitionService(), - this.clock = const SystemClock(), - }); - - /// Scheduling dependency used for required missed behavior. - final SchedulingEngine schedulingEngine; - - /// Canonical lifecycle transition dependency. - final TaskTransitionService transitionService; - - /// Clock boundary used when an action timestamp is not supplied. - final Clock clock; - - /// Apply one required-card state transition. - RequiredTaskActionResult apply({ - required Task task, - required RequiredTaskAction action, - DateTime? updatedAt, - String? operationId, - DateTime? actualStart, - DateTime? actualEnd, - List lockedIntervals = const [], - Iterable existingActivities = const [], - }) { - if (!task.isRequiredVisible) { - throw ArgumentError.value( - task.type, - 'task.type', - 'Task must be critical or inflexible.', - ); - } - - final now = updatedAt ?? clock.now(); - - final transitionCode = switch (action) { - RequiredTaskAction.done => TaskTransitionCode.complete, - RequiredTaskAction.missed => TaskTransitionCode.miss, - RequiredTaskAction.cancel => TaskTransitionCode.cancel, - RequiredTaskAction.noLongerRelevant => - TaskTransitionCode.noLongerRelevant, - }; - final activityCode = switch (action) { - RequiredTaskAction.done => TaskActivityCode.completed, - RequiredTaskAction.missed => TaskActivityCode.missed, - RequiredTaskAction.cancel => TaskActivityCode.cancelled, - RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, - }; - final opId = operationId ?? _defaultOperationId(action.name, task.id, now); - final transition = transitionService.apply( - task: task, - transitionCode: transitionCode, - operationId: opId, - activityId: _activityId(opId, task.id, activityCode), - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: lockedIntervals, - existingActivities: existingActivities, - ); - - return RequiredTaskActionResult( - action: action, - task: transition.task, - removedFromActivePlan: transition.task.status == TaskStatus.backlog || - transition.task.status == TaskStatus.cancelled || - transition.task.status == TaskStatus.noLongerRelevant, - activities: transition.activities, - transitionOutcomeCode: transition.outcomeCode, - ); - } -} - -/// Logs surprise completed work and repairs affected flexible tasks. -/// -/// Surprise work is already done, so the new task is created as completed. When -/// it has a concrete interval, overlapping planned flexible tasks are moved by -/// the existing push scheduler with the surprise interval treated as blocked -/// time. Required visible and locked time is never moved. -class SurpriseTaskLogService { - const SurpriseTaskLogService({ - this.schedulingEngine = const SchedulingEngine(), - this.accountingService = const TaskActivityAccountingService(), - this.clock = const SystemClock(), - this.idGenerator, - }); - - /// Scheduling dependency used to move overlapping flexible tasks. - final SchedulingEngine schedulingEngine; - - /// Activity-derived statistics updater. - final TaskActivityAccountingService accountingService; - - /// Clock boundary used when a log timestamp is not supplied. - final Clock clock; - - /// Optional ID boundary for outer convenience entry points. - final IdGenerator? idGenerator; - - /// Log one surprise task against a scheduling snapshot. - SurpriseTaskLogResult log({ - required SurpriseTaskLogRequest request, - required SchedulingInput input, - DateTime? updatedAt, - bool revealHiddenLockedDetails = false, - }) { - final now = updatedAt ?? clock.now(); - final existingTask = _taskById(input.tasks, request.id); - if (existingTask != null) { - if (existingTask.type != TaskType.surprise) { - throw ArgumentError.value( - request.id, - 'request.id', - 'Surprise task id is already used by another task.', - ); - } - - return SurpriseTaskLogResult( - surpriseTask: existingTask, - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice( - 'Surprise task already logged.', - issueCode: SchedulingIssueCode.duplicateSurpriseLog, - taskId: existingTask.id, - ), - ], - ), - ); - } - - final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now); - final completedActivity = _surpriseCompletedActivity( - task: rawSurpriseTask, - operationId: request.id, - occurredAt: now, - ); - final accountedSurprise = accountingService.apply( - task: rawSurpriseTask, - activities: [completedActivity], - lockedIntervals: _lockedIntervalsForAccounting(input), - ); - final surpriseTask = accountedSurprise.task; - final surpriseInterval = _occupyingIntervalForTask(surpriseTask); - var currentTasks = [surpriseTask, ...input.tasks]; - - if (surpriseInterval == null) { - return SurpriseTaskLogResult( - surpriseTask: surpriseTask, - schedulingResult: SchedulingResult( - tasks: List.unmodifiable(currentTasks), - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: SchedulingOutcomeCode.success, - notices: [ - SchedulingNotice('Surprise task logged.'), - ], - ), - activities: [completedActivity], - ); - } - - final requiredOverlaps = _requiredOverlaps( - input: input, - surpriseInterval: surpriseInterval, - ); - final lockedOverlaps = _lockedOverlaps( - input: input, - surpriseInterval: surpriseInterval, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - final flexibleTaskIds = _overlappingFlexibleTaskIds( - input.movablePlannedFlexibleTasks, - surpriseInterval, - ); - final changes = []; - final movementNotices = []; - var usedNormalPushRules = false; - - for (final taskId in flexibleTaskIds) { - final currentTask = _taskById(currentTasks, taskId); - final currentInterval = - currentTask == null ? null : _scheduledIntervalForTask(currentTask); - if (currentTask == null || - currentInterval == null || - !currentInterval.overlaps(surpriseInterval)) { - continue; - } - - final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( - input: SchedulingInput( - tasks: currentTasks, - window: input.window, - lockedIntervals: input.lockedIntervals, - requiredVisibleIntervals: input.requiredVisibleIntervals, - ), - taskId: taskId, - updatedAt: now, - countAsManualPush: false, - ); - - usedNormalPushRules = true; - currentTasks = pushResult.tasks; - changes.addAll(pushResult.changes); - movementNotices.addAll(pushResult.notices); - } - - final overlapNotices = requiredOverlaps - .map( - (overlap) => SchedulingNotice( - 'Surprise task overlaps required visible time.', - type: SchedulingNoticeType.overlap, - taskId: overlap.taskId, - conflictCode: - SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, - parameters: { - 'blockedStart': overlap.blockedInterval.start, - 'blockedEnd': overlap.blockedInterval.end, - }, - ), - ) - .toList(growable: false); - - return SurpriseTaskLogResult( - surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, - schedulingResult: SchedulingResult( - tasks: List.unmodifiable(currentTasks), - operationCode: SchedulingOperationCode.logSurpriseTask, - outcomeCode: requiredOverlaps.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: List.unmodifiable([ - SchedulingNotice('Surprise task logged.'), - ...movementNotices, - ...overlapNotices, - ]), - changes: List.unmodifiable(changes), - overlaps: List.unmodifiable(requiredOverlaps), - ), - requiredOverlaps: List.unmodifiable(requiredOverlaps), - lockedOverlaps: List.unmodifiable(lockedOverlaps), - activities: [completedActivity], - usedNormalPushRules: usedNormalPushRules, - ); - } - - /// Convenience outer-boundary wrapper that supplies id and creation time. - SurpriseTaskLogResult logNew({ - required String title, - required SchedulingInput input, - DateTime? startedAt, - int? timeUsedMinutes, - String projectId = 'inbox', - PriorityLevel? priority, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - DateTime? createdAt, - DateTime? updatedAt, - bool revealHiddenLockedDetails = false, - }) { - final generator = idGenerator; - if (generator == null) { - throw StateError('Surprise task logging needs an id generator.'); - } - - final now = createdAt ?? updatedAt ?? clock.now(); - - return log( - request: SurpriseTaskLogRequest( - id: generator.nextId(), - title: title, - createdAt: now, - startedAt: startedAt, - timeUsedMinutes: timeUsedMinutes, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - ), - input: input, - updatedAt: updatedAt ?? now, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - } - - Task _createSurpriseTask( - SurpriseTaskLogRequest request, { - required DateTime updatedAt, - }) { - final trimmedTitle = request.title.trim(); - if (trimmedTitle.isEmpty) { - throw ArgumentError.value(request.title, 'title', 'Title is required.'); - } - - final duration = request.timeUsedMinutes; - final start = request.startedAt; - final hasScheduledTime = start != null && duration != null && duration > 0; - - return Task( - id: request.id, - title: trimmedTitle, - projectId: request.projectId, - type: TaskType.surprise, - status: TaskStatus.completed, - priority: request.priority, - reward: request.reward, - difficulty: request.difficulty, - durationMinutes: duration != null && duration > 0 ? duration : null, - scheduledStart: hasScheduledTime ? start : null, - scheduledEnd: - hasScheduledTime ? start.add(Duration(minutes: duration)) : null, - actualStart: hasScheduledTime ? start : null, - actualEnd: - hasScheduledTime ? start.add(Duration(minutes: duration)) : null, - completedAt: updatedAt, - createdAt: request.createdAt, - updatedAt: updatedAt, - ); - } -} - -List _activitiesForPushDestination({ - required PushDestination destination, - required SchedulingInput input, - required SchedulingResult result, - required String taskId, - required String operationId, - required DateTime occurredAt, - required Iterable existingActivities, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - - final activities = []; - for (final change in result.changes) { - if (_hasDuplicateActivity( - existingActivities: existingActivities, - operationId: operationId, - taskId: change.taskId, - )) { - continue; - } - - final task = _taskById(input.tasks, change.taskId); - if (task == null) { - continue; - } - - final code = _activityCodeForPushChange( - destination: destination, - isSelectedTask: change.taskId == taskId, - ); - activities.add( - TaskActivity( - id: _activityId(operationId, change.taskId, code), - operationId: operationId, - code: code, - taskId: change.taskId, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': task.status.name, - 'nextStatus': code == TaskActivityCode.movedToBacklog - ? TaskStatus.backlog.name - : task.status.name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - 'destination': destination.name, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -TaskActivityCode _activityCodeForPushChange({ - required PushDestination destination, - required bool isSelectedTask, -}) { - if (destination == PushDestination.backlog) { - return TaskActivityCode.movedToBacklog; - } - - return isSelectedTask - ? TaskActivityCode.manuallyPushed - : TaskActivityCode.automaticallyPushed; -} - -SchedulingOperationCode _operationCodeForPushDestination( - PushDestination destination, -) { - return switch (destination) { - PushDestination.nextAvailableSlot => - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - PushDestination.tomorrowTopOfQueue => - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - PushDestination.backlog => - SchedulingOperationCode.moveFlexibleTaskToBacklog, - }; -} - -TaskActivity _surpriseCompletedActivity({ - required Task task, - required String operationId, - required DateTime occurredAt, -}) { - return TaskActivity( - id: _activityId(operationId, task.id, TaskActivityCode.completed), - operationId: operationId, - code: TaskActivityCode.completed, - taskId: task.id, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - 'nextStatus': TaskStatus.completed.name, - 'completedAt': occurredAt, - 'actualStart': task.actualStart, - 'actualEnd': task.actualEnd, - 'durationMinutes': task.durationMinutes, - 'reward': task.reward.name, - 'difficulty': task.difficulty.name, - if (task.reminderOverride != null) - 'reminderProfile': task.reminderOverride!.name, - 'pushesBeforeCompletion': 0, - 'source': 'surpriseLog', - }, - ); -} - -List _lockedIntervalsForAccounting(SchedulingInput input) { - return input.occupancyEntries - .where( - (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint, - ) - .map((entry) => entry.interval) - .whereType() - .toList(growable: false); -} - -bool _hasDuplicateActivity({ - required Iterable existingActivities, - required String operationId, - required String taskId, -}) { - for (final activity in existingActivities) { - if (activity.operationId == operationId && activity.taskId == taskId) { - return true; - } - } - - return false; -} - -String _defaultOperationId( - String actionName, String taskId, DateTime occurredAt) { - return '$actionName:$taskId:${occurredAt.toIso8601String()}'; -} - -String _activityId( - String operationId, - String taskId, - TaskActivityCode activityCode, -) { - return '$operationId:$taskId:${activityCode.name}'; -} - -/// Find one task by id in a list. -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -/// Build an interval for a task, or null when it has no usable placement. -TimeInterval? _scheduledIntervalForTask(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - if (start == null || end == null || !start.isBefore(end)) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -/// Build an actual-first occupancy interval for completed/active task facts. -TimeInterval? _occupyingIntervalForTask(Task task) { - final actualStart = task.actualStart; - final actualEnd = task.actualEnd; - - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - return TimeInterval(start: actualStart, end: actualEnd, label: task.id); - } - - return _scheduledIntervalForTask(task); -} - -/// Required visible overlaps with a surprise interval. -List _requiredOverlaps({ - required SchedulingInput input, - required TimeInterval surpriseInterval, -}) { - final overlaps = []; - - for (final occupancy in input.occupancyEntries) { - final task = occupancy.task; - final interval = occupancy.interval; - final isRequiredOccupancy = - occupancy.category == OccupancyCategory.requiredVisibleCommitment || - (occupancy.category == OccupancyCategory.activeWork && - task != null && - task.isRequiredVisible); - - if (task == null || - !isRequiredOccupancy || - interval == null || - !interval.overlaps(surpriseInterval)) { - continue; - } - - overlaps.add( - SchedulingOverlap( - taskId: task.id, - taskInterval: interval, - blockedInterval: surpriseInterval, - ), - ); - } - - return overlaps; -} - -/// Locked overlaps with a surprise interval, kept hidden from normal notices. -List _lockedOverlaps({ - required SchedulingInput input, - required TimeInterval surpriseInterval, - required bool revealHiddenLockedDetails, -}) { - return input.occupancyEntries - .where( - (occupancy) => - occupancy.category == OccupancyCategory.hiddenLockedConstraint, - ) - .map((occupancy) => occupancy.interval) - .whereType() - .where((interval) => interval.overlaps(surpriseInterval)) - .map((interval) { - if (revealHiddenLockedDetails) { - return interval; - } - return TimeInterval(start: interval.start, end: interval.end); - }).toList(growable: false); -} - -/// Planned flexible task ids overlapping a surprise interval in timeline order. -List _overlappingFlexibleTaskIds( - List tasks, - TimeInterval surpriseInterval, -) { - final overlappingTasks = tasks.where((task) { - if (task.status != TaskStatus.planned) { - return false; - } - - final interval = _scheduledIntervalForTask(task); - return interval != null && interval.overlaps(surpriseInterval); - }).toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? surpriseInterval.end; - final bStart = b.scheduledStart ?? surpriseInterval.end; - - return aStart.compareTo(bStart); - }); - - return overlappingTasks.map((task) => task.id).toList(growable: false); -} diff --git a/packages/scheduler_core/lib/src/time_contracts.dart b/packages/scheduler_core/lib/src/time_contracts.dart deleted file mode 100644 index 4740589..0000000 --- a/packages/scheduler_core/lib/src/time_contracts.dart +++ /dev/null @@ -1,296 +0,0 @@ -/// Implements Time Contracts behavior for the Scheduler Core package. -library; - -import 'models.dart'; - -/// Deterministic source of the current instant. -abstract interface class Clock { - DateTime now(); -} - -/// Production clock boundary. Domain services should receive this through -/// constructors rather than reading wall time directly. -class SystemClock implements Clock { - const SystemClock(); - - @override - DateTime now() => DateTime.now(); -} - -/// Test/application clock with a fixed instant. -class FixedClock implements Clock { - const FixedClock(this.instant); - - final DateTime instant; - - @override - DateTime now() => instant; -} - -/// Deterministic ID source for application/domain convenience entry points. -abstract interface class IdGenerator { - String nextId(); -} - -/// Simple deterministic ID generator useful for tests and local wiring. -class SequentialIdGenerator implements IdGenerator { - SequentialIdGenerator({ - this.prefix = 'id', - int start = 1, - }) : _next = start; - - final String prefix; - int _next; - - @override - String nextId() { - final id = '$prefix-$_next'; - _next += 1; - return id; - } -} - -/// Calendar date without a time or timezone. -class CivilDate implements Comparable { - CivilDate(this.year, this.month, this.day) { - final normalized = DateTime.utc(year, month, day); - if (normalized.year != year || - normalized.month != month || - normalized.day != day) { - throw DomainValidationException( - code: DomainValidationCode.invalidCivilDate, - invalidValue: {'year': year, 'month': month, 'day': day}, - name: 'CivilDate', - message: 'Civil date must be a valid calendar date.', - ); - } - } - - factory CivilDate.fromDateTime(DateTime value) { - return CivilDate(value.year, value.month, value.day); - } - - factory CivilDate.fromIsoString(String value) { - if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { - throw DomainValidationException( - code: DomainValidationCode.invalidCivilDate, - invalidValue: value, - name: 'CivilDate', - message: 'Civil date must use YYYY-MM-DD format.', - ); - } - final parts = value.split('-'); - return CivilDate( - int.parse(parts[0]), - int.parse(parts[1]), - int.parse(parts[2]), - ); - } - - final int year; - final int month; - final int day; - - int get weekday => DateTime.utc(year, month, day).weekday; - - CivilDate addDays(int days) { - final shifted = DateTime.utc(year, month, day).add(Duration(days: days)); - return CivilDate(shifted.year, shifted.month, shifted.day); - } - - String toIsoString() { - final monthText = month.toString().padLeft(2, '0'); - final dayText = day.toString().padLeft(2, '0'); - return '$year-$monthText-$dayText'; - } - - @override - int compareTo(CivilDate other) { - final yearComparison = year.compareTo(other.year); - if (yearComparison != 0) { - return yearComparison; - } - final monthComparison = month.compareTo(other.month); - if (monthComparison != 0) { - return monthComparison; - } - return day.compareTo(other.day); - } - - @override - bool operator ==(Object other) { - return other is CivilDate && - other.year == year && - other.month == month && - other.day == day; - } - - @override - int get hashCode => Object.hash(year, month, day); - - @override - String toString() => toIsoString(); -} - -/// Wall-clock time without a date or timezone. -class WallTime implements Comparable { - WallTime({ - required this.hour, - required this.minute, - }) { - if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) { - throw DomainValidationException( - code: DomainValidationCode.invalidClockTime, - invalidValue: {'hour': hour, 'minute': minute}, - name: 'WallTime', - message: 'Wall time must use hour 0-23 and minute 0-59.', - ); - } - } - - factory WallTime.fromIsoString(String value) { - if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) { - throw DomainValidationException( - code: DomainValidationCode.invalidClockTime, - invalidValue: value, - name: 'WallTime', - message: 'Wall time must use HH:MM format.', - ); - } - final parts = value.split(':'); - return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1])); - } - - final int hour; - final int minute; - - int get minutesSinceMidnight => hour * 60 + minute; - - String toIsoString() { - return '${hour.toString().padLeft(2, '0')}:' - '${minute.toString().padLeft(2, '0')}'; - } - - @override - int compareTo(WallTime other) { - return minutesSinceMidnight.compareTo(other.minutesSinceMidnight); - } - - @override - bool operator ==(Object other) { - return other is WallTime && other.hour == hour && other.minute == minute; - } - - @override - int get hashCode => Object.hash(hour, minute); - - @override - String toString() => toIsoString(); -} - -enum LocalTimeResolution { - exact, - shiftedForward, - repeatedEarlier, - repeatedLater, -} - -enum NonexistentLocalTimePolicy { - /// Move through a daylight-saving gap to the first valid local instant after it. - shiftForward, - - /// Reject local times that do not exist in the requested timezone. - reject, -} - -enum RepeatedLocalTimePolicy { - /// Use the first instant for a repeated local time during a fall-back transition. - earlier, - - /// Use the second instant for a repeated local time during a fall-back transition. - later, - - /// Reject local times that occur more than once in the requested timezone. - reject, -} - -class TimeZoneResolutionOptions { - const TimeZoneResolutionOptions({ - this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward, - this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier, - }); - - final NonexistentLocalTimePolicy nonexistentLocalTimePolicy; - final RepeatedLocalTimePolicy repeatedLocalTimePolicy; -} - -class ResolvedLocalDateTime { - const ResolvedLocalDateTime({ - required this.date, - required this.wallTime, - required this.timeZoneId, - required this.instant, - required this.resolution, - required this.utcOffset, - }); - - final CivilDate date; - final WallTime wallTime; - final String timeZoneId; - final DateTime instant; - final LocalTimeResolution resolution; - final Duration utcOffset; -} - -/// Boundary for converting local civil date/time values to instants. -abstract interface class TimeZoneResolver { - ResolvedLocalDateTime resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - TimeZoneResolutionOptions options, - }); -} - -/// Resolver for zones whose offset is already known by the application boundary. -class FixedOffsetTimeZoneResolver implements TimeZoneResolver { - const FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero; - - const FixedOffsetTimeZoneResolver({required this.offset}); - - final Duration offset; - - @override - ResolvedLocalDateTime resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(), - }) { - final trimmedZone = timeZoneId.trim(); - if (trimmedZone.isEmpty) { - throw DomainValidationException( - code: DomainValidationCode.blankTimeZoneId, - invalidValue: timeZoneId, - name: 'timeZoneId', - message: 'Time-zone id is required.', - ); - } - final localAsUtc = DateTime.utc( - date.year, - date.month, - date.day, - wallTime.hour, - wallTime.minute, - ); - - return ResolvedLocalDateTime( - date: date, - wallTime: wallTime, - timeZoneId: trimmedZone, - instant: localAsUtc.subtract(offset), - resolution: LocalTimeResolution.exact, - utcOffset: offset, - ); - } -} diff --git a/packages/scheduler_core/pubspec.yaml b/packages/scheduler_core/pubspec.yaml index d87a90c..c9dca2a 100644 --- a/packages/scheduler_core/pubspec.yaml +++ b/packages/scheduler_core/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_core description: A pure Dart scheduling core starter for an ADHD-focused scheduling app. version: 0.1.0 diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index 8e7a624..b95e931 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Application Commands behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V1 application commands', () { final day = CivilDate(2026, 6, 25); @@ -311,6 +316,43 @@ void main() { expect(store.currentProjectStatistics.single.completedTaskCount, 1); }); + test('uncomplete task clears completion while preserving schedule', + () async { + final completed = task( + id: 'completed', + title: 'Completed', + type: TaskType.flexible, + status: TaskStatus.completed, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + actualStart: DateTime.utc(2026, 6, 25, 9), + actualEnd: DateTime.utc(2026, 6, 25, 9, 20), + completedAt: DateTime.utc(2026, 6, 25, 9, 20), + ); + final store = storeWithSettings(tasks: [completed]); + + final result = await commands(store).uncompleteTask( + context: appContext( + 'uncomplete-task', + DateTime.utc(2026, 6, 25, 9, 25), + ), + localDate: day, + taskId: completed.id, + ); + + expect(result.isSuccess, isTrue); + final reopened = taskById(store.currentTasks, completed.id); + expect(reopened.status, TaskStatus.planned); + expect(reopened.scheduledStart, completed.scheduledStart); + expect(reopened.scheduledEnd, completed.scheduledEnd); + expect(reopened.actualStart, isNull); + expect(reopened.actualEnd, isNull); + expect(reopened.completedAt, isNull); + expect(reopened.updatedAt, DateTime.utc(2026, 6, 25, 9, 25)); + expect(store.currentTaskActivities, isEmpty); + }); + test('surprise logging creates completed work and repairs flexible overlap', () async { final planned = task( @@ -485,6 +527,8 @@ void main() { }); } +/// Runs the `commands` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { return V1ApplicationCommandUseCases( applicationStore: store, @@ -492,6 +536,8 @@ V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { ); } +/// Runs the `storeWithSettings` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. InMemoryApplicationUnitOfWork storeWithSettings({ List tasks = const [], }) { @@ -508,6 +554,8 @@ InMemoryApplicationUnitOfWork storeWithSettings({ ); } +/// Runs the `appContext` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ApplicationOperationContext appContext(String operationId, DateTime now) { return ApplicationOperationContext.start( operationId: operationId, @@ -517,6 +565,8 @@ ApplicationOperationContext appContext(String operationId, DateTime now) { ); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required String title, @@ -525,6 +575,9 @@ Task task({ required DateTime createdAt, DateTime? start, DateTime? end, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, int? durationMinutes, String projectId = 'home', String? parentTaskId, @@ -539,12 +592,17 @@ Task task({ (start != null && end != null ? end.difference(start).inMinutes : 30), scheduledStart: start, scheduledEnd: end, + actualStart: actualStart, + actualEnd: actualEnd, + completedAt: completedAt, parentTaskId: parentTaskId, createdAt: createdAt, updatedAt: createdAt, ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String taskId) { return tasks.singleWhere((task) => task.id == taskId); } diff --git a/packages/scheduler_core/test/application_layer_test.dart b/packages/scheduler_core/test/application_layer_test.dart index dbbaf99..0f13f8a 100644 --- a/packages/scheduler_core/test/application_layer_test.dart +++ b/packages/scheduler_core/test/application_layer_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Application Layer behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Application unit of work', () { final now = DateTime.utc(2026, 6, 25, 16); @@ -360,6 +365,8 @@ void main() { }); } +/// Runs the `appContext` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ApplicationOperationContext appContext({ required String operationId, required DateTime now, @@ -375,6 +382,8 @@ ApplicationOperationContext appContext({ ); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required TaskStatus status, @@ -396,6 +405,8 @@ Task task({ ); } +/// Runs the `activityFor` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. TaskActivity activityFor({ required String id, required String operationId, diff --git a/packages/scheduler_core/test/application_management_test.dart b/packages/scheduler_core/test/application_management_test.dart index 0af3111..92750d8 100644 --- a/packages/scheduler_core/test/application_management_test.dart +++ b/packages/scheduler_core/test/application_management_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Application Management behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V1ApplicationManagementUseCases', () { final now = DateTime.utc(2026, 6, 25, 12); @@ -316,6 +321,8 @@ void main() { }); } +/// Runs the `appContext` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ApplicationOperationContext appContext({ required String operationId, required DateTime now, @@ -328,6 +335,8 @@ ApplicationOperationContext appContext({ ); } +/// Runs the `backlogTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task backlogTask({ required String id, required DateTime createdAt, diff --git a/packages/scheduler_core/test/application_recovery_test.dart b/packages/scheduler_core/test/application_recovery_test.dart index ae451ee..a5666ae 100644 --- a/packages/scheduler_core/test/application_recovery_test.dart +++ b/packages/scheduler_core/test/application_recovery_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Application Recovery behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V1AppOpenRecoveryUseCases', () { final sourceDay = CivilDate(2026, 6, 25); @@ -194,6 +199,8 @@ void main() { }); } +/// Runs the `commands` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { return V1ApplicationCommandUseCases( applicationStore: store, @@ -201,6 +208,8 @@ V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { ); } +/// Runs the `recovery` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. V1AppOpenRecoveryUseCases recovery(InMemoryApplicationUnitOfWork store) { return V1AppOpenRecoveryUseCases( applicationStore: store, @@ -208,6 +217,8 @@ V1AppOpenRecoveryUseCases recovery(InMemoryApplicationUnitOfWork store) { ); } +/// Runs the `today` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. GetTodayStateQuery today(InMemoryApplicationUnitOfWork store) { return GetTodayStateQuery( applicationStore: store, @@ -215,6 +226,8 @@ GetTodayStateQuery today(InMemoryApplicationUnitOfWork store) { ); } +/// Runs the `storeWithSettings` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. InMemoryApplicationUnitOfWork storeWithSettings({ List tasks = const [], }) { @@ -231,6 +244,8 @@ InMemoryApplicationUnitOfWork storeWithSettings({ ); } +/// Runs the `appContext` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ApplicationOperationContext appContext(String operationId, DateTime now) { return ApplicationOperationContext.start( operationId: operationId, @@ -240,6 +255,8 @@ ApplicationOperationContext appContext(String operationId, DateTime now) { ); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required String title, @@ -264,6 +281,8 @@ Task task({ ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String taskId) { return tasks.singleWhere((task) => task.id == taskId); } diff --git a/packages/scheduler_core/test/child_tasks_test.dart b/packages/scheduler_core/test/child_tasks_test.dart index fa0d81e..14daddd 100644 --- a/packages/scheduler_core/test/child_tasks_test.dart +++ b/packages/scheduler_core/test/child_tasks_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Child Tasks behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Runs the `and` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void main() { group('Child task ownership', () { final now = DateTime(2026, 6, 19, 12); @@ -875,6 +880,8 @@ void main() { }); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required String title, @@ -899,6 +906,8 @@ Task task({ ); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required String title, @@ -919,6 +928,8 @@ Task scheduledTask({ ); } +/// Runs the `childEntry` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ChildTaskEntry childEntry({ required String id, required String title, @@ -933,6 +944,8 @@ ChildTaskEntry childEntry({ ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } diff --git a/packages/scheduler_core/test/document_mapping_test.dart b/packages/scheduler_core/test/document_mapping_test.dart index e942063..5de95f5 100644 --- a/packages/scheduler_core/test/document_mapping_test.dart +++ b/packages/scheduler_core/test/document_mapping_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Document Mapping behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V1 document codecs', () { final createdAt = DateTime.utc(2026, 6, 23, 8); @@ -523,6 +528,8 @@ void main() { }); } +/// Runs the `throwsMapping` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Matcher throwsMapping( DocumentMappingFailureCode code, [ String? fieldName, @@ -542,6 +549,8 @@ Matcher throwsMapping( return throwsA(matcher); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required DateTime createdAt, diff --git a/packages/scheduler_core/test/document_migration_test.dart b/packages/scheduler_core/test/document_migration_test.dart index 65a1c53..1455c63 100644 --- a/packages/scheduler_core/test/document_migration_test.dart +++ b/packages/scheduler_core/test/document_migration_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Document Migration behavior for the Scheduler Core package. library; @@ -7,6 +10,8 @@ import 'dart:io'; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V0 to V1 document migrations', () { final migratedAt = DateTime.utc(2026, 6, 25, 12); @@ -233,6 +238,8 @@ void main() { }); } +/// Runs the `fixture` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Map fixture(String name) { var file = File('test/fixtures/migration/$name'); if (!file.existsSync()) { diff --git a/packages/scheduler_core/test/domain_contracts_test.dart b/packages/scheduler_core/test/domain_contracts_test.dart index 9ee7f9d..336ff67 100644 --- a/packages/scheduler_core/test/domain_contracts_test.dart +++ b/packages/scheduler_core/test/domain_contracts_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Domain Contracts behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('V1 lifecycle contracts', () { final now = DateTime(2026, 6, 24, 9); @@ -109,6 +114,8 @@ void main() { }); } +/// Top-level helper that performs the `_scheduledRequiredTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Task _scheduledRequiredTask({ required String id, required TaskType type, diff --git a/packages/scheduler_core/test/domain_invariants_test.dart b/packages/scheduler_core/test/domain_invariants_test.dart index 2da8cb4..e0f270f 100644 --- a/packages/scheduler_core/test/domain_invariants_test.dart +++ b/packages/scheduler_core/test/domain_invariants_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Domain Invariants behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Domain invariants', () { final now = DateTime(2026, 6, 24, 9); @@ -310,6 +315,8 @@ void main() { }); } +/// Runs the `expectValidationCode` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void expectValidationCode( Object? Function() callback, DomainValidationCode code, @@ -326,6 +333,8 @@ void expectValidationCode( ); } +/// Top-level helper that performs the `_task` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Task _task({ required DateTime now, String id = 'task-1', diff --git a/packages/scheduler_core/test/edge_case_regression_test.dart b/packages/scheduler_core/test/edge_case_regression_test.dart index 889e064..58c6485 100644 --- a/packages/scheduler_core/test/edge_case_regression_test.dart +++ b/packages/scheduler_core/test/edge_case_regression_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Edge Case Regression behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Runs the `sorts` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void main() { group('Block 06.3 domain model regressions', () { final now = DateTime(2026, 6, 19, 12); @@ -730,6 +735,8 @@ void main() { }); } +/// Runs the `backlogTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task backlogTask({ required String id, required String title, @@ -753,6 +760,8 @@ Task backlogTask({ ).copyWith(durationMinutes: durationMinutes); } +/// Runs the `plannedFlexibleTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task plannedFlexibleTask({ required String id, required String title, @@ -772,6 +781,8 @@ Task plannedFlexibleTask({ ); } +/// Runs the `fixedTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task fixedTask({ required String id, required TaskType type, @@ -794,6 +805,8 @@ Task fixedTask({ ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } diff --git a/packages/scheduler_core/test/free_slots_test.dart b/packages/scheduler_core/test/free_slots_test.dart index c187994..d895ec5 100644 --- a/packages/scheduler_core/test/free_slots_test.dart +++ b/packages/scheduler_core/test/free_slots_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Free Slots behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('FreeSlotService', () { final now = DateTime(2026, 6, 19, 8); @@ -311,6 +316,8 @@ void main() { }); } +/// Runs the `inputFor` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. SchedulingInput inputFor(List tasks) { return SchedulingInput( tasks: tasks, @@ -321,6 +328,8 @@ SchedulingInput inputFor(List tasks) { ); } +/// Runs the `flexibleTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task flexibleTask({ required String id, required DateTime createdAt, @@ -339,6 +348,8 @@ Task flexibleTask({ ); } +/// Runs the `requiredTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task requiredTask({ required String id, required TaskType type, @@ -356,6 +367,8 @@ Task requiredTask({ ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } diff --git a/packages/scheduler_core/test/occupancy_policy_test.dart b/packages/scheduler_core/test/occupancy_policy_test.dart index ad94dd7..e7f1261 100644 --- a/packages/scheduler_core/test/occupancy_policy_test.dart +++ b/packages/scheduler_core/test/occupancy_policy_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Occupancy Policy behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('OccupancyPolicy', () { const policy = OccupancyPolicy(); @@ -167,6 +172,8 @@ void main() { }); } +/// Runs the `matrixTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task matrixTask({ required TaskType type, required TaskStatus status, @@ -192,6 +199,8 @@ Task matrixTask({ ); } +/// Runs the `expectationFor` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. OccupancyExpectation expectationFor({ required TaskType type, required TaskStatus status, @@ -247,7 +256,11 @@ OccupancyExpectation expectationFor({ return const OccupancyExpectation.nonOccupying(); } +/// Represents `OccupancyExpectation` within the pure scheduler domain package. +/// The type groups related data and behavior behind a named API so readers can understand this part of the scheduler without relying on tribal knowledge about the surrounding files. class OccupancyExpectation { + /// Creates a `OccupancyExpectation` 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 OccupancyExpectation({ required this.category, required this.isImmovable, @@ -257,6 +270,8 @@ class OccupancyExpectation { required this.hiddenByDefault, }); + /// Creates a `OccupancyExpectation.nonOccupying` 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 OccupancyExpectation.nonOccupying() : this( category: OccupancyCategory.nonOccupyingRecord, @@ -267,6 +282,8 @@ class OccupancyExpectation { hiddenByDefault: false, ); + /// Creates a `OccupancyExpectation.movableFlexible` 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 OccupancyExpectation.movableFlexible() : this( category: OccupancyCategory.movablePlannedFlexible, @@ -277,6 +294,8 @@ class OccupancyExpectation { hiddenByDefault: false, ); + /// Creates a `OccupancyExpectation.blocking` 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 OccupancyExpectation.blocking({ required OccupancyCategory category, bool mayBeExplicitlyOverlappedByRequiredCommitment = false, @@ -291,14 +310,33 @@ class OccupancyExpectation { hiddenByDefault: hiddenByDefault, ); + /// Stores the `category` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final OccupancyCategory category; + + /// Stores the `isImmovable` 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 isImmovable; + + /// Stores the `blocksAutomaticFlexiblePlacement` 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 blocksAutomaticFlexiblePlacement; + + /// Stores the `mayBeExplicitlyOverlappedByRequiredCommitment` 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 mayBeExplicitlyOverlappedByRequiredCommitment; + + /// Stores the `reportsConflict` 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 reportsConflict; + + /// Stores the `hiddenByDefault` 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 hiddenByDefault; } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } diff --git a/packages/scheduler_core/test/persistence_edge_cases_test.dart b/packages/scheduler_core/test/persistence_edge_cases_test.dart index bbc192d..ab42224 100644 --- a/packages/scheduler_core/test/persistence_edge_cases_test.dart +++ b/packages/scheduler_core/test/persistence_edge_cases_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Persistence Edge Cases behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Runs the `values` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void main() { group('Persistence edge-case regressions', () { final createdAt = DateTime.utc(2026, 6, 22, 8); @@ -372,6 +377,8 @@ void main() { }); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required DateTime createdAt, diff --git a/packages/scheduler_core/test/persistence_index_contract_test.dart b/packages/scheduler_core/test/persistence_index_contract_test.dart index 11ba418..ce6ae1f 100644 --- a/packages/scheduler_core/test/persistence_index_contract_test.dart +++ b/packages/scheduler_core/test/persistence_index_contract_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Persistence Index Contract behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Persistence index contract', () { test('index names and key fields are stable and adapter-neutral', () { @@ -159,6 +164,8 @@ void main() { }); } +/// Runs the `uniqueIndexNames` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Set get uniqueIndexNames { return { for (final spec in PersistenceIndexCatalog.all) @@ -166,6 +173,8 @@ Set get uniqueIndexNames { }; } +/// Runs the `indexByName` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. PersistenceIndexSpec indexByName(String name) { return PersistenceIndexCatalog.all.singleWhere((spec) => spec.name == name); } diff --git a/packages/scheduler_core/test/project_statistics_test.dart b/packages/scheduler_core/test/project_statistics_test.dart index 66de5a9..ed575b2 100644 --- a/packages/scheduler_core/test/project_statistics_test.dart +++ b/packages/scheduler_core/test/project_statistics_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Project Statistics behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Project statistics aggregation', () { final createdAt = DateTime.utc(2026, 6, 25, 8); @@ -286,6 +291,8 @@ void main() { }); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required String projectId, diff --git a/packages/scheduler_core/test/reminder_policy_test.dart b/packages/scheduler_core/test/reminder_policy_test.dart index 702b5b7..5d2264c 100644 --- a/packages/scheduler_core/test/reminder_policy_test.dart +++ b/packages/scheduler_core/test/reminder_policy_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Reminder Policy behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Effective reminder profile resolution', () { const service = ReminderPolicyService(); @@ -283,6 +288,8 @@ void main() { }); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required DateTime now, @@ -307,6 +314,8 @@ Task scheduledTask({ ); } +/// Runs the `freeSlotTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task freeSlotTask({ required String id, required DateTime start, diff --git a/packages/scheduler_core/test/repositories_test.dart b/packages/scheduler_core/test/repositories_test.dart index 3d070aa..6e33a52 100644 --- a/packages/scheduler_core/test/repositories_test.dart +++ b/packages/scheduler_core/test/repositories_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Repositories behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Repository interfaces', () { final createdAt = DateTime(2026, 6, 21, 8); @@ -181,6 +186,8 @@ void main() { ); } +/// Runs the `runCoreRepositoryConformance` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void runCoreRepositoryConformance({ required TaskRepository Function() createTaskRepository, required ProjectRepository Function() createProjectRepository, @@ -350,6 +357,8 @@ void runCoreRepositoryConformance({ }); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required String title, diff --git a/packages/scheduler_core/test/required_task_actions_test.dart b/packages/scheduler_core/test/required_task_actions_test.dart index e804cef..a114e6c 100644 --- a/packages/scheduler_core/test/required_task_actions_test.dart +++ b/packages/scheduler_core/test/required_task_actions_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Required Task Actions behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Required task action service', () { final now = DateTime(2026, 6, 19, 12); @@ -161,6 +166,8 @@ void main() { }); } +/// Runs the `requiredTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task requiredTask({ required String id, required TaskType type, diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 512091d..5097dc5 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Scheduling Engine behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Domain model', () { final now = DateTime(2026, 6, 19, 12); diff --git a/packages/scheduler_core/test/scheduling_invariants_test.dart b/packages/scheduler_core/test/scheduling_invariants_test.dart index 98eaa92..17338c3 100644 --- a/packages/scheduler_core/test/scheduling_invariants_test.dart +++ b/packages/scheduler_core/test/scheduling_invariants_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Scheduling Invariants behavior for the Scheduler Core package. library; @@ -6,6 +9,8 @@ import 'dart:math'; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Structured scheduling outcomes', () { final now = DateTime(2026, 6, 19, 8); @@ -236,6 +241,8 @@ void main() { }); } +/// Runs the `dayWindow` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. SchedulingWindow dayWindow() { return SchedulingWindow( start: DateTime(2026, 6, 19, 9), @@ -243,6 +250,8 @@ SchedulingWindow dayWindow() { ); } +/// Runs the `randomInsertionScenario` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. InsertionScenario randomInsertionScenario({ required int seed, required DateTime createdAt, @@ -308,6 +317,8 @@ InsertionScenario randomInsertionScenario({ ); } +/// Runs the `largeInsertionScenario` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. InsertionScenario largeInsertionScenario({required DateTime createdAt}) { final window = SchedulingWindow( start: DateTime(2026, 6, 19, 6), @@ -352,10 +363,14 @@ InsertionScenario largeInsertionScenario({required DateTime createdAt}) { ); } +/// Runs the `assertTaskIdsUnique` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertTaskIdsUnique(List tasks) { expect(tasks.map((task) => task.id).toSet().length, tasks.length); } +/// Runs the `assertIntervalsValid` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertIntervalsValid(List tasks) { for (final task in tasks) { final scheduledStart = task.scheduledStart; @@ -374,6 +389,8 @@ void assertIntervalsValid(List tasks) { } } +/// Runs the `assertNoMovableTaskOverlapsBlockers` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertNoMovableTaskOverlapsBlockers({ required List resultTasks, required SchedulingInput originalInput, @@ -396,6 +413,8 @@ void assertNoMovableTaskOverlapsBlockers({ } } +/// Runs the `assertImmovablePlacementsUnchanged` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertImmovablePlacementsUnchanged({ required SchedulingInput before, required List afterTasks, @@ -418,6 +437,8 @@ void assertImmovablePlacementsUnchanged({ } } +/// Runs the `assertFlexibleRelativeOrderPreserved` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertFlexibleRelativeOrderPreserved({ required List beforeTasks, required List afterTasks, @@ -430,6 +451,8 @@ void assertFlexibleRelativeOrderPreserved({ expect(afterOrder, beforeOrder); } +/// Runs the `plannedFlexibleIdsByStart` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. List plannedFlexibleIdsByStart(List tasks) { final planned = tasks.where((task) { return task.type == TaskType.flexible && @@ -443,6 +466,8 @@ List plannedFlexibleIdsByStart(List tasks) { return planned.map((task) => task.id).toList(growable: false); } +/// Runs the `assertTaskSchedulesUnchanged` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void assertTaskSchedulesUnchanged({ required List beforeTasks, required List afterTasks, @@ -461,6 +486,8 @@ void assertTaskSchedulesUnchanged({ } } +/// Runs the `scheduledInterval` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. TimeInterval? scheduledInterval(Task task) { final start = task.scheduledStart; final end = task.scheduledEnd; @@ -470,6 +497,8 @@ TimeInterval? scheduledInterval(Task task) { return TimeInterval(start: start, end: end, label: task.id); } +/// Runs the `backlogTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task backlogTask({ required String id, required DateTime createdAt, @@ -482,6 +511,8 @@ Task backlogTask({ ).copyWith(durationMinutes: durationMinutes); } +/// Runs the `flexibleTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task flexibleTask({ required String id, required DateTime createdAt, @@ -500,6 +531,8 @@ Task flexibleTask({ ); } +/// Runs the `fixedTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task fixedTask({ required String id, required TaskType type, @@ -522,17 +555,31 @@ Task fixedTask({ ); } +/// Represents `InsertionScenario` within the pure scheduler domain package. +/// The type groups related data and behavior behind a named API so readers can understand this part of the scheduler without relying on tribal knowledge about the surrounding files. class InsertionScenario { + /// Creates a `InsertionScenario` 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 InsertionScenario({ required this.seed, required this.backlogTaskId, required this.input, }); + /// Stores the `seed` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final int seed; + + /// Stores the `backlogTaskId` 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 backlogTaskId; + + /// Stores the `input` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final SchedulingInput input; + /// Performs the `describe` 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. String describe() { return 'seed=$seed tasks=${input.tasks.length} ' 'locked=${input.lockedIntervals.length} backlog=$backlogTaskId'; diff --git a/packages/scheduler_core/test/surprise_task_logging_test.dart b/packages/scheduler_core/test/surprise_task_logging_test.dart index fd16627..ea9d848 100644 --- a/packages/scheduler_core/test/surprise_task_logging_test.dart +++ b/packages/scheduler_core/test/surprise_task_logging_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Surprise Task Logging behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Surprise task logging', () { final now = DateTime(2026, 6, 19, 12); @@ -516,6 +521,8 @@ void main() { }); } +/// Runs the `emptyInput` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. SchedulingInput emptyInput() { return SchedulingInput( tasks: const [], @@ -526,6 +533,8 @@ SchedulingInput emptyInput() { ); } +/// Runs the `plannedFlexibleTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task plannedFlexibleTask({ required String id, required String title, @@ -545,6 +554,8 @@ Task plannedFlexibleTask({ ); } +/// Runs the `fixedTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task fixedTask({ required String id, required TaskType type, @@ -567,10 +578,14 @@ Task fixedTask({ ); } +/// Runs the `taskById` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task taskById(List tasks, String id) { return tasks.singleWhere((task) => task.id == id); } +/// Runs the `sameScheduleAs` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Matcher sameScheduleAs(Task expected) { return predicate((actual) { return actual.scheduledStart == expected.scheduledStart && diff --git a/packages/scheduler_core/test/task_lifecycle_test.dart b/packages/scheduler_core/test/task_lifecycle_test.dart index 1992495..e2a861c 100644 --- a/packages/scheduler_core/test/task_lifecycle_test.dart +++ b/packages/scheduler_core/test/task_lifecycle_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Task Lifecycle behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Task transition service', () { final now = DateTime(2026, 6, 25, 9); @@ -435,6 +440,8 @@ void main() { }); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required DateTime now, @@ -457,6 +464,8 @@ Task scheduledTask({ ); } +/// Runs the `requiredTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task requiredTask({ required String id, required TaskType type, diff --git a/packages/scheduler_core/test/time_contracts_test.dart b/packages/scheduler_core/test/time_contracts_test.dart index a422029..c93542f 100644 --- a/packages/scheduler_core/test/time_contracts_test.dart +++ b/packages/scheduler_core/test/time_contracts_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Time Contracts behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Civil date and wall-time contracts', () { test('civil dates preserve date-only boundaries', () { @@ -261,6 +266,8 @@ void main() { }); } +/// Runs the `expectValidationCode` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. void expectValidationCode( Object? Function() body, DomainValidationCode expectedCode, @@ -277,7 +284,11 @@ void expectValidationCode( ); } +/// Private implementation type for `_LosAngelesDstFixtureResolver` 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 _LosAngelesDstFixtureResolver implements TimeZoneResolver { + /// Performs the `resolve` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override ResolvedLocalDateTime resolve({ required CivilDate date, @@ -347,6 +358,8 @@ class _LosAngelesDstFixtureResolver implements TimeZoneResolver { ); } + /// Runs the `_offsetFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. Duration _offsetFor(CivilDate date, WallTime wallTime) { if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) { return const Duration(hours: -7); @@ -357,6 +370,8 @@ class _LosAngelesDstFixtureResolver implements TimeZoneResolver { return const Duration(hours: -8); } + /// Runs the `_resolved` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. ResolvedLocalDateTime _resolved({ required CivilDate date, required WallTime wallTime, diff --git a/packages/scheduler_core/test/timeline_state_test.dart b/packages/scheduler_core/test/timeline_state_test.dart index 71e248f..ba43181 100644 --- a/packages/scheduler_core/test/timeline_state_test.dart +++ b/packages/scheduler_core/test/timeline_state_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Timeline State behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('Timeline item view model', () { final createdAt = DateTime(2026, 6, 20, 8); @@ -485,6 +490,8 @@ void main() { }); } +/// Runs the `scheduledTask` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task scheduledTask({ required String id, required String title, diff --git a/packages/scheduler_core/test/today_state_test.dart b/packages/scheduler_core/test/today_state_test.dart index 0151db5..da71eff 100644 --- a/packages/scheduler_core/test/today_state_test.dart +++ b/packages/scheduler_core/test/today_state_test.dart @@ -1,9 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Today State behavior for the Scheduler Core package. library; import 'package:scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('GetTodayStateQuery', () { final day = CivilDate(2026, 6, 25); @@ -468,6 +473,8 @@ void main() { }); } +/// Runs the `todayQuery` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. GetTodayStateQuery todayQuery(InMemoryApplicationUnitOfWork store) { return GetTodayStateQuery( applicationStore: store, @@ -475,6 +482,8 @@ GetTodayStateQuery todayQuery(InMemoryApplicationUnitOfWork store) { ); } +/// Runs the `appContext` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ApplicationOperationContext appContext({ required String operationId, required DateTime now, @@ -491,6 +500,8 @@ ApplicationOperationContext appContext({ ); } +/// Runs the `task` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. Task task({ required String id, required String title, @@ -516,7 +527,11 @@ Task task({ ); } +/// Private implementation type for `_LosAngelesDstFixtureResolver` 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 _LosAngelesDstFixtureResolver implements TimeZoneResolver { + /// Performs the `resolve` behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override ResolvedLocalDateTime resolve({ required CivilDate date, @@ -552,6 +567,8 @@ class _LosAngelesDstFixtureResolver implements TimeZoneResolver { ); } + /// Runs the `_offsetFor` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. Duration _offsetFor(CivilDate date, WallTime wallTime) { if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) { return const Duration(hours: -7); diff --git a/packages/scheduler_export/LICENSE.md b/packages/scheduler_export/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_export/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_export/analysis_options.yaml b/packages/scheduler_export/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_export/analysis_options.yaml +++ b/packages/scheduler_export/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_export/lib/export.dart b/packages/scheduler_export/lib/export.dart index 21553e4..56c280f 100644 --- a/packages/scheduler_export/lib/export.dart +++ b/packages/scheduler_export/lib/export.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Readable export contracts and controller for scheduler data. library; diff --git a/packages/scheduler_export/lib/scheduler_export.dart b/packages/scheduler_export/lib/scheduler_export.dart index b46885b..123ab0c 100644 --- a/packages/scheduler_export/lib/scheduler_export.dart +++ b/packages/scheduler_export/lib/scheduler_export.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Package-name entry point for scheduler export contracts. library; diff --git a/packages/scheduler_export/lib/src/export_controller.dart b/packages/scheduler_export/lib/src/export_controller.dart index e6cfb1e..0a4d72f 100644 --- a/packages/scheduler_export/lib/src/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Export Controller behavior for the Scheduler Export package. library; @@ -5,302 +8,15 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; - -/// Export metadata shared with every writer. -final class ExportContext { - /// Creates an export context. - ExportContext({ - required this.ownerId, - required String timezone, - required this.version, - }) : timezone = _requiredTrimmed(timezone, 'timezone'); - - /// Owner scope being exported. - final core.OwnerId ownerId; - - /// Timezone id used to interpret local export presentation. - final String timezone; - - /// Export format/schema version. - final int version; -} - -/// Thrown when an export cannot complete. -final class ExportException implements Exception { - /// Creates an export exception with a diagnostic [message]. - const ExportException(this.message); - - /// Diagnostic text for logs and tests. - final String message; - - @override - String toString() => 'ExportException: $message'; -} - -/// Thrown when repository reads fail during export. -final class ExportRepositoryException extends ExportException { - /// Creates an exception wrapping a repository [failure]. - ExportRepositoryException(this.failure) - : super('Repository export read failed: ${failure.runtimeType}'); - - /// Repository failure returned by an adapter. - final RepositoryFailure failure; -} - -/// Writer interface for readable exports. -abstract interface class ExportWriter { - /// Start writing an export for [context]. - Future begin(ExportContext context); - - /// Write one task record. - Future writeTask(core.Task task); - - /// Finish writing the export. - Future end(); -} - -/// Factory signature for sink-backed export writers. -typedef ExportWriterFactory = ExportWriter Function(StringSink sink); - -/// Registry that resolves export format names to writer factories. -final class ExportWriterRegistry { - /// Creates a registry from [factories]. - ExportWriterRegistry({ - Map factories = - const {}, - }) : _factories = Map.fromEntries( - factories.entries.map( - (entry) => MapEntry(_normalizeFormat(entry.key), entry.value), - ), - ); - - final Map _factories; - - /// Default registry with JSON and CSV stub writers. - factory ExportWriterRegistry.defaults() { - return ExportWriterRegistry( - factories: { - 'json': JsonExportWriter.new, - 'csv': CsvExportWriter.new, - }, - ); - } - - /// Available format names. - Set get formats => Set.unmodifiable(_factories.keys); - - /// Register [factory] for [format]. - void register(String format, ExportWriterFactory factory) { - _factories[_normalizeFormat(format)] = factory; - } - - /// Create a writer for [format] using [sink]. - ExportWriter create(String format, StringSink sink) { - final normalized = _normalizeFormat(format); - final factory = _factories[normalized]; - if (factory == null) { - throw ExportException('Unknown export format: $format'); - } - return factory(sink); - } -} - -/// Coordinates repository reads and export writer calls. -final class ExportController { - /// Creates an export controller using [taskRepository]. - ExportController({ - required TaskRepository taskRepository, - ExportWriterRegistry? writerRegistry, - }) : _taskRepository = taskRepository, - _writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults(); - - final TaskRepository _taskRepository; - final ExportWriterRegistry _writerRegistry; - - /// Create a writer for [format] and export owner-scoped tasks to [sink]. - Future exportTasksToSink({ - required ExportContext context, - required String format, - required StringSink sink, - int pageSize = 100, - }) { - final writer = _writerRegistry.create(format, sink); - return exportTasks( - context: context, - writer: writer, - pageSize: pageSize, - ); - } - - /// Export all owner-scoped tasks through [writer]. - Future exportTasks({ - required ExportContext context, - required ExportWriter writer, - int pageSize = 100, - }) async { - if (pageSize <= 0) { - throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.'); - } - - await writer.begin(context); - String? cursor; - do { - final result = await _taskRepository.findByOwner( - ownerId: context.ownerId, - page: core.PageRequest(cursor: cursor, limit: pageSize), - ); - if (result.isLeft) { - throw ExportRepositoryException(result.left); - } - final page = result.right; - for (final task in page.items) { - await writer.writeTask(task); - } - cursor = page.nextCursor; - } while (cursor != null); - await writer.end(); - } -} - -/// Minimal JSON export writer stub. -final class JsonExportWriter implements ExportWriter { - /// Creates a JSON writer that writes to the provided string sink. - JsonExportWriter(this._sink); - - final StringSink _sink; - ExportContext? _context; - bool _hasTask = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _context = context; - _sink.write( - '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', - ); - } - - @override - Future writeTask(core.Task task) async { - final context = _requireContext(); - if (_hasTask) { - _sink.write(','); - } - _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( - task, - ownerId: context.ownerId.value, - ))); - _hasTask = true; - } - - @override - Future end() async { - _requireContext(); - _assertNotEnded(); - _sink.write(']}'); - _ended = true; - } - - ExportContext _requireContext() { - final context = _context; - if (context == null) { - throw const ExportException('Writer has not started.'); - } - return context; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -/// Minimal CSV export writer stub. -final class CsvExportWriter implements ExportWriter { - /// Creates a CSV writer that writes to the provided string sink. - CsvExportWriter(this._sink); - - final StringSink _sink; - bool _started = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _started = true; - _sink.writeln( - 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' - 'scheduledEndUtc', - ); - } - - @override - Future writeTask(core.Task task) async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _sink.writeln([ - task.id, - task.title, - task.projectId, - core.PersistenceEnumMapping.encodeTaskType(task.type), - core.PersistenceEnumMapping.encodeTaskStatus(task.status), - task.durationMinutes?.toString() ?? '', - task.scheduledStart == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledStart!, - ), - task.scheduledEnd == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledEnd!, - ), - ].map(_csvCell).join(',')); - } - - @override - Future end() async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _ended = true; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -Map _contextToJson(ExportContext context) { - return { - 'ownerId': context.ownerId.value, - 'timezone': context.timezone, - 'version': context.version, - }; -} - -String _csvCell(String value) { - final needsQuotes = - value.contains(',') || value.contains('"') || value.contains('\n'); - final escaped = value.replaceAll('"', '""'); - return needsQuotes ? '"$escaped"' : escaped; -} - -String _normalizeFormat(String format) { - return _requiredTrimmed(format, 'format').toLowerCase(); -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} +part 'export_controller/context/export_context.dart'; +part 'export_controller/errors/export_exception.dart'; +part 'export_controller/errors/export_repository_exception.dart'; +part 'export_controller/writers/core/export_writer.dart'; +part 'export_controller/writers/registry/export_writer_factory.dart'; +part 'export_controller/writers/registry/export_writer_registry.dart'; +part 'export_controller/controller/export_controller.dart'; +part 'export_controller/writers/formats/export_format_normalizer.dart'; +part 'export_controller/writers/json/export_json_context_encoder.dart'; +part 'export_controller/writers/json/json_export_writer.dart'; +part 'export_controller/writers/csv/csv_cell_encoder.dart'; +part 'export_controller/writers/csv/csv_export_writer.dart'; diff --git a/packages/scheduler_export/lib/src/export_controller/context/export_context.dart b/packages/scheduler_export/lib/src/export_controller/context/export_context.dart new file mode 100644 index 0000000..d94b036 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/context/export_context.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../export_controller.dart'; + +/// Export metadata shared with every writer. +final class ExportContext { + /// Creates an export context. + ExportContext({ + required this.ownerId, + required String timezone, + required this.version, + }) : timezone = _requiredTrimmed(timezone, 'timezone'); + + /// Owner scope being exported. + final core.OwnerId ownerId; + + /// Timezone id used to interpret local export presentation. + final String timezone; + + /// Export format/schema version. + final int version; +} diff --git a/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart new file mode 100644 index 0000000..de85645 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../export_controller.dart'; + +/// Coordinates repository reads and export writer calls. +final class ExportController { + /// Creates an export controller using [taskRepository]. + ExportController({ + required TaskRepository taskRepository, + ExportWriterRegistry? writerRegistry, + }) : _taskRepository = taskRepository, + _writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults(); + + /// Private state stored as `_taskRepository` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final TaskRepository _taskRepository; + + /// Private state stored as `_writerRegistry` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final ExportWriterRegistry _writerRegistry; + + /// Create a writer for [format] and export owner-scoped tasks to [sink]. + Future exportTasksToSink({ + required ExportContext context, + required String format, + required StringSink sink, + int pageSize = 100, + }) { + final writer = _writerRegistry.create(format, sink); + return exportTasks( + context: context, + writer: writer, + pageSize: pageSize, + ); + } + + /// Export all owner-scoped tasks through [writer]. + Future exportTasks({ + required ExportContext context, + required ExportWriter writer, + int pageSize = 100, + }) async { + if (pageSize <= 0) { + throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.'); + } + + await writer.begin(context); + String? cursor; + do { + final result = await _taskRepository.findByOwner( + ownerId: context.ownerId, + page: core.PageRequest(cursor: cursor, limit: pageSize), + ); + if (result.isLeft) { + throw ExportRepositoryException(result.left); + } + final page = result.right; + for (final task in page.items) { + await writer.writeTask(task); + } + cursor = page.nextCursor; + } while (cursor != null); + await writer.end(); + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart b/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart new file mode 100644 index 0000000..05bf419 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../export_controller.dart'; + +/// Thrown when an export cannot complete. +final class ExportException implements Exception { + /// Creates an export exception with a diagnostic [message]. + const ExportException(this.message); + + /// Diagnostic text for logs and tests. + final String message; + + /// Converts scheduler data for `toString` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + @override + String toString() => 'ExportException: $message'; +} diff --git a/packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart b/packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart new file mode 100644 index 0000000..0fcfaa0 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../export_controller.dart'; + +/// Thrown when repository reads fail during export. +final class ExportRepositoryException extends ExportException { + /// Creates an exception wrapping a repository [failure]. + ExportRepositoryException(this.failure) + : super('Repository export read failed: ${failure.runtimeType}'); + + /// Repository failure returned by an adapter. + final RepositoryFailure failure; +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart new file mode 100644 index 0000000..224f620 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Writer interface for readable exports. +abstract interface class ExportWriter { + /// Start writing an export for [context]. + Future begin(ExportContext context); + + /// Write one task record. + Future writeTask(core.Task task); + + /// Finish writing the export. + Future end(); +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart new file mode 100644 index 0000000..181dc3b --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Top-level helper that performs the `_csvCell` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _csvCell(String value) { + final needsQuotes = + value.contains(',') || value.contains('"') || value.contains('\n'); + final escaped = value.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart new file mode 100644 index 0000000..a1470ff --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Minimal CSV export writer stub. +final class CsvExportWriter implements ExportWriter { + /// Creates a CSV writer that writes to the provided string sink. + CsvExportWriter(this._sink); + + /// Private state stored as `_sink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final StringSink _sink; + + /// Private state stored as `_started` 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 _started = false; + + /// Private state stored as `_ended` 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 _ended = false; + + /// Runs the `begin` 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 begin(ExportContext context) async { + _assertNotEnded(); + _started = true; + _sink.writeln( + 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' + 'scheduledEndUtc', + ); + } + + /// Runs the `writeTask` 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 writeTask(core.Task task) async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _sink.writeln([ + task.id, + task.title, + task.projectId, + core.PersistenceEnumMapping.encodeTaskType(task.type), + core.PersistenceEnumMapping.encodeTaskStatus(task.status), + task.durationMinutes?.toString() ?? '', + task.scheduledStart == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledStart!, + ), + task.scheduledEnd == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledEnd!, + ), + ].map(_csvCell).join(',')); + } + + /// Runs the `end` 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 end() async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _ended = true; + } + + /// Runs the `_assertNotEnded` 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 _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart b/packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart new file mode 100644 index 0000000..61d503a --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Top-level helper that performs the `_normalizeFormat` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _normalizeFormat(String format) { + return _requiredTrimmed(format, 'format').toLowerCase(); +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart b/packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart new file mode 100644 index 0000000..50e27ab --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Top-level helper that performs the `_contextToJson` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _contextToJson(ExportContext context) { + return { + 'ownerId': context.ownerId.value, + 'timezone': context.timezone, + 'version': context.version, + }; +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart new file mode 100644 index 0000000..a0835b2 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Minimal JSON export writer stub. +final class JsonExportWriter implements ExportWriter { + /// Creates a JSON writer that writes to the provided string sink. + JsonExportWriter(this._sink); + + /// Private state stored as `_sink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final StringSink _sink; + + /// Private state stored as `_context` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + ExportContext? _context; + + /// Private state stored as `_hasTask` 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 _hasTask = false; + + /// Private state stored as `_ended` 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 _ended = false; + + /// Runs the `begin` 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 begin(ExportContext context) async { + _assertNotEnded(); + _context = context; + _sink.write( + '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', + ); + } + + /// Runs the `writeTask` 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 writeTask(core.Task task) async { + final context = _requireContext(); + if (_hasTask) { + _sink.write(','); + } + _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( + task, + ownerId: context.ownerId.value, + ))); + _hasTask = true; + } + + /// Runs the `end` 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 end() async { + _requireContext(); + _assertNotEnded(); + _sink.write(']}'); + _ended = true; + } + + /// Runs the `_requireContext` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + ExportContext _requireContext() { + final context = _context; + if (context == null) { + throw const ExportException('Writer has not started.'); + } + return context; + } + + /// Runs the `_assertNotEnded` 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 _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart new file mode 100644 index 0000000..653dfae --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Factory signature for sink-backed export writers. +typedef ExportWriterFactory = ExportWriter Function(StringSink sink); diff --git a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart new file mode 100644 index 0000000..b70406b --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../export_controller.dart'; + +/// Registry that resolves export format names to writer factories. +final class ExportWriterRegistry { + /// Creates a registry from [factories]. + ExportWriterRegistry({ + Map factories = + const {}, + }) : _factories = Map.fromEntries( + factories.entries.map( + (entry) => MapEntry(_normalizeFormat(entry.key), entry.value), + ), + ); + + /// Private state stored as `_factories` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _factories; + + /// Default registry with JSON and CSV stub writers. + factory ExportWriterRegistry.defaults() { + return ExportWriterRegistry( + factories: { + 'json': JsonExportWriter.new, + 'csv': CsvExportWriter.new, + }, + ); + } + + /// Available format names. + Set get formats => Set.unmodifiable(_factories.keys); + + /// Register [factory] for [format]. + void register(String format, ExportWriterFactory factory) { + _factories[_normalizeFormat(format)] = factory; + } + + /// Create a writer for [format] using [sink]. + ExportWriter create(String format, StringSink sink) { + final normalized = _normalizeFormat(format); + final factory = _factories[normalized]; + if (factory == null) { + throw ExportException('Unknown export format: $format'); + } + return factory(sink); + } +} diff --git a/packages/scheduler_export/pubspec.yaml b/packages/scheduler_export/pubspec.yaml index 9ed4597..ada4f30 100644 --- a/packages/scheduler_export/pubspec.yaml +++ b/packages/scheduler_export/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_export description: Export controller and writer contracts for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_export/test/export_controller_test.dart b/packages/scheduler_export/test/export_controller_test.dart index 1167cb1..64c95dd 100644 --- a/packages/scheduler_export/test/export_controller_test.dart +++ b/packages/scheduler_export/test/export_controller_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Export Controller behavior for the Scheduler Export package. library; @@ -8,6 +11,8 @@ import 'package:scheduler_export/export.dart'; import 'package:scheduler_persistence_memory/persistence_memory.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { test('JSON writer exports owner-scoped tasks through controller', () async { final ownerId = core.OwnerId('owner-a'); @@ -72,6 +77,8 @@ void main() { }); } +/// Top-level helper that performs the `_task` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.Task _task(String id) { return core.Task( id: id, @@ -85,19 +92,31 @@ core.Task _task(String id) { ); } +/// Private implementation type for `_CountingWriter` 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. final class _CountingWriter implements ExportWriter { + /// Creates a `_CountingWriter` 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. _CountingWriter(StringSink sink); + /// Stores the `count` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. var count = 0; + /// Runs the `begin` 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 begin(ExportContext context) async {} + /// Runs the `writeTask` 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 writeTask(core.Task task) async { count += 1; } + /// Runs the `end` 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 end() async {} } diff --git a/packages/scheduler_export_json/LICENSE.md b/packages/scheduler_export_json/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_export_json/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_export_json/analysis_options.yaml b/packages/scheduler_export_json/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_export_json/analysis_options.yaml +++ b/packages/scheduler_export_json/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_export_json/bin/export.dart b/packages/scheduler_export_json/bin/export.dart index 1e224f5..f06ee19 100644 --- a/packages/scheduler_export_json/bin/export.dart +++ b/packages/scheduler_export_json/bin/export.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Export command for the Scheduler Export Json package. library; @@ -8,6 +11,8 @@ import 'package:scheduler_export/export.dart'; import 'package:scheduler_export_json/export_json.dart'; import 'package:scheduler_persistence_memory/persistence_memory.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. Future main(List arguments) async { final format = _format(arguments); if (format == null) { @@ -34,6 +39,8 @@ Future main(List arguments) async { stdout.write(output.toString()); } +/// Top-level helper that performs the `_format` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _format(List arguments) { final wantsJson = arguments.contains('--json'); final wantsCsv = arguments.contains('--csv'); @@ -43,6 +50,8 @@ String? _format(List arguments) { return wantsJson ? 'json' : 'csv'; } +/// Top-level helper that performs the `_option` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _option(List arguments, String name) { final index = arguments.indexOf(name); if (index == -1 || index + 1 >= arguments.length) { diff --git a/packages/scheduler_export_json/lib/export_json.dart b/packages/scheduler_export_json/lib/export_json.dart index 03bb5a4..a0325ea 100644 --- a/packages/scheduler_export_json/lib/export_json.dart +++ b/packages/scheduler_export_json/lib/export_json.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// JSON and CSV readable export writers. library; diff --git a/packages/scheduler_export_json/lib/scheduler_export_json.dart b/packages/scheduler_export_json/lib/scheduler_export_json.dart index aa86deb..26a1702 100644 --- a/packages/scheduler_export_json/lib/scheduler_export_json.dart +++ b/packages/scheduler_export_json/lib/scheduler_export_json.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Package-name entry point for scheduler readable export writers. library; diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers.dart b/packages/scheduler_export_json/lib/src/json_csv_writers.dart index a23c3b7..7fa4548 100644 --- a/packages/scheduler_export_json/lib/src/json_csv_writers.dart +++ b/packages/scheduler_export_json/lib/src/json_csv_writers.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Json Csv Writers behavior for the Scheduler Export Json package. library; @@ -5,6 +8,10 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_export/export.dart'; +part 'json_csv_writers/export_json_context_encoder.dart'; +part 'json_csv_writers/json_export_writer.dart'; +part 'json_csv_writers/csv_cell_encoder.dart'; +part 'json_csv_writers/csv_export_writer.dart'; /// Create the readable export writer registry used by export commands. ExportWriterRegistry readableExportWriterRegistry() { @@ -15,135 +22,3 @@ ExportWriterRegistry readableExportWriterRegistry() { }, ); } - -/// JSON export writer for scheduler tasks. -final class JsonExportWriter implements ExportWriter { - /// Creates a JSON writer that writes to the provided string sink. - JsonExportWriter(this._sink); - - final StringSink _sink; - ExportContext? _context; - bool _hasTask = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _context = context; - _sink.write( - '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', - ); - } - - @override - Future writeTask(core.Task task) async { - final context = _requireContext(); - _assertNotEnded(); - if (_hasTask) { - _sink.write(','); - } - _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( - task, - ownerId: context.ownerId.value, - ))); - _hasTask = true; - } - - @override - Future end() async { - _requireContext(); - _assertNotEnded(); - _sink.write(']}'); - _ended = true; - } - - ExportContext _requireContext() { - final context = _context; - if (context == null) { - throw const ExportException('Writer has not started.'); - } - return context; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -/// CSV export writer for scheduler tasks. -final class CsvExportWriter implements ExportWriter { - /// Creates a CSV writer that writes to the provided string sink. - CsvExportWriter(this._sink); - - final StringSink _sink; - bool _started = false; - bool _ended = false; - - @override - Future begin(ExportContext context) async { - _assertNotEnded(); - _started = true; - _sink.writeln( - 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' - 'scheduledEndUtc', - ); - } - - @override - Future writeTask(core.Task task) async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _sink.writeln([ - task.id, - task.title, - task.projectId, - core.PersistenceEnumMapping.encodeTaskType(task.type), - core.PersistenceEnumMapping.encodeTaskStatus(task.status), - task.durationMinutes?.toString() ?? '', - task.scheduledStart == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledStart!, - ), - task.scheduledEnd == null - ? '' - : core.PersistenceDateTimeConvention.toStoredString( - task.scheduledEnd!, - ), - ].map(_csvCell).join(',')); - } - - @override - Future end() async { - if (!_started) { - throw const ExportException('Writer has not started.'); - } - _assertNotEnded(); - _ended = true; - } - - void _assertNotEnded() { - if (_ended) { - throw const ExportException('Writer has already ended.'); - } - } -} - -Map _contextToJson(ExportContext context) { - return { - 'ownerId': context.ownerId.value, - 'timezone': context.timezone, - 'version': context.version, - }; -} - -String _csvCell(String value) { - final needsQuotes = - value.contains(',') || value.contains('"') || value.contains('\n'); - final escaped = value.replaceAll('"', '""'); - return needsQuotes ? '"$escaped"' : escaped; -} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart new file mode 100644 index 0000000..e2d8c23 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../json_csv_writers.dart'; + +/// Top-level helper that performs the `_csvCell` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _csvCell(String value) { + final needsQuotes = + value.contains(',') || value.contains('"') || value.contains('\n'); + final escaped = value.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart new file mode 100644 index 0000000..b5d60df --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../json_csv_writers.dart'; + +/// CSV export writer for scheduler tasks. +final class CsvExportWriter implements ExportWriter { + /// Creates a CSV writer that writes to the provided string sink. + CsvExportWriter(this._sink); + + /// Private state stored as `_sink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final StringSink _sink; + + /// Private state stored as `_started` 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 _started = false; + + /// Private state stored as `_ended` 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 _ended = false; + + /// Runs the `begin` 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 begin(ExportContext context) async { + _assertNotEnded(); + _started = true; + _sink.writeln( + 'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,' + 'scheduledEndUtc', + ); + } + + /// Runs the `writeTask` 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 writeTask(core.Task task) async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _sink.writeln([ + task.id, + task.title, + task.projectId, + core.PersistenceEnumMapping.encodeTaskType(task.type), + core.PersistenceEnumMapping.encodeTaskStatus(task.status), + task.durationMinutes?.toString() ?? '', + task.scheduledStart == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledStart!, + ), + task.scheduledEnd == null + ? '' + : core.PersistenceDateTimeConvention.toStoredString( + task.scheduledEnd!, + ), + ].map(_csvCell).join(',')); + } + + /// Runs the `end` 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 end() async { + if (!_started) { + throw const ExportException('Writer has not started.'); + } + _assertNotEnded(); + _ended = true; + } + + /// Runs the `_assertNotEnded` 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 _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart new file mode 100644 index 0000000..3bb9291 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../json_csv_writers.dart'; + +/// Top-level helper that performs the `_contextToJson` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _contextToJson(ExportContext context) { + return { + 'ownerId': context.ownerId.value, + 'timezone': context.timezone, + 'version': context.version, + }; +} diff --git a/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart b/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart new file mode 100644 index 0000000..f04a7db --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../json_csv_writers.dart'; + +/// JSON export writer for scheduler tasks. +final class JsonExportWriter implements ExportWriter { + /// Creates a JSON writer that writes to the provided string sink. + JsonExportWriter(this._sink); + + /// Private state stored as `_sink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final StringSink _sink; + + /// Private state stored as `_context` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + ExportContext? _context; + + /// Private state stored as `_hasTask` 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 _hasTask = false; + + /// Private state stored as `_ended` 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 _ended = false; + + /// Runs the `begin` 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 begin(ExportContext context) async { + _assertNotEnded(); + _context = context; + _sink.write( + '{"context":${jsonEncode(_contextToJson(context))},"tasks":[', + ); + } + + /// Runs the `writeTask` 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 writeTask(core.Task task) async { + final context = _requireContext(); + _assertNotEnded(); + if (_hasTask) { + _sink.write(','); + } + _sink.write(jsonEncode(core.TaskDocumentMapping.toDocument( + task, + ownerId: context.ownerId.value, + ))); + _hasTask = true; + } + + /// Runs the `end` 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 end() async { + _requireContext(); + _assertNotEnded(); + _sink.write(']}'); + _ended = true; + } + + /// Runs the `_requireContext` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + ExportContext _requireContext() { + final context = _context; + if (context == null) { + throw const ExportException('Writer has not started.'); + } + return context; + } + + /// Runs the `_assertNotEnded` 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 _assertNotEnded() { + if (_ended) { + throw const ExportException('Writer has already ended.'); + } + } +} diff --git a/packages/scheduler_export_json/pubspec.yaml b/packages/scheduler_export_json/pubspec.yaml index 3c27b9c..bd00cd4 100644 --- a/packages/scheduler_export_json/pubspec.yaml +++ b/packages/scheduler_export_json/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_export_json description: JSON and CSV readable export writers for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_export_json/test/export_json_test.dart b/packages/scheduler_export_json/test/export_json_test.dart index cf787b3..9787aea 100644 --- a/packages/scheduler_export_json/test/export_json_test.dart +++ b/packages/scheduler_export_json/test/export_json_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Export Json behavior for the Scheduler Export Json package. library; @@ -10,6 +13,8 @@ import 'package:scheduler_export_json/export_json.dart'; import 'package:scheduler_persistence_memory/persistence_memory.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { test('readable registry exports valid JSON through ExportController', () async { @@ -68,6 +73,8 @@ void main() { }, timeout: const Timeout(Duration(seconds: 20))); } +/// Top-level helper that performs the `_packageDirectory` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Directory _packageDirectory() { final currentPubspec = File('${Directory.current.path}${Platform.pathSeparator}pubspec.yaml'); @@ -83,6 +90,8 @@ Directory _packageDirectory() { ); } +/// Top-level helper that performs the `_task` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.Task _task(String id, {String? title}) { return core.Task( id: id, diff --git a/packages/scheduler_integration_tests/LICENSE.md b/packages/scheduler_integration_tests/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_integration_tests/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_integration_tests/analysis_options.yaml b/packages/scheduler_integration_tests/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_integration_tests/analysis_options.yaml +++ b/packages/scheduler_integration_tests/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_integration_tests/integration/scheduler_flow_test.dart b/packages/scheduler_integration_tests/integration/scheduler_flow_test.dart index 26a7931..0eb900d 100644 --- a/packages/scheduler_integration_tests/integration/scheduler_flow_test.dart +++ b/packages/scheduler_integration_tests/integration/scheduler_flow_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Exercises Scheduler Flow integration behavior for the Scheduler Integration Tests package. library; @@ -8,6 +11,8 @@ import 'package:scheduler_persistence_memory/persistence_memory.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { test('scheduler flow schedules, notifies, persists, and reloads', () async { final runtime = Stopwatch()..start(); @@ -116,6 +121,8 @@ void main() { }); } +/// Top-level helper that performs the `_taskById` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.Task _taskById(Iterable tasks, String id) { return tasks.singleWhere((task) => task.id == id); } diff --git a/packages/scheduler_integration_tests/pubspec.yaml b/packages/scheduler_integration_tests/pubspec.yaml index 50207eb..0a325c9 100644 --- a/packages/scheduler_integration_tests/pubspec.yaml +++ b/packages/scheduler_integration_tests/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_integration_tests description: End-to-end scheduler integration tests. version: 0.1.0 diff --git a/packages/scheduler_integration_tests/test/scheduler_flow_test.dart b/packages/scheduler_integration_tests/test/scheduler_flow_test.dart index 6373b6c..98d8390 100644 --- a/packages/scheduler_integration_tests/test/scheduler_flow_test.dart +++ b/packages/scheduler_integration_tests/test/scheduler_flow_test.dart @@ -1,8 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Scheduler Flow behavior for the Scheduler Integration Tests package. library; import '../integration/scheduler_flow_test.dart' as scheduler_flow_test; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { scheduler_flow_test.main(); } diff --git a/packages/scheduler_notifications/LICENSE.md b/packages/scheduler_notifications/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_notifications/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_notifications/analysis_options.yaml b/packages/scheduler_notifications/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_notifications/analysis_options.yaml +++ b/packages/scheduler_notifications/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_notifications/lib/notifications.dart b/packages/scheduler_notifications/lib/notifications.dart index 168d793..080cf0b 100644 --- a/packages/scheduler_notifications/lib/notifications.dart +++ b/packages/scheduler_notifications/lib/notifications.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Notification contracts for scheduler reminder delivery. library; diff --git a/packages/scheduler_notifications/lib/scheduler_notifications.dart b/packages/scheduler_notifications/lib/scheduler_notifications.dart index 5f7f907..a08e382 100644 --- a/packages/scheduler_notifications/lib/scheduler_notifications.dart +++ b/packages/scheduler_notifications/lib/scheduler_notifications.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Package-name entry point for scheduler notification contracts. library; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter.dart index 93b7e68..8105622 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -1,153 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Notification Adapter behavior for the Scheduler Notifications package. library; import 'dart:async'; - -/// Request to schedule one notification through a platform adapter. -sealed class NotificationRequest { - /// Creates a notification request. - factory NotificationRequest({ - required String id, - required String title, - required String body, - required DateTime scheduledDateTimeUtc, - String payloadJson = '{}', - }) { - return _NotificationRequest( - id: _requiredTrimmed(id, 'id'), - title: _requiredTrimmed(title, 'title'), - body: body, - scheduledDateTimeUtc: scheduledDateTimeUtc.toUtc(), - payloadJson: payloadJson, - ); - } - - const NotificationRequest._({ - required this.id, - required this.title, - required this.body, - required this.scheduledDateTimeUtc, - required this.payloadJson, - }); - - /// Stable notification id used for scheduling and cancellation. - final String id; - - /// User-visible notification title. - final String title; - - /// User-visible notification body. - final String body; - - /// UTC instant when the notification should be delivered. - final DateTime scheduledDateTimeUtc; - - /// Adapter-neutral JSON payload string. - final String payloadJson; -} - -final class _NotificationRequest extends NotificationRequest { - const _NotificationRequest({ - required super.id, - required super.title, - required super.body, - required super.scheduledDateTimeUtc, - required super.payloadJson, - }) : super._(); -} - -/// User feedback category emitted by a notification adapter. -enum NotificationFeedbackType { - /// The notification was opened or clicked. - clicked, - - /// The notification was dismissed. - dismissed, - - /// A notification action was selected. - action, -} - -/// Feedback event emitted by a notification adapter. -final class NotificationFeedback { - /// Creates a notification feedback event. - NotificationFeedback({ - required String id, - required this.type, - this.actionId, - this.payloadJson = '{}', - }) : id = _requiredTrimmed(id, 'id'); - - /// Stable notification id associated with the feedback. - final String id; - - /// Type of user feedback. - final NotificationFeedbackType type; - - /// Optional action id for [NotificationFeedbackType.action]. - final String? actionId; - - /// Adapter-neutral JSON payload string. - final String payloadJson; -} - -/// Platform-neutral notification adapter boundary. -abstract interface class NotificationAdapter { - /// Schedule [request] for future delivery. - Future schedule(NotificationRequest request); - - /// Cancel a pending notification by stable [id]. - Future cancel(String id); - - /// Stream of user feedback events from delivered notifications. - Stream get feedback; -} - -/// Test fake for notification adapter behavior. -final class FakeNotificationAdapter implements NotificationAdapter { - final StreamController _feedbackController = - StreamController.broadcast(); - final Map _scheduled = - {}; - final List _cancelledIds = []; - - /// Snapshot of currently scheduled requests by id. - Map get scheduledRequests => - Map.unmodifiable(_scheduled); - - /// Snapshot of cancellation ids in call order. - List get cancelledIds => List.unmodifiable(_cancelledIds); - - @override - Stream get feedback => _feedbackController.stream; - - @override - Future schedule(NotificationRequest request) async { - _scheduled[request.id] = request; - } - - @override - Future cancel(String id) async { - final trimmed = _requiredTrimmed(id, 'id'); - _scheduled.remove(trimmed); - _cancelledIds.add(trimmed); - } - - /// Emit [event] to feedback listeners. - void emitFeedback(NotificationFeedback event) { - _feedbackController.add(event); - } - - /// Release stream resources held by this fake. - Future close() { - return _feedbackController.close(); - } -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value must not be blank.'); - } - return trimmed; -} +part 'notification_adapter/requests/notification_request.dart'; +part 'notification_adapter/requests/notification_request_impl.dart'; +part 'notification_adapter/feedback/notification_feedback_type.dart'; +part 'notification_adapter/feedback/notification_feedback.dart'; +part 'notification_adapter/adapter/notification_adapter.dart'; +part 'notification_adapter/adapter/fake_notification_adapter.dart'; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart new file mode 100644 index 0000000..6eca676 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// Test fake for notification adapter behavior. +final class FakeNotificationAdapter implements NotificationAdapter { + /// Private state stored as `_feedbackController` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final StreamController _feedbackController = + StreamController.broadcast(); + + /// Private state stored as `_scheduled` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _scheduled = + {}; + + /// Private state stored as `_cancelledIds` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final List _cancelledIds = []; + + /// Snapshot of currently scheduled requests by id. + Map get scheduledRequests => + Map.unmodifiable(_scheduled); + + /// Snapshot of cancellation ids in call order. + List get cancelledIds => List.unmodifiable(_cancelledIds); + + /// Returns the derived `feedback` 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. + @override + Stream get feedback => _feedbackController.stream; + + /// Runs the `schedule` 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 schedule(NotificationRequest request) async { + _scheduled[request.id] = request; + } + + /// Runs the `cancel` 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 cancel(String id) async { + final trimmed = _requiredTrimmed(id, 'id'); + _scheduled.remove(trimmed); + _cancelledIds.add(trimmed); + } + + /// Emit [event] to feedback listeners. + void emitFeedback(NotificationFeedback event) { + _feedbackController.add(event); + } + + /// Release stream resources held by this fake. + Future close() { + return _feedbackController.close(); + } +} + +/// Top-level helper that performs the `_requiredTrimmed` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value must not be blank.'); + } + return trimmed; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart new file mode 100644 index 0000000..37cc756 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// Platform-neutral notification adapter boundary. +abstract interface class NotificationAdapter { + /// Schedule [request] for future delivery. + Future schedule(NotificationRequest request); + + /// Cancel a pending notification by stable [id]. + Future cancel(String id); + + /// Stream of user feedback events from delivered notifications. + Stream get feedback; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart new file mode 100644 index 0000000..5ccfdd5 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// Feedback event emitted by a notification adapter. +final class NotificationFeedback { + /// Creates a notification feedback event. + NotificationFeedback({ + required String id, + required this.type, + this.actionId, + this.payloadJson = '{}', + }) : id = _requiredTrimmed(id, 'id'); + + /// Stable notification id associated with the feedback. + final String id; + + /// Type of user feedback. + final NotificationFeedbackType type; + + /// Optional action id for [NotificationFeedbackType.action]. + final String? actionId; + + /// Adapter-neutral JSON payload string. + final String payloadJson; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart new file mode 100644 index 0000000..752c012 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// User feedback category emitted by a notification adapter. +enum NotificationFeedbackType { + /// The notification was opened or clicked. + clicked, + + /// The notification was dismissed. + dismissed, + + /// A notification action was selected. + action, +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart new file mode 100644 index 0000000..eb2ca29 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// Request to schedule one notification through a platform adapter. +sealed class NotificationRequest { + /// Creates a notification request. + factory NotificationRequest({ + required String id, + required String title, + required String body, + required DateTime scheduledDateTimeUtc, + String payloadJson = '{}', + }) { + return _NotificationRequest( + id: _requiredTrimmed(id, 'id'), + title: _requiredTrimmed(title, 'title'), + body: body, + scheduledDateTimeUtc: scheduledDateTimeUtc.toUtc(), + payloadJson: payloadJson, + ); + } + + /// Creates a `NotificationRequest._` 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 NotificationRequest._({ + required this.id, + required this.title, + required this.body, + required this.scheduledDateTimeUtc, + required this.payloadJson, + }); + + /// Stable notification id used for scheduling and cancellation. + final String id; + + /// User-visible notification title. + final String title; + + /// User-visible notification body. + final String body; + + /// UTC instant when the notification should be delivered. + final DateTime scheduledDateTimeUtc; + + /// Adapter-neutral JSON payload string. + final String payloadJson; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart new file mode 100644 index 0000000..0a7ea6a --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../notification_adapter.dart'; + +/// Private implementation type for `_NotificationRequest` 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. +final class _NotificationRequest extends NotificationRequest { + /// Creates a `_NotificationRequest` 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 _NotificationRequest({ + required super.id, + required super.title, + required super.body, + required super.scheduledDateTimeUtc, + required super.payloadJson, + }) : super._(); +} diff --git a/packages/scheduler_notifications/pubspec.yaml b/packages/scheduler_notifications/pubspec.yaml index e9d29dd..7cbf28c 100644 --- a/packages/scheduler_notifications/pubspec.yaml +++ b/packages/scheduler_notifications/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_notifications description: Notification adapter contracts for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_notifications/test/fake_adapter_test.dart b/packages/scheduler_notifications/test/fake_adapter_test.dart index 5a8743e..31b0a79 100644 --- a/packages/scheduler_notifications/test/fake_adapter_test.dart +++ b/packages/scheduler_notifications/test/fake_adapter_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Fake Adapter behavior for the Scheduler Notifications package. library; @@ -6,6 +9,8 @@ import 'package:test/test.dart'; import 'notification_adapter_contract.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { runNotificationAdapterContractTests('fake', () { final adapter = FakeNotificationAdapter(); diff --git a/packages/scheduler_notifications/test/notification_adapter_contract.dart b/packages/scheduler_notifications/test/notification_adapter_contract.dart index 34ebca5..c8c8c9f 100644 --- a/packages/scheduler_notifications/test/notification_adapter_contract.dart +++ b/packages/scheduler_notifications/test/notification_adapter_contract.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Notification Adapter Contract behavior for the Scheduler Notifications package. library; @@ -63,6 +66,8 @@ void runNotificationAdapterContractTests( }); } +/// Top-level helper that performs the `_request` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. NotificationRequest _request(String id) { return NotificationRequest( id: id, diff --git a/packages/scheduler_notifications_desktop/LICENSE.md b/packages/scheduler_notifications_desktop/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_notifications_desktop/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_notifications_desktop/analysis_options.yaml b/packages/scheduler_notifications_desktop/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_notifications_desktop/analysis_options.yaml +++ b/packages/scheduler_notifications_desktop/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_notifications_desktop/lib/desktop_notifications.dart b/packages/scheduler_notifications_desktop/lib/desktop_notifications.dart index 34b3ec4..4194ce3 100644 --- a/packages/scheduler_notifications_desktop/lib/desktop_notifications.dart +++ b/packages/scheduler_notifications_desktop/lib/desktop_notifications.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Desktop notification adapter for scheduler reminder delivery. library; diff --git a/packages/scheduler_notifications_desktop/lib/scheduler_notifications_desktop.dart b/packages/scheduler_notifications_desktop/lib/scheduler_notifications_desktop.dart index ab4603d..8c12457 100644 --- a/packages/scheduler_notifications_desktop/lib/scheduler_notifications_desktop.dart +++ b/packages/scheduler_notifications_desktop/lib/scheduler_notifications_desktop.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Package-name entry point for desktop notification adapters. library; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter.dart index a072eae..39ec666 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Desktop Notification Adapter behavior for the Scheduler Notifications Desktop package. library; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart index 676f7c5..7720b84 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Desktop Notification Adapter Io behavior for the Scheduler Notifications Desktop package. library; @@ -6,226 +9,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:scheduler_notifications/notifications.dart'; - -/// Supported desktop platform categories. -enum DesktopNotificationPlatform { - /// Linux desktop. - linux, - - /// macOS desktop. - macos, - - /// Windows desktop. - windows, - - /// Unsupported or unknown platform. - unsupported, -} - -/// Logging callback used by fallback notification backends. -typedef DesktopNotificationLogSink = void Function(String line); - -/// Process runner used by command-backed notification backends. -typedef DesktopProcessRunner = Future Function( - String executable, - List arguments, -); - -/// Platform-neutral backend used by [DesktopNotificationAdapter]. -abstract interface class DesktopNotificationBackend { - /// Whether this backend has native notification support. - bool get isSupported; - - /// Deliver [request]. - Future deliver(NotificationRequest request); - - /// Cancel a pending notification by [id], when supported. - Future cancel(String id); -} - -/// Notification adapter that delegates to a desktop backend. -final class DesktopNotificationAdapter implements NotificationAdapter { - /// Creates an adapter backed by [backend]. - DesktopNotificationAdapter({required DesktopNotificationBackend backend}) - : _backend = backend; - - final DesktopNotificationBackend _backend; - - /// Creates the best available desktop adapter for the current platform. - factory DesktopNotificationAdapter.defaultInstance({ - DesktopNotificationPlatform? platform, - DesktopProcessRunner? processRunner, - DesktopNotificationLogSink? logSink, - }) { - return DesktopNotificationAdapter( - backend: createDefaultDesktopNotificationBackend( - platform: platform, - processRunner: processRunner, - logSink: logSink, - ), - ); - } - - @override - Stream get feedback => const Stream.empty(); - - @override - Future schedule(NotificationRequest request) { - return _backend.deliver(request); - } - - @override - Future cancel(String id) { - return _backend.cancel(id.trim()); - } -} - -/// Create the best default desktop notification backend. -DesktopNotificationBackend createDefaultDesktopNotificationBackend({ - DesktopNotificationPlatform? platform, - DesktopProcessRunner? processRunner, - DesktopNotificationLogSink? logSink, -}) { - final resolvedPlatform = platform ?? _currentPlatform(); - final runner = processRunner ?? Process.run; - final fallback = StdoutNotificationBackend( - logSink: logSink ?? stdout.writeln, - platform: resolvedPlatform, - ); - - return switch (resolvedPlatform) { - DesktopNotificationPlatform.linux => LinuxNotifySendBackend( - processRunner: runner, - fallback: fallback, - ), - DesktopNotificationPlatform.macos => MacOsNotificationBackend( - processRunner: runner, - fallback: fallback, - ), - DesktopNotificationPlatform.windows => fallback, - DesktopNotificationPlatform.unsupported => fallback, - }; -} - -/// Linux backend using `notify-send`. -final class LinuxNotifySendBackend implements DesktopNotificationBackend { - /// Creates a Linux command-backed backend. - LinuxNotifySendBackend({ - required DesktopProcessRunner processRunner, - required DesktopNotificationBackend fallback, - }) : _processRunner = processRunner, - _fallback = fallback; - - final DesktopProcessRunner _processRunner; - final DesktopNotificationBackend _fallback; - - @override - bool get isSupported => true; - - @override - Future deliver(NotificationRequest request) async { - await _runOrFallback( - request, - () => _processRunner('notify-send', [ - '--app-name=ADHD Scheduler', - request.title, - request.body, - ]), - ); - } - - @override - Future cancel(String id) async { - await _fallback.cancel(id); - } - - Future _runOrFallback( - NotificationRequest request, - Future Function() run, - ) async { - try { - final result = await run(); - if (result.exitCode != 0) { - await _fallback.deliver(request); - } - } on ProcessException { - await _fallback.deliver(request); - } - } -} - -/// macOS backend using `osascript`. -final class MacOsNotificationBackend implements DesktopNotificationBackend { - /// Creates a macOS command-backed backend. - MacOsNotificationBackend({ - required DesktopProcessRunner processRunner, - required DesktopNotificationBackend fallback, - }) : _processRunner = processRunner, - _fallback = fallback; - - final DesktopProcessRunner _processRunner; - final DesktopNotificationBackend _fallback; - - @override - bool get isSupported => true; - - @override - Future deliver(NotificationRequest request) async { - final script = 'display notification ${_appleScriptString(request.body)} ' - 'with title ${_appleScriptString(request.title)}'; - try { - final result = await _processRunner('osascript', ['-e', script]); - if (result.exitCode != 0) { - await _fallback.deliver(request); - } - } on ProcessException { - await _fallback.deliver(request); - } - } - - @override - Future cancel(String id) async { - await _fallback.cancel(id); - } -} - -/// Fallback backend that writes notification requests to stdout/logs. -final class StdoutNotificationBackend implements DesktopNotificationBackend { - /// Creates a stdout fallback backend. - StdoutNotificationBackend({ - required DesktopNotificationLogSink logSink, - required this.platform, - }) : _logSink = logSink; - - final DesktopNotificationLogSink _logSink; - - /// Platform this fallback represents. - final DesktopNotificationPlatform platform; - - @override - bool get isSupported => false; - - @override - Future deliver(NotificationRequest request) async { - _logSink( - 'notification[$platform] ${request.id}: ${request.title} - ' - '${request.body}', - ); - } - - @override - Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); - } -} - -DesktopNotificationPlatform _currentPlatform() { - if (Platform.isLinux) return DesktopNotificationPlatform.linux; - if (Platform.isMacOS) return DesktopNotificationPlatform.macos; - if (Platform.isWindows) return DesktopNotificationPlatform.windows; - return DesktopNotificationPlatform.unsupported; -} - -String _appleScriptString(String value) { - return jsonEncode(value); -} +part 'desktop_notification_adapter_io/platform/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_io/callbacks/desktop_process_runner.dart'; +part 'desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart'; +part 'desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart'; +part 'desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart'; +part 'desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart'; +part 'desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart new file mode 100644 index 0000000..50b2c4f --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_io.dart'; + +/// Notification adapter that delegates to a desktop backend. +final class DesktopNotificationAdapter implements NotificationAdapter { + /// Creates an adapter backed by [backend]. + DesktopNotificationAdapter({required DesktopNotificationBackend backend}) + : _backend = backend; + + /// Private state stored as `_backend` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationBackend _backend; + + /// Creates the best available desktop adapter for the current platform. + factory DesktopNotificationAdapter.defaultInstance({ + DesktopNotificationPlatform? platform, + DesktopProcessRunner? processRunner, + DesktopNotificationLogSink? logSink, + }) { + return DesktopNotificationAdapter( + backend: createDefaultDesktopNotificationBackend( + platform: platform, + processRunner: processRunner, + logSink: logSink, + ), + ); + } + + /// Returns the derived `feedback` 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. + @override + Stream get feedback => const Stream.empty(); + + /// Runs the `schedule` 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 schedule(NotificationRequest request) { + return _backend.deliver(request); + } + + /// Runs the `cancel` 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 cancel(String id) { + return _backend.cancel(id.trim()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart new file mode 100644 index 0000000..5d63ef6 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// Platform-neutral backend used by [DesktopNotificationAdapter]. +abstract interface class DesktopNotificationBackend { + /// Whether this backend has native notification support. + bool get isSupported; + + /// Deliver [request]. + Future deliver(NotificationRequest request); + + /// Cancel a pending notification by [id], when supported. + Future cancel(String id); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..a07ff17 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// Create the best default desktop notification backend. +DesktopNotificationBackend createDefaultDesktopNotificationBackend({ + DesktopNotificationPlatform? platform, + DesktopProcessRunner? processRunner, + DesktopNotificationLogSink? logSink, +}) { + final resolvedPlatform = platform ?? _currentPlatform(); + final runner = processRunner ?? Process.run; + final fallback = StdoutNotificationBackend( + logSink: logSink ?? stdout.writeln, + platform: resolvedPlatform, + ); + + return switch (resolvedPlatform) { + DesktopNotificationPlatform.linux => LinuxNotifySendBackend( + processRunner: runner, + fallback: fallback, + ), + DesktopNotificationPlatform.macos => MacOsNotificationBackend( + processRunner: runner, + fallback: fallback, + ), + DesktopNotificationPlatform.windows => fallback, + DesktopNotificationPlatform.unsupported => fallback, + }; +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart new file mode 100644 index 0000000..b8e3d14 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// Fallback backend that writes notification requests to stdout/logs. +final class StdoutNotificationBackend implements DesktopNotificationBackend { + /// Creates a stdout fallback backend. + StdoutNotificationBackend({ + required DesktopNotificationLogSink logSink, + required this.platform, + }) : _logSink = logSink; + + /// Private state stored as `_logSink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationLogSink _logSink; + + /// Platform this fallback represents. + final DesktopNotificationPlatform platform; + + /// Returns the derived `isSupported` 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. + @override + bool get isSupported => false; + + /// Runs the `deliver` 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 deliver(NotificationRequest request) async { + _logSink( + 'notification[$platform] ${request.id}: ${request.title} - ' + '${request.body}', + ); + } + + /// Runs the `cancel` 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 cancel(String id) async { + _logSink('notification[$platform] cancel: ${id.trim()}'); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart new file mode 100644 index 0000000..6a677d0 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// Top-level helper that performs the `_appleScriptString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _appleScriptString(String value) { + return jsonEncode(value); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart new file mode 100644 index 0000000..e336a69 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// Linux backend using `notify-send`. +final class LinuxNotifySendBackend implements DesktopNotificationBackend { + /// Creates a Linux command-backed backend. + LinuxNotifySendBackend({ + required DesktopProcessRunner processRunner, + required DesktopNotificationBackend fallback, + }) : _processRunner = processRunner, + _fallback = fallback; + + /// Private state stored as `_processRunner` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopProcessRunner _processRunner; + + /// Private state stored as `_fallback` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationBackend _fallback; + + /// Returns the derived `isSupported` 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. + @override + bool get isSupported => true; + + /// Runs the `deliver` 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 deliver(NotificationRequest request) async { + await _runOrFallback( + request, + () => _processRunner('notify-send', [ + '--app-name=ADHD Scheduler', + request.title, + request.body, + ]), + ); + } + + /// Runs the `cancel` 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 cancel(String id) async { + await _fallback.cancel(id); + } + + /// Runs the `_runOrFallback` 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 _runOrFallback( + NotificationRequest request, + Future Function() run, + ) async { + try { + final result = await run(); + if (result.exitCode != 0) { + await _fallback.deliver(request); + } + } on ProcessException { + await _fallback.deliver(request); + } + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart new file mode 100644 index 0000000..70bbcf3 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../desktop_notification_adapter_io.dart'; + +/// macOS backend using `osascript`. +final class MacOsNotificationBackend implements DesktopNotificationBackend { + /// Creates a macOS command-backed backend. + MacOsNotificationBackend({ + required DesktopProcessRunner processRunner, + required DesktopNotificationBackend fallback, + }) : _processRunner = processRunner, + _fallback = fallback; + + /// Private state stored as `_processRunner` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopProcessRunner _processRunner; + + /// Private state stored as `_fallback` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationBackend _fallback; + + /// Returns the derived `isSupported` 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. + @override + bool get isSupported => true; + + /// Runs the `deliver` 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 deliver(NotificationRequest request) async { + final script = 'display notification ${_appleScriptString(request.body)} ' + 'with title ${_appleScriptString(request.title)}'; + try { + final result = await _processRunner('osascript', ['-e', script]); + if (result.exitCode != 0) { + await _fallback.deliver(request); + } + } on ProcessException { + await _fallback.deliver(request); + } + } + + /// Runs the `cancel` 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 cancel(String id) async { + await _fallback.cancel(id); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart new file mode 100644 index 0000000..86df51c --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_io.dart'; + +/// Logging callback used by fallback notification backends. +typedef DesktopNotificationLogSink = void Function(String line); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart new file mode 100644 index 0000000..0b379d6 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_io.dart'; + +/// Process runner used by command-backed notification backends. +typedef DesktopProcessRunner = Future Function( + String executable, + List arguments, +); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart new file mode 100644 index 0000000..be1cb92 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_io.dart'; + +/// Supported desktop platform categories. +enum DesktopNotificationPlatform { + /// Linux desktop. + linux, + + /// macOS desktop. + macos, + + /// Windows desktop. + windows, + + /// Unsupported or unknown platform. + unsupported, +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart new file mode 100644 index 0000000..ac2d5c0 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_io.dart'; + +/// Top-level helper that performs the `_currentPlatform` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +DesktopNotificationPlatform _currentPlatform() { + if (Platform.isLinux) return DesktopNotificationPlatform.linux; + if (Platform.isMacOS) return DesktopNotificationPlatform.macos; + if (Platform.isWindows) return DesktopNotificationPlatform.windows; + return DesktopNotificationPlatform.unsupported; +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart index 5346375..eabbd58 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub.dart @@ -1,120 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Desktop Notification Adapter Stub behavior for the Scheduler Notifications Desktop package. library; import 'dart:async'; import 'package:scheduler_notifications/notifications.dart'; - -/// Supported desktop platform categories. -enum DesktopNotificationPlatform { - /// Linux desktop. - linux, - - /// macOS desktop. - macos, - - /// Windows desktop. - windows, - - /// Unsupported or unknown platform. - unsupported, -} - -/// Logging callback used by fallback notification backends. -typedef DesktopNotificationLogSink = void Function(String line); - -/// Platform-neutral backend used by [DesktopNotificationAdapter]. -abstract interface class DesktopNotificationBackend { - /// Whether this backend has native notification support. - bool get isSupported; - - /// Deliver [request]. - Future deliver(NotificationRequest request); - - /// Cancel a pending notification by [id], when supported. - Future cancel(String id); -} - -/// Notification adapter that delegates to a desktop backend. -final class DesktopNotificationAdapter implements NotificationAdapter { - /// Creates an adapter backed by [backend]. - DesktopNotificationAdapter({required DesktopNotificationBackend backend}) - : _backend = backend; - - final DesktopNotificationBackend _backend; - - /// Creates the best available desktop adapter for the current platform. - factory DesktopNotificationAdapter.defaultInstance({ - DesktopNotificationPlatform? platform, - Object? processRunner, - DesktopNotificationLogSink? logSink, - }) { - return DesktopNotificationAdapter( - backend: createDefaultDesktopNotificationBackend( - platform: platform, - processRunner: processRunner, - logSink: logSink, - ), - ); - } - - @override - Stream get feedback => const Stream.empty(); - - @override - Future schedule(NotificationRequest request) { - return _backend.deliver(request); - } - - @override - Future cancel(String id) { - return _backend.cancel(id.trim()); - } -} - -/// Create the best default desktop notification backend. -DesktopNotificationBackend createDefaultDesktopNotificationBackend({ - DesktopNotificationPlatform? platform, - Object? processRunner, - DesktopNotificationLogSink? logSink, -}) { - return StdoutNotificationBackend( - logSink: logSink ?? _defaultLogSink, - platform: platform ?? DesktopNotificationPlatform.unsupported, - ); -} - -/// Fallback backend that writes notification requests to stdout/logs. -final class StdoutNotificationBackend implements DesktopNotificationBackend { - /// Creates a stdout fallback backend. - StdoutNotificationBackend({ - required DesktopNotificationLogSink logSink, - required this.platform, - }) : _logSink = logSink; - - final DesktopNotificationLogSink _logSink; - - /// Platform this fallback represents. - final DesktopNotificationPlatform platform; - - @override - bool get isSupported => false; - - @override - Future deliver(NotificationRequest request) async { - _logSink( - 'notification[$platform] ${request.id}: ${request.title} - ' - '${request.body}', - ); - } - - @override - Future cancel(String id) async { - _logSink('notification[$platform] cancel: ${id.trim()}'); - } -} - -void _defaultLogSink(String line) { - // ignore: avoid_print - print(line); -} +part 'desktop_notification_adapter_stub/platform/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_stub/backends/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_stub/callbacks/default_log_sink.dart'; +part 'desktop_notification_adapter_stub/backends/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart new file mode 100644 index 0000000..3b876dd --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Notification adapter that delegates to a desktop backend. +final class DesktopNotificationAdapter implements NotificationAdapter { + /// Creates an adapter backed by [backend]. + DesktopNotificationAdapter({required DesktopNotificationBackend backend}) + : _backend = backend; + + /// Private state stored as `_backend` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationBackend _backend; + + /// Creates the best available desktop adapter for the current platform. + factory DesktopNotificationAdapter.defaultInstance({ + DesktopNotificationPlatform? platform, + Object? processRunner, + DesktopNotificationLogSink? logSink, + }) { + return DesktopNotificationAdapter( + backend: createDefaultDesktopNotificationBackend( + platform: platform, + processRunner: processRunner, + logSink: logSink, + ), + ); + } + + /// Returns the derived `feedback` 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. + @override + Stream get feedback => const Stream.empty(); + + /// Runs the `schedule` 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 schedule(NotificationRequest request) { + return _backend.deliver(request); + } + + /// Runs the `cancel` 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 cancel(String id) { + return _backend.cancel(id.trim()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart new file mode 100644 index 0000000..497f848 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Platform-neutral backend used by [DesktopNotificationAdapter]. +abstract interface class DesktopNotificationBackend { + /// Whether this backend has native notification support. + bool get isSupported; + + /// Deliver [request]. + Future deliver(NotificationRequest request); + + /// Cancel a pending notification by [id], when supported. + Future cancel(String id); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..39626a7 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Create the best default desktop notification backend. +DesktopNotificationBackend createDefaultDesktopNotificationBackend({ + DesktopNotificationPlatform? platform, + Object? processRunner, + DesktopNotificationLogSink? logSink, +}) { + return StdoutNotificationBackend( + logSink: logSink ?? _defaultLogSink, + platform: platform ?? DesktopNotificationPlatform.unsupported, + ); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart new file mode 100644 index 0000000..797cc2d --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Fallback backend that writes notification requests to stdout/logs. +final class StdoutNotificationBackend implements DesktopNotificationBackend { + /// Creates a stdout fallback backend. + StdoutNotificationBackend({ + required DesktopNotificationLogSink logSink, + required this.platform, + }) : _logSink = logSink; + + /// Private state stored as `_logSink` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final DesktopNotificationLogSink _logSink; + + /// Platform this fallback represents. + final DesktopNotificationPlatform platform; + + /// Returns the derived `isSupported` 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. + @override + bool get isSupported => false; + + /// Runs the `deliver` 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 deliver(NotificationRequest request) async { + _logSink( + 'notification[$platform] ${request.id}: ${request.title} - ' + '${request.body}', + ); + } + + /// Runs the `cancel` 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 cancel(String id) async { + _logSink('notification[$platform] cancel: ${id.trim()}'); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart new file mode 100644 index 0000000..a00c134 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Top-level helper that performs the `_defaultLogSink` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +void _defaultLogSink(String line) { + // ignore: avoid_print + print(line); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart new file mode 100644 index 0000000..b4f4dd3 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Logging callback used by fallback notification backends. +typedef DesktopNotificationLogSink = void Function(String line); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart new file mode 100644 index 0000000..a15b633 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../desktop_notification_adapter_stub.dart'; + +/// Supported desktop platform categories. +enum DesktopNotificationPlatform { + /// Linux desktop. + linux, + + /// macOS desktop. + macos, + + /// Windows desktop. + windows, + + /// Unsupported or unknown platform. + unsupported, +} diff --git a/packages/scheduler_notifications_desktop/pubspec.yaml b/packages/scheduler_notifications_desktop/pubspec.yaml index 71c23f7..aebf6bc 100644 --- a/packages/scheduler_notifications_desktop/pubspec.yaml +++ b/packages/scheduler_notifications_desktop/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_notifications_desktop description: Desktop notification adapter for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_notifications_desktop/test/desktop_notification_adapter_test.dart b/packages/scheduler_notifications_desktop/test/desktop_notification_adapter_test.dart index 40cd4c4..207f02d 100644 --- a/packages/scheduler_notifications_desktop/test/desktop_notification_adapter_test.dart +++ b/packages/scheduler_notifications_desktop/test/desktop_notification_adapter_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Desktop Notification Adapter behavior for the Scheduler Notifications Desktop package. library; @@ -11,6 +14,8 @@ import '../../scheduler_notifications/test/notification_adapter_contract.dart'; // ignore: avoid_relative_lib_imports import '../lib/desktop_notifications.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { runNotificationAdapterContractTests('desktop', () { final backend = _RecordingBackend(); @@ -39,7 +44,7 @@ void main() { final logs = []; final backend = createDefaultDesktopNotificationBackend( platform: DesktopNotificationPlatform.linux, - processRunner: (executable, arguments) async { + processRunner: (String executable, List arguments) async { calls.add(_ProcessCall(executable, arguments)); return ProcessResult(1, 0, '', ''); }, @@ -58,7 +63,7 @@ void main() { final calls = <_ProcessCall>[]; final backend = createDefaultDesktopNotificationBackend( platform: DesktopNotificationPlatform.macos, - processRunner: (executable, arguments) async { + processRunner: (String executable, List arguments) async { calls.add(_ProcessCall(executable, arguments)); return ProcessResult(1, 0, '', ''); }, @@ -112,6 +117,8 @@ void main() { }); } +/// Top-level helper that performs the `_scheduleAndCancelReminder` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _scheduleAndCancelReminder( NotificationAdapter adapter, NotificationRequest request, @@ -120,6 +127,8 @@ Future _scheduleAndCancelReminder( await adapter.cancel(request.id); } +/// Top-level helper that performs the `_request` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. NotificationRequest _request(String id) { return NotificationRequest( id: id, @@ -129,27 +138,49 @@ NotificationRequest _request(String id) { ); } +/// Private implementation type for `_RecordingBackend` 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. final class _RecordingBackend implements DesktopNotificationBackend { + /// Stores the `delivered` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final List delivered = []; + + /// Stores the `cancelled` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final List cancelled = []; + /// Returns the derived `isSupported` 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. @override bool get isSupported => true; + /// Runs the `deliver` 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 deliver(NotificationRequest request) async { delivered.add(request); } + /// Runs the `cancel` 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 cancel(String id) async { cancelled.add(id); } } +/// Private implementation type for `_ProcessCall` 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. final class _ProcessCall { + /// Creates a `_ProcessCall` 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 _ProcessCall(this.executable, this.arguments); + /// Stores the `executable` 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 executable; + + /// Stores the `arguments` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final List arguments; } diff --git a/packages/scheduler_persistence/LICENSE.md b/packages/scheduler_persistence/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_persistence/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_persistence/analysis_options.yaml b/packages/scheduler_persistence/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_persistence/analysis_options.yaml +++ b/packages/scheduler_persistence/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_persistence/lib/persistence.dart b/packages/scheduler_persistence/lib/persistence.dart index 55ebd67..93546d6 100644 --- a/packages/scheduler_persistence/lib/persistence.dart +++ b/packages/scheduler_persistence/lib/persistence.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Domain-only repository contracts for scheduler persistence adapters. /// /// This package defines the stable boundary that SQLite, memory, and future @@ -7,319 +10,19 @@ library; import 'package:scheduler_core/scheduler_core.dart' as core; - -/// Adapter-neutral repository failure. -sealed class RepositoryFailure { - const RepositoryFailure({ - this.entityId, - this.expectedRevision, - this.actualRevision, - this.message, - }); - - /// Stable id of the entity related to the failure, when available. - final String? entityId; - - /// Revision the caller expected for a compare-and-set operation. - final core.Revision? expectedRevision; - - /// Revision currently stored by the adapter. - final core.Revision? actualRevision; - - /// Optional non-localized diagnostic text for logs and tests. - final String? message; -} - -/// The requested entity was not found. -final class RepositoryNotFound extends RepositoryFailure { - /// Creates a not-found failure for [entityId]. - const RepositoryNotFound({super.entityId, super.message}); -} - -/// A record with the requested id already exists. -final class RepositoryDuplicateId extends RepositoryFailure { - /// Creates a duplicate-id failure for [entityId]. - const RepositoryDuplicateId({super.entityId, super.message}); -} - -/// The caller supplied an invalid revision value. -final class RepositoryInvalidRevision extends RepositoryFailure { - /// Creates an invalid-revision failure. - const RepositoryInvalidRevision({ - super.entityId, - super.expectedRevision, - super.message, - }); -} - -/// The caller attempted to write using a stale revision. -final class RepositoryStaleRevision extends RepositoryFailure { - /// Creates a stale-revision failure. - const RepositoryStaleRevision({ - super.entityId, - required super.expectedRevision, - required super.actualRevision, - super.message, - }); -} - -/// The requested record exists under a different owner scope. -final class RepositoryOwnerMismatch extends RepositoryFailure { - /// Creates an owner-mismatch failure for [entityId]. - const RepositoryOwnerMismatch({super.entityId, super.message}); -} - -/// The adapter could not complete the operation due to storage failure. -final class RepositoryStorageFailure extends RepositoryFailure { - /// Creates a storage failure with optional [message]. - const RepositoryStorageFailure({super.entityId, super.message}); -} - -/// Minimal Either type for expected repository success/failure paths. -sealed class Either { - const Either(); - - /// Whether this value is a left/failure value. - bool get isLeft => this is Left; - - /// Whether this value is a right/success value. - bool get isRight => this is Right; - - /// Return the left value or throw when this is right. - L get left => switch (this) { - Left(:final value) => value, - Right() => throw StateError('Either does not contain left.'), - }; - - /// Return the right value or throw when this is left. - R get right => switch (this) { - Left() => throw StateError('Either does not contain right.'), - Right(:final value) => value, - }; -} - -/// Failure side of [Either]. -final class Left extends Either { - /// Creates a left/failure value. - const Left(this.value); - - /// Wrapped failure value. - final L value; -} - -/// Success side of [Either]. -final class Right extends Either { - /// Creates a right/success value. - const Right(this.value); - - /// Wrapped success value. - final R value; -} - -/// Repository result wrapper for expected adapter outcomes. -typedef RepoResult = Either; - -/// Repository contract for task records. -abstract interface class TaskRepository { - /// Return the task with [taskId] in [ownerId], or null when it is absent. - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }); - - /// Return owner-scoped tasks in deterministic adapter order. - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped tasks assigned to [projectId]. - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped direct child tasks for [parentTaskId]. - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return owner-scoped tasks with [status]. - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [task] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }); - - /// Save [task] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }); - - /// Remove [taskId] if [expectedRevision] still matches the stored record. - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for project profile records. -abstract interface class ProjectRepository { - /// Return the project with [projectId] in [ownerId], or null when absent. - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }); - - /// Return owner-scoped projects, optionally including archived profiles. - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [project] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }); - - /// Save [project] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }); - - /// Archive [projectId] without deleting task history. - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }); -} - -/// Repository contract for locked blocks and date-scoped overrides. -abstract interface class LockedBlockRepository { - /// Return the locked block with [blockId] in [ownerId], or null when absent. - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }); - - /// Return owner-scoped locked blocks. - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }); - - /// Return the override with [overrideId] in [ownerId], or null when absent. - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }); - - /// Return owner-scoped overrides for [date]. - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [block] for [ownerId] and return the assigned revision. - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }); - - /// Save [block] if [expectedRevision] still matches the stored record. - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }); - - /// Archive [blockId] without deleting historical schedule context. - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }); - - /// Insert [override] for [ownerId] and return the assigned revision. - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }); - - /// Save [override] if [expectedRevision] still matches the stored record. - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for owner settings. -abstract interface class SettingsRepository { - /// Return settings for [ownerId], or null when no settings have been saved. - Future> findByOwner({ - required core.OwnerId ownerId, - }); - - /// Insert [settings] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }); - - /// Save [settings] if [expectedRevision] still matches the stored record. - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }); -} - -/// Repository contract for diagnostic schedule snapshots. -abstract interface class ScheduleSnapshotRepository { - /// Return the snapshot with [snapshotId] in [ownerId], or null when absent. - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }); - - /// Return snapshots captured for [ownerId] that overlap [window]. - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }); - - /// Insert [snapshot] for [ownerId] and return the assigned revision. - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }); - - /// Delete snapshots whose retention expiry is before [nowUtc]. - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }); -} +part 'persistence/failures/core/repository_failure.dart'; +part 'persistence/failures/identity/repository_not_found.dart'; +part 'persistence/failures/identity/repository_duplicate_id.dart'; +part 'persistence/failures/revision/repository_invalid_revision.dart'; +part 'persistence/failures/revision/repository_stale_revision.dart'; +part 'persistence/failures/identity/repository_owner_mismatch.dart'; +part 'persistence/failures/storage/repository_storage_failure.dart'; +part 'persistence/results/either.dart'; +part 'persistence/results/left.dart'; +part 'persistence/results/right.dart'; +part 'persistence/results/repo_result.dart'; +part 'persistence/repositories/task_repository.dart'; +part 'persistence/repositories/project_repository.dart'; +part 'persistence/repositories/locked_block_repository.dart'; +part 'persistence/repositories/settings_repository.dart'; +part 'persistence/repositories/schedule_snapshot_repository.dart'; diff --git a/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart new file mode 100644 index 0000000..374db6d --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// Adapter-neutral repository failure. +sealed class RepositoryFailure { + /// Creates a `RepositoryFailure` 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 RepositoryFailure({ + this.entityId, + this.expectedRevision, + this.actualRevision, + this.message, + }); + + /// Stable id of the entity related to the failure, when available. + final String? entityId; + + /// Revision the caller expected for a compare-and-set operation. + final core.Revision? expectedRevision; + + /// Revision currently stored by the adapter. + final core.Revision? actualRevision; + + /// Optional non-localized diagnostic text for logs and tests. + final String? message; +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart new file mode 100644 index 0000000..7fc5705 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// A record with the requested id already exists. +final class RepositoryDuplicateId extends RepositoryFailure { + /// Creates a duplicate-id failure for [entityId]. + const RepositoryDuplicateId({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart new file mode 100644 index 0000000..7ccf6b2 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// The requested entity was not found. +final class RepositoryNotFound extends RepositoryFailure { + /// Creates a not-found failure for [entityId]. + const RepositoryNotFound({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart new file mode 100644 index 0000000..af3fefb --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// The requested record exists under a different owner scope. +final class RepositoryOwnerMismatch extends RepositoryFailure { + /// Creates an owner-mismatch failure for [entityId]. + const RepositoryOwnerMismatch({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart new file mode 100644 index 0000000..8a30884 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// The caller supplied an invalid revision value. +final class RepositoryInvalidRevision extends RepositoryFailure { + /// Creates an invalid-revision failure. + const RepositoryInvalidRevision({ + super.entityId, + super.expectedRevision, + super.message, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart new file mode 100644 index 0000000..1de5aab --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// The caller attempted to write using a stale revision. +final class RepositoryStaleRevision extends RepositoryFailure { + /// Creates a stale-revision failure. + const RepositoryStaleRevision({ + super.entityId, + required super.expectedRevision, + required super.actualRevision, + super.message, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart b/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart new file mode 100644 index 0000000..887535e --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../persistence.dart'; + +/// The adapter could not complete the operation due to storage failure. +final class RepositoryStorageFailure extends RepositoryFailure { + /// Creates a storage failure with optional [message]. + const RepositoryStorageFailure({super.entityId, super.message}); +} diff --git a/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart new file mode 100644 index 0000000..ddb631c --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository contract for locked blocks and date-scoped overrides. +abstract interface class LockedBlockRepository { + /// Return the locked block with [blockId] in [ownerId], or null when absent. + Future> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }); + + /// Return owner-scoped locked blocks. + Future>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return the override with [overrideId] in [ownerId], or null when absent. + Future> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }); + + /// Return owner-scoped overrides for [date]. + Future>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [block] for [ownerId] and return the assigned revision. + Future> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }); + + /// Save [block] if [expectedRevision] still matches the stored record. + Future> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }); + + /// Archive [blockId] without deleting historical schedule context. + Future> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }); + + /// Insert [override] for [ownerId] and return the assigned revision. + Future> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }); + + /// Save [override] if [expectedRevision] still matches the stored record. + Future> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart new file mode 100644 index 0000000..344408e --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository contract for project profile records. +abstract interface class ProjectRepository { + /// Return the project with [projectId] in [ownerId], or null when absent. + Future> findById({ + required core.OwnerId ownerId, + required String projectId, + }); + + /// Return owner-scoped projects, optionally including archived profiles. + Future>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [project] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }); + + /// Save [project] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }); + + /// Archive [projectId] without deleting task history. + Future> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart new file mode 100644 index 0000000..30fc0ab --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository contract for diagnostic schedule snapshots. +abstract interface class ScheduleSnapshotRepository { + /// Return the snapshot with [snapshotId] in [ownerId], or null when absent. + Future> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }); + + /// Return snapshots captured for [ownerId] that overlap [window]. + Future>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [snapshot] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }); + + /// Delete snapshots whose retention expiry is before [nowUtc]. + Future> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart new file mode 100644 index 0000000..1d540ec --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository contract for owner settings. +abstract interface class SettingsRepository { + /// Return settings for [ownerId], or null when no settings have been saved. + Future> findByOwner({ + required core.OwnerId ownerId, + }); + + /// Insert [settings] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }); + + /// Save [settings] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart new file mode 100644 index 0000000..49d8022 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository contract for task records. +abstract interface class TaskRepository { + /// Return the task with [taskId] in [ownerId], or null when it is absent. + Future> findById({ + required core.OwnerId ownerId, + required String taskId, + }); + + /// Return owner-scoped tasks in deterministic adapter order. + Future>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped tasks assigned to [projectId]. + Future>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped direct child tasks for [parentTaskId]. + Future>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }); + + /// Return owner-scoped tasks with [status]. + Future>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }); + + /// Insert [task] for [ownerId] and return the assigned revision. + Future> insert({ + required core.OwnerId ownerId, + required core.Task task, + }); + + /// Save [task] if [expectedRevision] still matches the stored record. + Future> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }); + + /// Remove [taskId] if [expectedRevision] still matches the stored record. + Future> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }); +} diff --git a/packages/scheduler_persistence/lib/persistence/results/either.dart b/packages/scheduler_persistence/lib/persistence/results/either.dart new file mode 100644 index 0000000..b8c1097 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/results/either.dart @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Minimal Either type for expected repository success/failure paths. +sealed class Either { + /// Creates a `Either` 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 Either(); + + /// Whether this value is a left/failure value. + bool get isLeft => this is Left; + + /// Whether this value is a right/success value. + bool get isRight => this is Right; + + /// Return the left value or throw when this is right. + L get left => switch (this) { + Left(:final value) => value, + Right() => throw StateError('Either does not contain left.'), + }; + + /// Return the right value or throw when this is left. + R get right => switch (this) { + Left() => throw StateError('Either does not contain right.'), + Right(:final value) => value, + }; +} diff --git a/packages/scheduler_persistence/lib/persistence/results/left.dart b/packages/scheduler_persistence/lib/persistence/results/left.dart new file mode 100644 index 0000000..288866c --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/results/left.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Failure side of [Either]. +final class Left extends Either { + /// Creates a left/failure value. + const Left(this.value); + + /// Wrapped failure value. + final L value; +} diff --git a/packages/scheduler_persistence/lib/persistence/results/repo_result.dart b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart new file mode 100644 index 0000000..e91dde6 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Repository result wrapper for expected adapter outcomes. +typedef RepoResult = Either; diff --git a/packages/scheduler_persistence/lib/persistence/results/right.dart b/packages/scheduler_persistence/lib/persistence/results/right.dart new file mode 100644 index 0000000..2932ea4 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/results/right.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence.dart'; + +/// Success side of [Either]. +final class Right extends Either { + /// Creates a right/success value. + const Right(this.value); + + /// Wrapped success value. + final R value; +} diff --git a/packages/scheduler_persistence/pubspec.yaml b/packages/scheduler_persistence/pubspec.yaml index 3d050ef..723b54b 100644 --- a/packages/scheduler_persistence/pubspec.yaml +++ b/packages/scheduler_persistence/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_persistence description: Domain-only repository contracts for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_persistence/test/repo_conformance.dart b/packages/scheduler_persistence/test/repo_conformance.dart index 024e765..a59da81 100644 --- a/packages/scheduler_persistence/test/repo_conformance.dart +++ b/packages/scheduler_persistence/test/repo_conformance.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Repo Conformance behavior for the Scheduler Persistence package. library; @@ -7,6 +10,8 @@ import 'package:test/test.dart'; /// Factory set used by adapter packages to run the shared repository contract. final class RepositoryComplianceFactories { + /// Creates a `RepositoryComplianceFactories` 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 RepositoryComplianceFactories({ required this.taskRepository, required this.projectRepository, @@ -15,10 +20,24 @@ final class RepositoryComplianceFactories { required this.scheduleSnapshotRepository, }); + /// Stores the `taskRepository` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final TaskRepository Function() taskRepository; + + /// Stores the `projectRepository` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final ProjectRepository Function() projectRepository; + + /// Stores the `lockedBlockRepository` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final LockedBlockRepository Function() lockedBlockRepository; + + /// Stores the `settingsRepository` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final SettingsRepository Function() settingsRepository; + + /// Stores the `scheduleSnapshotRepository` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final ScheduleSnapshotRepository Function() scheduleSnapshotRepository; } @@ -384,10 +403,14 @@ void runRepositoryComplianceTests(RepositoryComplianceFactories factories) { }); } +/// Top-level helper that performs the `_instant` 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 _instant(int day, {int hour = 9}) { return DateTime.utc(2026, 1, 1 + day, hour); } +/// Top-level helper that performs the `_task` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.Task _task( String id, { String projectId = 'project-a', @@ -407,6 +430,8 @@ core.Task _task( ); } +/// Top-level helper that performs the `_project` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.ProjectProfile _project(String id) { return core.ProjectProfile( id: id, @@ -416,6 +441,8 @@ core.ProjectProfile _project(String id) { ); } +/// Top-level helper that performs the `_lockedBlock` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.LockedBlock _lockedBlock(String id) { return core.LockedBlock( id: id, @@ -428,6 +455,8 @@ core.LockedBlock _lockedBlock(String id) { ); } +/// Top-level helper that performs the `_override` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.LockedBlockOverride _override( String id, { String name = 'Added block', @@ -442,6 +471,8 @@ core.LockedBlockOverride _override( ); } +/// Top-level helper that performs the `_settings` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.OwnerSettings _settings(String ownerId) { return core.OwnerSettings( ownerId: ownerId, @@ -451,6 +482,8 @@ core.OwnerSettings _settings(String ownerId) { ); } +/// Top-level helper that performs the `_snapshot` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. core.SchedulingStateSnapshot _snapshot(String id) { return core.SchedulingStateSnapshot( id: id, diff --git a/packages/scheduler_persistence/test/repo_contract_smoke_test.dart b/packages/scheduler_persistence/test/repo_contract_smoke_test.dart index 6ac1102..28155b7 100644 --- a/packages/scheduler_persistence/test/repo_contract_smoke_test.dart +++ b/packages/scheduler_persistence/test/repo_contract_smoke_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Repo Contract Smoke behavior for the Scheduler Persistence package. library; @@ -5,6 +8,8 @@ import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { group('repository contract smoke test', () { test('exports required repository interfaces', () { diff --git a/packages/scheduler_persistence_memory/LICENSE.md b/packages/scheduler_persistence_memory/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_persistence_memory/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_persistence_memory/analysis_options.yaml b/packages/scheduler_persistence_memory/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_persistence_memory/analysis_options.yaml +++ b/packages/scheduler_persistence_memory/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_persistence_memory/lib/memory.dart b/packages/scheduler_persistence_memory/lib/memory.dart index 3b2621a..0ba6782 100644 --- a/packages/scheduler_persistence_memory/lib/memory.dart +++ b/packages/scheduler_persistence_memory/lib/memory.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Compatibility entry point for in-memory scheduler persistence adapters. library; diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory.dart b/packages/scheduler_persistence_memory/lib/persistence_memory.dart index 53ecd8e..c4236cc 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// In-memory implementations of the scheduler persistence repository contracts. /// /// These repositories are intended for core tests, local integration tests, and @@ -9,844 +12,19 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; +part 'persistence_memory/repositories/in_memory_task_repository.dart'; +part 'persistence_memory/repositories/in_memory_project_repository.dart'; +part 'persistence_memory/repositories/in_memory_locked_block_repository.dart'; +part 'persistence_memory/repositories/in_memory_settings_repository.dart'; +part 'persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart'; +part 'persistence_memory/records/identified_record.dart'; +part 'persistence_memory/records/record.dart'; +part 'persistence_memory/records/snapshot_record.dart'; +/// Private file-level value for `_snapshotRetention`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _snapshotRetention = Duration(days: 30); +/// Private file-level value for `_epoch`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - -/// In-memory task repository with owner scoping and revision checks. -final class InMemoryTaskRepository implements TaskRepository { - final Map> _records = - >{}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }) async { - final record = _records[taskId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneTask(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where((record) => record.ownerId == ownerId), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - record.value.parentTaskId == parentTaskId, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && record.value.projectId == projectId, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => record.ownerId == ownerId && record.value.status == status, - ), - page, - (record) => _cloneTask(record.value, record.ownerId, record.revision), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }) async { - if (_records.containsKey(task.id)) { - return Left(RepositoryDuplicateId(entityId: task.id)); - } - _records[task.id] = _Record( - id: task.id, - ownerId: ownerId, - value: _cloneTask(task, ownerId, core.Revision.initial), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: task.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _records[task.id] = record.copyWith( - value: _cloneTask(task, ownerId, nextRevision), - revision: nextRevision, - updatedAt: task.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: taskId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - _records.remove(taskId); - return Right(nextRevision); - } -} - -/// In-memory project repository with owner scoping and revision checks. -final class InMemoryProjectRepository implements ProjectRepository { - final Map> _records = - >{}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }) async { - final record = _records[projectId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneProject(record)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - (includeArchived || !record.value.isArchived), - ), - page, - _cloneProject, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }) async { - if (_records.containsKey(project.id)) { - return Left(RepositoryDuplicateId(entityId: project.id)); - } - _records[project.id] = _Record( - id: project.id, - ownerId: ownerId, - value: _cloneProjectValue( - project, - ownerId: ownerId, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ), - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: project.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _records[project.id] = record.copyWith( - value: _cloneProjectValue( - project, - ownerId: ownerId, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } - - @override - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = _checkWrite( - records: _records, - id: projectId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final archived = record.value.copyWith(archivedAt: archivedAtUtc); - _records[projectId] = record.copyWith( - value: _cloneProjectValue( - archived, - ownerId: ownerId, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } -} - -/// In-memory locked-block repository with owner scoping and revision checks. -final class InMemoryLockedBlockRepository implements LockedBlockRepository { - final Map> _blocks = - >{}; - final Map> _overrides = - >{}; - - @override - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }) async { - final record = _blocks[blockId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right( - _cloneLockedBlock(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _blocks.values.where( - (record) => - record.ownerId == ownerId && - (includeArchived || !record.value.isArchived), - ), - page, - (record) => _cloneLockedBlock( - record.value, - record.ownerId, - record.revision, - ), - )); - } - - @override - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }) async { - final record = _overrides[overrideId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right( - _cloneLockedBlockOverride(record.value, record.ownerId, record.revision), - ); - } - - @override - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _overrides.values.where( - (record) => record.ownerId == ownerId && record.value.date == date, - ), - page, - (record) => _cloneLockedBlockOverride( - record.value, - record.ownerId, - record.revision, - ), - )); - } - - @override - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }) async { - if (_blocks.containsKey(block.id)) { - return Left(RepositoryDuplicateId(entityId: block.id)); - } - _blocks[block.id] = _Record( - id: block.id, - ownerId: ownerId, - value: _cloneLockedBlock(block, ownerId, core.Revision.initial), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _blocks, - id: block.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _blocks[block.id] = record.copyWith( - value: _cloneLockedBlock(block, ownerId, nextRevision), - revision: nextRevision, - updatedAt: block.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = _checkWrite( - records: _blocks, - id: blockId, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final archived = record.value.copyWith(archivedAt: archivedAtUtc); - _blocks[blockId] = record.copyWith( - value: _cloneLockedBlock(archived, ownerId, nextRevision), - revision: nextRevision, - updatedAt: archived.updatedAt, - ); - return Right(nextRevision); - } - - @override - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }) async { - if (_overrides.containsKey(override.id)) { - return Left(RepositoryDuplicateId(entityId: override.id)); - } - _overrides[override.id] = _Record( - id: override.id, - ownerId: ownerId, - value: _cloneLockedBlockOverride( - override, - ownerId, - core.Revision.initial, - ), - revision: core.Revision.initial, - ); - return const Right(core.Revision.initial); - } - - @override - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _overrides, - id: override.id, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - _overrides[override.id] = record.copyWith( - value: _cloneLockedBlockOverride(override, ownerId, nextRevision), - revision: nextRevision, - updatedAt: override.updatedAt, - ); - return Right(nextRevision); - } -} - -/// In-memory owner settings repository with owner scoping and revision checks. -final class InMemorySettingsRepository implements SettingsRepository { - final Map> _records = - >{}; - - @override - Future> findByOwner({ - required core.OwnerId ownerId, - }) async { - final record = _records[ownerId.value]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneSettings(record)); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }) async { - if (_records.containsKey(ownerId.value)) { - return Left(RepositoryDuplicateId(entityId: ownerId.value)); - } - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - _records[ownerId.value] = _Record( - id: ownerId.value, - ownerId: ownerId, - value: _cloneSettingsValue( - ownedSettings, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ), - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - ); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }) async { - final checked = _checkWrite( - records: _records, - id: ownerId.value, - ownerId: ownerId, - expectedRevision: expectedRevision, - ); - if (checked is Left>) { - return Left(checked.value); - } - final record = checked.right; - final nextRevision = record.revision.next(); - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - _records[ownerId.value] = record.copyWith( - value: _cloneSettingsValue( - ownedSettings, - revision: nextRevision, - createdAt: record.createdAt, - updatedAt: _epoch, - ), - revision: nextRevision, - updatedAt: _epoch, - ); - return Right(nextRevision); - } -} - -/// In-memory diagnostic schedule snapshot repository. -final class InMemoryScheduleSnapshotRepository - implements ScheduleSnapshotRepository { - final Map _records = {}; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }) async { - final record = _records[snapshotId]; - if (record == null || record.ownerId != ownerId) { - return const Right(null); - } - return Right(_cloneSnapshot(record.value, record.ownerId, record.revision)); - } - - @override - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(_pageRecords( - _records.values.where( - (record) => - record.ownerId == ownerId && - _windowsOverlap(record.value.window, window), - ), - page, - (record) => _cloneSnapshot(record.value, record.ownerId, record.revision), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }) async { - if (_records.containsKey(snapshot.id)) { - return Left(RepositoryDuplicateId(entityId: snapshot.id)); - } - _records[snapshot.id] = _SnapshotRecord( - id: snapshot.id, - ownerId: ownerId, - value: _cloneSnapshot(snapshot, ownerId, core.Revision.initial), - revision: core.Revision.initial, - retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), - ); - return const Right(core.Revision.initial); - } - - @override - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }) async { - final expiredIds = _records.values - .where( - (record) => - record.ownerId == ownerId && - record.retentionExpiresAt.isBefore(nowUtc), - ) - .map((record) => record.id) - .toList(growable: false); - for (final id in expiredIds) { - _records.remove(id); - } - return Right(expiredIds.length); - } -} - -Either> _checkWrite({ - required Map> records, - required String id, - required core.OwnerId ownerId, - required core.Revision expectedRevision, -}) { - if (expectedRevision.value <= 0) { - return Left( - RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - ), - ); - } - final record = records[id]; - if (record == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (record.ownerId != ownerId) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (record.revision != expectedRevision) { - return Left( - RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: record.revision, - ), - ); - } - return Right(record); -} - -core.Page _pageRecords( - Iterable records, - core.PageRequest page, - R Function(T record) clone, -) { - final ordered = records.toList(growable: false) - ..sort((left, right) => left.id.compareTo(right.id)); - final start = _cursorOffset(page.cursor); - final end = (start + page.limit).clamp(0, ordered.length); - final items = ordered.sublist(start, end).map(clone).toList(growable: false); - final nextCursor = end < ordered.length ? end.toString() : null; - return core.Page(items: items, nextCursor: nextCursor); -} - -int _cursorOffset(String? cursor) { - if (cursor == null) { - return 0; - } - final parsed = int.tryParse(cursor); - if (parsed == null || parsed < 0) { - return 0; - } - return parsed; -} - -bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) { - return left.start.isBefore(right.end) && right.start.isBefore(left.end); -} - -core.Task _cloneTask( - core.Task task, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.TaskDocumentMapping.fromDocument(_roundTrip( - core.TaskDocumentMapping.toDocument( - task, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.ProjectProfile _cloneProject(_Record record) { - return _cloneProjectValue( - record.value, - ownerId: record.ownerId, - revision: record.revision, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - ); -} - -core.ProjectProfile _cloneProjectValue( - core.ProjectProfile project, { - required core.OwnerId ownerId, - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - return core.ProjectDocumentMapping.fromDocument(_roundTrip( - core.ProjectDocumentMapping.toDocument( - project, - ownerId: ownerId.value, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ), - )); -} - -core.LockedBlock _cloneLockedBlock( - core.LockedBlock block, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.LockedBlockDocumentMapping.fromDocument(_roundTrip( - core.LockedBlockDocumentMapping.toDocument( - block, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.LockedBlockOverride _cloneLockedBlockOverride( - core.LockedBlockOverride override, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.LockedBlockOverrideDocumentMapping.fromDocument(_roundTrip( - core.LockedBlockOverrideDocumentMapping.toDocument( - override, - ownerId: ownerId.value, - revision: revision.value, - ), - )); -} - -core.OwnerSettings _cloneSettings(_Record record) { - return _cloneSettingsValue( - record.value, - revision: record.revision, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - ); -} - -core.OwnerSettings _cloneSettingsValue( - core.OwnerSettings settings, { - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - return core.OwnerSettingsDocumentMapping.fromDocument(_roundTrip( - core.OwnerSettingsDocumentMapping.toDocument( - settings, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ), - )); -} - -core.SchedulingStateSnapshot _cloneSnapshot( - core.SchedulingStateSnapshot snapshot, - core.OwnerId ownerId, - core.Revision revision, -) { - return core.SchedulingSnapshotDocumentMapping.fromDocument(_roundTrip( - core.SchedulingSnapshotDocumentMapping.toDocument( - snapshot, - ownerId: ownerId.value, - revision: revision.value, - retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), - ), - )); -} - -Map _roundTrip(Map document) { - return Map.from( - jsonDecode(jsonEncode(document)) as Map, - ); -} - -abstract interface class _IdentifiedRecord { - String get id; -} - -final class _Record implements _IdentifiedRecord { - _Record({ - required this.id, - required this.ownerId, - required this.value, - required this.revision, - DateTime? createdAt, - DateTime? updatedAt, - }) : createdAt = createdAt ?? _epoch, - updatedAt = updatedAt ?? _epoch; - - @override - final String id; - final core.OwnerId ownerId; - final T value; - final core.Revision revision; - final DateTime createdAt; - final DateTime updatedAt; - - _Record copyWith({ - T? value, - core.Revision? revision, - DateTime? updatedAt, - }) { - return _Record( - id: id, - ownerId: ownerId, - value: value ?? this.value, - revision: revision ?? this.revision, - createdAt: createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); - } -} - -final class _SnapshotRecord implements _IdentifiedRecord { - _SnapshotRecord({ - required this.id, - required this.ownerId, - required this.value, - required this.revision, - required this.retentionExpiresAt, - }); - - @override - final String id; - final core.OwnerId ownerId; - final core.SchedulingStateSnapshot value; - final core.Revision revision; - final DateTime retentionExpiresAt; -} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart new file mode 100644 index 0000000..9dcc908 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// Private implementation type for `_IdentifiedRecord` 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. +abstract interface class _IdentifiedRecord { + /// Returns the derived `id` 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. + String get id; +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart new file mode 100644 index 0000000..f4ff449 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// Private implementation type for `_Record` 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. +final class _Record implements _IdentifiedRecord { + /// Creates a `_Record` 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. + _Record({ + required this.id, + required this.ownerId, + required this.value, + required this.revision, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? _epoch, + updatedAt = updatedAt ?? _epoch; + + /// Stores the `id` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final String id; + + /// Stores the `ownerId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final core.OwnerId ownerId; + + /// Stores the `value` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final T value; + + /// Stores the `revision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final core.Revision revision; + + /// Stores the `createdAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime createdAt; + + /// Stores the `updatedAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime updatedAt; + + /// Returns a copy of this value with selected fields replaced. + /// The scheduler uses copy-style updates to keep domain values immutable and make before-and-after behavior easier to test. + _Record copyWith({ + T? value, + core.Revision? revision, + DateTime? updatedAt, + }) { + return _Record( + id: id, + ownerId: ownerId, + value: value ?? this.value, + revision: revision ?? this.revision, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart new file mode 100644 index 0000000..ee07118 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// Private implementation type for `_SnapshotRecord` 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. +final class _SnapshotRecord implements _IdentifiedRecord { + /// Creates a `_SnapshotRecord` 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. + _SnapshotRecord({ + required this.id, + required this.ownerId, + required this.value, + required this.revision, + required this.retentionExpiresAt, + }); + + /// Stores the `id` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + @override + final String id; + + /// Stores the `ownerId` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final core.OwnerId ownerId; + + /// Stores the `value` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final core.SchedulingStateSnapshot value; + + /// Stores the `revision` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final core.Revision revision; + + /// Stores the `retentionExpiresAt` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final DateTime retentionExpiresAt; +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..497c4c0 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// In-memory locked-block repository with owner scoping and revision checks. +final class InMemoryLockedBlockRepository implements LockedBlockRepository { + /// Private state stored as `_blocks` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map> _blocks = + >{}; + + /// Private state stored as `_overrides` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map> _overrides = + >{}; + + /// Runs the `findBlockById` 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> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }) async { + final record = _blocks[blockId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right( + _cloneLockedBlock(record.value, record.ownerId, record.revision)); + } + + /// Runs the `findBlocksByOwner` 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>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _blocks.values.where( + (record) => + record.ownerId == ownerId && + (includeArchived || !record.value.isArchived), + ), + page, + (record) => _cloneLockedBlock( + record.value, + record.ownerId, + record.revision, + ), + )); + } + + /// Runs the `findOverrideById` 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> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }) async { + final record = _overrides[overrideId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right( + _cloneLockedBlockOverride(record.value, record.ownerId, record.revision), + ); + } + + /// Runs the `findOverridesForDate` 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>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _overrides.values.where( + (record) => record.ownerId == ownerId && record.value.date == date, + ), + page, + (record) => _cloneLockedBlockOverride( + record.value, + record.ownerId, + record.revision, + ), + )); + } + + /// Runs the `insertBlock` 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> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }) async { + if (_blocks.containsKey(block.id)) { + return Left(RepositoryDuplicateId(entityId: block.id)); + } + _blocks[block.id] = _Record( + id: block.id, + ownerId: ownerId, + value: _cloneLockedBlock(block, ownerId, core.Revision.initial), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + /// Runs the `saveBlock` 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> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _blocks, + id: block.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _blocks[block.id] = record.copyWith( + value: _cloneLockedBlock(block, ownerId, nextRevision), + revision: nextRevision, + updatedAt: block.updatedAt, + ); + return Right(nextRevision); + } + + /// Runs the `archiveBlock` 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> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = _checkWrite( + records: _blocks, + id: blockId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final archived = record.value.copyWith(archivedAt: archivedAtUtc); + _blocks[blockId] = record.copyWith( + value: _cloneLockedBlock(archived, ownerId, nextRevision), + revision: nextRevision, + updatedAt: archived.updatedAt, + ); + return Right(nextRevision); + } + + /// Runs the `insertOverride` 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> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }) async { + if (_overrides.containsKey(override.id)) { + return Left(RepositoryDuplicateId(entityId: override.id)); + } + _overrides[override.id] = _Record( + id: override.id, + ownerId: ownerId, + value: _cloneLockedBlockOverride( + override, + ownerId, + core.Revision.initial, + ), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + /// Runs the `saveOverride` 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> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _overrides, + id: override.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _overrides[override.id] = record.copyWith( + value: _cloneLockedBlockOverride(override, ownerId, nextRevision), + revision: nextRevision, + updatedAt: override.updatedAt, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart new file mode 100644 index 0000000..bf146db --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// In-memory project repository with owner scoping and revision checks. +final class InMemoryProjectRepository implements ProjectRepository { + /// Private state stored as `_records` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map> _records = + >{}; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String projectId, + }) async { + final record = _records[projectId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneProject(record)); + } + + /// Runs the `findByOwner` 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>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + (includeArchived || !record.value.isArchived), + ), + page, + _cloneProject, + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }) async { + if (_records.containsKey(project.id)) { + return Left(RepositoryDuplicateId(entityId: project.id)); + } + _records[project.id] = _Record( + id: project.id, + ownerId: ownerId, + value: _cloneProjectValue( + project, + ownerId: ownerId, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ), + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: project.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _records[project.id] = record.copyWith( + value: _cloneProjectValue( + project, + ownerId: ownerId, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } + + /// Runs the `archive` 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> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = _checkWrite( + records: _records, + id: projectId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final archived = record.value.copyWith(archivedAt: archivedAtUtc); + _records[projectId] = record.copyWith( + value: _cloneProjectValue( + archived, + ownerId: ownerId, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart new file mode 100644 index 0000000..ca2c902 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// In-memory diagnostic schedule snapshot repository. +final class InMemoryScheduleSnapshotRepository + implements ScheduleSnapshotRepository { + /// Private state stored as `_records` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map _records = {}; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }) async { + final record = _records[snapshotId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneSnapshot(record.value, record.ownerId, record.revision)); + } + + /// Runs the `findInWindow` 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>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + _windowsOverlap(record.value.window, window), + ), + page, + (record) => _cloneSnapshot(record.value, record.ownerId, record.revision), + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }) async { + if (_records.containsKey(snapshot.id)) { + return Left(RepositoryDuplicateId(entityId: snapshot.id)); + } + _records[snapshot.id] = _SnapshotRecord( + id: snapshot.id, + ownerId: ownerId, + value: _cloneSnapshot(snapshot, ownerId, core.Revision.initial), + revision: core.Revision.initial, + retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), + ); + return const Right(core.Revision.initial); + } + + /// Runs the `deleteExpired` 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> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }) async { + final expiredIds = _records.values + .where( + (record) => + record.ownerId == ownerId && + record.retentionExpiresAt.isBefore(nowUtc), + ) + .map((record) => record.id) + .toList(growable: false); + for (final id in expiredIds) { + _records.remove(id); + } + return Right(expiredIds.length); + } +} + +/// Top-level helper that performs the `_checkWrite` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Either> _checkWrite({ + required Map> records, + required String id, + required core.OwnerId ownerId, + required core.Revision expectedRevision, +}) { + if (expectedRevision.value <= 0) { + return Left( + RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + ), + ); + } + final record = records[id]; + if (record == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (record.ownerId != ownerId) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (record.revision != expectedRevision) { + return Left( + RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: record.revision, + ), + ); + } + return Right(record); +} + +/// Runs the `R>` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. +core.Page _pageRecords( + Iterable records, + core.PageRequest page, + R Function(T record) clone, +) { + final ordered = records.toList(growable: false) + ..sort((left, right) => left.id.compareTo(right.id)); + final start = _cursorOffset(page.cursor); + final end = (start + page.limit).clamp(0, ordered.length); + final items = ordered.sublist(start, end).map(clone).toList(growable: false); + final nextCursor = end < ordered.length ? end.toString() : null; + return core.Page(items: items, nextCursor: nextCursor); +} + +/// Top-level helper that performs the `_cursorOffset` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _cursorOffset(String? cursor) { + if (cursor == null) { + return 0; + } + final parsed = int.tryParse(cursor); + if (parsed == null || parsed < 0) { + return 0; + } + return parsed; +} + +/// Top-level helper that performs the `_windowsOverlap` 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 _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) { + return left.start.isBefore(right.end) && right.start.isBefore(left.end); +} + +/// Top-level helper that performs the `_cloneTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.Task _cloneTask( + core.Task task, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.TaskDocumentMapping.fromDocument(_roundTrip( + core.TaskDocumentMapping.toDocument( + task, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +/// Top-level helper that performs the `_cloneProject` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.ProjectProfile _cloneProject(_Record record) { + return _cloneProjectValue( + record.value, + ownerId: record.ownerId, + revision: record.revision, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ); +} + +/// Top-level helper that performs the `_cloneProjectValue` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.ProjectProfile _cloneProjectValue( + core.ProjectProfile project, { + required core.OwnerId ownerId, + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + return core.ProjectDocumentMapping.fromDocument(_roundTrip( + core.ProjectDocumentMapping.toDocument( + project, + ownerId: ownerId.value, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ), + )); +} + +/// Top-level helper that performs the `_cloneLockedBlock` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.LockedBlock _cloneLockedBlock( + core.LockedBlock block, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.LockedBlockDocumentMapping.fromDocument(_roundTrip( + core.LockedBlockDocumentMapping.toDocument( + block, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +/// Top-level helper that performs the `_cloneLockedBlockOverride` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.LockedBlockOverride _cloneLockedBlockOverride( + core.LockedBlockOverride override, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.LockedBlockOverrideDocumentMapping.fromDocument(_roundTrip( + core.LockedBlockOverrideDocumentMapping.toDocument( + override, + ownerId: ownerId.value, + revision: revision.value, + ), + )); +} + +/// Top-level helper that performs the `_cloneSettings` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.OwnerSettings _cloneSettings(_Record record) { + return _cloneSettingsValue( + record.value, + revision: record.revision, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ); +} + +/// Top-level helper that performs the `_cloneSettingsValue` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.OwnerSettings _cloneSettingsValue( + core.OwnerSettings settings, { + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + return core.OwnerSettingsDocumentMapping.fromDocument(_roundTrip( + core.OwnerSettingsDocumentMapping.toDocument( + settings, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ), + )); +} + +/// Top-level helper that performs the `_cloneSnapshot` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.SchedulingStateSnapshot _cloneSnapshot( + core.SchedulingStateSnapshot snapshot, + core.OwnerId ownerId, + core.Revision revision, +) { + return core.SchedulingSnapshotDocumentMapping.fromDocument(_roundTrip( + core.SchedulingSnapshotDocumentMapping.toDocument( + snapshot, + ownerId: ownerId.value, + revision: revision.value, + retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention), + ), + )); +} + +/// Top-level helper that performs the `_roundTrip` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _roundTrip(Map document) { + return Map.from( + jsonDecode(jsonEncode(document)) as Map, + ); +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart new file mode 100644 index 0000000..fadfca1 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// In-memory owner settings repository with owner scoping and revision checks. +final class InMemorySettingsRepository implements SettingsRepository { + /// Private state stored as `_records` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map> _records = + >{}; + + /// Runs the `findByOwner` 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> findByOwner({ + required core.OwnerId ownerId, + }) async { + final record = _records[ownerId.value]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneSettings(record)); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }) async { + if (_records.containsKey(ownerId.value)) { + return Left(RepositoryDuplicateId(entityId: ownerId.value)); + } + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + _records[ownerId.value] = _Record( + id: ownerId.value, + ownerId: ownerId, + value: _cloneSettingsValue( + ownedSettings, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ), + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + ); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: ownerId.value, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + _records[ownerId.value] = record.copyWith( + value: _cloneSettingsValue( + ownedSettings, + revision: nextRevision, + createdAt: record.createdAt, + updatedAt: _epoch, + ), + revision: nextRevision, + updatedAt: _epoch, + ); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart new file mode 100644 index 0000000..6f5bd66 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../persistence_memory.dart'; + +/// In-memory task repository with owner scoping and revision checks. +final class InMemoryTaskRepository implements TaskRepository { + /// Private state stored as `_records` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final Map> _records = + >{}; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String taskId, + }) async { + final record = _records[taskId]; + if (record == null || record.ownerId != ownerId) { + return const Right(null); + } + return Right(_cloneTask(record.value, record.ownerId, record.revision)); + } + + /// Runs the `findByOwner` 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>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where((record) => record.ownerId == ownerId), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + /// Runs the `findByParentTaskId` 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>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && + record.value.parentTaskId == parentTaskId, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + /// Runs the `findByProjectId` 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>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => + record.ownerId == ownerId && record.value.projectId == projectId, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + /// Runs the `findByStatus` 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>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(_pageRecords( + _records.values.where( + (record) => record.ownerId == ownerId && record.value.status == status, + ), + page, + (record) => _cloneTask(record.value, record.ownerId, record.revision), + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.Task task, + }) async { + if (_records.containsKey(task.id)) { + return Left(RepositoryDuplicateId(entityId: task.id)); + } + _records[task.id] = _Record( + id: task.id, + ownerId: ownerId, + value: _cloneTask(task, ownerId, core.Revision.initial), + revision: core.Revision.initial, + ); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: task.id, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final record = checked.right; + final nextRevision = record.revision.next(); + _records[task.id] = record.copyWith( + value: _cloneTask(task, ownerId, nextRevision), + revision: nextRevision, + updatedAt: task.updatedAt, + ); + return Right(nextRevision); + } + + /// Runs the `delete` 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> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }) async { + final checked = _checkWrite( + records: _records, + id: taskId, + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + if (checked is Left>) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + _records.remove(taskId); + return Right(nextRevision); + } +} diff --git a/packages/scheduler_persistence_memory/pubspec.yaml b/packages/scheduler_persistence_memory/pubspec.yaml index add5b9a..6db2fe6 100644 --- a/packages/scheduler_persistence_memory/pubspec.yaml +++ b/packages/scheduler_persistence_memory/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_persistence_memory publish_to: none diff --git a/packages/scheduler_persistence_memory/test/memory_repository_conformance_test.dart b/packages/scheduler_persistence_memory/test/memory_repository_conformance_test.dart index 2d5f076..0c2b05e 100644 --- a/packages/scheduler_persistence_memory/test/memory_repository_conformance_test.dart +++ b/packages/scheduler_persistence_memory/test/memory_repository_conformance_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Memory Repository Conformance behavior for the Scheduler Persistence Memory package. library; @@ -5,6 +8,8 @@ import 'package:scheduler_persistence_memory/persistence_memory.dart'; import '../../scheduler_persistence/test/repo_conformance.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { runRepositoryComplianceTests( RepositoryComplianceFactories( diff --git a/packages/scheduler_persistence_sqlite/LICENSE.md b/packages/scheduler_persistence_sqlite/LICENSE.md new file mode 100644 index 0000000..16fafd9 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/LICENSE.md @@ -0,0 +1,6 @@ + + + +# License + +This package uses the repository-level license at [../../LICENSE.md](../../LICENSE.md). diff --git a/packages/scheduler_persistence_sqlite/analysis_options.yaml b/packages/scheduler_persistence_sqlite/analysis_options.yaml index 572dd23..7df02e8 100644 --- a/packages/scheduler_persistence_sqlite/analysis_options.yaml +++ b/packages/scheduler_persistence_sqlite/analysis_options.yaml @@ -1 +1,4 @@ -include: package:lints/recommended.yaml +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +include: ../../analysis_options.yaml diff --git a/packages/scheduler_persistence_sqlite/lib/sqlite.dart b/packages/scheduler_persistence_sqlite/lib/sqlite.dart index be88c1d..57cecc4 100644 --- a/packages/scheduler_persistence_sqlite/lib/sqlite.dart +++ b/packages/scheduler_persistence_sqlite/lib/sqlite.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Drift-backed SQLite persistence surface for the scheduler. library; diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart index 1e063c6..66b8cd7 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Drift schema for the scheduler's local SQLite database. /// /// This package owns SQLite and Drift details. The scheduler core continues to @@ -7,225 +10,15 @@ library; import 'package:drift/drift.dart'; part 'scheduler_db.g.dart'; - -/// Authoritative task rows. -@DataClassName('TaskRow') -class Tasks extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get title => text()(); - TextColumn get projectId => text().named('project_id')(); - TextColumn get parentId => text().named('parent_id').nullable()(); - TextColumn get type => text()(); - TextColumn get status => text()(); - TextColumn get priority => text().nullable()(); - TextColumn get reward => text()(); - TextColumn get difficulty => text()(); - IntColumn get durationMinutes => - integer().named('duration_minutes').nullable()(); - DateTimeColumn get scheduledStartUtc => - dateTime().named('scheduled_start_utc').nullable()(); - DateTimeColumn get scheduledEndUtc => - dateTime().named('scheduled_end_utc').nullable()(); - DateTimeColumn get actualStartUtc => - dateTime().named('actual_start_utc').nullable()(); - DateTimeColumn get actualEndUtc => - dateTime().named('actual_end_utc').nullable()(); - DateTimeColumn get completedAtUtc => - dateTime().named('completed_at_utc').nullable()(); - TextColumn get backlogTagsJson => text().named('backlog_tags_json')(); - TextColumn get reminderOverride => - text().named('reminder_override').nullable()(); - TextColumn get statsJson => text().named('stats_json')(); - DateTimeColumn get backlogEnteredAtUtc => - dateTime().named('backlog_entered_at_utc').nullable()(); - TextColumn get backlogEnteredProvenance => - text().named('backlog_entered_provenance').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Project configuration rows. -@DataClassName('ProjectRow') -class Projects extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get name => text()(); - TextColumn get colorKey => text().named('color_key')(); - TextColumn get defaultPriority => text().named('default_priority')(); - TextColumn get defaultReward => text().named('default_reward')(); - TextColumn get defaultDifficulty => text().named('default_difficulty')(); - TextColumn get defaultReminderProfile => - text().named('default_reminder_profile')(); - IntColumn get defaultDurationMinutes => - integer().named('default_duration_minutes').nullable()(); - DateTimeColumn get archivedAtUtc => - dateTime().named('archived_at_utc').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Recurring and one-off locked-time rows. -@DataClassName('LockedBlockRow') -class LockedBlocks extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get name => text()(); - TextColumn get date => text().nullable()(); - TextColumn get startTime => text().named('start_time')(); - TextColumn get endTime => text().named('end_time')(); - TextColumn get recurrenceJson => text().named('recurrence_json').nullable()(); - BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); - TextColumn get projectId => text().named('project_id').nullable()(); - DateTimeColumn get archivedAtUtc => - dateTime().named('archived_at_utc').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Date-scoped overrides for recurring locked blocks. -@DataClassName('LockedOverrideRow') -class LockedOverrides extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get lockedBlockId => text().named('locked_block_id').nullable()(); - TextColumn get date => text()(); - TextColumn get type => text()(); - TextColumn get name => text().nullable()(); - TextColumn get startTime => text().named('start_time').nullable()(); - TextColumn get endTime => text().named('end_time').nullable()(); - BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); - TextColumn get projectId => text().named('project_id').nullable()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Owner-level settings rows. -@DataClassName('SettingsRow') -class SettingsTable extends Table { - @override - String get tableName => 'settings'; - - TextColumn get ownerId => text().named('owner_id')(); - TextColumn get timeZoneId => text().named('timezone_id')(); - IntColumn get dayStartMinutes => integer().named('day_start_minutes')(); - IntColumn get dayEndMinutes => integer().named('day_end_minutes')(); - BoolColumn get compactMode => boolean().named('compact_mode')(); - TextColumn get backlogStalenessJson => - text().named('backlog_staleness_json')(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {ownerId}; -} - -/// Bounded diagnostic schedule snapshots. -@DataClassName('SnapshotRow') -class Snapshots extends Table { - TextColumn get id => text()(); - TextColumn get ownerId => text().named('owner_id')(); - DateTimeColumn get capturedAtUtc => dateTime().named('captured_at_utc')(); - TextColumn get operationName => text().named('operation_name').nullable()(); - TextColumn get sourceDate => text().named('source_date').nullable()(); - TextColumn get targetDate => text().named('target_date').nullable()(); - TextColumn get windowJson => text().named('window_json')(); - TextColumn get tasksJson => text().named('tasks_json')(); - TextColumn get lockedIntervalsJson => text().named('locked_intervals_json')(); - TextColumn get requiredVisibleIntervalsJson => - text().named('required_visible_intervals_json')(); - TextColumn get noticesJson => text().named('notices_json')(); - TextColumn get changesJson => text().named('changes_json')(); - TextColumn get overlapsJson => text().named('overlaps_json')(); - DateTimeColumn get retentionExpiresUtc => - dateTime().named('retention_expires_utc')(); - BoolColumn get truncated => boolean()(); - IntColumn get revision => integer()(); - DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); - DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); - - @override - Set> get primaryKey => {id}; -} - -/// Generated task-row accessor. -@DriftAccessor(tables: [Tasks]) -class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { - TaskDao(super.db); -} - -/// Generated project-row accessor. -@DriftAccessor(tables: [Projects]) -class ProjectDao extends DatabaseAccessor with _$ProjectDaoMixin { - ProjectDao(super.db); -} - -/// Generated locked-time accessors for blocks and date overrides. -@DriftAccessor(tables: [LockedBlocks, LockedOverrides]) -class LockedBlockDao extends DatabaseAccessor - with _$LockedBlockDaoMixin { - LockedBlockDao(super.db); -} - -/// Generated owner-settings accessor. -@DriftAccessor(tables: [SettingsTable]) -class SettingsDao extends DatabaseAccessor - with _$SettingsDaoMixin { - SettingsDao(super.db); -} - -/// Generated diagnostic snapshot accessor. -@DriftAccessor(tables: [Snapshots]) -class SnapshotDao extends DatabaseAccessor - with _$SnapshotDaoMixin { - SnapshotDao(super.db); -} - -/// Drift database for scheduler persistence. -@DriftDatabase( - tables: [ - Tasks, - Projects, - LockedBlocks, - LockedOverrides, - SettingsTable, - Snapshots, - ], - daos: [ - TaskDao, - ProjectDao, - LockedBlockDao, - SettingsDao, - SnapshotDao, - ], -) -class SchedulerDb extends _$SchedulerDb { - SchedulerDb(super.executor); - - @override - int get schemaVersion => 1; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (Migrator migrator) async { - await migrator.createAll(); - }, - ); -} +part 'scheduler_db/tables/tasks/tasks.dart'; +part 'scheduler_db/tables/projects/projects.dart'; +part 'scheduler_db/tables/locked_time/locked_blocks.dart'; +part 'scheduler_db/tables/locked_time/locked_overrides.dart'; +part 'scheduler_db/tables/settings/settings_table.dart'; +part 'scheduler_db/tables/snapshots/snapshots.dart'; +part 'scheduler_db/daos/task_dao.dart'; +part 'scheduler_db/daos/project_dao.dart'; +part 'scheduler_db/daos/locked_block_dao.dart'; +part 'scheduler_db/daos/settings_dao.dart'; +part 'scheduler_db/daos/snapshot_dao.dart'; +part 'scheduler_db/database/scheduler_db.dart'; diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart new file mode 100644 index 0000000..54d9f48 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Generated locked-time accessors for blocks and date overrides. +@DriftAccessor(tables: [LockedBlocks, LockedOverrides]) +class LockedBlockDao extends DatabaseAccessor + with _$LockedBlockDaoMixin { + /// Creates a `LockedBlockDao` 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. + LockedBlockDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart new file mode 100644 index 0000000..1b1e08a --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Generated project-row accessor. +@DriftAccessor(tables: [Projects]) +class ProjectDao extends DatabaseAccessor with _$ProjectDaoMixin { + /// Creates a `ProjectDao` 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. + ProjectDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart new file mode 100644 index 0000000..55f6841 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Generated owner-settings accessor. +@DriftAccessor(tables: [SettingsTable]) +class SettingsDao extends DatabaseAccessor + with _$SettingsDaoMixin { + /// Creates a `SettingsDao` 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. + SettingsDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart new file mode 100644 index 0000000..8bb67cb --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Generated diagnostic snapshot accessor. +@DriftAccessor(tables: [Snapshots]) +class SnapshotDao extends DatabaseAccessor + with _$SnapshotDaoMixin { + /// Creates a `SnapshotDao` 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. + SnapshotDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart new file mode 100644 index 0000000..98f4939 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Generated task-row accessor. +@DriftAccessor(tables: [Tasks]) +class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { + /// Creates a `TaskDao` 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. + TaskDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart new file mode 100644 index 0000000..d2115be --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../scheduler_db.dart'; + +/// Drift database for scheduler persistence. +@DriftDatabase( + tables: [ + Tasks, + Projects, + LockedBlocks, + LockedOverrides, + SettingsTable, + Snapshots, + ], + daos: [ + TaskDao, + ProjectDao, + LockedBlockDao, + SettingsDao, + SnapshotDao, + ], +) +class SchedulerDb extends _$SchedulerDb { + /// Creates a `SchedulerDb` 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. + SchedulerDb(super.executor); + + /// Returns the derived `schemaVersion` 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. + @override + int get schemaVersion => 1; + + /// Runs the `migration` 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 + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator migrator) async { + await migrator.createAll(); + }, + ); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart new file mode 100644 index 0000000..403da32 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Recurring and one-off locked-time rows. +@DataClassName('LockedBlockRow') +class LockedBlocks extends Table { + /// Returns the derived `id` 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. + TextColumn get id => text()(); + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `name` 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. + TextColumn get name => text()(); + + /// Returns the derived `date` 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. + TextColumn get date => text().nullable()(); + + /// Returns the derived `startTime` 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. + TextColumn get startTime => text().named('start_time')(); + + /// Returns the derived `endTime` 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. + TextColumn get endTime => text().named('end_time')(); + + /// Converts scheduler data for `recurrenceJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get recurrenceJson => text().named('recurrence_json').nullable()(); + + /// Returns the derived `hiddenByDefault` 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. + BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); + + /// Returns the derived `projectId` 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. + TextColumn get projectId => text().named('project_id').nullable()(); + + /// Returns the derived `archivedAtUtc` 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. + DateTimeColumn get archivedAtUtc => + dateTime().named('archived_at_utc').nullable()(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart new file mode 100644 index 0000000..5d955ba --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Date-scoped overrides for recurring locked blocks. +@DataClassName('LockedOverrideRow') +class LockedOverrides extends Table { + /// Returns the derived `id` 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. + TextColumn get id => text()(); + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `lockedBlockId` 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. + TextColumn get lockedBlockId => text().named('locked_block_id').nullable()(); + + /// Returns the derived `date` 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. + TextColumn get date => text()(); + + /// Returns the derived `type` 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. + TextColumn get type => text()(); + + /// Returns the derived `name` 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. + TextColumn get name => text().nullable()(); + + /// Returns the derived `startTime` 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. + TextColumn get startTime => text().named('start_time').nullable()(); + + /// Returns the derived `endTime` 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. + TextColumn get endTime => text().named('end_time').nullable()(); + + /// Returns the derived `hiddenByDefault` 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. + BoolColumn get hiddenByDefault => boolean().named('hidden_by_default')(); + + /// Returns the derived `projectId` 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. + TextColumn get projectId => text().named('project_id').nullable()(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart new file mode 100644 index 0000000..59a675f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Project configuration rows. +@DataClassName('ProjectRow') +class Projects extends Table { + /// Returns the derived `id` 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. + TextColumn get id => text()(); + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `name` 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. + TextColumn get name => text()(); + + /// Returns the derived `colorKey` 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. + TextColumn get colorKey => text().named('color_key')(); + + /// Returns the derived `defaultPriority` 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. + TextColumn get defaultPriority => text().named('default_priority')(); + + /// Returns the derived `defaultReward` 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. + TextColumn get defaultReward => text().named('default_reward')(); + + /// Returns the derived `defaultDifficulty` 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. + TextColumn get defaultDifficulty => text().named('default_difficulty')(); + + /// Returns the derived `defaultReminderProfile` 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. + TextColumn get defaultReminderProfile => + text().named('default_reminder_profile')(); + + /// Returns the derived `defaultDurationMinutes` 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. + IntColumn get defaultDurationMinutes => + integer().named('default_duration_minutes').nullable()(); + + /// Returns the derived `archivedAtUtc` 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. + DateTimeColumn get archivedAtUtc => + dateTime().named('archived_at_utc').nullable()(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart new file mode 100644 index 0000000..b436228 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Owner-level settings rows. +@DataClassName('SettingsRow') +class SettingsTable extends Table { + /// Returns the derived `tableName` 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. + @override + String get tableName => 'settings'; + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `timeZoneId` 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. + TextColumn get timeZoneId => text().named('timezone_id')(); + + /// Returns the derived `dayStartMinutes` 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. + IntColumn get dayStartMinutes => integer().named('day_start_minutes')(); + + /// Returns the derived `dayEndMinutes` 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. + IntColumn get dayEndMinutes => integer().named('day_end_minutes')(); + + /// Returns the derived `compactMode` 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. + BoolColumn get compactMode => boolean().named('compact_mode')(); + + /// Converts scheduler data for `backlogStalenessJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get backlogStalenessJson => + text().named('backlog_staleness_json')(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {ownerId}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart new file mode 100644 index 0000000..c86386f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Bounded diagnostic schedule snapshots. +@DataClassName('SnapshotRow') +class Snapshots extends Table { + /// Returns the derived `id` 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. + TextColumn get id => text()(); + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `capturedAtUtc` 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. + DateTimeColumn get capturedAtUtc => dateTime().named('captured_at_utc')(); + + /// Returns the derived `operationName` 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. + TextColumn get operationName => text().named('operation_name').nullable()(); + + /// Returns the derived `sourceDate` 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. + TextColumn get sourceDate => text().named('source_date').nullable()(); + + /// Returns the derived `targetDate` 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. + TextColumn get targetDate => text().named('target_date').nullable()(); + + /// Converts scheduler data for `windowJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get windowJson => text().named('window_json')(); + + /// Converts scheduler data for `tasksJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get tasksJson => text().named('tasks_json')(); + + /// Converts scheduler data for `lockedIntervalsJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get lockedIntervalsJson => text().named('locked_intervals_json')(); + + /// Converts scheduler data for `requiredVisibleIntervalsJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get requiredVisibleIntervalsJson => + text().named('required_visible_intervals_json')(); + + /// Converts scheduler data for `noticesJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get noticesJson => text().named('notices_json')(); + + /// Converts scheduler data for `changesJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get changesJson => text().named('changes_json')(); + + /// Converts scheduler data for `overlapsJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get overlapsJson => text().named('overlaps_json')(); + + /// Returns the derived `retentionExpiresUtc` 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. + DateTimeColumn get retentionExpiresUtc => + dateTime().named('retention_expires_utc')(); + + /// Returns the derived `truncated` 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. + BoolColumn get truncated => boolean()(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart new file mode 100644 index 0000000..0fbf2c3 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../scheduler_db.dart'; + +/// Authoritative task rows. +@DataClassName('TaskRow') +class Tasks extends Table { + /// Returns the derived `id` 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. + TextColumn get id => text()(); + + /// Returns the derived `ownerId` 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. + TextColumn get ownerId => text().named('owner_id')(); + + /// Returns the derived `title` 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. + TextColumn get title => text()(); + + /// Returns the derived `projectId` 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. + TextColumn get projectId => text().named('project_id')(); + + /// Returns the derived `parentId` 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. + TextColumn get parentId => text().named('parent_id').nullable()(); + + /// Returns the derived `type` 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. + TextColumn get type => text()(); + + /// Returns the derived `status` 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. + TextColumn get status => text()(); + + /// Returns the derived `priority` 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. + TextColumn get priority => text().nullable()(); + + /// Returns the derived `reward` 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. + TextColumn get reward => text()(); + + /// Returns the derived `difficulty` 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. + TextColumn get difficulty => text()(); + + /// Returns the derived `durationMinutes` 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. + IntColumn get durationMinutes => + integer().named('duration_minutes').nullable()(); + + /// Returns the derived `scheduledStartUtc` 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. + DateTimeColumn get scheduledStartUtc => + dateTime().named('scheduled_start_utc').nullable()(); + + /// Returns the derived `scheduledEndUtc` 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. + DateTimeColumn get scheduledEndUtc => + dateTime().named('scheduled_end_utc').nullable()(); + + /// Returns the derived `actualStartUtc` 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. + DateTimeColumn get actualStartUtc => + dateTime().named('actual_start_utc').nullable()(); + + /// Returns the derived `actualEndUtc` 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. + DateTimeColumn get actualEndUtc => + dateTime().named('actual_end_utc').nullable()(); + + /// Returns the derived `completedAtUtc` 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. + DateTimeColumn get completedAtUtc => + dateTime().named('completed_at_utc').nullable()(); + + /// Converts scheduler data for `backlogTagsJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get backlogTagsJson => text().named('backlog_tags_json')(); + + /// Returns the derived `reminderOverride` 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. + TextColumn get reminderOverride => + text().named('reminder_override').nullable()(); + + /// Converts scheduler data for `statsJson` without changing the source object. + /// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details. + TextColumn get statsJson => text().named('stats_json')(); + + /// Returns the derived `backlogEnteredAtUtc` 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. + DateTimeColumn get backlogEnteredAtUtc => + dateTime().named('backlog_entered_at_utc').nullable()(); + + /// Returns the derived `backlogEnteredProvenance` 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. + TextColumn get backlogEnteredProvenance => + text().named('backlog_entered_provenance').nullable()(); + + /// Returns the derived `revision` 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. + IntColumn get revision => integer()(); + + /// Returns the derived `createdAtUtc` 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. + DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')(); + + /// Returns the derived `updatedAtUtc` 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. + DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')(); + + /// Returns the derived `primaryKey` 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. + @override + Set> get primaryKey => {id}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart index 574f213..e734091 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// SQLite repository implementations backed by [SchedulerDb]. library; @@ -8,1367 +11,30 @@ import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; import 'scheduler_db.dart'; +part 'sqlite_repositories/sqlite_task_repository.dart'; +part 'sqlite_repositories/sqlite_project_repository.dart'; +part 'sqlite_repositories/sqlite_locked_block_repository.dart'; +part 'sqlite_repositories/sqlite_settings_repository.dart'; +part 'sqlite_repositories/sqlite_schedule_snapshot_repository.dart'; +/// Private file-level value for `_snapshotRetention`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. const _snapshotRetention = Duration(days: 30); +/// Private file-level value for `_epoch`. +/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); -/// Drift-backed task repository. -final class SqliteTaskRepository implements TaskRepository { - /// Creates a task repository using [db]. - SqliteTaskRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String taskId, - }) async { - final row = await _taskById(taskId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_taskFromRow(row)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => table.ownerId.equals(ownerId.value), - )); - } - - @override - Future>> findByParentTaskId({ - required core.OwnerId ownerId, - required String parentTaskId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.parentId.equals(parentTaskId), - )); - } - - @override - Future>> findByProjectId({ - required core.OwnerId ownerId, - required String projectId, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.projectId.equals(projectId), - )); - } - - @override - Future>> findByStatus({ - required core.OwnerId ownerId, - required core.TaskStatus status, - core.PageRequest page = const core.PageRequest(), - }) async { - return Right(await _pageTaskRows( - page: page, - where: (table) => - table.ownerId.equals(ownerId.value) & - table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus( - status, - )), - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.Task task, - }) async { - if (await _taskById(task.id) != null) { - return Left(RepositoryDuplicateId(entityId: task.id)); - } - await db.into(db.tasks).insert(_taskCompanion( - task, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.Task task, - required core.Revision expectedRevision, - }) async { - final checked = await _checkTaskWrite( - task.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - final updates = _taskCompanion( - task, - ownerId: ownerId, - revision: nextRevision, - ); - final updated = await (db.update(db.tasks) - ..where( - (table) => - table.id.equals(task.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(updates); - if (updated != 1) { - return Left(await _staleTaskFailure(task.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> delete({ - required core.OwnerId ownerId, - required String taskId, - required core.Revision expectedRevision, - }) async { - final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = checked.right.revision.next(); - final deleted = await (db.delete(db.tasks) - ..where( - (table) => - table.id.equals(taskId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .go(); - if (deleted != 1) { - return Left(await _staleTaskFailure(taskId, expectedRevision)); - } - return Right(nextRevision); - } - - Future> _pageTaskRows({ - required core.PageRequest page, - required drift.Expression Function($TasksTable table) where, - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.tasks) - ..where(where) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return core.Page( - items: pageRows.map(_taskFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - ); - } - - Future _taskById(String id) { - return (db.select(db.tasks)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkTaskWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _taskById(id); - if (row == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleTaskFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _taskById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed project repository. -final class SqliteProjectRepository implements ProjectRepository { - /// Creates a project repository using [db]. - SqliteProjectRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String projectId, - }) async { - final row = await _projectById(projectId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_projectFromRow(row)); - } - - @override - Future>> findByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.projects) - ..where((table) { - final ownerClause = table.ownerId.equals(ownerId.value); - return includeArchived - ? ownerClause - : ownerClause & table.archivedAtUtc.isNull(); - }) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_projectFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - }) async { - if (await _projectById(project.id) != null) { - return Left(RepositoryDuplicateId(entityId: project.id)); - } - await db.into(db.projects).insert(_projectCompanion( - project, - ownerId: ownerId, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.ProjectProfile project, - required core.Revision expectedRevision, - }) async { - final checked = await _checkProjectWrite( - project.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final updated = await (db.update(db.projects) - ..where( - (table) => - table.id.equals(project.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_projectCompanion( - project, - ownerId: ownerId, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: _epoch, - )); - if (updated != 1) { - return Left(await _staleProjectFailure(project.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> archive({ - required core.OwnerId ownerId, - required String projectId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = await _checkProjectWrite( - projectId, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc); - final updated = await (db.update(db.projects) - ..where( - (table) => - table.id.equals(projectId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_projectCompanion( - archived, - ownerId: ownerId, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: archivedAtUtc, - )); - if (updated != 1) { - return Left(await _staleProjectFailure(projectId, expectedRevision)); - } - return Right(nextRevision); - } - - Future _projectById(String id) { - return (db.select(db.projects)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkProjectWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _projectById(id); - if (row == null) { - return Left(RepositoryNotFound(entityId: id)); - } - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleProjectFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _projectById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed locked-block and override repository. -final class SqliteLockedBlockRepository implements LockedBlockRepository { - /// Creates a locked-time repository using [db]. - SqliteLockedBlockRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findBlockById({ - required core.OwnerId ownerId, - required String blockId, - }) async { - final row = await _blockById(blockId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_lockedBlockFromRow(row)); - } - - @override - Future>> findBlocksByOwner({ - required core.OwnerId ownerId, - bool includeArchived = false, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.lockedBlocks) - ..where((table) { - final ownerClause = table.ownerId.equals(ownerId.value); - return includeArchived - ? ownerClause - : ownerClause & table.archivedAtUtc.isNull(); - }) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_lockedBlockFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> findOverrideById({ - required core.OwnerId ownerId, - required String overrideId, - }) async { - final row = await _overrideById(overrideId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_lockedOverrideFromRow(row)); - } - - @override - Future>> findOverridesForDate({ - required core.OwnerId ownerId, - required core.CivilDate date, - core.PageRequest page = const core.PageRequest(), - }) async { - final offset = _cursorOffset(page.cursor); - final query = db.select(db.lockedOverrides) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.date.equals(date.toIsoString()), - ) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) - ..limit(page.limit + 1, offset: offset); - final rows = await query.get(); - final pageRows = rows.take(page.limit).toList(growable: false); - return Right(core.Page( - items: pageRows.map(_lockedOverrideFromRow), - nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, - )); - } - - @override - Future> insertBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - }) async { - if (await _blockById(block.id) != null) { - return Left(RepositoryDuplicateId(entityId: block.id)); - } - await db.into(db.lockedBlocks).insert(_lockedBlockCompanion( - block, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> saveBlock({ - required core.OwnerId ownerId, - required core.LockedBlock block, - required core.Revision expectedRevision, - }) async { - final checked = await _checkBlockWrite( - block.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = core.Revision(checked.right.revision).next(); - final updated = await (db.update(db.lockedBlocks) - ..where( - (table) => - table.id.equals(block.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedBlockCompanion( - block, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleBlockFailure(block.id, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> archiveBlock({ - required core.OwnerId ownerId, - required String blockId, - required core.Revision expectedRevision, - required DateTime archivedAtUtc, - }) async { - final checked = await _checkBlockWrite(blockId, ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final archived = - _lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc); - final updated = await (db.update(db.lockedBlocks) - ..where( - (table) => - table.id.equals(blockId) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedBlockCompanion( - archived, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleBlockFailure(blockId, expectedRevision)); - } - return Right(nextRevision); - } - - @override - Future> insertOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - }) async { - if (await _overrideById(override.id) != null) { - return Left(RepositoryDuplicateId(entityId: override.id)); - } - await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion( - override, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> saveOverride({ - required core.OwnerId ownerId, - required core.LockedBlockOverride override, - required core.Revision expectedRevision, - }) async { - final checked = await _checkOverrideWrite( - override.id, - ownerId, - expectedRevision, - ); - if (checked is Left) { - return Left(checked.value); - } - final nextRevision = core.Revision(checked.right.revision).next(); - final updated = await (db.update(db.lockedOverrides) - ..where( - (table) => - table.id.equals(override.id) & - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_lockedOverrideCompanion( - override, - ownerId: ownerId, - revision: nextRevision, - )); - if (updated != 1) { - return Left(await _staleOverrideFailure( - override.id, - expectedRevision, - )); - } - return Right(nextRevision); - } - - Future _blockById(String id) { - return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future _overrideById(String id) { - return (db.select(db.lockedOverrides) - ..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } - - Future> _checkBlockWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _blockById(id); - if (row == null) return Left(RepositoryNotFound(entityId: id)); - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future> _checkOverrideWrite( - String id, - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: id, - expectedRevision: expectedRevision, - )); - } - final row = await _overrideById(id); - if (row == null) return Left(RepositoryNotFound(entityId: id)); - if (row.ownerId != ownerId.value) { - return Left(RepositoryOwnerMismatch(entityId: id)); - } - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleBlockFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _blockById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } - - Future _staleOverrideFailure( - String id, - core.Revision expectedRevision, - ) async { - final row = await _overrideById(id); - return RepositoryStaleRevision( - entityId: id, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed owner settings repository. -final class SqliteSettingsRepository implements SettingsRepository { - /// Creates a settings repository using [db]. - SqliteSettingsRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findByOwner({ - required core.OwnerId ownerId, - }) async { - final row = await _settingsByOwner(ownerId.value); - if (row == null) return const Right(null); - return Right(_settingsFromRow(row)); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - }) async { - if (await _settingsByOwner(ownerId.value) != null) { - return Left(RepositoryDuplicateId(entityId: ownerId.value)); - } - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - await db.into(db.settingsTable).insert(_settingsCompanion( - ownedSettings, - revision: core.Revision.initial, - createdAt: _epoch, - updatedAt: _epoch, - )); - return const Right(core.Revision.initial); - } - - @override - Future> save({ - required core.OwnerId ownerId, - required core.OwnerSettings settings, - required core.Revision expectedRevision, - }) async { - final checked = await _checkSettingsWrite(ownerId, expectedRevision); - if (checked is Left) { - return Left(checked.value); - } - final row = checked.right; - final nextRevision = core.Revision(row.revision).next(); - final ownedSettings = settings.ownerId == ownerId.value - ? settings - : settings.copyWith(ownerId: ownerId.value); - final updated = await (db.update(db.settingsTable) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.revision.equals(expectedRevision.value), - )) - .write(_settingsCompanion( - ownedSettings, - revision: nextRevision, - createdAt: row.createdAtUtc, - updatedAt: _epoch, - )); - if (updated != 1) { - return Left(await _staleSettingsFailure(ownerId, expectedRevision)); - } - return Right(nextRevision); - } - - Future _settingsByOwner(String ownerId) { - return (db.select(db.settingsTable) - ..where((table) => table.ownerId.equals(ownerId))) - .getSingleOrNull(); - } - - Future> _checkSettingsWrite( - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - if (expectedRevision.value <= 0) { - return Left(RepositoryInvalidRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - )); - } - final row = await _settingsByOwner(ownerId.value); - if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value)); - if (row.revision != expectedRevision.value) { - return Left(RepositoryStaleRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - actualRevision: core.Revision(row.revision), - )); - } - return Right(row); - } - - Future _staleSettingsFailure( - core.OwnerId ownerId, - core.Revision expectedRevision, - ) async { - final row = await _settingsByOwner(ownerId.value); - return RepositoryStaleRevision( - entityId: ownerId.value, - expectedRevision: expectedRevision, - actualRevision: - row == null ? expectedRevision.next() : core.Revision(row.revision), - ); - } -} - -/// Drift-backed schedule snapshot repository. -final class SqliteScheduleSnapshotRepository - implements ScheduleSnapshotRepository { - /// Creates a snapshot repository using [db]. - SqliteScheduleSnapshotRepository(this.db); - - /// Shared SQLite database handle. - final SchedulerDb db; - - @override - Future> findById({ - required core.OwnerId ownerId, - required String snapshotId, - }) async { - final row = await _snapshotById(snapshotId); - if (row == null || row.ownerId != ownerId.value) { - return const Right(null); - } - return Right(_snapshotFromRow(row)); - } - - @override - Future>> findInWindow({ - required core.OwnerId ownerId, - required core.SchedulingWindow window, - core.PageRequest page = const core.PageRequest(), - }) async { - final rows = await (db.select(db.snapshots) - ..where((table) => table.ownerId.equals(ownerId.value)) - ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])) - .get(); - final matching = rows - .map(_snapshotFromRow) - .where((snapshot) => _windowsOverlap(snapshot.window, window)) - .toList(growable: false); - final offset = _cursorOffset(page.cursor); - final pageItems = matching.skip(offset).take(page.limit).toList(); - final nextOffset = offset + page.limit; - return Right(core.Page( - items: pageItems, - nextCursor: nextOffset < matching.length ? '$nextOffset' : null, - )); - } - - @override - Future> insert({ - required core.OwnerId ownerId, - required core.SchedulingStateSnapshot snapshot, - }) async { - if (await _snapshotById(snapshot.id) != null) { - return Left(RepositoryDuplicateId(entityId: snapshot.id)); - } - await db.into(db.snapshots).insert(_snapshotCompanion( - snapshot, - ownerId: ownerId, - revision: core.Revision.initial, - )); - return const Right(core.Revision.initial); - } - - @override - Future> deleteExpired({ - required core.OwnerId ownerId, - required DateTime nowUtc, - }) async { - final deleted = await (db.delete(db.snapshots) - ..where( - (table) => - table.ownerId.equals(ownerId.value) & - table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()), - )) - .go(); - return Right(deleted); - } - - Future _snapshotById(String id) { - return (db.select(db.snapshots)..where((table) => table.id.equals(id))) - .getSingleOrNull(); - } -} - -TasksCompanion _taskCompanion( - core.Task task, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.TaskDocumentMapping.toDocument( - task, - ownerId: ownerId.value, - revision: revision.value, - ); - return TasksCompanion.insert( - id: task.id, - ownerId: ownerId.value, - title: _string(doc, core.TaskDocumentFields.title), - projectId: _string(doc, core.TaskDocumentFields.projectId), - parentId: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.parentTaskId, - )), - type: _string(doc, core.TaskDocumentFields.type), - status: _string(doc, core.TaskDocumentFields.status), - priority: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.priority, - )), - reward: _string(doc, core.TaskDocumentFields.reward), - difficulty: _string(doc, core.TaskDocumentFields.difficulty), - durationMinutes: drift.Value(_nullableInt( - doc, - core.TaskDocumentFields.durationMinutes, - )), - scheduledStartUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.scheduledStart, - )), - scheduledEndUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.scheduledEnd, - )), - actualStartUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.actualStart, - )), - actualEndUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.actualEnd, - )), - completedAtUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.completedAt, - )), - backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]), - reminderOverride: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.reminderOverride, - )), - statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]), - backlogEnteredAtUtc: drift.Value(_nullableDateTime( - doc, - core.TaskDocumentFields.backlogEnteredAt, - )), - backlogEnteredProvenance: drift.Value(_nullableString( - doc, - core.TaskDocumentFields.backlogEnteredAtProvenance, - )), - revision: revision.value, - createdAtUtc: task.createdAt.toUtc(), - updatedAtUtc: task.updatedAt.toUtc(), - ); -} - -core.Task _taskFromRow(TaskRow row) { - return core.TaskDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.TaskDocumentFields.title: row.title, - core.TaskDocumentFields.projectId: row.projectId, - core.TaskDocumentFields.type: row.type, - core.TaskDocumentFields.status: row.status, - core.TaskDocumentFields.priority: row.priority, - core.TaskDocumentFields.reward: row.reward, - core.TaskDocumentFields.difficulty: row.difficulty, - core.TaskDocumentFields.durationMinutes: row.durationMinutes, - core.TaskDocumentFields.scheduledStart: - _storedDateTimeOrNull(row.scheduledStartUtc), - core.TaskDocumentFields.scheduledEnd: - _storedDateTimeOrNull(row.scheduledEndUtc), - core.TaskDocumentFields.actualStart: - _storedDateTimeOrNull(row.actualStartUtc), - core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc), - core.TaskDocumentFields.completedAt: - _storedDateTimeOrNull(row.completedAtUtc), - core.TaskDocumentFields.parentTaskId: row.parentId, - core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson), - core.TaskDocumentFields.reminderOverride: row.reminderOverride, - core.TaskDocumentFields.stats: _jsonMap(row.statsJson), - core.TaskDocumentFields.backlogEnteredAt: - _storedDateTimeOrNull(row.backlogEnteredAtUtc), - core.TaskDocumentFields.backlogEnteredAtProvenance: - row.backlogEnteredProvenance, - }); -} - -ProjectsCompanion _projectCompanion( - core.ProjectProfile project, { - required core.OwnerId ownerId, - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - final doc = core.ProjectDocumentMapping.toDocument( - project, - ownerId: ownerId.value, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ); - return ProjectsCompanion.insert( - id: project.id, - ownerId: ownerId.value, - name: _string(doc, core.ProjectDocumentFields.name), - colorKey: _string(doc, core.ProjectDocumentFields.colorKey), - defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority), - defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward), - defaultDifficulty: - _string(doc, core.ProjectDocumentFields.defaultDifficulty), - defaultReminderProfile: - _string(doc, core.ProjectDocumentFields.defaultReminderProfile), - defaultDurationMinutes: drift.Value(_nullableInt( - doc, - core.ProjectDocumentFields.defaultDurationMinutes, - )), - archivedAtUtc: drift.Value(_nullableDateTime( - doc, - core.ProjectDocumentFields.archivedAt, - )), - revision: revision.value, - createdAtUtc: createdAt.toUtc(), - updatedAtUtc: updatedAt.toUtc(), - ); -} - -core.ProjectProfile _projectFromRow(ProjectRow row) { - return core.ProjectDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.ProjectDocumentFields.name: row.name, - core.ProjectDocumentFields.colorKey: row.colorKey, - core.ProjectDocumentFields.defaultPriority: row.defaultPriority, - core.ProjectDocumentFields.defaultReward: row.defaultReward, - core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty, - core.ProjectDocumentFields.defaultReminderProfile: - row.defaultReminderProfile, - core.ProjectDocumentFields.defaultDurationMinutes: - row.defaultDurationMinutes, - core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull( - row.archivedAtUtc, - ), - }); -} - -LockedBlocksCompanion _lockedBlockCompanion( - core.LockedBlock block, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.LockedBlockDocumentMapping.toDocument( - block, - ownerId: ownerId.value, - revision: revision.value, - ); - return LockedBlocksCompanion.insert( - id: block.id, - ownerId: ownerId.value, - name: _string(doc, core.LockedBlockDocumentFields.name), - date: - drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)), - startTime: _string(doc, core.LockedBlockDocumentFields.startTime), - endTime: _string(doc, core.LockedBlockDocumentFields.endTime), - recurrenceJson: drift.Value(_jsonEncodeNullable( - doc[core.LockedBlockDocumentFields.recurrence], - )), - hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault), - projectId: drift.Value(_nullableString( - doc, - core.LockedBlockDocumentFields.projectId, - )), - archivedAtUtc: drift.Value(_nullableDateTime( - doc, - core.LockedBlockDocumentFields.archivedAt, - )), - revision: revision.value, - createdAtUtc: block.createdAt.toUtc(), - updatedAtUtc: block.updatedAt.toUtc(), - ); -} - -core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) { - return core.LockedBlockDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.LockedBlockDocumentFields.name: row.name, - core.LockedBlockDocumentFields.startTime: row.startTime, - core.LockedBlockDocumentFields.endTime: row.endTime, - core.LockedBlockDocumentFields.date: row.date, - core.LockedBlockDocumentFields.recurrence: - row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!), - core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault, - core.LockedBlockDocumentFields.projectId: row.projectId, - core.LockedBlockDocumentFields.archivedAt: - _storedDateTimeOrNull(row.archivedAtUtc), - }); -} - -LockedOverridesCompanion _lockedOverrideCompanion( - core.LockedBlockOverride override, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final doc = core.LockedBlockOverrideDocumentMapping.toDocument( - override, - ownerId: ownerId.value, - revision: revision.value, - ); - return LockedOverridesCompanion.insert( - id: override.id, - ownerId: ownerId.value, - lockedBlockId: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.lockedBlockId, - )), - date: _string(doc, core.LockedBlockOverrideDocumentFields.date), - type: _string(doc, core.LockedBlockOverrideDocumentFields.type), - name: drift.Value( - _nullableString(doc, core.LockedBlockOverrideDocumentFields.name), - ), - startTime: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.startTime, - )), - endTime: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.endTime, - )), - hiddenByDefault: - _bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault), - projectId: drift.Value(_nullableString( - doc, - core.LockedBlockOverrideDocumentFields.projectId, - )), - revision: revision.value, - createdAtUtc: override.createdAt.toUtc(), - updatedAtUtc: override.updatedAt.toUtc(), - ); -} - -core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) { - return core.LockedBlockOverrideDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId, - core.LockedBlockOverrideDocumentFields.date: row.date, - core.LockedBlockOverrideDocumentFields.type: row.type, - core.LockedBlockOverrideDocumentFields.name: row.name, - core.LockedBlockOverrideDocumentFields.startTime: row.startTime, - core.LockedBlockOverrideDocumentFields.endTime: row.endTime, - core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault, - core.LockedBlockOverrideDocumentFields.projectId: row.projectId, - }); -} - -SettingsTableCompanion _settingsCompanion( - core.OwnerSettings settings, { - required core.Revision revision, - required DateTime createdAt, - required DateTime updatedAt, -}) { - final doc = core.OwnerSettingsDocumentMapping.toDocument( - settings, - revision: revision.value, - createdAt: createdAt, - updatedAt: updatedAt, - ); - return SettingsTableCompanion.insert( - ownerId: settings.ownerId, - timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId), - dayStartMinutes: settings.dayStart.minutesSinceMidnight, - dayEndMinutes: settings.dayEnd.minutesSinceMidnight, - compactMode: - _bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled), - backlogStalenessJson: - jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]), - revision: revision.value, - createdAtUtc: createdAt.toUtc(), - updatedAtUtc: updatedAt.toUtc(), - ); -} - -core.OwnerSettings _settingsFromRow(SettingsRow row) { - return core.OwnerSettingsDocumentMapping.fromDocument({ - ..._commonDocumentFields(row.ownerId, row.ownerId, row.revision, - row.createdAtUtc, row.updatedAtUtc), - core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId, - core.OwnerSettingsDocumentFields.dayStart: - _wallTimeFromMinutes(row.dayStartMinutes).toIsoString(), - core.OwnerSettingsDocumentFields.dayEnd: - _wallTimeFromMinutes(row.dayEndMinutes).toIsoString(), - core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode, - core.OwnerSettingsDocumentFields.backlogStaleness: - _jsonMap(row.backlogStalenessJson), - }); -} - -SnapshotsCompanion _snapshotCompanion( - core.SchedulingStateSnapshot snapshot, { - required core.OwnerId ownerId, - required core.Revision revision, -}) { - final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention); - final doc = core.SchedulingSnapshotDocumentMapping.toDocument( - snapshot, - ownerId: ownerId.value, - revision: revision.value, - retentionExpiresAt: retentionExpiresAt, - ); - return SnapshotsCompanion.insert( - id: snapshot.id, - ownerId: ownerId.value, - capturedAtUtc: snapshot.capturedAt.toUtc(), - operationName: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.operationName, - )), - sourceDate: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.sourceDate, - )), - targetDate: drift.Value(_nullableString( - doc, - core.SchedulingSnapshotDocumentFields.targetDate, - )), - windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]), - tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]), - lockedIntervalsJson: - jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]), - requiredVisibleIntervalsJson: jsonEncode( - doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals], - ), - noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]), - changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]), - overlapsJson: - jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]), - retentionExpiresUtc: retentionExpiresAt.toUtc(), - truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated), - revision: revision.value, - createdAtUtc: snapshot.capturedAt.toUtc(), - updatedAtUtc: snapshot.capturedAt.toUtc(), - ); -} - -core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) { - return core.SchedulingSnapshotDocumentMapping.fromDocument({ - ..._commonDocumentFields( - row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), - core.SchedulingSnapshotDocumentFields.capturedAt: - core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc), - core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate, - core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate, - core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson), - core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson), - core.SchedulingSnapshotDocumentFields.lockedIntervals: - _jsonList(row.lockedIntervalsJson), - core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals: - _jsonList(row.requiredVisibleIntervalsJson), - core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson), - core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson), - core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson), - core.SchedulingSnapshotDocumentFields.operationName: row.operationName, - core.SchedulingSnapshotDocumentFields.retentionExpiresAt: - core.PersistenceDateTimeConvention.toStoredString( - row.retentionExpiresUtc, - ), - core.SchedulingSnapshotDocumentFields.truncated: row.truncated, - }); -} - -Map _commonDocumentFields( - String id, - String ownerId, - int revision, - DateTime createdAt, - DateTime updatedAt, -) { - return { - core.DocumentFields.schemaVersion: core.v1SchemaVersion, - core.DocumentFields.id: id, - core.DocumentFields.ownerId: ownerId, - core.DocumentFields.revision: revision, - core.DocumentFields.createdAt: - core.PersistenceDateTimeConvention.toStoredString(createdAt), - core.DocumentFields.updatedAt: - core.PersistenceDateTimeConvention.toStoredString(updatedAt), - }; -} - -String _string(Map doc, String fieldName) { - return doc[fieldName] as String; -} - -String? _nullableString(Map doc, String fieldName) { - return doc[fieldName] as String?; -} - -int? _nullableInt(Map doc, String fieldName) { - return doc[fieldName] as int?; -} - -bool _bool(Map doc, String fieldName) { - return doc[fieldName] as bool; -} - -DateTime? _nullableDateTime(Map doc, String fieldName) { - final value = doc[fieldName] as String?; - return value == null - ? null - : core.PersistenceDateTimeConvention.fromStoredString(value); -} - -String? _storedDateTimeOrNull(DateTime? value) { - return value == null - ? null - : core.PersistenceDateTimeConvention.toStoredString(value); -} - -String? _jsonEncodeNullable(Object? value) { - return value == null ? null : jsonEncode(value); -} - -Map _jsonMap(String value) { - return Map.from( - jsonDecode(value) as Map, - ); -} - -List _jsonList(String value) { - return List.from(jsonDecode(value) as List); -} - -core.WallTime _wallTimeFromMinutes(int minutes) { - return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60); -} - -int _cursorOffset(String? cursor) { - if (cursor == null) return 0; - final parsed = int.tryParse(cursor); - if (parsed == null || parsed < 0) return 0; - return parsed; -} - +/// Adds focused helper behavior through `on`. +/// The extension keeps conversion or convenience logic near the type it supports while avoiding changes to the original model surface. extension on int { + /// Performs the `next` 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. core.Revision next() => core.Revision(this).next(); } +/// Top-level helper that performs the `_windowsOverlap` 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 _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) { return left.start.isBefore(right.end) && right.start.isBefore(left.end); } diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart new file mode 100644 index 0000000..dc127f5 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../sqlite_repositories.dart'; + +/// Drift-backed locked-block and override repository. +final class SqliteLockedBlockRepository implements LockedBlockRepository { + /// Creates a locked-time repository using [db]. + SqliteLockedBlockRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + /// Runs the `findBlockById` 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> findBlockById({ + required core.OwnerId ownerId, + required String blockId, + }) async { + final row = await _blockById(blockId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_lockedBlockFromRow(row)); + } + + /// Runs the `findBlocksByOwner` 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>> findBlocksByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.lockedBlocks) + ..where((table) { + final ownerClause = table.ownerId.equals(ownerId.value); + return includeArchived + ? ownerClause + : ownerClause & table.archivedAtUtc.isNull(); + }) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_lockedBlockFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + /// Runs the `findOverrideById` 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> findOverrideById({ + required core.OwnerId ownerId, + required String overrideId, + }) async { + final row = await _overrideById(overrideId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_lockedOverrideFromRow(row)); + } + + /// Runs the `findOverridesForDate` 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>> findOverridesForDate({ + required core.OwnerId ownerId, + required core.CivilDate date, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.lockedOverrides) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.date.equals(date.toIsoString()), + ) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_lockedOverrideFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + /// Runs the `insertBlock` 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> insertBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + }) async { + if (await _blockById(block.id) != null) { + return Left(RepositoryDuplicateId(entityId: block.id)); + } + await db.into(db.lockedBlocks).insert(_lockedBlockCompanion( + block, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `saveBlock` 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> saveBlock({ + required core.OwnerId ownerId, + required core.LockedBlock block, + required core.Revision expectedRevision, + }) async { + final checked = await _checkBlockWrite( + block.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = core.Revision(checked.right.revision).next(); + final updated = await (db.update(db.lockedBlocks) + ..where( + (table) => + table.id.equals(block.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedBlockCompanion( + block, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleBlockFailure(block.id, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `archiveBlock` 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> archiveBlock({ + required core.OwnerId ownerId, + required String blockId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = await _checkBlockWrite(blockId, ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final archived = + _lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc); + final updated = await (db.update(db.lockedBlocks) + ..where( + (table) => + table.id.equals(blockId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedBlockCompanion( + archived, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleBlockFailure(blockId, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `insertOverride` 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> insertOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + }) async { + if (await _overrideById(override.id) != null) { + return Left(RepositoryDuplicateId(entityId: override.id)); + } + await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion( + override, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `saveOverride` 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> saveOverride({ + required core.OwnerId ownerId, + required core.LockedBlockOverride override, + required core.Revision expectedRevision, + }) async { + final checked = await _checkOverrideWrite( + override.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = core.Revision(checked.right.revision).next(); + final updated = await (db.update(db.lockedOverrides) + ..where( + (table) => + table.id.equals(override.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_lockedOverrideCompanion( + override, + ownerId: ownerId, + revision: nextRevision, + )); + if (updated != 1) { + return Left(await _staleOverrideFailure( + override.id, + expectedRevision, + )); + } + return Right(nextRevision); + } + + /// Runs the `_blockById` 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 _blockById(String id) { + return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + /// Runs the `_overrideById` 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 _overrideById(String id) { + return (db.select(db.lockedOverrides) + ..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + /// Runs the `_checkBlockWrite` 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> _checkBlockWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _blockById(id); + if (row == null) return Left(RepositoryNotFound(entityId: id)); + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + /// Runs the `_checkOverrideWrite` 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> _checkOverrideWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _overrideById(id); + if (row == null) return Left(RepositoryNotFound(entityId: id)); + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + /// Runs the `_staleBlockFailure` 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 _staleBlockFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _blockById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } + + /// Runs the `_staleOverrideFailure` 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 _staleOverrideFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _overrideById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart new file mode 100644 index 0000000..7fea479 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../sqlite_repositories.dart'; + +/// Drift-backed project repository. +final class SqliteProjectRepository implements ProjectRepository { + /// Creates a project repository using [db]. + SqliteProjectRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String projectId, + }) async { + final row = await _projectById(projectId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_projectFromRow(row)); + } + + /// Runs the `findByOwner` 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>> findByOwner({ + required core.OwnerId ownerId, + bool includeArchived = false, + core.PageRequest page = const core.PageRequest(), + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.projects) + ..where((table) { + final ownerClause = table.ownerId.equals(ownerId.value); + return includeArchived + ? ownerClause + : ownerClause & table.archivedAtUtc.isNull(); + }) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return Right(core.Page( + items: pageRows.map(_projectFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + }) async { + if (await _projectById(project.id) != null) { + return Left(RepositoryDuplicateId(entityId: project.id)); + } + await db.into(db.projects).insert(_projectCompanion( + project, + ownerId: ownerId, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.ProjectProfile project, + required core.Revision expectedRevision, + }) async { + final checked = await _checkProjectWrite( + project.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final updated = await (db.update(db.projects) + ..where( + (table) => + table.id.equals(project.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_projectCompanion( + project, + ownerId: ownerId, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: _epoch, + )); + if (updated != 1) { + return Left(await _staleProjectFailure(project.id, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `archive` 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> archive({ + required core.OwnerId ownerId, + required String projectId, + required core.Revision expectedRevision, + required DateTime archivedAtUtc, + }) async { + final checked = await _checkProjectWrite( + projectId, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc); + final updated = await (db.update(db.projects) + ..where( + (table) => + table.id.equals(projectId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_projectCompanion( + archived, + ownerId: ownerId, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: archivedAtUtc, + )); + if (updated != 1) { + return Left(await _staleProjectFailure(projectId, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `_projectById` 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 _projectById(String id) { + return (db.select(db.projects)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + /// Runs the `_checkProjectWrite` 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> _checkProjectWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _projectById(id); + if (row == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + /// Runs the `_staleProjectFailure` 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 _staleProjectFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _projectById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart new file mode 100644 index 0000000..16c1c26 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart @@ -0,0 +1,602 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../sqlite_repositories.dart'; + +/// Drift-backed schedule snapshot repository. +final class SqliteScheduleSnapshotRepository + implements ScheduleSnapshotRepository { + /// Creates a snapshot repository using [db]. + SqliteScheduleSnapshotRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String snapshotId, + }) async { + final row = await _snapshotById(snapshotId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_snapshotFromRow(row)); + } + + /// Runs the `findInWindow` 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>> findInWindow({ + required core.OwnerId ownerId, + required core.SchedulingWindow window, + core.PageRequest page = const core.PageRequest(), + }) async { + final rows = await (db.select(db.snapshots) + ..where((table) => table.ownerId.equals(ownerId.value)) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])) + .get(); + final matching = rows + .map(_snapshotFromRow) + .where((snapshot) => _windowsOverlap(snapshot.window, window)) + .toList(growable: false); + final offset = _cursorOffset(page.cursor); + final pageItems = matching.skip(offset).take(page.limit).toList(); + final nextOffset = offset + page.limit; + return Right(core.Page( + items: pageItems, + nextCursor: nextOffset < matching.length ? '$nextOffset' : null, + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.SchedulingStateSnapshot snapshot, + }) async { + if (await _snapshotById(snapshot.id) != null) { + return Left(RepositoryDuplicateId(entityId: snapshot.id)); + } + await db.into(db.snapshots).insert(_snapshotCompanion( + snapshot, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `deleteExpired` 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> deleteExpired({ + required core.OwnerId ownerId, + required DateTime nowUtc, + }) async { + final deleted = await (db.delete(db.snapshots) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()), + )) + .go(); + return Right(deleted); + } + + /// Runs the `_snapshotById` 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 _snapshotById(String id) { + return (db.select(db.snapshots)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } +} + +/// Top-level helper that performs the `_taskCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +TasksCompanion _taskCompanion( + core.Task task, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.TaskDocumentMapping.toDocument( + task, + ownerId: ownerId.value, + revision: revision.value, + ); + return TasksCompanion.insert( + id: task.id, + ownerId: ownerId.value, + title: _string(doc, core.TaskDocumentFields.title), + projectId: _string(doc, core.TaskDocumentFields.projectId), + parentId: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.parentTaskId, + )), + type: _string(doc, core.TaskDocumentFields.type), + status: _string(doc, core.TaskDocumentFields.status), + priority: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.priority, + )), + reward: _string(doc, core.TaskDocumentFields.reward), + difficulty: _string(doc, core.TaskDocumentFields.difficulty), + durationMinutes: drift.Value(_nullableInt( + doc, + core.TaskDocumentFields.durationMinutes, + )), + scheduledStartUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.scheduledStart, + )), + scheduledEndUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.scheduledEnd, + )), + actualStartUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.actualStart, + )), + actualEndUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.actualEnd, + )), + completedAtUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.completedAt, + )), + backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]), + reminderOverride: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.reminderOverride, + )), + statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]), + backlogEnteredAtUtc: drift.Value(_nullableDateTime( + doc, + core.TaskDocumentFields.backlogEnteredAt, + )), + backlogEnteredProvenance: drift.Value(_nullableString( + doc, + core.TaskDocumentFields.backlogEnteredAtProvenance, + )), + revision: revision.value, + createdAtUtc: task.createdAt.toUtc(), + updatedAtUtc: task.updatedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_taskFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.Task _taskFromRow(TaskRow row) { + return core.TaskDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.TaskDocumentFields.title: row.title, + core.TaskDocumentFields.projectId: row.projectId, + core.TaskDocumentFields.type: row.type, + core.TaskDocumentFields.status: row.status, + core.TaskDocumentFields.priority: row.priority, + core.TaskDocumentFields.reward: row.reward, + core.TaskDocumentFields.difficulty: row.difficulty, + core.TaskDocumentFields.durationMinutes: row.durationMinutes, + core.TaskDocumentFields.scheduledStart: + _storedDateTimeOrNull(row.scheduledStartUtc), + core.TaskDocumentFields.scheduledEnd: + _storedDateTimeOrNull(row.scheduledEndUtc), + core.TaskDocumentFields.actualStart: + _storedDateTimeOrNull(row.actualStartUtc), + core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc), + core.TaskDocumentFields.completedAt: + _storedDateTimeOrNull(row.completedAtUtc), + core.TaskDocumentFields.parentTaskId: row.parentId, + core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson), + core.TaskDocumentFields.reminderOverride: row.reminderOverride, + core.TaskDocumentFields.stats: _jsonMap(row.statsJson), + core.TaskDocumentFields.backlogEnteredAt: + _storedDateTimeOrNull(row.backlogEnteredAtUtc), + core.TaskDocumentFields.backlogEnteredAtProvenance: + row.backlogEnteredProvenance, + }); +} + +/// Top-level helper that performs the `_projectCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +ProjectsCompanion _projectCompanion( + core.ProjectProfile project, { + required core.OwnerId ownerId, + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + final doc = core.ProjectDocumentMapping.toDocument( + project, + ownerId: ownerId.value, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ); + return ProjectsCompanion.insert( + id: project.id, + ownerId: ownerId.value, + name: _string(doc, core.ProjectDocumentFields.name), + colorKey: _string(doc, core.ProjectDocumentFields.colorKey), + defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority), + defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward), + defaultDifficulty: + _string(doc, core.ProjectDocumentFields.defaultDifficulty), + defaultReminderProfile: + _string(doc, core.ProjectDocumentFields.defaultReminderProfile), + defaultDurationMinutes: drift.Value(_nullableInt( + doc, + core.ProjectDocumentFields.defaultDurationMinutes, + )), + archivedAtUtc: drift.Value(_nullableDateTime( + doc, + core.ProjectDocumentFields.archivedAt, + )), + revision: revision.value, + createdAtUtc: createdAt.toUtc(), + updatedAtUtc: updatedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_projectFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.ProjectProfile _projectFromRow(ProjectRow row) { + return core.ProjectDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.ProjectDocumentFields.name: row.name, + core.ProjectDocumentFields.colorKey: row.colorKey, + core.ProjectDocumentFields.defaultPriority: row.defaultPriority, + core.ProjectDocumentFields.defaultReward: row.defaultReward, + core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty, + core.ProjectDocumentFields.defaultReminderProfile: + row.defaultReminderProfile, + core.ProjectDocumentFields.defaultDurationMinutes: + row.defaultDurationMinutes, + core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull( + row.archivedAtUtc, + ), + }); +} + +/// Top-level helper that performs the `_lockedBlockCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +LockedBlocksCompanion _lockedBlockCompanion( + core.LockedBlock block, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.LockedBlockDocumentMapping.toDocument( + block, + ownerId: ownerId.value, + revision: revision.value, + ); + return LockedBlocksCompanion.insert( + id: block.id, + ownerId: ownerId.value, + name: _string(doc, core.LockedBlockDocumentFields.name), + date: + drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)), + startTime: _string(doc, core.LockedBlockDocumentFields.startTime), + endTime: _string(doc, core.LockedBlockDocumentFields.endTime), + recurrenceJson: drift.Value(_jsonEncodeNullable( + doc[core.LockedBlockDocumentFields.recurrence], + )), + hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault), + projectId: drift.Value(_nullableString( + doc, + core.LockedBlockDocumentFields.projectId, + )), + archivedAtUtc: drift.Value(_nullableDateTime( + doc, + core.LockedBlockDocumentFields.archivedAt, + )), + revision: revision.value, + createdAtUtc: block.createdAt.toUtc(), + updatedAtUtc: block.updatedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_lockedBlockFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) { + return core.LockedBlockDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.LockedBlockDocumentFields.name: row.name, + core.LockedBlockDocumentFields.startTime: row.startTime, + core.LockedBlockDocumentFields.endTime: row.endTime, + core.LockedBlockDocumentFields.date: row.date, + core.LockedBlockDocumentFields.recurrence: + row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!), + core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault, + core.LockedBlockDocumentFields.projectId: row.projectId, + core.LockedBlockDocumentFields.archivedAt: + _storedDateTimeOrNull(row.archivedAtUtc), + }); +} + +/// Top-level helper that performs the `_lockedOverrideCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +LockedOverridesCompanion _lockedOverrideCompanion( + core.LockedBlockOverride override, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final doc = core.LockedBlockOverrideDocumentMapping.toDocument( + override, + ownerId: ownerId.value, + revision: revision.value, + ); + return LockedOverridesCompanion.insert( + id: override.id, + ownerId: ownerId.value, + lockedBlockId: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.lockedBlockId, + )), + date: _string(doc, core.LockedBlockOverrideDocumentFields.date), + type: _string(doc, core.LockedBlockOverrideDocumentFields.type), + name: drift.Value( + _nullableString(doc, core.LockedBlockOverrideDocumentFields.name), + ), + startTime: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.startTime, + )), + endTime: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.endTime, + )), + hiddenByDefault: + _bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault), + projectId: drift.Value(_nullableString( + doc, + core.LockedBlockOverrideDocumentFields.projectId, + )), + revision: revision.value, + createdAtUtc: override.createdAt.toUtc(), + updatedAtUtc: override.updatedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_lockedOverrideFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) { + return core.LockedBlockOverrideDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId, + core.LockedBlockOverrideDocumentFields.date: row.date, + core.LockedBlockOverrideDocumentFields.type: row.type, + core.LockedBlockOverrideDocumentFields.name: row.name, + core.LockedBlockOverrideDocumentFields.startTime: row.startTime, + core.LockedBlockOverrideDocumentFields.endTime: row.endTime, + core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault, + core.LockedBlockOverrideDocumentFields.projectId: row.projectId, + }); +} + +/// Top-level helper that performs the `_settingsCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +SettingsTableCompanion _settingsCompanion( + core.OwnerSettings settings, { + required core.Revision revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + final doc = core.OwnerSettingsDocumentMapping.toDocument( + settings, + revision: revision.value, + createdAt: createdAt, + updatedAt: updatedAt, + ); + return SettingsTableCompanion.insert( + ownerId: settings.ownerId, + timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId), + dayStartMinutes: settings.dayStart.minutesSinceMidnight, + dayEndMinutes: settings.dayEnd.minutesSinceMidnight, + compactMode: + _bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled), + backlogStalenessJson: + jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]), + revision: revision.value, + createdAtUtc: createdAt.toUtc(), + updatedAtUtc: updatedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_settingsFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.OwnerSettings _settingsFromRow(SettingsRow row) { + return core.OwnerSettingsDocumentMapping.fromDocument({ + ..._commonDocumentFields(row.ownerId, row.ownerId, row.revision, + row.createdAtUtc, row.updatedAtUtc), + core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId, + core.OwnerSettingsDocumentFields.dayStart: + _wallTimeFromMinutes(row.dayStartMinutes).toIsoString(), + core.OwnerSettingsDocumentFields.dayEnd: + _wallTimeFromMinutes(row.dayEndMinutes).toIsoString(), + core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode, + core.OwnerSettingsDocumentFields.backlogStaleness: + _jsonMap(row.backlogStalenessJson), + }); +} + +/// Top-level helper that performs the `_snapshotCompanion` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +SnapshotsCompanion _snapshotCompanion( + core.SchedulingStateSnapshot snapshot, { + required core.OwnerId ownerId, + required core.Revision revision, +}) { + final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention); + final doc = core.SchedulingSnapshotDocumentMapping.toDocument( + snapshot, + ownerId: ownerId.value, + revision: revision.value, + retentionExpiresAt: retentionExpiresAt, + ); + return SnapshotsCompanion.insert( + id: snapshot.id, + ownerId: ownerId.value, + capturedAtUtc: snapshot.capturedAt.toUtc(), + operationName: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.operationName, + )), + sourceDate: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.sourceDate, + )), + targetDate: drift.Value(_nullableString( + doc, + core.SchedulingSnapshotDocumentFields.targetDate, + )), + windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]), + tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]), + lockedIntervalsJson: + jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]), + requiredVisibleIntervalsJson: jsonEncode( + doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals], + ), + noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]), + changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]), + overlapsJson: + jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]), + retentionExpiresUtc: retentionExpiresAt.toUtc(), + truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated), + revision: revision.value, + createdAtUtc: snapshot.capturedAt.toUtc(), + updatedAtUtc: snapshot.capturedAt.toUtc(), + ); +} + +/// Top-level helper that performs the `_snapshotFromRow` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) { + return core.SchedulingSnapshotDocumentMapping.fromDocument({ + ..._commonDocumentFields( + row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc), + core.SchedulingSnapshotDocumentFields.capturedAt: + core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc), + core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate, + core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate, + core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson), + core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson), + core.SchedulingSnapshotDocumentFields.lockedIntervals: + _jsonList(row.lockedIntervalsJson), + core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals: + _jsonList(row.requiredVisibleIntervalsJson), + core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson), + core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson), + core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson), + core.SchedulingSnapshotDocumentFields.operationName: row.operationName, + core.SchedulingSnapshotDocumentFields.retentionExpiresAt: + core.PersistenceDateTimeConvention.toStoredString( + row.retentionExpiresUtc, + ), + core.SchedulingSnapshotDocumentFields.truncated: row.truncated, + }); +} + +/// Top-level helper that performs the `_commonDocumentFields` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _commonDocumentFields( + String id, + String ownerId, + int revision, + DateTime createdAt, + DateTime updatedAt, +) { + return { + core.DocumentFields.schemaVersion: core.v1SchemaVersion, + core.DocumentFields.id: id, + core.DocumentFields.ownerId: ownerId, + core.DocumentFields.revision: revision, + core.DocumentFields.createdAt: + core.PersistenceDateTimeConvention.toStoredString(createdAt), + core.DocumentFields.updatedAt: + core.PersistenceDateTimeConvention.toStoredString(updatedAt), + }; +} + +/// Top-level helper that performs the `_string` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String _string(Map doc, String fieldName) { + return doc[fieldName] as String; +} + +/// Top-level helper that performs the `_nullableString` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _nullableString(Map doc, String fieldName) { + return doc[fieldName] as String?; +} + +/// Top-level helper that performs the `_nullableInt` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int? _nullableInt(Map doc, String fieldName) { + return doc[fieldName] as int?; +} + +/// Top-level helper that performs the `_bool` 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 _bool(Map doc, String fieldName) { + return doc[fieldName] as bool; +} + +/// Top-level helper that performs the `_nullableDateTime` 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? _nullableDateTime(Map doc, String fieldName) { + final value = doc[fieldName] as String?; + return value == null + ? null + : core.PersistenceDateTimeConvention.fromStoredString(value); +} + +/// Top-level helper that performs the `_storedDateTimeOrNull` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _storedDateTimeOrNull(DateTime? value) { + return value == null + ? null + : core.PersistenceDateTimeConvention.toStoredString(value); +} + +/// Top-level helper that performs the `_jsonEncodeNullable` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +String? _jsonEncodeNullable(Object? value) { + return value == null ? null : jsonEncode(value); +} + +/// Top-level helper that performs the `_jsonMap` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _jsonMap(String value) { + return Map.from( + jsonDecode(value) as Map, + ); +} + +/// Top-level helper that performs the `_jsonList` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _jsonList(String value) { + return List.from(jsonDecode(value) as List); +} + +/// Top-level helper that performs the `_wallTimeFromMinutes` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +core.WallTime _wallTimeFromMinutes(int minutes) { + return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60); +} + +/// Top-level helper that performs the `_cursorOffset` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +int _cursorOffset(String? cursor) { + if (cursor == null) return 0; + final parsed = int.tryParse(cursor); + if (parsed == null || parsed < 0) return 0; + return parsed; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart new file mode 100644 index 0000000..1de3ae3 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../sqlite_repositories.dart'; + +/// Drift-backed owner settings repository. +final class SqliteSettingsRepository implements SettingsRepository { + /// Creates a settings repository using [db]. + SqliteSettingsRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + /// Runs the `findByOwner` 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> findByOwner({ + required core.OwnerId ownerId, + }) async { + final row = await _settingsByOwner(ownerId.value); + if (row == null) return const Right(null); + return Right(_settingsFromRow(row)); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + }) async { + if (await _settingsByOwner(ownerId.value) != null) { + return Left(RepositoryDuplicateId(entityId: ownerId.value)); + } + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + await db.into(db.settingsTable).insert(_settingsCompanion( + ownedSettings, + revision: core.Revision.initial, + createdAt: _epoch, + updatedAt: _epoch, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.OwnerSettings settings, + required core.Revision expectedRevision, + }) async { + final checked = await _checkSettingsWrite(ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final row = checked.right; + final nextRevision = core.Revision(row.revision).next(); + final ownedSettings = settings.ownerId == ownerId.value + ? settings + : settings.copyWith(ownerId: ownerId.value); + final updated = await (db.update(db.settingsTable) + ..where( + (table) => + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(_settingsCompanion( + ownedSettings, + revision: nextRevision, + createdAt: row.createdAtUtc, + updatedAt: _epoch, + )); + if (updated != 1) { + return Left(await _staleSettingsFailure(ownerId, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `_settingsByOwner` 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 _settingsByOwner(String ownerId) { + return (db.select(db.settingsTable) + ..where((table) => table.ownerId.equals(ownerId))) + .getSingleOrNull(); + } + + /// Runs the `_checkSettingsWrite` 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> _checkSettingsWrite( + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + )); + } + final row = await _settingsByOwner(ownerId.value); + if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value)); + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + /// Runs the `_staleSettingsFailure` 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 _staleSettingsFailure( + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + final row = await _settingsByOwner(ownerId.value); + return RepositoryStaleRevision( + entityId: ownerId.value, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart new file mode 100644 index 0000000..d23fcb9 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../sqlite_repositories.dart'; + +/// Drift-backed task repository. +final class SqliteTaskRepository implements TaskRepository { + /// Creates a task repository using [db]. + SqliteTaskRepository(this.db); + + /// Shared SQLite database handle. + final SchedulerDb db; + + /// Runs the `findById` 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> findById({ + required core.OwnerId ownerId, + required String taskId, + }) async { + final row = await _taskById(taskId); + if (row == null || row.ownerId != ownerId.value) { + return const Right(null); + } + return Right(_taskFromRow(row)); + } + + /// Runs the `findByOwner` 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>> findByOwner({ + required core.OwnerId ownerId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => table.ownerId.equals(ownerId.value), + )); + } + + /// Runs the `findByParentTaskId` 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>> findByParentTaskId({ + required core.OwnerId ownerId, + required String parentTaskId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.parentId.equals(parentTaskId), + )); + } + + /// Runs the `findByProjectId` 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>> findByProjectId({ + required core.OwnerId ownerId, + required String projectId, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.projectId.equals(projectId), + )); + } + + /// Runs the `findByStatus` 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>> findByStatus({ + required core.OwnerId ownerId, + required core.TaskStatus status, + core.PageRequest page = const core.PageRequest(), + }) async { + return Right(await _pageTaskRows( + page: page, + where: (table) => + table.ownerId.equals(ownerId.value) & + table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus( + status, + )), + )); + } + + /// Runs the `insert` 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> insert({ + required core.OwnerId ownerId, + required core.Task task, + }) async { + if (await _taskById(task.id) != null) { + return Left(RepositoryDuplicateId(entityId: task.id)); + } + await db.into(db.tasks).insert(_taskCompanion( + task, + ownerId: ownerId, + revision: core.Revision.initial, + )); + return const Right(core.Revision.initial); + } + + /// Runs the `save` 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> save({ + required core.OwnerId ownerId, + required core.Task task, + required core.Revision expectedRevision, + }) async { + final checked = await _checkTaskWrite( + task.id, + ownerId, + expectedRevision, + ); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + final updates = _taskCompanion( + task, + ownerId: ownerId, + revision: nextRevision, + ); + final updated = await (db.update(db.tasks) + ..where( + (table) => + table.id.equals(task.id) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .write(updates); + if (updated != 1) { + return Left(await _staleTaskFailure(task.id, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `delete` 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> delete({ + required core.OwnerId ownerId, + required String taskId, + required core.Revision expectedRevision, + }) async { + final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision); + if (checked is Left) { + return Left(checked.value); + } + final nextRevision = checked.right.revision.next(); + final deleted = await (db.delete(db.tasks) + ..where( + (table) => + table.id.equals(taskId) & + table.ownerId.equals(ownerId.value) & + table.revision.equals(expectedRevision.value), + )) + .go(); + if (deleted != 1) { + return Left(await _staleTaskFailure(taskId, expectedRevision)); + } + return Right(nextRevision); + } + + /// Runs the `_pageTaskRows` 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> _pageTaskRows({ + required core.PageRequest page, + required drift.Expression Function($TasksTable table) where, + }) async { + final offset = _cursorOffset(page.cursor); + final query = db.select(db.tasks) + ..where(where) + ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]) + ..limit(page.limit + 1, offset: offset); + final rows = await query.get(); + final pageRows = rows.take(page.limit).toList(growable: false); + return core.Page( + items: pageRows.map(_taskFromRow), + nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null, + ); + } + + /// Runs the `_taskById` 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 _taskById(String id) { + return (db.select(db.tasks)..where((table) => table.id.equals(id))) + .getSingleOrNull(); + } + + /// Runs the `_checkTaskWrite` 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> _checkTaskWrite( + String id, + core.OwnerId ownerId, + core.Revision expectedRevision, + ) async { + if (expectedRevision.value <= 0) { + return Left(RepositoryInvalidRevision( + entityId: id, + expectedRevision: expectedRevision, + )); + } + final row = await _taskById(id); + if (row == null) { + return Left(RepositoryNotFound(entityId: id)); + } + if (row.ownerId != ownerId.value) { + return Left(RepositoryOwnerMismatch(entityId: id)); + } + if (row.revision != expectedRevision.value) { + return Left(RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: core.Revision(row.revision), + )); + } + return Right(row); + } + + /// Runs the `_staleTaskFailure` 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 _staleTaskFailure( + String id, + core.Revision expectedRevision, + ) async { + final row = await _taskById(id); + return RepositoryStaleRevision( + entityId: id, + expectedRevision: expectedRevision, + actualRevision: + row == null ? expectedRevision.next() : core.Revision(row.revision), + ); + } +} diff --git a/packages/scheduler_persistence_sqlite/pubspec.yaml b/packages/scheduler_persistence_sqlite/pubspec.yaml index 54d5d7a..195202e 100644 --- a/packages/scheduler_persistence_sqlite/pubspec.yaml +++ b/packages/scheduler_persistence_sqlite/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: scheduler_persistence_sqlite description: Drift-backed SQLite persistence adapter for the ADHD scheduler. version: 0.1.0 diff --git a/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart b/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart index 7e568bd..d1c02f6 100644 --- a/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart +++ b/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Scheduler Db behavior for the Scheduler Persistence Sqlite package. library; @@ -5,6 +8,8 @@ import 'package:drift/native.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart'; import 'package:test/test.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { test('opens schema version 1 with expected tables', () async { final db = SchedulerDb(NativeDatabase.memory()); diff --git a/packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart b/packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart index fc1ee73..ebf7dc3 100644 --- a/packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart +++ b/packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Sqlite Repository Conformance behavior for the Scheduler Persistence Sqlite package. library; @@ -8,6 +11,8 @@ import 'package:test/test.dart'; import '../../scheduler_persistence/test/repo_conformance.dart'; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { runRepositoryComplianceTests( RepositoryComplianceFactories( @@ -53,6 +58,8 @@ void main() { }); } +/// Top-level helper that performs the `_repo` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. T _repo(T Function(SchedulerDb db) create) { final db = SchedulerDb(NativeDatabase.memory()); addTearDown(db.close); diff --git a/pubspec.yaml b/pubspec.yaml index 6bdf50a..fd21421 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + name: adhd_scheduling_workspace publish_to: none @@ -29,6 +32,8 @@ dependencies: scheduler_persistence_sqlite: any dev_dependencies: + analyzer: ^13.3.0 build_runner: ^2.4.13 coverage: ^1.11.0 + lints: ^5.0.0 test: ^1.25.0 diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh index 7bd460d..d89d001 100755 --- a/scripts/bootstrap_dev.sh +++ b/scripts/bootstrap_dev.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + # Prepare local dependencies and the default scheduler SQLite location. set -euo pipefail @@ -8,9 +11,14 @@ scheduler_dir="${SCHEDULER_HOME:-"$HOME/ADHD_Scheduler"}" cd "$root_dir" dart pub get +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git config core.hooksPath .githooks +fi + mkdir -p "$scheduler_dir/backups" if [[ ! -e "$scheduler_dir/scheduler.sqlite" ]]; then : > "$scheduler_dir/scheduler.sqlite" fi printf 'Scheduler SQLite path: %s\n' "$scheduler_dir/scheduler.sqlite" +printf 'Git hooks path: .githooks\n' diff --git a/scripts/build.dart b/scripts/build.dart index fe42c70..c55fa56 100644 --- a/scripts/build.dart +++ b/scripts/build.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Build workspace script. library; @@ -30,6 +33,8 @@ Future main(List arguments) async { } } +/// Top-level helper that performs the `_buildMacOs` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _buildMacOs(Directory outputDirectory) async { await _run('flutter', ['build', 'macos', '--release']); final releaseDir = Directory('build/macos/Build/Products/Release'); @@ -47,6 +52,8 @@ Future _buildMacOs(Directory outputDirectory) async { } } +/// Top-level helper that performs the `_buildWindows` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _buildWindows(Directory outputDirectory) async { await _run('flutter', ['build', 'windows', '--release']); final bundle = Directory('build/windows/x64/runner/Release'); @@ -69,6 +76,8 @@ Future _buildWindows(Directory outputDirectory) async { } } +/// Top-level helper that performs the `_buildLinux` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _buildLinux(Directory outputDirectory) async { await _run('flutter', ['build', 'linux', '--release']); await _run('appimage-builder', [ @@ -89,6 +98,8 @@ Future _buildLinux(Directory outputDirectory) async { } } +/// Top-level helper that performs the `_run` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _run(String executable, List arguments) async { stdout.writeln('> $executable ${arguments.join(' ')}'); final result = await Process.run( @@ -108,6 +119,8 @@ Future _run(String executable, List arguments) async { } } +/// Top-level helper that performs the `_copyDirectory` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _copyDirectory(Directory source, Directory destination) async { await destination.create(recursive: true); await for (final entity in source.list(recursive: false)) { @@ -120,6 +133,8 @@ Future _copyDirectory(Directory source, Directory destination) async { } } +/// Top-level helper that performs the `_currentPlatform` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _currentPlatform() { if (Platform.isMacOS) return 'macos'; if (Platform.isWindows) return 'windows'; @@ -127,11 +142,15 @@ String _currentPlatform() { throw UnsupportedError('No Flutter desktop build target for this platform.'); } +/// Top-level helper that performs the `_basename` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _basename(String path) { final normalized = path.replaceAll('\\', '/'); return normalized.substring(normalized.lastIndexOf('/') + 1); } +/// Top-level helper that performs the `_join` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _join(String first, String second) { final separator = Platform.pathSeparator; final left = first.endsWith(separator) @@ -141,6 +160,8 @@ String _join(String first, String second) { return '$left$separator$right'; } +/// Top-level helper that performs the `_option` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _option(List arguments, String name) { final index = arguments.indexOf(name); if (index == -1 || index + 1 >= arguments.length) { diff --git a/scripts/dev.dart b/scripts/dev.dart index b421233..9f84ca8 100644 --- a/scripts/dev.dart +++ b/scripts/dev.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Dev workspace script. library; @@ -46,6 +49,8 @@ Future main(List arguments) async { } } +/// Top-level helper that performs the `_watchForExit` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. void _watchForExit(ProcessSignal signal, int code, Process buildRunner) { signal.watch().listen((_) { buildRunner.kill(); @@ -53,6 +58,8 @@ void _watchForExit(ProcessSignal signal, int code, Process buildRunner) { }); } +/// Top-level helper that performs the `_start` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _start(String executable, List arguments) async { stdout.writeln('> $executable ${arguments.join(' ')}'); try { @@ -69,12 +76,16 @@ Future _start(String executable, List arguments) async { } } +/// Top-level helper that performs the `_defaultDesktopDevice` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String _defaultDesktopDevice() { if (Platform.isMacOS) return 'macos'; if (Platform.isWindows) return 'windows'; return 'linux'; } +/// Top-level helper that performs the `_option` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. String? _option(List arguments, String name) { final index = arguments.indexOf(name); if (index == -1 || index + 1 >= arguments.length) { diff --git a/scripts/dev.sh b/scripts/dev.sh index 8dd6556..7c504e3 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + # Compatibility wrapper for the Dart desktop development runner. set -euo pipefail diff --git a/scripts/package_release.sh b/scripts/package_release.sh index 5d70313..5ffc1fa 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + # Compatibility wrapper for the Dart release packaging runner. set -euo pipefail diff --git a/scripts/test.dart b/scripts/test.dart index bc20af4..5488a73 100644 --- a/scripts/test.dart +++ b/scripts/test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Test workspace script. library; @@ -25,9 +28,12 @@ Future main(List arguments) async { 'coverage/lcov.info', '80', ]); + await _run('dart', ['run', 'tool/check_reuse.dart']); await _run('dart', ['run', 'tool/check_docs.dart']); } +/// Top-level helper that performs the `_deleteCoverageDirectory` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _deleteCoverageDirectory() async { final coverage = Directory('coverage'); if (await coverage.exists()) { @@ -35,6 +41,8 @@ Future _deleteCoverageDirectory() async { } } +/// Top-level helper that performs the `_run` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _run(String executable, List arguments) async { final command = '$executable ${arguments.join(' ')}'; stdout.writeln('> $command'); diff --git a/scripts/test.sh b/scripts/test.sh index ab1acb8..821bef6 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + set -euo pipefail dart analyze diff --git a/test/scheduler_core_test.dart b/test/scheduler_core_test.dart index 0c57b88..bba78f8 100644 --- a/test/scheduler_core_test.dart +++ b/test/scheduler_core_test.dart @@ -70,6 +70,8 @@ import '../packages/scheduler_persistence_memory/test/memory_repository_conforma import '../packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart' as sqlite_repository_conformance_test; +/// Entry point for this executable Dart file. +/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API. void main() { encrypted_backup_test.main(); application_commands_test.main(); diff --git a/tool/check_coverage.dart b/tool/check_coverage.dart index fca1692..aa28aa7 100644 --- a/tool/check_coverage.dart +++ b/tool/check_coverage.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Check Coverage workspace validation tool. library; @@ -45,6 +48,8 @@ void main(List arguments) { } } +/// Runs the `this operation` top-level operation. +/// This function is part of the file-level API and gives callers a direct way to use the associated scheduler behavior. ({int coveredLines, int totalLines, double percent}) _readCoverage(File file) { var includeFile = false; var coveredLines = 0; @@ -77,6 +82,8 @@ void main(List arguments) { ); } +/// Top-level helper that performs the `_includeSourceFile` 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 _includeSourceFile(String path) { final normalized = path.replaceAll('\\', '/'); return normalized.contains('/packages/') && diff --git a/tool/check_docs.dart b/tool/check_docs.dart index 2ec8a75..222b63a 100644 --- a/tool/check_docs.dart +++ b/tool/check_docs.dart @@ -1,10 +1,25 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Runs the Check Docs workspace validation tool. library; import 'dart:io'; -/// Generates Dart docs for each package with public libraries and fails on warnings. +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/source/line_info.dart'; + +/// Generates Dart docs for each package and fails on documentation gaps. +/// +/// This tool is intentionally stricter than `dart doc` alone. It checks that +/// tracked Dart files have file-level library docs where appropriate and that +/// every declaration-like API surface has a Dartdoc comment, including private +/// helpers and part files. That keeps internal scheduler behavior readable for +/// future contributors instead of relying on project history or tribal context. Future main(List arguments) async { + final skipDartdoc = arguments.contains('--skip-dartdoc'); final missingFileDocs = await _trackedDartFilesMissingFileDocs(); if (missingFileDocs.isNotEmpty) { stderr.writeln('Dart files missing file-level Dartdoc library headers:'); @@ -13,6 +28,25 @@ Future main(List arguments) async { } } + final missingDeclarationDocs = + await _trackedDartFilesMissingDeclarationDocs(); + if (missingDeclarationDocs.isNotEmpty) { + stderr.writeln('Dart declarations missing Dartdoc comments:'); + for (final missing in missingDeclarationDocs) { + stderr.writeln('- $missing'); + } + } + + if (skipDartdoc) { + if (missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty) { + stderr.writeln('Dart documentation quick gate failed.'); + exitCode = 1; + } else { + stdout.writeln('Dart documentation quick gate passed.'); + } + return; + } + final outputRoot = Directory(_join(_join('doc', 'api'), 'packages')); if (await outputRoot.exists()) { await outputRoot.delete(recursive: true); @@ -28,7 +62,7 @@ Future main(List arguments) async { }).toList(growable: false) ..sort((left, right) => left.path.compareTo(right.path)); - var failed = missingFileDocs.isNotEmpty; + var failed = missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty; for (final package in packages) { final outputPath = _join(outputRoot.absolute.path, _basename(package.path)); stdout.writeln('> dart doc --output $outputPath (${package.path})'); @@ -51,7 +85,40 @@ Future main(List arguments) async { } } -Future> _trackedDartFilesMissingFileDocs() async { +/// Finds tracked Dart declarations that do not have Dartdoc comments. +/// +/// The audit includes classes, mixins, enums, enum values, extensions, +/// extension types, constructors, methods, top-level functions, fields, and +/// top-level variables. Generated files are skipped because their source of +/// truth is the generator configuration, not hand-maintained comments. +Future> _trackedDartFilesMissingDeclarationDocs() async { + final paths = await _trackedDartFiles(); + final missing = []; + + for (final path in paths) { + final file = File(path); + final contents = await file.readAsString(); + if (_isGenerated(path, contents)) { + continue; + } + final result = parseString( + content: contents, + path: file.absolute.path, + throwIfDiagnostics: false, + ); + final visitor = _DeclarationDocVisitor(path, result.unit.lineInfo); + result.unit.accept(visitor); + missing.addAll(visitor.missing); + } + + return missing; +} + +/// Returns tracked Dart files in stable path order. +/// +/// Git is the source of truth so local build outputs, generated docs, and +/// editor scratch files do not affect the documentation gate. +Future> _trackedDartFiles() async { final result = await Process.run( 'git', ['ls-files', '*.dart'], @@ -66,12 +133,20 @@ Future> _trackedDartFilesMissingFileDocs() async { ); } - final paths = (result.stdout as String) + return (result.stdout as String) .split('\n') .where((path) => path.trim().isNotEmpty) .toList(growable: false) ..sort(); +} +/// Finds tracked Dart files that are missing file-level library docs. +/// +/// Part files and generated files are excluded from this specific check because +/// they cannot declare their own `library;` header. Their declarations are still +/// covered by the declaration-level audit above. +Future> _trackedDartFilesMissingFileDocs() async { + final paths = await _trackedDartFiles(); final missing = []; for (final path in paths) { final file = File(path); @@ -86,25 +161,185 @@ Future> _trackedDartFilesMissingFileDocs() async { return missing; } +/// Reports whether a file is generated or a library part. +/// +/// SPDX headers may appear before `part of`, so the check removes the standard +/// leading SPDX block before identifying part files. bool _isGeneratedPart(String path, String contents) { - return path.endsWith('.g.dart') || contents.startsWith("part of '"); + final source = _stripLeadingSpdxHeader(contents).trimLeft(); + return _isGenerated(path, contents) || source.startsWith('part of '); } +/// Reports whether a file should be treated as generated source. +/// +/// Generated sources are not edited by hand, so enforcing hand-written docs on +/// them would create churn every time the generator runs. +bool _isGenerated(String path, String contents) { + return path.endsWith('.g.dart') || + contents.contains('// GENERATED CODE - DO NOT MODIFY BY HAND'); +} + +/// Reports whether a Dart file starts with a Dartdoc library header. +/// +/// SPDX headers are allowed to precede the docs so files can satisfy both REUSE +/// metadata conventions and Dartdoc's library-level documentation expectations. bool _hasFileLevelLibraryDocs(String contents) { - return RegExp(r'^\s*///[\s\S]*?\nlibrary;', multiLine: false) - .hasMatch(contents); + final source = _stripLeadingSpdxHeader(contents); + return RegExp( + r'^\s*///[\s\S]*?\nlibrary;', + multiLine: false, + ).hasMatch(source); } +/// Removes the standard leading SPDX comment block from a Dart source string. +/// +/// The function deliberately handles only SPDX and copyright line comments at +/// the top of the file; ordinary explanatory comments remain visible to the +/// file-level documentation check. +String _stripLeadingSpdxHeader(String contents) { + return contents.replaceFirst( + RegExp( + r'^(?:(?://\s*SPDX-[^\n]*|//\s*Copyright[^\n]*|//\s*$)\n)*', + multiLine: false, + ), + '', + ); +} + +/// Visits Dart declarations and records any declaration without Dartdoc. +/// +/// The visitor intentionally ignores local variables and local functions inside +/// method bodies. Those are implementation details where inline comments should +/// remain rare and reserved for unusually complex logic. +class _DeclarationDocVisitor extends RecursiveAstVisitor { + /// Creates a visitor for one parsed Dart file. + /// + /// The [lineInfo] object is reused for every finding so error messages can + /// point directly at the declaration line that needs documentation. + _DeclarationDocVisitor(this.path, this.lineInfo); + + /// Repository-relative path for the file currently being audited. + final String path; + + /// Line lookup table produced by the analyzer parser. + final LineInfo lineInfo; + + /// Human-readable documentation findings for this file. + final List missing = []; + + /// Checks class declarations for Dartdoc. + @override + void visitClassDeclaration(ClassDeclaration node) { + _check(node, 'class'); + super.visitClassDeclaration(node); + } + + /// Checks mixin declarations for Dartdoc. + @override + void visitMixinDeclaration(MixinDeclaration node) { + _check(node, 'mixin'); + super.visitMixinDeclaration(node); + } + + /// Checks enum declarations for Dartdoc. + @override + void visitEnumDeclaration(EnumDeclaration node) { + _check(node, 'enum'); + super.visitEnumDeclaration(node); + } + + /// Checks enum constants for Dartdoc. + @override + void visitEnumConstantDeclaration(EnumConstantDeclaration node) { + _check(node, 'enum value'); + super.visitEnumConstantDeclaration(node); + } + + /// Checks extension declarations for Dartdoc. + @override + void visitExtensionDeclaration(ExtensionDeclaration node) { + _check(node, 'extension'); + super.visitExtensionDeclaration(node); + } + + /// Checks extension type declarations for Dartdoc. + @override + void visitExtensionTypeDeclaration(ExtensionTypeDeclaration node) { + _check(node, 'extension type'); + super.visitExtensionTypeDeclaration(node); + } + + /// Checks constructor declarations for Dartdoc. + @override + void visitConstructorDeclaration(ConstructorDeclaration node) { + _check(node, 'constructor'); + super.visitConstructorDeclaration(node); + } + + /// Checks class, mixin, extension, and enum methods for Dartdoc. + @override + void visitMethodDeclaration(MethodDeclaration node) { + _check(node, 'method'); + super.visitMethodDeclaration(node); + } + + /// Checks only top-level functions for Dartdoc. + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + if (node.parent is CompilationUnit) { + _check(node, 'top-level function'); + } + super.visitFunctionDeclaration(node); + } + + /// Checks instance and static fields for Dartdoc. + @override + void visitFieldDeclaration(FieldDeclaration node) { + _check(node, 'field'); + super.visitFieldDeclaration(node); + } + + /// Checks top-level variables for Dartdoc. + @override + void visitTopLevelVariableDeclaration(TopLevelVariableDeclaration node) { + _check(node, 'top-level variable'); + super.visitTopLevelVariableDeclaration(node); + } + + /// Adds a missing-doc finding for [node] when it has no documentation comment. + void _check(Declaration node, String kind) { + if (node.documentationComment != null) { + return; + } + final location = lineInfo.getLocation(node.offset); + final summary = node.toSource().split('\n').first.trim(); + missing.add('$path:${location.lineNumber}: $kind `$summary`'); + } +} + +/// Reports whether `dart doc` produced warning text. +/// +/// Dartdoc may return success while still reporting warnings, and this project +/// treats warnings as documentation failures so broken references are caught +/// before they reach CI or generated docs. bool _hasWarnings(String output) { return output.contains(' warning:') || RegExp(r'Found [1-9][0-9]* warnings').hasMatch(output); } +/// Joins two path segments using the platform separator. +/// +/// The helper avoids pulling in another dependency for the small amount of path +/// handling needed by this repository-local script. String _join(String first, String second) { final separator = Platform.pathSeparator; return '$first$separator$second'; } +/// Returns the final path segment for a platform-neutral path string. +/// +/// Dartdoc output directory names use package basenames, so this helper keeps +/// the script independent of whether Git produced slash or backslash paths. String _basename(String path) { final normalized = path.replaceAll('\\', '/'); return normalized.substring(normalized.lastIndexOf('/') + 1); diff --git a/tool/check_reuse.dart b/tool/check_reuse.dart new file mode 100644 index 0000000..84e3c23 --- /dev/null +++ b/tool/check_reuse.dart @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Runs the local SPDX and REUSE metadata validation tool. +library; + +import 'dart:io'; + +/// SPDX expression used by package and app source metadata. +/// +/// The top-level `LICENSE.md` contains the GNU Affero General Public License +/// version 3 text, so the repository uses the corresponding SPDX identifier. +const _licenseIdentifier = 'AGPL-3.0-only'; + +/// Repository-local copyright text used in SPDX headers. +/// +/// The project uses a contributor-oriented copyright holder because individual +/// source files do not currently carry more specific ownership notices. +const _copyrightText = '2026 FocusFlow contributors'; + +/// Package directories that must include a pointer to the root license file. +/// +/// Each Dart package is publishable as its own unit even though publishing is +/// disabled, so every package folder carries a small `LICENSE.md` reference. +const _packageDirectories = [ + 'packages/scheduler_backup', + 'packages/scheduler_core', + 'packages/scheduler_export', + 'packages/scheduler_export_json', + 'packages/scheduler_integration_tests', + 'packages/scheduler_notifications', + 'packages/scheduler_notifications_desktop', + 'packages/scheduler_persistence', + 'packages/scheduler_persistence_memory', + 'packages/scheduler_persistence_sqlite', +]; + +/// App directories that must include a pointer to the root license file. +/// +/// The Flutter app is outside the root Dart workspace, but it still ships with +/// repository code and therefore follows the same licensing metadata rules. +const _appDirectories = ['apps/focus_flow_flutter']; + +/// Validates SPDX headers, REUSE metadata, and package/app license pointers. +void main(List arguments) { + final failures = [ + ..._reuseTomlFailures(), + ..._licensePointerFailures(), + ..._spdxHeaderFailures(), + ]; + + if (failures.isEmpty) { + stdout.writeln('SPDX and REUSE metadata checks passed.'); + return; + } + + stderr.writeln('SPDX and REUSE metadata checks failed:'); + for (final failure in failures) { + stderr.writeln('- $failure'); + } + exitCode = 1; +} + +/// Checks that `REUSE.toml` exists and covers package/app trees. +/// +/// This does not replace the upstream REUSE tool, but it keeps the repository's +/// expected local metadata shape from drifting when contributors add files. +List _reuseTomlFailures() { + final file = File('REUSE.toml'); + if (!file.existsSync()) { + return ['REUSE.toml is missing.']; + } + + final contents = file.readAsStringSync(); + final failures = []; + for (final required in [ + 'packages/**', + 'apps/focus_flow_flutter/**', + 'SPDX-License-Identifier = "$_licenseIdentifier"', + 'SPDX-FileCopyrightText = "$_copyrightText"', + ]) { + if (!contents.contains(required)) { + failures.add('REUSE.toml does not contain `$required`.'); + } + } + return failures; +} + +/// Checks package and app `LICENSE.md` pointer files. +/// +/// Pointer files keep each package understandable when opened in isolation while +/// still making the root `LICENSE.md` the single license text source. +List _licensePointerFailures() { + final failures = []; + for (final directory in [ + ..._packageDirectories, + ..._appDirectories + ]) { + final file = File('$directory/LICENSE.md'); + if (!file.existsSync()) { + failures.add('$directory/LICENSE.md is missing.'); + continue; + } + final contents = file.readAsStringSync(); + if (!contents.contains('../../LICENSE.md')) { + failures + .add('$directory/LICENSE.md does not reference ../../LICENSE.md.'); + } + if (!contents.contains('SPDX-License-Identifier: $_licenseIdentifier')) { + failures.add('$directory/LICENSE.md is missing its SPDX license header.'); + } + } + return failures; +} + +/// Checks safe text files for inline SPDX headers. +/// +/// Binary files, JSON resources, generated platform project files, and XML/plist +/// files are covered by `REUSE.toml` annotations because adding comments to +/// those formats can either be invalid or be overwritten by platform tooling. +List _spdxHeaderFailures() { + final failures = []; + for (final path in _trackedFiles()) { + if (!_requiresInlineSpdxHeader(path)) { + continue; + } + final contents = File(path).readAsStringSync(); + if (!contents.contains('SPDX-License-Identifier: $_licenseIdentifier')) { + failures.add('$path is missing SPDX-License-Identifier.'); + } + if (!contents.contains('SPDX-FileCopyrightText: $_copyrightText')) { + failures.add('$path is missing SPDX-FileCopyrightText.'); + } + } + return failures; +} + +/// Returns tracked repository files in stable path order. +/// +/// Git is used so ignored build output and local editor files do not affect the +/// metadata check. +List _trackedFiles() { + final result = Process.runSync( + 'git', + ['ls-files'], + runInShell: Platform.isWindows, + ); + if (result.exitCode != 0) { + throw ProcessException( + 'git', + ['ls-files'], + 'Unable to list tracked files.', + result.exitCode, + ); + } + return (result.stdout as String) + .split('\n') + .where((path) => path.trim().isNotEmpty) + .toList(growable: false) + ..sort(); +} + +/// Reports whether [path] should carry an inline SPDX header. +/// +/// The allow-list is intentionally conservative and includes only formats where +/// adding a leading comment is safe and unlikely to break generated tooling. +bool _requiresInlineSpdxHeader(String path) { + if (!_isComplianceScope(path)) { + return false; + } + if (path.endsWith('.g.dart') || path.endsWith('LICENSE.md')) { + return false; + } + return path.endsWith('.dart') || + path.endsWith('.yaml') || + path.endsWith('.yml') || + path.endsWith('.sh') || + path.endsWith('.md'); +} + +/// Reports whether [path] belongs to the checked compliance surface. +/// +/// The package and app folders are the main requested scope; root scripts and +/// tool files are included because they enforce the same standards. +bool _isComplianceScope(String path) { + return path.startsWith('packages/') || + path.startsWith('apps/focus_flow_flutter/') || + path.startsWith('scripts/') || + path.startsWith('tool/') || + path == 'AGENTS.md' || + path == 'Focus Flow Contributors.md' || + path == 'pubspec.yaml' || + path == 'analysis_options.yaml' || + path == '.github/workflows/ci.yml'; +}