From f985b3ee841d886f01a13c3142e239ab1aa8cfd4 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Mon, 29 Jun 2026 21:27:34 -0700 Subject: [PATCH 01/16] docs: document focus flow flutter app --- apps/focus_flow_flutter/analysis_options.yaml | 1 + .../lib/app/demo_scheduler_composition.dart | 25 +++++++ .../lib/app/focus_flow_app.dart | 6 ++ .../scheduler_command_controller.dart | 31 ++++++++ .../scheduler_read_controller.dart | 31 ++++++++ .../controllers/today_screen_controller.dart | 21 ++++++ apps/focus_flow_flutter/lib/main.dart | 1 + .../lib/models/today_screen_models.dart | 64 +++++++++++++++++ .../lib/theme/focus_flow_theme.dart | 2 + .../lib/theme/focus_flow_tokens.dart | 71 +++++++++++++++++++ .../lib/theme/scheduler_visual_tokens.dart | 8 +++ .../lib/widgets/app_shell.dart | 5 ++ .../lib/widgets/backlog_pane.dart | 16 +++++ .../lib/widgets/read_state_view.dart | 27 +++++++ .../lib/widgets/required_banner.dart | 3 + .../lib/widgets/schedule_components.dart | 20 ++++++ .../lib/widgets/sidebar.dart | 2 + .../lib/widgets/task_selection_modal.dart | 5 ++ .../lib/widgets/timeline/difficulty_bars.dart | 9 +++ .../lib/widgets/timeline/reward_icon.dart | 5 ++ .../widgets/timeline/task_timeline_card.dart | 5 ++ .../lib/widgets/timeline/timeline_axis.dart | 6 ++ .../widgets/timeline/timeline_geometry.dart | 24 +++++++ .../lib/widgets/timeline/timeline_view.dart | 7 ++ .../lib/widgets/today_pane.dart | 10 +++ .../lib/widgets/top_bar.dart | 3 + .../test/forbidden_imports_test.dart | 1 + apps/focus_flow_flutter/test/widget_test.dart | 1 + 28 files changed, 410 insertions(+) diff --git a/apps/focus_flow_flutter/analysis_options.yaml b/apps/focus_flow_flutter/analysis_options.yaml index 0d29021..74a6706 100644 --- a/apps/focus_flow_flutter/analysis_options.yaml +++ b/apps/focus_flow_flutter/analysis_options.yaml @@ -21,6 +21,7 @@ linter: # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: + public_member_api_docs: 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/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 7672641..6bb91ed 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -7,6 +7,7 @@ import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_read_controller.dart'; import '../controllers/today_screen_controller.dart'; +/// Wires the Flutter demo UI to in-memory scheduler application use cases. class DemoSchedulerComposition { DemoSchedulerComposition._({ required this.store, @@ -17,19 +18,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; + /// 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 +106,7 @@ class DemoSchedulerComposition { ); } + /// Creates the controller used by the compact Today screen mockup. TodayScreenController createTodayScreenController() { return TodayScreenController( read: () { @@ -101,6 +121,7 @@ class DemoSchedulerComposition { ); } + /// Creates a generic read controller for Today-state panes. UiReadController createTodayController() { return ApplicationReadController( read: () { @@ -118,6 +139,7 @@ class DemoSchedulerComposition { ); } + /// Creates a generic read controller for backlog panes. UiReadController createBacklogController() { return ApplicationReadController( read: () { @@ -132,6 +154,7 @@ class DemoSchedulerComposition { ); } + /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, }) { @@ -143,6 +166,7 @@ class DemoSchedulerComposition { ); } + /// Creates an application operation context for the supplied [operationId]. ApplicationOperationContext context(String operationId) { return ApplicationOperationContext.start( operationId: operationId, @@ -155,6 +179,7 @@ class DemoSchedulerComposition { ); } + /// Formats a civil date as a long month/day/year label. static String formatDateLabel(CivilDate date) { const months = [ 'January', 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..af749f1 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -15,9 +15,12 @@ import '../widgets/timeline/timeline_view.dart'; import '../widgets/top_bar.dart'; import 'demo_scheduler_composition.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; @override @@ -31,9 +34,12 @@ class FocusFlowApp extends StatelessWidget { } } +/// 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; @override 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..638af12 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -4,37 +4,55 @@ library; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; +/// Builds an application operation context for a UI command operation. typedef OperationContextFactory = ApplicationOperationContext Function(String operationId); +/// Refreshes any reads that should reflect a completed command. typedef ReadRefresh = Future Function(); +/// 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) { @@ -44,7 +62,9 @@ class SchedulerCommandFailure extends SchedulerCommandState { } } +/// 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, @@ -52,15 +72,24 @@ class SchedulerCommandController extends ChangeNotifier { required this.refreshReads, }); + /// 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; var _sequence = 0; 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', @@ -76,6 +105,7 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Schedules a backlog task into the next available slot. Future scheduleBacklogItem({ required String taskId, required int durationMinutes, @@ -97,6 +127,7 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Completes a planned flexible task from the Today view. Future completeFlexibleTask({ required String taskId, DateTime? expectedUpdatedAt, 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..f6e5d38 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart @@ -4,43 +4,66 @@ 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 +73,24 @@ 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; 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,6 +112,7 @@ class ApplicationReadController extends UiReadController { } } + /// Retries by running [load] again. @override Future retry() => load(); 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..225d5cb 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -6,44 +6,63 @@ 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; TodayScreenState _state = const TodayScreenLoading(); 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(); @@ -67,6 +86,7 @@ class TodayScreenController extends ChangeNotifier { } } + /// Selects [card] when the card allows selection. void selectCard(TimelineCardModel card) { if (!card.isSelectable) { return; @@ -75,6 +95,7 @@ class TodayScreenController extends ChangeNotifier { notifyListeners(); } + /// Clears the selected timeline card. void clearSelection() { if (_selectedCard == null) { return; diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index e1262e0..1532609 100644 --- a/apps/focus_flow_flutter/lib/main.dart +++ b/apps/focus_flow_flutter/lib/main.dart @@ -9,6 +9,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_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index 839ba9f..905826a 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -3,32 +3,54 @@ library; import 'package:scheduler_core/scheduler_core.dart'; +/// Presentation model for the next-required-task banner. class RequiredBannerModel { + /// Creates a required banner model. const RequiredBannerModel({required this.title, required this.timeText}); + /// Title of the next required task. final String title; + + /// Display-ready time for the next required task. final String timeText; } +/// 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; } +/// 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, } +/// Presentation model for the compact Today screen. class TodayScreenData { + /// Creates Today screen data. const TodayScreenData({ required this.dateLabel, required this.timelineRange, @@ -36,6 +58,7 @@ class TodayScreenData { this.requiredBanner, }); + /// Creates an empty Today screen model for [dateLabel]. factory TodayScreenData.empty({required String dateLabel}) { return TodayScreenData( dateLabel: dateLabel, @@ -44,6 +67,7 @@ class TodayScreenData { ); } + /// Maps scheduler core [TodayState] into UI presentation data. factory TodayScreenData.fromTodayState(TodayState state) { final cards = [ for (final item in state.timelineItems) TimelineCardModel.fromItem(item), @@ -62,18 +86,28 @@ class TodayScreenData { ); } + /// Default visible range for the compact timeline. static const defaultRange = TimelineRangeModel( startMinutes: 16 * 60, endMinutes: 20 * 60 + 45, ); + /// 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; } +/// Presentation model for a single compact timeline card. class TimelineCardModel { + /// Creates a timeline card model. const TimelineCardModel({ required this.id, required this.title, @@ -91,6 +125,7 @@ class TimelineCardModel { this.durationMinutes, }); + /// Maps a scheduler timeline item into card presentation data. factory TimelineCardModel.fromItem(TodayTimelineItem item) { final start = item.start; final end = item.end; @@ -121,21 +156,49 @@ class TimelineCardModel { ); } + /// 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; + + /// 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; + + /// 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; + /// Accessibility and modal label for the reward icon. String get rewardLabel { return switch (rewardIconToken) { TimelineRewardIconToken.notSet => 'Reward not set', @@ -147,6 +210,7 @@ class TimelineCardModel { }; } + /// Accessibility and modal label for the effort icon. String get effortLabel { return switch (difficultyIconToken) { TimelineDifficultyIconToken.notSet => 'Effort not set', 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..57ed6de 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart @@ -5,7 +5,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..d6ab29a 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart @@ -3,48 +3,119 @@ 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; + + /// Border radius for timeline cards. static const cardRadius = 8.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; + + /// Font size for sidebar navigation labels. static const sidebarNavSize = 18.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..00a8b97 100644 --- a/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/scheduler_visual_tokens.dart @@ -4,7 +4,9 @@ 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 +15,7 @@ abstract final class SchedulerVisualTokens { }; } + /// Returns the background color associated with a timeline background token. static Color backgroundColor( TimelineBackgroundToken token, ColorScheme scheme, @@ -27,6 +30,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 +42,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 +54,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 +66,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 +75,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..6c94d90 100644 --- a/apps/focus_flow_flutter/lib/widgets/app_shell.dart +++ b/apps/focus_flow_flutter/lib/widgets/app_shell.dart @@ -5,10 +5,15 @@ 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; @override diff --git a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart b/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart index 11d8216..9d56f95 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart @@ -9,14 +9,19 @@ 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; @override @@ -32,14 +37,19 @@ 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; @override @@ -73,9 +83,12 @@ 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; @override @@ -121,9 +134,12 @@ 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; @override 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..8ffd8ec 100644 --- a/apps/focus_flow_flutter/lib/widgets/read_state_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/read_state_view.dart @@ -5,7 +5,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,10 +17,19 @@ 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; @override @@ -52,7 +63,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,8 +73,13 @@ 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; @override @@ -97,7 +115,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,9 +126,16 @@ 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; @override diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index de60125..3a2c83f 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -6,9 +6,12 @@ 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 { + /// Creates a required-task banner. const RequiredBanner({this.model, super.key}); + /// Optional required task model to render. final RequiredBannerModel? model; @override diff --git a/apps/focus_flow_flutter/lib/widgets/schedule_components.dart b/apps/focus_flow_flutter/lib/widgets/schedule_components.dart index ed46286..1aae2db 100644 --- a/apps/focus_flow_flutter/lib/widgets/schedule_components.dart +++ b/apps/focus_flow_flutter/lib/widgets/schedule_components.dart @@ -6,9 +6,12 @@ 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; @override @@ -28,10 +31,15 @@ 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; @override @@ -85,10 +93,15 @@ 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; @override @@ -154,9 +167,12 @@ 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; @override @@ -168,9 +184,12 @@ 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; @override @@ -187,6 +206,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; diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index 329b7df..fce3bbd 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -5,7 +5,9 @@ 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}); @override 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..b7fd4de 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -8,14 +8,19 @@ import '../theme/focus_flow_tokens.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.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, super.key, }); + /// Card model whose details and actions should be displayed. final TimelineCardModel card; + + /// Callback invoked when the modal should close. final VoidCallback onClose; @override 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..aa9926b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart @@ -6,7 +6,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 +16,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 +37,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, 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..2586f53 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart @@ -5,14 +5,19 @@ 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; @override 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..d7046a1 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 @@ -8,10 +8,15 @@ import '../../theme/focus_flow_tokens.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; +/// Interactive card that renders a scheduled task on the compact timeline. class TaskTimelineCard extends StatelessWidget { + /// Creates a task timeline card. const TaskTimelineCard({required this.card, required this.onTap, super.key}); + /// Card model to render. final TimelineCardModel card; + + /// Callback invoked when the card is tapped. final VoidCallback onTap; @override 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..3858515 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -6,9 +6,12 @@ import 'package:flutter/material.dart'; import '../../theme/focus_flow_tokens.dart'; import 'timeline_geometry.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, super.key}); + /// Geometry used to position labels and tick marks. final TimelineGeometry geometry; @override @@ -60,9 +63,12 @@ class TimelineAxis extends StatelessWidget { } } +/// Background grid aligned to timeline tick marks. class TimelineGrid extends StatelessWidget { + /// Creates a timeline grid for [geometry]. const TimelineGrid({required this.geometry, super.key}); + /// Geometry used to position grid lines. final TimelineGeometry geometry; @override 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..e36478f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -3,7 +3,9 @@ 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, @@ -11,26 +13,39 @@ class TimelineGeometry { this.minCardHeight = 72, }); + /// First visible time on the timeline. final TimeOfDay visibleStart; + + /// Last visible time on the timeline. final TimeOfDay visibleEnd; + + /// Vertical scale used to convert minutes into pixels. final double pixelsPerMinute; + + /// Minimum rendered height for a timeline card. final double minCardHeight; + /// First visible minute from midnight. int get startMinutes => visibleStart.hour * 60 + visibleStart.minute; + /// Last visible minute from midnight. 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 ( @@ -60,7 +75,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 +85,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..a32e786 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -9,7 +9,9 @@ import 'task_timeline_card.dart'; import 'timeline_axis.dart'; import 'timeline_geometry.dart'; +/// Scrollable compact timeline view for Today cards. class TimelineView extends StatelessWidget { + /// Creates a timeline view. const TimelineView({ required this.cards, required this.range, @@ -17,8 +19,13 @@ class TimelineView extends StatelessWidget { 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; @override diff --git a/apps/focus_flow_flutter/lib/widgets/today_pane.dart b/apps/focus_flow_flutter/lib/widgets/today_pane.dart index 0c0ff33..de0ec10 100644 --- a/apps/focus_flow_flutter/lib/widgets/today_pane.dart +++ b/apps/focus_flow_flutter/lib/widgets/today_pane.dart @@ -9,14 +9,19 @@ 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; @override @@ -32,10 +37,15 @@ 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; @override diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index 6c52cb9..f779190 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -5,9 +5,12 @@ import 'package:flutter/material.dart'; import '../theme/focus_flow_tokens.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; @override diff --git a/apps/focus_flow_flutter/test/forbidden_imports_test.dart b/apps/focus_flow_flutter/test/forbidden_imports_test.dart index 93e2390..2aaeaa6 100644 --- a/apps/focus_flow_flutter/test/forbidden_imports_test.dart +++ b/apps/focus_flow_flutter/test/forbidden_imports_test.dart @@ -5,6 +5,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; diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 2824db8..3b111f0 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -10,6 +10,7 @@ import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; +/// Runs FocusFlow widget and visual adapter tests. void main() { testWidgets('app shell renders compact Today frame', (tester) async { await _pumpPlanApp(tester); From dacd1894352d6ac0ac50e161fc5618bff2c148c7 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:09:48 -0700 Subject: [PATCH 02/16] fix: scale timeline task card contents --- .../widgets/timeline/task_timeline_card.dart | 325 +++++++++++++----- apps/focus_flow_flutter/test/widget_test.dart | 92 +++++ 2 files changed, 324 insertions(+), 93 deletions(-) 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 d7046a1..9e4ff41 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,8 @@ /// 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'; @@ -22,87 +24,198 @@ class TaskTimelineCard extends StatelessWidget { @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, + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _CardMetrics.fromConstraints( + constraints, + kind: card.visualKind, + ); + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(card.id), + borderRadius: BorderRadius.circular(metrics.cardRadius), + onTap: onTap, + child: Container( + padding: metrics.padding, + 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: Row( + children: [ + _StatusRing( + card: card, + color: colors.accent, + metrics: metrics, + ), + SizedBox(width: metrics.statusGap), + Expanded( + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), + ), + if (card.showsQuickActions) ...[ + _NoOpIcon( + icon: Icons.check, + color: colors.accent, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.arrow_forward, + color: colors.accent, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.calendar_today, + color: colors.accent, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.wb_sunny_outlined, + color: colors.accent, + metrics: metrics, + ), + SizedBox(width: metrics.actionGap), + ], + _Badge( + metrics: metrics, + child: RewardIcon( + color: colors.reward, + size: metrics.rewardIconSize, + ), + ), + SizedBox(width: metrics.badgeGap), + _Badge( + metrics: metrics, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), + ), + ], + ), + ), ), - ), - ), + ); + }, ); } } +class _CardMetrics { + const _CardMetrics(this.scale); + + static const _referenceWidth = 640.0; + static const _referenceHeight = 88.0; + static const _freeSlotReferenceHeight = 108.0; + static const _minimumScale = 0.62; + + 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.toDouble()); + } + + final double scale; + + double get cardRadius => FocusFlowTokens.cardRadius * scale; + double get borderWidth => math.max(1, 1.2 * scale); + double get shadowBlur => 18 * scale; + double get shadowSpread => scale; + double get statusRingSize => 34 * scale; + double get statusBorderWidth => math.max(1.2, 3 * scale); + double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); + double get statusIconSize => 24 * scale; + double get statusGap => 22 * scale; + double get actionButtonSize => 40 * scale; + double get actionIconSize => 22 * scale; + double get actionGap => 10 * scale; + double get badgeWidth => 62 * scale; + double get badgeHeight => 48 * scale; + double get badgeRadius => 7 * scale; + double get badgeGap => 8 * scale; + double get rewardIconSize => 26 * scale; + double get titleSize => FocusFlowTokens.cardTitleSize * scale; + double get metaSize => FocusFlowTokens.cardMetaSize * scale; + double get titleMetaGap => 4 * scale; + double get freeSlotTimeGap => 10 * scale; + Size get difficultySize => Size(54 * scale, 34 * scale); + EdgeInsets get padding => + EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); +} + class _StatusRing extends StatelessWidget { - const _StatusRing({required this.card, required this.color}); + const _StatusRing({ + required this.card, + required this.color, + required this.metrics, + }); final TimelineCardModel card; final Color color; + final _CardMetrics metrics; @override Widget build(BuildContext context) { final border = Border.all( color: color, - width: card.visualKind == TaskVisualKind.freeSlot ? 2 : 3, + width: card.visualKind == TaskVisualKind.freeSlot + ? metrics.freeSlotStatusBorderWidth + : metrics.statusBorderWidth, style: card.visualKind == TaskVisualKind.freeSlot ? BorderStyle.solid : BorderStyle.solid, ); return Container( - width: 34, - height: 34, + width: metrics.statusRingSize, + height: metrics.statusRingSize, decoration: BoxDecoration(shape: BoxShape.circle, border: border), child: card.isCompleted - ? Icon(Icons.check, color: color, size: 24) + ? Icon(Icons.check, color: color, size: metrics.statusIconSize) : null, ); } } class _CardText extends StatelessWidget { - const _CardText({required this.card, required this.color}); + const _CardText({ + required this.card, + required this.color, + required this.metrics, + }); final TimelineCardModel card; final Color color; + final _CardMetrics metrics; @override Widget build(BuildContext context) { @@ -110,85 +223,111 @@ class _CardText extends StatelessWidget { color: card.isCompleted ? FocusFlowTokens.completedText : FocusFlowTokens.textPrimary, - fontSize: FocusFlowTokens.cardTitleSize, + fontSize: metrics.titleSize, fontWeight: FontWeight.w800, decoration: card.isCompleted ? TextDecoration.lineThrough : null, decorationColor: FocusFlowTokens.completedText, - decorationThickness: 2, + decorationThickness: 2 * metrics.scale, ); - 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, + 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), + ); + }, ); } } class _NoOpIcon extends StatelessWidget { - const _NoOpIcon({required this.icon, required this.color}); + const _NoOpIcon({ + required this.icon, + required this.color, + required this.metrics, + }); final IconData icon; final Color color; + final _CardMetrics metrics; @override Widget build(BuildContext context) { return IconButton( onPressed: () {}, + constraints: BoxConstraints.tightFor( + width: metrics.actionButtonSize, + height: metrics.actionButtonSize, + ), color: color, + iconSize: metrics.actionIconSize, + padding: EdgeInsets.zero, icon: Icon(icon), tooltip: 'No-op action', + visualDensity: VisualDensity.compact, ); } } class _Badge extends StatelessWidget { - const _Badge({required this.child}); + const _Badge({required this.child, required this.metrics}); final Widget child; + final _CardMetrics metrics; @override Widget build(BuildContext context) { return Container( - width: 62, - height: 48, + width: metrics.badgeWidth, + height: metrics.badgeHeight, alignment: Alignment.center, decoration: BoxDecoration( color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84), - borderRadius: BorderRadius.circular(7), + borderRadius: BorderRadius.circular(metrics.badgeRadius), border: Border.all(color: FocusFlowTokens.subtleBorder), ), child: child, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 3b111f0..90960f2 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -9,6 +9,7 @@ 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/widgets/timeline/difficulty_bars.dart'; +import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; /// Runs FocusFlow widget and visual adapter tests. void main() { @@ -109,6 +110,48 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.text('Pay bill'), findsOneWidget); }); + + 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); + + final button = tester.widget( + find.widgetWithIcon(IconButton, Icons.arrow_forward), + ); + expect(button.iconSize, lessThan(18)); + expect(button.constraints?.maxWidth, lessThan(32)); + }); + + 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.text('Intentional rest'), findsOneWidget); + expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget); + }); } Future _pumpPlanApp(WidgetTester tester) async { @@ -136,3 +179,52 @@ Future _readSeededToday() async { TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } + +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: () {}), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +TimelineCardModel _timelineCard({ + required String id, + required String title, + String subtitle = '30 min', + String timeText = '6:00 PM - 6:30 PM', + TaskVisualKind visualKind = TaskVisualKind.flexible, + bool showsQuickActions = true, +}) { + return TimelineCardModel( + id: id, + title: title, + typeLabel: 'Flexible', + subtitle: subtitle, + timeText: timeText, + startMinutes: 18 * 60, + endMinutes: 18 * 60 + 30, + visualKind: visualKind, + rewardIconToken: TimelineRewardIconToken.medium, + difficultyIconToken: TimelineDifficultyIconToken.medium, + isCompleted: false, + isSelectable: true, + showsQuickActions: showsQuickActions, + durationMinutes: 30, + ); +} From 5c6649945dfc769a13dec2129267699358f2a044 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:18:50 -0700 Subject: [PATCH 03/16] fix: hide task quick actions until hover --- .../widgets/timeline/task_timeline_card.dart | 186 +++++++++++------- apps/focus_flow_flutter/test/widget_test.dart | 18 ++ 2 files changed, 137 insertions(+), 67 deletions(-) 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 9e4ff41..e55676a 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 @@ -11,7 +11,7 @@ import 'difficulty_bars.dart'; import 'reward_icon.dart'; /// Interactive card that renders a scheduled task on the compact timeline. -class TaskTimelineCard extends StatelessWidget { +class TaskTimelineCard extends StatefulWidget { /// Creates a task timeline card. const TaskTimelineCard({required this.card, required this.onTap, super.key}); @@ -21,8 +21,16 @@ class TaskTimelineCard extends StatelessWidget { /// Callback invoked when the card is tapped. final VoidCallback onTap; + @override + State createState() => _TaskTimelineCardState(); +} + +class _TaskTimelineCardState extends State { + bool _isHovered = false; + @override Widget build(BuildContext context) { + final card = widget.card; final colors = _CardColors.forKind(card.visualKind); return LayoutBuilder( builder: (context, constraints) { @@ -30,84 +38,79 @@ class TaskTimelineCard extends StatelessWidget { constraints, kind: card.visualKind, ); - return Material( - color: Colors.transparent, - child: InkWell( - key: ValueKey(card.id), - borderRadius: BorderRadius.circular(metrics.cardRadius), - onTap: onTap, - child: Container( - padding: metrics.padding, - 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: Row( - children: [ - _StatusRing( - card: card, + 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, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(metrics.cardRadius), + border: Border.all( color: colors.accent, - metrics: metrics, + width: metrics.borderWidth, ), - SizedBox(width: metrics.statusGap), - Expanded( - child: _CardText( + boxShadow: [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.12), + blurRadius: metrics.shadowBlur, + spreadRadius: metrics.shadowSpread, + ), + ], + ), + child: Row( + children: [ + _StatusRing( card: card, color: colors.accent, metrics: metrics, ), - ), - if (card.showsQuickActions) ...[ - _NoOpIcon( - icon: Icons.check, - color: colors.accent, - metrics: metrics, + SizedBox(width: metrics.statusGap), + Expanded( + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), ), - _NoOpIcon( - icon: Icons.arrow_forward, - color: colors.accent, + if (card.showsQuickActions) + _QuickActions( + color: colors.accent, + metrics: metrics, + visible: _isHovered, + ), + _Badge( metrics: metrics, + child: RewardIcon( + color: colors.reward, + size: metrics.rewardIconSize, + ), ), - _NoOpIcon( - icon: Icons.calendar_today, - color: colors.accent, + SizedBox(width: metrics.badgeGap), + _Badge( metrics: metrics, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), ), - _NoOpIcon( - icon: Icons.wb_sunny_outlined, - color: colors.accent, - metrics: metrics, - ), - SizedBox(width: metrics.actionGap), ], - _Badge( - metrics: metrics, - child: RewardIcon( - color: colors.reward, - size: metrics.rewardIconSize, - ), - ), - SizedBox(width: metrics.badgeGap), - _Badge( - metrics: metrics, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ), - ], + ), ), ), ), @@ -284,6 +287,55 @@ class _CardText extends StatelessWidget { } } +class _QuickActions extends StatelessWidget { + const _QuickActions({ + required this.color, + required this.metrics, + required this.visible, + }); + + final Color color; + final _CardMetrics metrics; + final bool visible; + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + excluding: !visible, + child: IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 120), + 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), + ], + ), + ), + ), + ); + } +} + class _NoOpIcon extends StatelessWidget { const _NoOpIcon({ required this.icon, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 90960f2..f3b3096 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -1,6 +1,7 @@ /// 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'; @@ -124,12 +125,29 @@ void main() { ); 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 button = tester.widget( find.widgetWithIcon(IconButton, Icons.arrow_forward), ); expect(button.iconSize, lessThan(18)); expect(button.constraints?.maxWidth, lessThan(32)); + + await gesture.removePointer(); }); testWidgets('free slot text scales inside a short task bubble', ( From bfc2c86088d10193d3902e81f09bc70557cd7f83 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:29:48 -0700 Subject: [PATCH 04/16] fix: scale header controls responsively --- .../lib/widgets/required_banner.dart | 204 +++++++++++----- .../lib/widgets/top_bar.dart | 229 ++++++++++++++---- apps/focus_flow_flutter/test/widget_test.dart | 59 +++++ 3 files changed, 384 insertions(+), 108 deletions(-) diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 3a2c83f..817df32 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -18,77 +18,153 @@ class RequiredBanner extends StatelessWidget { Widget build(BuildContext context) { final banner = model; if (banner == null) { - return const SizedBox(height: 72); + return const SizedBox(height: _RequiredBannerMetrics.referenceHeight); } - 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), + ], + ), + ); + }, + ); + } +} + +class _RequiredBannerMetrics { + const _RequiredBannerMetrics(this.scale); + + static const referenceHeight = 72.0; + static const _referenceWidth = 760.0; + static const _minimumScale = 0.5; + + factory _RequiredBannerMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _RequiredBannerMetrics(scale.toDouble()); + } + + final double scale; + + double get horizontalPadding => 18 * scale; + double get bannerRadius => FocusFlowTokens.bannerRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + double get iconCircleSize => 48 * scale; + double get iconSize => 24 * scale; + double get iconGap => 26 * scale; + double get buttonGap => 20 * scale; + double get textSize => 19 * scale; + double get buttonWidth => 176 * scale; + double get buttonHeight => 46 * scale; + double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale; + double get buttonTextSize => 16 * scale; + double get buttonIconSize => 20 * scale; + double get buttonTextGap => 6 * scale; + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} + +class _UpcomingButton extends StatelessWidget { + const _UpcomingButton({required this.metrics}); + + final _RequiredBannerMetrics metrics; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + 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/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index f779190..2e6c7af 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -15,73 +15,196 @@ class TopBar extends StatelessWidget { @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, - ), + 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, + ), + ), + ), + ), + 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), + ], ), + ); + }, + ); + } +} + +class _TopBarMetrics { + const _TopBarMetrics(this.scale); + + static const referenceHeight = 48.0; + static const _referenceWidth = 900.0; + static const _minimumScale = 0.44; + + factory _TopBarMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _TopBarMetrics(scale.toDouble()); + } + + final double scale; + + double get titleWidth => 112 * scale; + double get titleSize => FocusFlowTokens.pageTitleSize * scale; + double get titleGap => 30 * scale; + double get controlHeight => referenceHeight * scale; + double get iconButtonSize => referenceHeight * scale; + double get iconSize => 24 * scale; + double get dateWidth => 144 * scale; + double get dateSize => 17 * scale; + double get modeWidth => 220 * scale; + double get modeHeight => referenceHeight * scale; + double get modeLabelSize => 17 * scale; + double get settingsGap => 28 * scale; + double get settingsWidth => 136 * scale; + double get settingsHeight => referenceHeight * scale; + double get settingsIconSize => 20 * scale; + double get settingsTextSize => 15 * scale; + double get settingsTextGap => 8 * scale; + double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} + +class _SettingsButton extends StatelessWidget { + const _SettingsButton({required this.metrics}); + + final _TopBarMetrics metrics; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, ), - _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, + 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, ), ), - ), - icon: const Icon(Icons.wb_sunny_outlined), - label: const Text('Settings'), + ], ), - ], + ), ); } } class _IconButton extends StatelessWidget { - const _IconButton({required this.icon, required this.onPressed}); + const _IconButton({ + required this.icon, + required this.metrics, + required this.onPressed, + }); final IconData icon; + final _TopBarMetrics metrics; final VoidCallback onPressed; @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, ); } } class _ModeToggle extends StatelessWidget { - const _ModeToggle(); + const _ModeToggle({required this.metrics}); + + final _TopBarMetrics metrics; @override Widget build(BuildContext context) { return Container( - height: 48, - width: 220, + height: metrics.modeHeight, + width: metrics.modeWidth, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), ), child: Row( children: [ @@ -90,20 +213,38 @@ class _ModeToggle extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: FocusFlowTokens.glassPanelStrong, - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + 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), ), - border: Border.all(color: FocusFlowTokens.navBorder), ), - child: const Text('Compact'), ), ), Expanded( child: TextButton( onPressed: () {}, - child: const Text( - 'Normal', - style: TextStyle(color: FocusFlowTokens.textPrimary), + 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/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index f3b3096..fd89a94 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -9,8 +9,11 @@ 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/task_timeline_card.dart'; +import 'package:focus_flow_flutter/widgets/top_bar.dart'; /// Runs FocusFlow widget and visual adapter tests. void main() { @@ -112,6 +115,42 @@ void main() { expect(find.text('Pay bill'), findsOneWidget); }); + 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(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('task card controls scale inside a narrow task bubble', ( tester, ) async { @@ -221,6 +260,26 @@ Future _pumpTimelineCard( await tester.pumpAndSettle(); } +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(); +} + TimelineCardModel _timelineCard({ required String id, required String title, From adf542291715d3954ddcb871ec0ff27688ab1d15 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:40:37 -0700 Subject: [PATCH 05/16] fix: make timeline scroll full day --- .../lib/models/today_screen_models.dart | 4 +- .../widgets/timeline/timeline_geometry.dart | 26 ++-- .../lib/widgets/timeline/timeline_view.dart | 134 ++++++++++++------ apps/focus_flow_flutter/test/widget_test.dart | 38 +++++ 4 files changed, 144 insertions(+), 58 deletions(-) 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 905826a..4cdc533 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -88,8 +88,8 @@ class TodayScreenData { /// Default visible range for the compact timeline. static const defaultRange = TimelineRangeModel( - startMinutes: 16 * 60, - endMinutes: 20 * 60 + 45, + startMinutes: 0, + endMinutes: 24 * 60, ); /// Display-ready date label. 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 e36478f..9558c3b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -1,23 +1,23 @@ /// 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, - }); + }) : assert(startMinutes >= 0), + assert(endMinutes <= 24 * 60), + assert(endMinutes > startMinutes); - /// First visible time on the timeline. - final TimeOfDay visibleStart; + /// First visible minute from midnight. + final int startMinutes; - /// Last visible time on the timeline. - final TimeOfDay visibleEnd; + /// Last visible minute from midnight. + final int endMinutes; /// Vertical scale used to convert minutes into pixels. final double pixelsPerMinute; @@ -25,12 +25,6 @@ class TimelineGeometry { /// Minimum rendered height for a timeline card. final double minCardHeight; - /// First visible minute from midnight. - int get startMinutes => visibleStart.hour * 60 + visibleStart.minute; - - /// Last visible minute from midnight. - int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute; - /// Total vertical height of the visible timeline. double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute; @@ -63,7 +57,7 @@ class TimelineGeometry { } 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; 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 a32e786..eb09690 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -10,12 +10,13 @@ import 'timeline_axis.dart'; import 'timeline_geometry.dart'; /// Scrollable compact timeline view for Today cards. -class TimelineView extends StatelessWidget { +class TimelineView extends StatefulWidget { /// Creates a timeline view. const TimelineView({ required this.cards, required this.range, required this.onCardSelected, + this.now, super.key, }); @@ -28,53 +29,106 @@ class TimelineView extends StatelessWidget { /// Callback invoked when a card is selected. final ValueChanged onCardSelected; + /// Clock used to center the initial scroll position. + final DateTime Function()? now; + + @override + State createState() => _TimelineViewState(); +} + +class _TimelineViewState extends State { + final _scrollController = ScrollController(); + bool _didSetInitialScroll = false; + + @override + void didUpdateWidget(covariant TimelineView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.range.startMinutes != widget.range.startMinutes || + oldWidget.range.endMinutes != widget.range.endMinutes) { + _didSetInitialScroll = false; + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @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, - ), + startMinutes: widget.range.startMinutes, + endMinutes: widget.range.endMinutes, 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), - ), - ), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + _scheduleInitialScroll(geometry, constraints.maxHeight); + return SingleChildScrollView( + controller: _scrollController, + 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 widget.cards) + Positioned( + top: geometry.yForMinutesSinceMidnight( + card.startMinutes, + ), + left: FocusFlowTokens.cardHorizontalPadding, + right: 0, + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), + child: TaskTimelineCard( + card: card, + onTap: () => widget.onCardSelected(card), + ), + ), + ], + ), + ), + ], ), - ], - ), - ), + ), + ); + }, ); } + + 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) - viewportHeight / 2; + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = centeredOffset.clamp(0, maxOffset).toDouble(); + _scrollController.jumpTo(offset); + _didSetInitialScroll = true; + }); + } } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index fd89a94..b286cd5 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -13,6 +13,7 @@ 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/task_timeline_card.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. @@ -82,6 +83,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(); @@ -106,6 +108,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)); @@ -115,6 +118,36 @@ void main() { 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 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(1564, 1)); + }, + ); + testWidgets('top bar controls scale inside a compact header width', ( tester, ) async { @@ -237,6 +270,11 @@ TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } +Future _scrollIntoView(WidgetTester tester, ValueKey key) async { + await tester.ensureVisible(find.byKey(key)); + await tester.pumpAndSettle(); +} + Future _pumpTimelineCard( WidgetTester tester, { required TimelineCardModel card, From 1e0e76d72f812ff76b13055f895efa53c9b4d64a Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:52:04 -0700 Subject: [PATCH 06/16] fix: jump required banner to linked task --- .../lib/app/focus_flow_app.dart | 44 ++++++++++++++----- .../lib/models/today_screen_models.dart | 10 ++++- .../lib/widgets/required_banner.dart | 12 +++-- .../lib/widgets/timeline/timeline_view.dart | 39 ++++++++++++++++ apps/focus_flow_flutter/test/widget_test.dart | 17 ++++++- 5 files changed, 106 insertions(+), 16 deletions(-) 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 af749f1..6837123 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -113,7 +113,7 @@ class _PlanIssue extends StatelessWidget { } } -class _TodayScreenScaffold extends StatelessWidget { +class _TodayScreenScaffold extends StatefulWidget { const _TodayScreenScaffold({ required this.data, required this.selectedCard, @@ -126,6 +126,25 @@ class _TodayScreenScaffold extends StatelessWidget { final ValueChanged onSelectCard; final VoidCallback onClearSelection; + @override + State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState(); +} + +class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { + String? _timelineScrollTargetCardId; + var _timelineScrollRequest = 0; + + void _showUpcomingRequiredTask() { + final targetCardId = widget.data.requiredBanner?.timelineCardId; + if (targetCardId == null) { + return; + } + setState(() { + _timelineScrollTargetCardId = targetCardId; + _timelineScrollRequest += 1; + }); + } + @override Widget build(BuildContext context) { return Stack( @@ -140,33 +159,38 @@ class _TodayScreenScaffold extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TopBar(dateLabel: data.dateLabel), + TopBar(dateLabel: widget.data.dateLabel), const SizedBox(height: 24), - RequiredBanner(model: data.requiredBanner), + RequiredBanner( + model: widget.data.requiredBanner, + onShowUpcoming: _showUpcomingRequiredTask, + ), const SizedBox(height: 22), Expanded( child: TimelineView( - cards: data.cards, - range: data.timelineRange, - onCardSelected: onSelectCard, + cards: widget.data.cards, + range: widget.data.timelineRange, + scrollTargetCardId: _timelineScrollTargetCardId, + scrollRequest: _timelineScrollRequest, + onCardSelected: widget.onSelectCard, ), ), ], ), ), - if (selectedCard != null) ...[ + if (widget.selectedCard != null) ...[ Positioned.fill( child: GestureDetector( key: const ValueKey('modal-click-away'), behavior: HitTestBehavior.opaque, - onTap: onClearSelection, + onTap: widget.onClearSelection, child: const ColoredBox(color: Colors.transparent), ), ), Center( child: TaskSelectionModal( - card: selectedCard!, - onClose: onClearSelection, + card: widget.selectedCard!, + onClose: widget.onClearSelection, ), ), ], 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 4cdc533..eb9f28e 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -6,7 +6,14 @@ import 'package:scheduler_core/scheduler_core.dart'; /// Presentation model for the next-required-task banner. class RequiredBannerModel { /// Creates a required banner model. - const RequiredBannerModel({required this.title, required this.timeText}); + 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; @@ -79,6 +86,7 @@ class TodayScreenData { requiredBanner: nextRequired == null ? null : RequiredBannerModel( + timelineCardId: nextRequired.id, title: nextRequired.item.displayTitle, timeText: _formatTime(nextRequired.start), ), diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 817df32..0bbb5c7 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -9,11 +9,14 @@ import '../theme/focus_flow_tokens.dart'; /// Banner that highlights the next required task, if one exists. class RequiredBanner extends StatelessWidget { /// Creates a required-task banner. - const RequiredBanner({this.model, super.key}); + 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; + @override Widget build(BuildContext context) { final banner = model; @@ -85,7 +88,7 @@ class RequiredBanner extends StatelessWidget { ), ), SizedBox(width: metrics.buttonGap), - _UpcomingButton(metrics: metrics), + _UpcomingButton(metrics: metrics, onPressed: onShowUpcoming), ], ), ); @@ -127,14 +130,15 @@ class _RequiredBannerMetrics { } class _UpcomingButton extends StatelessWidget { - const _UpcomingButton({required this.metrics}); + const _UpcomingButton({required this.metrics, required this.onPressed}); final _RequiredBannerMetrics metrics; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { return OutlinedButton( - onPressed: () {}, + onPressed: onPressed, style: OutlinedButton.styleFrom( foregroundColor: FocusFlowTokens.textPrimary, side: BorderSide( 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 eb09690..96ccdf4 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -17,6 +17,8 @@ class TimelineView extends StatefulWidget { required this.range, required this.onCardSelected, this.now, + this.scrollTargetCardId, + this.scrollRequest = 0, super.key, }); @@ -32,6 +34,12 @@ class TimelineView extends StatefulWidget { /// 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; + @override State createState() => _TimelineViewState(); } @@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget { class _TimelineViewState extends State { final _scrollController = ScrollController(); bool _didSetInitialScroll = false; + var _handledScrollRequest = 0; @override void didUpdateWidget(covariant TimelineView oldWidget) { @@ -67,6 +76,7 @@ class _TimelineViewState extends State { return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(geometry, constraints.maxHeight); + _scheduleTargetScroll(geometry); return SingleChildScrollView( controller: _scrollController, child: SizedBox( @@ -131,4 +141,33 @@ class _TimelineViewState extends State { _didSetInitialScroll = true; }); } + + 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) - 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/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index b286cd5..b5e9b64 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -123,6 +123,17 @@ void main() { expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); }); + 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(2622, 1)); + expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget); + }); + testWidgets( 'timeline defaults near the current time while keeping full day', (tester) async { @@ -171,7 +182,11 @@ void main() { tester, size: const Size(440, 72), child: const RequiredBanner( - model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'), + model: RequiredBannerModel( + timelineCardId: 'pay-bill', + title: 'Pay bill', + timeText: '6:00 PM', + ), ), ); From 4f198600548a902086be20fe7f67bf327b6b2e41 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 14:07:25 -0700 Subject: [PATCH 07/16] fix: pack overlapping timeline tasks --- .../widgets/timeline/task_timeline_card.dart | 2 +- .../lib/widgets/timeline/timeline_view.dart | 165 ++++++++++++++++-- apps/focus_flow_flutter/test/widget_test.dart | 57 +++++- 3 files changed, 202 insertions(+), 22 deletions(-) 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 e55676a..d8188b0 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 @@ -126,7 +126,7 @@ class _CardMetrics { static const _referenceWidth = 640.0; static const _referenceHeight = 88.0; static const _freeSlotReferenceHeight = 108.0; - static const _minimumScale = 0.62; + static const _minimumScale = 0.38; factory _CardMetrics.fromConstraints( BoxConstraints constraints, { 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 96ccdf4..0d3537f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -73,6 +73,7 @@ class _TimelineViewState extends State { minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; + final placements = _TimelineCardPlacement.pack(widget.cards); return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(geometry, constraints.maxHeight); @@ -87,26 +88,35 @@ class _TimelineViewState extends State { TimelineAxis(geometry: geometry), const SizedBox(width: 18), Expanded( - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: TimelineGrid(geometry: geometry)), - for (final card in widget.cards) - Positioned( - top: geometry.yForMinutesSinceMidnight( - card.startMinutes, + child: LayoutBuilder( + builder: (context, cardConstraints) { + final trackWidth = cardConstraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: TimelineGrid(geometry: geometry), ), - left: FocusFlowTokens.cardHorizontalPadding, - right: 0, - height: geometry.heightForDuration( - card.endMinutes - card.startMinutes, - ), - child: TaskTimelineCard( - card: card, - onTap: () => widget.onCardSelected(card), - ), - ), - ], + for (final placement in placements) + Positioned( + top: geometry.yForMinutesSinceMidnight( + placement.card.startMinutes, + ), + left: placement.left(trackWidth), + width: placement.width(trackWidth), + height: geometry.heightForDuration( + placement.card.endMinutes - + placement.card.startMinutes, + ), + child: TaskTimelineCard( + card: placement.card, + onTap: () => + widget.onCardSelected(placement.card), + ), + ), + ], + ); + }, ), ), ], @@ -171,3 +181,120 @@ class _TimelineViewState extends State { }); } } + +class _TimelineCardPlacement { + const _TimelineCardPlacement({ + required this.card, + required this.lane, + required this.laneCount, + }); + + static const _laneGap = 8.0; + + final TimelineCardModel card; + final int lane; + final int laneCount; + + static List<_TimelineCardPlacement> pack(List cards) { + 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 = sorted.first.endMinutes; + for (var index = 1; index < sorted.length; index += 1) { + final card = sorted[index]; + if (card.startMinutes < groupEnd) { + if (card.endMinutes > groupEnd) { + groupEnd = card.endMinutes; + } + continue; + } + placements.addAll(_packGroup(sorted.sublist(groupStart, index))); + groupStart = index; + groupEnd = card.endMinutes; + } + placements.addAll(_packGroup(sorted.sublist(groupStart))); + return placements; + } + + static List<_TimelineCardPlacement> _packGroup( + List group, + ) { + final laneEnds = []; + final laneByCard = {}; + for (final card in group) { + var assignedLane = -1; + for (var lane = 0; lane < laneEnds.length; lane += 1) { + if (laneEnds[lane] <= card.startMinutes) { + assignedLane = lane; + break; + } + } + if (assignedLane == -1) { + assignedLane = laneEnds.length; + laneEnds.add(card.endMinutes); + } else { + laneEnds[assignedLane] = card.endMinutes; + } + laneByCard[card] = assignedLane; + } + final laneCount = laneEnds.length; + return [ + for (final card in group) + _TimelineCardPlacement( + card: card, + lane: laneByCard[card]!, + laneCount: laneCount, + ), + ]; + } + + double left(double trackWidth) { + final laneWidth = _laneWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); + } + + double width(double trackWidth) { + return _laneWidth(trackWidth); + } + + double _availableWidth(double trackWidth) { + final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; + if (availableWidth < 0) { + return 0; + } + return availableWidth; + } + + double _laneWidth(double trackWidth) { + final availableWidth = _availableWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return (availableWidth - laneGap * (laneCount - 1)) / laneCount; + } + + 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/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index b5e9b64..aabd58c 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -159,6 +159,57 @@ void main() { }, ); + testWidgets('timeline reuses empty overlap lanes for chained tasks', ( + 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('top bar controls scale inside a compact header width', ( tester, ) async { @@ -338,6 +389,8 @@ TimelineCardModel _timelineCard({ 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, bool showsQuickActions = true, }) { @@ -347,8 +400,8 @@ TimelineCardModel _timelineCard({ typeLabel: 'Flexible', subtitle: subtitle, timeText: timeText, - startMinutes: 18 * 60, - endMinutes: 18 * 60 + 30, + startMinutes: startMinutes, + endMinutes: endMinutes, visualKind: visualKind, rewardIconToken: TimelineRewardIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium, From acee1da2642d0e0e73eaf2cda99612f1736ace8e Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 14:17:49 -0700 Subject: [PATCH 08/16] fix: seed partial overlap timeline tasks --- .../lib/app/demo_scheduler_composition.dart | 42 ++++++++++++++++++ apps/focus_flow_flutter/test/widget_test.dart | 43 ++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) 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 6bb91ed..8a2df03 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -229,6 +229,48 @@ class DemoSchedulerComposition { completedAt: _instant(date, 16, 20), createdAt: createdAt, ), + _task( + 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, + ), + _task( + 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, + ), + _task( + 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, + ), _task( id: 'pay-bill', title: 'Pay bill', diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index aabd58c..146d61a 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -44,6 +44,9 @@ void main() { ); expect(find.text('Clean coffee maker'), findsOneWidget); expect(find.text('Cleaned kitchen counter'), findsNWidgets(2)); + 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); @@ -57,6 +60,9 @@ 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); @@ -159,7 +165,7 @@ void main() { }, ); - testWidgets('timeline reuses empty overlap lanes for chained tasks', ( + testWidgets('timeline splits partial overlaps and reuses empty lanes', ( tester, ) async { await _pumpConstrainedWidget( @@ -210,6 +216,41 @@ void main() { 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')), + ); + + 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)); + }); + testWidgets('top bar controls scale inside a compact header width', ( tester, ) async { From dd6552e68d789df0e1d371339f518dd861f631e0 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 14:22:48 -0700 Subject: [PATCH 09/16] fix: pack visually colliding timeline cards --- .../lib/widgets/timeline/timeline_view.dart | 57 ++++++++++++++----- apps/focus_flow_flutter/test/widget_test.dart | 8 +++ 2 files changed, 50 insertions(+), 15 deletions(-) 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 0d3537f..fe9ea4f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -73,7 +73,10 @@ class _TimelineViewState extends State { minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; - final placements = _TimelineCardPlacement.pack(widget.cards); + final placements = _TimelineCardPlacement.pack( + widget.cards, + geometry: geometry, + ); return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(geometry, constraints.maxHeight); @@ -195,7 +198,10 @@ class _TimelineCardPlacement { final int lane; final int laneCount; - static List<_TimelineCardPlacement> pack(List cards) { + static List<_TimelineCardPlacement> pack( + List cards, { + required TimelineGeometry geometry, + }) { if (cards.isEmpty) { return const []; } @@ -213,41 +219,50 @@ class _TimelineCardPlacement { }); final placements = <_TimelineCardPlacement>[]; var groupStart = 0; - var groupEnd = sorted.first.endMinutes; + var groupEnd = _visualEnd(sorted.first, geometry); for (var index = 1; index < sorted.length; index += 1) { final card = sorted[index]; - if (card.startMinutes < groupEnd) { - if (card.endMinutes > groupEnd) { - groupEnd = card.endMinutes; + 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))); + placements.addAll( + _packGroup(sorted.sublist(groupStart, index), geometry: geometry), + ); groupStart = index; - groupEnd = card.endMinutes; + groupEnd = cardEnd; } - placements.addAll(_packGroup(sorted.sublist(groupStart))); + placements.addAll( + _packGroup(sorted.sublist(groupStart), geometry: geometry), + ); return placements; } static List<_TimelineCardPlacement> _packGroup( - List group, - ) { - final laneEnds = []; + 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] <= card.startMinutes) { + if (laneEnds[lane] <= cardStart) { assignedLane = lane; break; } } if (assignedLane == -1) { assignedLane = laneEnds.length; - laneEnds.add(card.endMinutes); + laneEnds.add(cardEnd); } else { - laneEnds[assignedLane] = card.endMinutes; + laneEnds[assignedLane] = cardEnd; } laneByCard[card] = assignedLane; } @@ -262,6 +277,18 @@ class _TimelineCardPlacement { ]; } + static double _visualStart( + TimelineCardModel card, + TimelineGeometry geometry, + ) { + return geometry.yForMinutesSinceMidnight(card.startMinutes); + } + + static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { + final duration = card.endMinutes - card.startMinutes; + return _visualStart(card, geometry) + geometry.heightForDuration(duration); + } + double left(double trackWidth) { final laneWidth = _laneWidth(trackWidth); final laneGap = _laneGapForWidth(trackWidth); diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 146d61a..cc3b324 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -242,6 +242,12 @@ void main() { final waterPlants = tester.getRect( find.byKey(const ValueKey('water-plants')), ); + final cleanCoffee = tester.getRect( + find.byKey(const ValueKey('clean-coffee-maker')), + ); + final cleanedKitchenEarly = tester.getRect( + find.byKey(const ValueKey('cleaned-kitchen-counter-early')), + ); expect(startLaundry.left, greaterThan(sortMail.left)); expect(waterPlants.left, closeTo(sortMail.left, 1)); @@ -249,6 +255,8 @@ void main() { expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(sortMail.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left)); + expect(cleanedKitchenEarly.left, greaterThan(cleanCoffee.left)); + expect(cleanCoffee.right, lessThan(cleanedKitchenEarly.left)); }); testWidgets('top bar controls scale inside a compact header width', ( From f6afe0d793cc877ed8d29764c5164ce4f03b6fd2 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 15:25:50 -0700 Subject: [PATCH 10/16] fix: size timeline tasks to exact duration --- .../widgets/timeline/task_timeline_card.dart | 198 +++++++++++++----- .../widgets/timeline/timeline_geometry.dart | 8 +- .../lib/widgets/timeline/timeline_view.dart | 1 - apps/focus_flow_flutter/test/widget_test.dart | 75 ++++++- 4 files changed, 228 insertions(+), 54 deletions(-) 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 d8188b0..94beab4 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 @@ -38,6 +38,51 @@ class _TaskTimelineCardState extends State { constraints, kind: card.visualKind, ); + final content = metrics.isCompact + ? _CompactCardText( + card: card, + color: colors.accent, + metrics: metrics, + ) + : Row( + children: [ + _StatusRing( + card: card, + color: colors.accent, + metrics: metrics, + ), + SizedBox(width: metrics.statusGap), + Expanded( + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), + ), + if (card.showsQuickActions) + _QuickActions( + color: colors.accent, + metrics: metrics, + visible: _isHovered, + ), + _Badge( + metrics: metrics, + child: RewardIcon( + color: colors.reward, + size: metrics.rewardIconSize, + ), + ), + SizedBox(width: metrics.badgeGap), + _Badge( + metrics: metrics, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), + ), + ], + ); return MouseRegion( onEnter: (_) { setState(() { @@ -57,6 +102,7 @@ class _TaskTimelineCardState extends State { onTap: widget.onTap, child: Container( padding: metrics.padding, + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: colors.background, borderRadius: BorderRadius.circular(metrics.cardRadius), @@ -72,45 +118,7 @@ class _TaskTimelineCardState extends State { ), ], ), - child: Row( - children: [ - _StatusRing( - card: card, - color: colors.accent, - metrics: metrics, - ), - SizedBox(width: metrics.statusGap), - Expanded( - child: _CardText( - card: card, - color: colors.accent, - metrics: metrics, - ), - ), - if (card.showsQuickActions) - _QuickActions( - color: colors.accent, - metrics: metrics, - visible: _isHovered, - ), - _Badge( - metrics: metrics, - child: RewardIcon( - color: colors.reward, - size: metrics.rewardIconSize, - ), - ), - SizedBox(width: metrics.badgeGap), - _Badge( - metrics: metrics, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ), - ], - ), + child: content, ), ), ), @@ -121,12 +129,13 @@ class _TaskTimelineCardState extends State { } class _CardMetrics { - const _CardMetrics(this.scale); + const _CardMetrics({required this.scale, required this.height}); static const _referenceWidth = 640.0; static const _referenceHeight = 88.0; static const _freeSlotReferenceHeight = 108.0; - static const _minimumScale = 0.38; + static const _compactHeightThreshold = 56.0; + static const _minimumScale = 0.16; factory _CardMetrics.fromConstraints( BoxConstraints constraints, { @@ -145,15 +154,19 @@ class _CardMetrics { ? _freeSlotReferenceHeight : _referenceHeight); final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0); - return _CardMetrics(scale.toDouble()); + return _CardMetrics(scale: scale.toDouble(), height: height); } final double scale; + final double height; - double get cardRadius => FocusFlowTokens.cardRadius * scale; - double get borderWidth => math.max(1, 1.2 * scale); - double get shadowBlur => 18 * scale; - double get shadowSpread => scale; + bool get isCompact => height < _compactHeightThreshold; + + double get cardRadius => + math.min(FocusFlowTokens.cardRadius * scale, height / 2); + double get borderWidth => math.max(0.75, 1.2 * scale); + double get shadowBlur => isCompact ? 0 : 18 * scale; + double get shadowSpread => isCompact ? 0 : scale; double get statusRingSize => 34 * scale; double get statusBorderWidth => math.max(1.2, 3 * scale); double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); @@ -171,9 +184,17 @@ class _CardMetrics { double get metaSize => FocusFlowTokens.cardMetaSize * scale; double get titleMetaGap => 4 * scale; double get freeSlotTimeGap => 10 * scale; + double get compactTextGap => math.max(6, 16 * scale); Size get difficultySize => Size(54 * scale, 34 * scale); - EdgeInsets get padding => - EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); + EdgeInsets get padding { + if (isCompact) { + return EdgeInsets.symmetric( + horizontal: math.max(4, 12 * scale), + vertical: math.max(1, 3 * scale), + ); + } + return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); + } } class _StatusRing extends StatelessWidget { @@ -287,6 +308,87 @@ class _CardText extends StatelessWidget { } } +class _CompactCardText extends StatelessWidget { + const _CompactCardText({ + required this.card, + required this.color, + required this.metrics, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + + @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: [ + Expanded( + flex: 3, + child: Text( + card.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + SizedBox(width: metrics.compactTextGap), + Flexible( + flex: 2, + child: Text( + metaText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), + ), + ], + ); + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return line; + } + return FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: line), + ); + }, + ); + } + + 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; + } +} + class _QuickActions extends StatelessWidget { const _QuickActions({ required this.color, 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 9558c3b..e6f806b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -8,10 +8,11 @@ class TimelineGeometry { 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(endMinutes > startMinutes), + assert(minCardHeight >= 0); /// First visible minute from midnight. final int startMinutes; @@ -23,6 +24,9 @@ class TimelineGeometry { 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; /// Total vertical height of the visible timeline. 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 fe9ea4f..dcd98d5 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -70,7 +70,6 @@ class _TimelineViewState extends State { startMinutes: widget.range.startMinutes, endMinutes: widget.range.endMinutes, pixelsPerMinute: 2.45, - minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; final placements = _TimelineCardPlacement.pack( diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index cc3b324..d641983 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -129,6 +129,50 @@ void main() { 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); @@ -255,8 +299,10 @@ void main() { expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(sortMail.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left)); - expect(cleanedKitchenEarly.left, greaterThan(cleanCoffee.left)); - expect(cleanCoffee.right, lessThan(cleanedKitchenEarly.left)); + expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1)); + expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5)); + expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5)); + expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top)); }); testWidgets('top bar controls scale inside a compact header width', ( @@ -337,6 +383,29 @@ void main() { 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')); + + expect(title.right, lessThan(time.left)); + expect((title.center.dy - time.center.dy).abs(), lessThan(2)); + }); + testWidgets('free slot text scales inside a short task bubble', ( tester, ) async { @@ -457,6 +526,6 @@ TimelineCardModel _timelineCard({ isCompleted: false, isSelectable: true, showsQuickActions: showsQuickActions, - durationMinutes: 30, + durationMinutes: endMinutes - startMinutes, ); } From 52dcc84b7d77d340ac18cf3a7e14dc9c4c7d597f Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 12:01:18 -0700 Subject: [PATCH 11/16] fix: refine timeline task interactions --- .../lib/app/demo_scheduler_composition.dart | 33 +- .../lib/app/focus_flow_app.dart | 48 ++- .../scheduler_command_controller.dart | 71 +++- .../controllers/today_screen_controller.dart | 15 + .../lib/models/today_screen_models.dart | 52 ++- .../lib/theme/focus_flow_tokens.dart | 4 +- .../lib/widgets/required_banner.dart | 6 +- .../lib/widgets/task_selection_modal.dart | 110 ++++- .../widgets/timeline/task_timeline_card.dart | 384 +++++++++++------- .../lib/widgets/timeline/timeline_axis.dart | 32 +- .../widgets/timeline/timeline_geometry.dart | 20 + .../lib/widgets/timeline/timeline_view.dart | 205 +++++++--- apps/focus_flow_flutter/test/widget_test.dart | 253 +++++++++++- .../lib/src/application_commands.dart | 58 +++ .../lib/src/timeline_state.dart | 5 + .../test/application_commands_test.dart | 43 ++ 16 files changed, 1054 insertions(+), 285 deletions(-) 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 8a2df03..a1d37f5 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -167,14 +167,14 @@ class DemoSchedulerComposition { } /// Creates an application operation context for the supplied [operationId]. - ApplicationOperationContext context(String 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), ); } @@ -215,18 +215,18 @@ class DemoSchedulerComposition { createdAt: createdAt, ), _task( - id: 'cleaned-kitchen-counter-early', + id: 'cleaned-kitchen-counter', title: 'Cleaned kitchen counter', - type: TaskType.surprise, + type: TaskType.flexible, status: TaskStatus.completed, date: date, - startHour: 16, - startMinute: 45, - endHour: 17, - endMinute: 0, + startHour: 8, + startMinute: 15, + endHour: 8, + endMinute: 30, reward: RewardLevel.medium, difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 16, 20), + completedAt: _instant(date, 8, 5), createdAt: createdAt, ), _task( @@ -313,21 +313,6 @@ class DemoSchedulerComposition { 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, - ), ]; } 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 6837123..a56520d 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -3,6 +3,7 @@ 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'; @@ -47,21 +48,40 @@ class FocusFlowHome extends StatefulWidget { } class _FocusFlowHomeState extends State { - late final TodayScreenController controller = widget.composition - .createTodayScreenController(); + late final TodayScreenController controller; + late final SchedulerCommandController commandController; @override void initState() { super.initState(); + controller = widget.composition.createTodayScreenController(); + commandController = widget.composition.createCommandController( + refreshReads: controller.load, + ); controller.load(); } @override void dispose() { + commandController.dispose(); controller.dispose(); super.dispose(); } + 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, + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -84,12 +104,14 @@ class _FocusFlowHomeState extends State { ), 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, ), }; @@ -118,12 +140,14 @@ class _TodayScreenScaffold extends StatefulWidget { required this.data, required this.selectedCard, required this.onSelectCard, + required this.onCompleteCard, required this.onClearSelection, }); final TodayScreenData data; final TimelineCardModel? selectedCard; final ValueChanged onSelectCard; + final Future Function(TimelineCardModel card) onCompleteCard; final VoidCallback onClearSelection; @override @@ -160,12 +184,16 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TopBar(dateLabel: widget.data.dateLabel), - const SizedBox(height: 24), - RequiredBanner( - model: widget.data.requiredBanner, - onShowUpcoming: _showUpcomingRequiredTask, - ), - const SizedBox(height: 22), + 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, @@ -173,6 +201,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { scrollTargetCardId: _timelineScrollTargetCardId, scrollRequest: _timelineScrollRequest, onCardSelected: widget.onSelectCard, + onCardCompleted: widget.onCompleteCard, ), ), ], @@ -191,6 +220,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { 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/controllers/scheduler_command_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart index 638af12..b820393 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -6,7 +6,7 @@ import 'package:scheduler_core/scheduler_core.dart'; /// Builds an application operation context for a UI command operation. typedef OperationContextFactory = - ApplicationOperationContext Function(String operationId); + ApplicationOperationContext Function(String operationId, {DateTime? now}); /// Refreshes any reads that should reflect a completed command. typedef ReadRefresh = Future Function(); @@ -70,7 +70,8 @@ class SchedulerCommandController extends ChangeNotifier { 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; @@ -83,6 +84,9 @@ class SchedulerCommandController extends ChangeNotifier { /// 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; var _sequence = 0; SchedulerCommandState _state = const SchedulerCommandIdle(); @@ -132,13 +136,70 @@ class SchedulerCommandController extends ChangeNotifier { 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) { - return commands.completeFlexibleTask( - context: contextFor(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, @@ -183,4 +244,6 @@ class SchedulerCommandController extends ChangeNotifier { _state = next; notifyListeners(); } + + static DateTime _utcNow() => DateTime.now().toUtc(); } 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 225d5cb..a7e2953 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -76,6 +76,7 @@ class TodayScreenController extends ChangeNotifier { } final data = TodayScreenData.fromTodayState(result.requireValue); + _syncSelectedCard(data); _state = data.cards.isEmpty ? const TodayScreenEmpty() : TodayScreenReady(data); @@ -103,4 +104,18 @@ class TodayScreenController extends ChangeNotifier { _selectedCard = null; notifyListeners(); } + + 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/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index eb9f28e..fd18048 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -124,12 +124,14 @@ class TimelineCardModel { 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, }); @@ -146,18 +148,21 @@ class TimelineCardModel { ? '' : '$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(visualKind), + 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, @@ -179,6 +184,9 @@ class TimelineCardModel { /// 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; @@ -188,6 +196,9 @@ class TimelineCardModel { /// Optional task duration in minutes. final int? durationMinutes; + /// Source task scheduling behavior type. + final TaskType taskType; + /// Visual category used for styling. final TaskVisualKind visualKind; @@ -206,6 +217,20 @@ class TimelineCardModel { /// 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) { @@ -247,13 +272,17 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) { }; } -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 _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', }; } @@ -265,9 +294,10 @@ String _subtitleFor( 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.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'; 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 d6ab29a..616f985 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart @@ -93,7 +93,7 @@ abstract final class FocusFlowTokens { static const bannerRadius = 8.0; /// Border radius for timeline cards. - static const cardRadius = 8.0; + static const cardRadius = 12.0; /// Border radius for task selection modals. static const modalRadius = 8.0; @@ -111,7 +111,7 @@ abstract final class FocusFlowTokens { static const cardMetaSize = 16.0; /// Font size for sidebar navigation labels. - static const sidebarNavSize = 18.0; + static const sidebarNavSize = 16.0; /// Font size for modal titles. static const modalTitleSize = 26.0; diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 0bbb5c7..99cd71d 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -21,7 +21,7 @@ class RequiredBanner extends StatelessWidget { Widget build(BuildContext context) { final banner = model; if (banner == null) { - return const SizedBox(height: _RequiredBannerMetrics.referenceHeight); + return const SizedBox.shrink(); } return LayoutBuilder( builder: (context, constraints) { @@ -119,11 +119,11 @@ class _RequiredBannerMetrics { double get iconSize => 24 * scale; double get iconGap => 26 * scale; double get buttonGap => 20 * scale; - double get textSize => 19 * scale; + double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble(); double get buttonWidth => 176 * scale; double get buttonHeight => 46 * scale; double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale; - double get buttonTextSize => 16 * scale; + double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble(); double get buttonIconSize => 20 * scale; double get buttonTextGap => 6 * scale; EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); 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 b7fd4de..df6c5fb 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -14,6 +14,7 @@ class TaskSelectionModal extends StatelessWidget { const TaskSelectionModal({ required this.card, required this.onClose, + this.onStatusPressed, super.key, }); @@ -23,6 +24,9 @@ class TaskSelectionModal extends StatelessWidget { /// Callback invoked when the modal should close. final VoidCallback onClose; + /// Callback invoked when the status circle should toggle completion. + final VoidCallback? onStatusPressed; + @override Widget build(BuildContext context) { final accent = _accentFor(card.visualKind); @@ -43,13 +47,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( @@ -65,13 +66,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), ], ), ), @@ -137,6 +132,93 @@ class TaskSelectionModal extends StatelessWidget { } } +class _ModalStatusButton extends StatelessWidget { + const _ModalStatusButton({ + required this.card, + required this.color, + required this.onPressed, + }); + + final TimelineCardModel card; + final Color color; + final VoidCallback? onPressed; + + @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, + ), + ), + ), + ); + } +} + +class _HeaderMetadata extends StatelessWidget { + const _HeaderMetadata({required this.card}); + + final TimelineCardModel card; + + @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), + ), + ], + ); + } +} + class _InfoTile extends StatelessWidget { const _InfoTile({required this.child, required this.label}); 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 94beab4..46053e2 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 @@ -13,7 +13,12 @@ import 'reward_icon.dart'; /// 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, super.key}); + const TaskTimelineCard({ + required this.card, + required this.onTap, + this.onStatusPressed, + super.key, + }); /// Card model to render. final TimelineCardModel card; @@ -21,6 +26,9 @@ class TaskTimelineCard extends StatefulWidget { /// Callback invoked when the card is tapped. final VoidCallback onTap; + /// Callback invoked when the left status control is clicked. + final VoidCallback? onStatusPressed; + @override State createState() => _TaskTimelineCardState(); } @@ -38,51 +46,37 @@ class _TaskTimelineCardState extends State { constraints, kind: card.visualKind, ); - final content = metrics.isCompact - ? _CompactCardText( - card: card, - color: colors.accent, - metrics: metrics, - ) - : Row( - children: [ - _StatusRing( - card: card, - color: colors.accent, - metrics: metrics, - ), - SizedBox(width: metrics.statusGap), - Expanded( - child: _CardText( - card: card, - color: colors.accent, - metrics: metrics, - ), - ), - if (card.showsQuickActions) - _QuickActions( - color: colors.accent, - metrics: metrics, - visible: _isHovered, - ), - _Badge( - metrics: metrics, - child: RewardIcon( - color: colors.reward, - size: metrics.rewardIconSize, - ), - ), - SizedBox(width: metrics.badgeGap), - _Badge( - metrics: metrics, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ), - ], - ); + 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(() { @@ -167,29 +161,36 @@ class _CardMetrics { double get borderWidth => math.max(0.75, 1.2 * scale); double get shadowBlur => isCompact ? 0 : 18 * scale; double get shadowSpread => isCompact ? 0 : scale; - double get statusRingSize => 34 * scale; - double get statusBorderWidth => math.max(1.2, 3 * scale); - double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); - double get statusIconSize => 24 * scale; - double get statusGap => 22 * scale; - double get actionButtonSize => 40 * scale; - double get actionIconSize => 22 * scale; - double get actionGap => 10 * scale; - double get badgeWidth => 62 * scale; - double get badgeHeight => 48 * scale; - double get badgeRadius => 7 * scale; - double get badgeGap => 8 * scale; - double get rewardIconSize => 26 * scale; - double get titleSize => FocusFlowTokens.cardTitleSize * scale; - double get metaSize => FocusFlowTokens.cardMetaSize * scale; + double get statusRingSize { + final availableHeight = math.max(8, height - padding.vertical); + return math.min(24, availableHeight * 0.78).toDouble(); + } + + double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08); + double get statusIconSize => statusRingSize * 0.58; + double get statusGap => isCompact ? 7 : 22 * scale; + double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); + double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); + double get actionGap => math.max(3, 5 * scale); + double get actionsWidth => actionButtonSize * 4 + actionGap; + double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); + double get indicatorGap => math.max(3, 5 * scale); + double get rewardIconSize => 14; + double get titleSize => 10; + double get metaSize => 7; double get titleMetaGap => 4 * scale; double get freeSlotTimeGap => 10 * scale; - double get compactTextGap => math.max(6, 16 * scale); - Size get difficultySize => Size(54 * scale, 34 * scale); + double get compactTextGap => math.max(3, 8 * scale); + double get indicatorWidth => + rewardIconSize + indicatorGap + difficultySize.width; + double get expandedTrailingReserve => + indicatorWidth + math.max(8, 12 * scale); + double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); + Size get difficultySize => Size(26, 14); EdgeInsets get padding { if (isCompact) { return EdgeInsets.symmetric( - horizontal: math.max(4, 12 * scale), + horizontal: math.max(4, 8 * scale), vertical: math.max(1, 3 * scale), ); } @@ -197,36 +198,100 @@ class _CardMetrics { } } +class _ExpandedCardContent extends StatelessWidget { + const _ExpandedCardContent({ + required this.card, + required this.colors, + required this.metrics, + required this.onStatusPressed, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final VoidCallback? onStatusPressed; + + @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, + ), + ), + ), + ], + ); + } +} + class _StatusRing extends StatelessWidget { const _StatusRing({ required this.card, required this.color, required this.metrics, + this.onPressed, }); final TimelineCardModel card; final Color color; final _CardMetrics metrics; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - final border = Border.all( - color: color, - width: card.visualKind == TaskVisualKind.freeSlot - ? metrics.freeSlotStatusBorderWidth - : metrics.statusBorderWidth, - style: card.visualKind == TaskVisualKind.freeSlot - ? BorderStyle.solid - : BorderStyle.solid, - ); - return Container( + 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, border: border), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: border, + ), child: card.isCompleted - ? Icon(Icons.check, color: color, size: metrics.statusIconSize) + ? 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, + ), + ), + ), + ); } } @@ -313,11 +378,13 @@ class _CompactCardText extends StatelessWidget { required this.card, required this.color, required this.metrics, + required this.onStatusPressed, }); final TimelineCardModel card; final Color color; final _CardMetrics metrics; + final VoidCallback? onStatusPressed; @override Widget build(BuildContext context) { @@ -344,8 +411,18 @@ class _CompactCardText extends StatelessWidget { final line = Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - flex: 3, + 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, @@ -355,7 +432,8 @@ class _CompactCardText extends StatelessWidget { ), SizedBox(width: metrics.compactTextGap), Flexible( - flex: 2, + flex: 1, + fit: FlexFit.loose, child: Text( metaText, maxLines: 1, @@ -363,6 +441,7 @@ class _CompactCardText extends StatelessWidget { style: metaStyle, ), ), + SizedBox(width: metrics.compactTrailingReserve), ], ); if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { @@ -389,14 +468,53 @@ class _CompactCardText extends StatelessWidget { } } +class _TrailingControls extends StatelessWidget { + const _TrailingControls({ + required this.card, + required this.colors, + required this.metrics, + required this.actionsVisible, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final bool actionsVisible; + + @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, + ), + ], + ); + } +} + class _QuickActions extends StatelessWidget { const _QuickActions({ required this.color, + required this.backgroundColor, required this.metrics, required this.visible, }); final Color color; + final Color backgroundColor; final _CardMetrics metrics; final bool visible; @@ -406,31 +524,49 @@ class _QuickActions extends StatelessWidget { excluding: !visible, child: IgnorePointer( ignoring: !visible, - child: AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 120), - 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, + 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), + ], + ), ), - _NoOpIcon( - icon: Icons.calendar_today, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.wb_sunny_outlined, - color: color, - metrics: metrics, - ), - SizedBox(width: metrics.actionGap), - ], + ), ), ), ), @@ -451,40 +587,16 @@ class _NoOpIcon extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - onPressed: () {}, - constraints: BoxConstraints.tightFor( - width: metrics.actionButtonSize, - height: metrics.actionButtonSize, + 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), + ), ), - color: color, - iconSize: metrics.actionIconSize, - padding: EdgeInsets.zero, - icon: Icon(icon), - tooltip: 'No-op action', - visualDensity: VisualDensity.compact, - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({required this.child, required this.metrics}); - - final Widget child; - final _CardMetrics metrics; - - @override - Widget build(BuildContext context) { - return Container( - width: metrics.badgeWidth, - height: metrics.badgeHeight, - alignment: Alignment.center, - decoration: BoxDecoration( - color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84), - borderRadius: BorderRadius.circular(metrics.badgeRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: child, ); } } 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 3858515..a00aa8a 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -9,32 +9,38 @@ import 'timeline_geometry.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, super.key}); + 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; + @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 @@ -47,7 +53,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( @@ -66,24 +72,28 @@ class TimelineAxis extends StatelessWidget { /// Background grid aligned to timeline tick marks. class TimelineGrid extends StatelessWidget { /// Creates a timeline grid for [geometry]. - const TimelineGrid({required this.geometry, super.key}); + 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; + @override Widget build(BuildContext context) { return CustomPaint( - painter: _TimelineGridPainter(geometry), + painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), size: Size.infinite, ); } } class _TimelineGridPainter extends CustomPainter { - const _TimelineGridPainter(this.geometry); + const _TimelineGridPainter({required this.geometry, required this.topInset}); final TimelineGeometry geometry; + final double topInset; @override void paint(Canvas canvas, Size size) { @@ -92,8 +102,8 @@ class _TimelineGridPainter extends CustomPainter { ..strokeWidth = 1; for (final tick in geometry.ticks()) { canvas.drawLine( - Offset(0, tick.y), - Offset(size.width, tick.y), + Offset(0, tick.y + topInset), + Offset(size.width, tick.y + topInset), paint ..color = tick.major ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) @@ -104,6 +114,6 @@ class _TimelineGridPainter extends CustomPainter { @override bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { - return oldDelegate.geometry != geometry; + return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; } } 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 e6f806b..1556809 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -60,6 +60,26 @@ class TimelineGeometry { ]; } + @override + bool operator ==(Object other) { + return identical(this, other) || + other is TimelineGeometry && + other.startMinutes == startMinutes && + other.endMinutes == endMinutes && + other.pixelsPerMinute == pixelsPerMinute && + other.minCardHeight == minCardHeight; + } + + @override + int get hashCode { + return Object.hash( + startMinutes, + endMinutes, + pixelsPerMinute, + minCardHeight, + ); + } + static String _labelFor(int minutes) { final hour24 = (minutes ~/ 60) % 24; final minute = minutes % 60; 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 dcd98d5..fb1d50e 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,7 @@ /// 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'; @@ -16,6 +17,7 @@ class TimelineView extends StatefulWidget { required this.cards, required this.range, required this.onCardSelected, + this.onCardCompleted, this.now, this.scrollTargetCardId, this.scrollRequest = 0, @@ -31,6 +33,9 @@ class TimelineView extends StatefulWidget { /// 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; @@ -45,17 +50,37 @@ class TimelineView extends StatefulWidget { } class _TimelineViewState extends State { + static const _topInset = 12.0; + final _scrollController = ScrollController(); bool _didSetInitialScroll = false; var _handledScrollRequest = 0; + late TimelineGeometry _geometry; + late double _contentHeight; + late List<_TimelineCardPlacement> _placements; + late int _cardsLayoutHash; + + @override + void initState() { + super.initState(); + _updateLayoutCache(); + } @override void didUpdateWidget(covariant TimelineView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.range.startMinutes != widget.range.startMinutes || - oldWidget.range.endMinutes != widget.range.endMinutes) { + 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(); + } } @override @@ -66,62 +91,68 @@ class _TimelineViewState extends State { @override Widget build(BuildContext context) { - final geometry = TimelineGeometry( - startMinutes: widget.range.startMinutes, - endMinutes: widget.range.endMinutes, - pixelsPerMinute: 2.45, - ); - final contentHeight = geometry.totalHeight + 24; - final placements = _TimelineCardPlacement.pack( - widget.cards, - geometry: geometry, - ); return LayoutBuilder( builder: (context, constraints) { - _scheduleInitialScroll(geometry, constraints.maxHeight); - _scheduleTargetScroll(geometry); - return SingleChildScrollView( - controller: _scrollController, - child: SizedBox( - height: contentHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TimelineAxis(geometry: geometry), - const SizedBox(width: 18), - Expanded( - child: LayoutBuilder( - builder: (context, cardConstraints) { - final trackWidth = cardConstraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: TimelineGrid(geometry: geometry), - ), - for (final placement in placements) - Positioned( - top: geometry.yForMinutesSinceMidnight( - placement.card.startMinutes, - ), - left: placement.left(trackWidth), - width: placement.width(trackWidth), - height: geometry.heightForDuration( - placement.card.endMinutes - - placement.card.startMinutes, - ), - child: TaskTimelineCard( - card: placement.card, - onTap: () => - widget.onCardSelected(placement.card), + _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, + ), + ), + ), + ), + ], + ); + }, + ), ), - ), - ], + ], + ), ), ), ); @@ -129,6 +160,36 @@ class _TimelineViewState extends State { ); } + 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); + } + + int _layoutHashFor(List cards) { + return Object.hashAll( + cards.map( + (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), + ), + ); + } + + 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), + ]; + } + void _scheduleInitialScroll( TimelineGeometry geometry, double viewportHeight, @@ -146,7 +207,9 @@ class _TimelineViewState extends State { .clamp(geometry.startMinutes, geometry.endMinutes) .toInt(); final centeredOffset = - geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2; + geometry.yForMinutesSinceMidnight(clampedMinute) + + _topInset - + viewportHeight / 2; final maxOffset = _scrollController.position.maxScrollExtent; final offset = centeredOffset.clamp(0, maxOffset).toDouble(); _scrollController.jumpTo(offset); @@ -175,7 +238,9 @@ class _TimelineViewState extends State { return; } final targetOffset = - geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24; + geometry.yForMinutesSinceMidnight(targetCard.startMinutes) + + _topInset - + 24; final maxOffset = _scrollController.position.maxScrollExtent; final offset = targetOffset.clamp(0, maxOffset).toDouble(); _scrollController.jumpTo(offset); @@ -184,11 +249,22 @@ class _TimelineViewState extends State { } } +class _TimelineScrollBehavior extends MaterialScrollBehavior { + const _TimelineScrollBehavior(); + + @override + Set get dragDevices { + return {...super.dragDevices, PointerDeviceKind.mouse}; + } +} + class _TimelineCardPlacement { const _TimelineCardPlacement({ required this.card, required this.lane, required this.laneCount, + required this.top, + required this.height, }); static const _laneGap = 8.0; @@ -196,6 +272,21 @@ class _TimelineCardPlacement { final TimelineCardModel card; final int lane; final int laneCount; + final double top; + final double height; + + _TimelineCardPlacement withCard(TimelineCardModel nextCard) { + if (identical(card, nextCard)) { + return this; + } + return _TimelineCardPlacement( + card: nextCard, + lane: lane, + laneCount: laneCount, + top: top, + height: height, + ); + } static List<_TimelineCardPlacement> pack( List cards, { @@ -272,6 +363,10 @@ class _TimelineCardPlacement { card: card, lane: laneByCard[card]!, laneCount: laneCount, + top: _visualStart(card, geometry), + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), ), ]; } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index d641983..00c6893 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -12,7 +12,10 @@ 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'; @@ -43,7 +46,9 @@ 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); @@ -67,7 +72,7 @@ void main() { 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, ); }, @@ -180,7 +185,7 @@ void main() { await tester.tap(find.text('Show upcoming')); await tester.pumpAndSettle(); - expect(scrollable.position.pixels, closeTo(2622, 1)); + expect(scrollable.position.pixels, closeTo(2634, 1)); expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget); }); @@ -205,10 +210,69 @@ void main() { final scrollable = tester.state(find.byType(Scrollable)); final position = scrollable.position; expect(position.maxScrollExtent, greaterThan(3000)); - expect(position.pixels, closeTo(1564, 1)); + 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 { @@ -289,8 +353,8 @@ void main() { final cleanCoffee = tester.getRect( find.byKey(const ValueKey('clean-coffee-maker')), ); - final cleanedKitchenEarly = tester.getRect( - find.byKey(const ValueKey('cleaned-kitchen-counter-early')), + final cleanedKitchenCounter = tester.getRect( + find.byKey(const ValueKey('cleaned-kitchen-counter')), ); expect(startLaundry.left, greaterThan(sortMail.left)); @@ -299,10 +363,14 @@ void main() { expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(sortMail.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left)); - expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1)); expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5)); - expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5)); - expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top)); + 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', ( @@ -345,6 +413,24 @@ void main() { 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 { @@ -373,12 +459,21 @@ void main() { tester.widget(find.byType(AnimatedOpacity)).opacity, 1, ); - - final button = tester.widget( - find.widgetWithIcon(IconButton, Icons.arrow_forward), + 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), ); - expect(button.iconSize, lessThan(18)); - expect(button.constraints?.maxWidth, lessThan(32)); await gesture.removePointer(); }); @@ -401,9 +496,110 @@ void main() { 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', ( @@ -423,15 +619,21 @@ void main() { ); 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 { +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(); } @@ -473,7 +675,11 @@ Future _pumpTimelineCard( child: SizedBox( width: size.width, height: size.height, - child: TaskTimelineCard(card: card, onTap: () {}), + child: TaskTimelineCard( + card: card, + onTap: () {}, + onStatusPressed: () {}, + ), ), ), ), @@ -510,8 +716,19 @@ TimelineCardModel _timelineCard({ 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, @@ -520,9 +737,11 @@ TimelineCardModel _timelineCard({ timeText: timeText, startMinutes: startMinutes, endMinutes: endMinutes, + taskType: resolvedTaskType, visualKind: visualKind, rewardIconToken: TimelineRewardIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium, + completedTimeText: completedTimeText, isCompleted: false, isSelectable: true, showsQuickActions: showsQuickActions, diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index fda6996..cf39363 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -28,6 +28,7 @@ enum ApplicationCommandCode { pushFlexibleToTomorrowTopOfQueue, moveFlexibleToBacklog, completeFlexibleTask, + uncompleteTask, applyRequiredTaskAction, logSurpriseTask, createProtectedFreeSlot, @@ -558,6 +559,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, diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 1bb5e4f..4f463ab 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -76,6 +76,7 @@ class TimelineItem { required this.category, this.start, this.end, + this.completedAt, this.durationMinutes, }) : quickActions = List.unmodifiable(quickActions); @@ -109,6 +110,9 @@ class TimelineItem { /// 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; @@ -198,6 +202,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 diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index 8e7a624..bf93be9 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -311,6 +311,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( @@ -525,6 +562,9 @@ Task task({ required DateTime createdAt, DateTime? start, DateTime? end, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, int? durationMinutes, String projectId = 'home', String? parentTaskId, @@ -539,6 +579,9 @@ 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, From cbefe201d3fc3da5f047937f4847b171483b3c02 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 12:17:02 -0700 Subject: [PATCH 12/16] refactor: split focused Flutter UI files --- .../lib/app/demo/demo_date_formatter.dart | 24 + .../lib/app/demo/demo_seed_tasks.dart | 156 +++++ .../lib/app/demo_scheduler_composition.dart | 183 +----- .../lib/app/focus_flow_app.dart | 202 +----- .../lib/app/home/focus_flow_home.dart | 13 + .../lib/app/home/focus_flow_home_state.dart | 76 +++ .../lib/app/home/plan_issue.dart | 14 + .../lib/app/home/today_screen_scaffold.dart | 20 + .../app/home/today_screen_scaffold_state.dart | 78 +++ .../command/scheduler_command_callbacks.dart | 8 + .../scheduler_command_controller_impl.dart | 187 ++++++ .../command/scheduler_command_state.dart | 52 ++ .../scheduler_command_controller.dart | 246 +------ .../models/today/required_banner_model.dart | 20 + .../lib/models/today/task_visual_kind.dart | 19 + .../today/time/time_string_formatter.dart | 37 ++ .../lib/models/today/timeline_card_model.dart | 144 ++++ .../timeline_item_presentation_mapper.dart | 50 ++ .../models/today/timeline_range_model.dart | 16 + .../lib/models/today/today_screen_data.dart | 59 ++ .../lib/models/today_screen_models.dart | 344 +--------- .../widgets/task_selection/action_button.dart | 32 + .../task_selection/header_metadata.dart | 35 + .../lib/widgets/task_selection/info_tile.dart | 38 ++ .../widgets/task_selection/modal_accent.dart | 11 + .../task_selection/modal_status_button.dart | 54 ++ .../lib/widgets/task_selection_modal.dart | 171 +---- .../widgets/timeline/axis/timeline_grid.dart | 21 + .../timeline/axis/timeline_grid_painter.dart | 30 + .../timeline/task_card/card_colors.dart | 43 ++ .../timeline/task_card/card_metrics.dart | 77 +++ .../widgets/timeline/task_card/card_text.dart | 79 +++ .../timeline/task_card/compact_card_text.dart | 96 +++ .../task_card/expanded_card_content.dart | 42 ++ .../timeline/task_card/no_op_icon.dart | 28 + .../timeline/task_card/quick_actions.dart | 70 ++ .../timeline/task_card/status_ring.dart | 57 ++ .../task_card/task_timeline_card_state.dart | 90 +++ .../timeline/task_card/trailing_controls.dart | 38 ++ .../widgets/timeline/task_timeline_card.dart | 621 +----------------- .../lib/widgets/timeline/timeline_axis.dart | 52 +- .../lib/widgets/timeline/timeline_view.dart | 375 +---------- .../timeline_card_placement.dart | 163 +++++ .../timeline_scroll_behavior.dart | 10 + .../timeline_view/timeline_view_state.dart | 201 ++++++ .../lib/widgets/top_bar.dart | 179 +---- .../widgets/top_bar/top_bar_icon_button.dart | 29 + .../lib/widgets/top_bar/top_bar_metrics.dart | 38 ++ .../widgets/top_bar/top_bar_mode_toggle.dart | 66 ++ .../top_bar/top_bar_settings_button.dart | 45 ++ 50 files changed, 2415 insertions(+), 2324 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart create mode 100644 apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart create mode 100644 apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart create mode 100644 apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart create mode 100644 apps/focus_flow_flutter/lib/app/home/plan_issue.dart create mode 100644 apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart create mode 100644 apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart create mode 100644 apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart create mode 100644 apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart create mode 100644 apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/required_banner_model.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart create mode 100644 apps/focus_flow_flutter/lib/models/today/today_screen_data.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart 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..58d2025 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart @@ -0,0 +1,24 @@ +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}'; +} + +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..e5d117e --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart @@ -0,0 +1,156 @@ +part of '../demo_scheduler_composition.dart'; + +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, + ), + ]; +} + +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 a1d37f5..a4a28d5 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -7,6 +7,9 @@ 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 { DemoSchedulerComposition._({ @@ -46,7 +49,7 @@ class DemoSchedulerComposition { final DateTime readAt; /// Human-readable label for [date]. - String get dateLabel => formatDateLabel(date); + String get dateLabel => _formatDateLabel(date); /// Creates a deterministic seeded composition for the demo app and tests. factory DemoSchedulerComposition.seeded({ @@ -178,182 +181,4 @@ class DemoSchedulerComposition { idGenerator: SequentialIdGenerator(prefix: operationId), ); } - - /// Formats a civil date as a long month/day/year label. - 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', - 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, - ), - _task( - 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, - ), - _task( - 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, - ), - _task( - 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, - ), - _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, - ), - ]; - } - - 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 a56520d..0912db6 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -16,6 +16,12 @@ 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. @@ -34,199 +40,3 @@ class FocusFlowApp extends StatelessWidget { ); } } - -/// 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; - - @override - State createState() => _FocusFlowHomeState(); -} - -class _FocusFlowHomeState extends State { - late final TodayScreenController controller; - late final SchedulerCommandController commandController; - - @override - void initState() { - super.initState(); - controller = widget.composition.createTodayScreenController(); - commandController = widget.composition.createCommandController( - refreshReads: controller.load, - ); - controller.load(); - } - - @override - void dispose() { - commandController.dispose(); - controller.dispose(); - super.dispose(); - } - - 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, - ); - } - } - - @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, - ), - }; - }, - ), - ), - ); - } -} - -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 StatefulWidget { - const _TodayScreenScaffold({ - required this.data, - required this.selectedCard, - required this.onSelectCard, - required this.onCompleteCard, - required this.onClearSelection, - }); - - final TodayScreenData data; - final TimelineCardModel? selectedCard; - final ValueChanged onSelectCard; - final Future Function(TimelineCardModel card) onCompleteCard; - final VoidCallback onClearSelection; - - @override - State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState(); -} - -class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { - String? _timelineScrollTargetCardId; - var _timelineScrollRequest = 0; - - void _showUpcomingRequiredTask() { - final targetCardId = widget.data.requiredBanner?.timelineCardId; - if (targetCardId == null) { - return; - } - setState(() { - _timelineScrollTargetCardId = targetCardId; - _timelineScrollRequest += 1; - }); - } - - @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/app/home/focus_flow_home.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart new file mode 100644 index 0000000..bf4788f --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart @@ -0,0 +1,13 @@ +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; + + @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..82d70e8 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -0,0 +1,76 @@ +part of '../focus_flow_app.dart'; + +class _FocusFlowHomeState extends State { + late final TodayScreenController controller; + late final SchedulerCommandController commandController; + + @override + void initState() { + super.initState(); + controller = widget.composition.createTodayScreenController(); + commandController = widget.composition.createCommandController( + refreshReads: controller.load, + ); + controller.load(); + } + + @override + void dispose() { + commandController.dispose(); + controller.dispose(); + super.dispose(); + } + + 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, + ); + } + } + + @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..4508e4a --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart @@ -0,0 +1,14 @@ +part of '../focus_flow_app.dart'; + +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), + ); + } +} 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..96a4d98 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -0,0 +1,20 @@ +part of '../focus_flow_app.dart'; + +class _TodayScreenScaffold extends StatefulWidget { + const _TodayScreenScaffold({ + required this.data, + required this.selectedCard, + required this.onSelectCard, + required this.onCompleteCard, + required this.onClearSelection, + }); + + final TodayScreenData data; + final TimelineCardModel? selectedCard; + final ValueChanged onSelectCard; + final Future Function(TimelineCardModel card) onCompleteCard; + final VoidCallback onClearSelection; + + @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..02fa25c --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -0,0 +1,78 @@ +part of '../focus_flow_app.dart'; + +class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { + String? _timelineScrollTargetCardId; + var _timelineScrollRequest = 0; + + void _showUpcomingRequiredTask() { + final targetCardId = widget.data.requiredBanner?.timelineCardId; + if (targetCardId == null) { + return; + } + setState(() { + _timelineScrollTargetCardId = targetCardId; + _timelineScrollRequest += 1; + }); + } + + @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/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart new file mode 100644 index 0000000..2a4d532 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -0,0 +1,8 @@ +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..802398a --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -0,0 +1,187 @@ +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; + var _sequence = 0; + 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, + ); + }, + ); + } + + 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(); + } + + 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..8f814f0 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart @@ -0,0 +1,52 @@ +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 b820393..417af9e 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -4,246 +4,6 @@ library; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.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(); - -/// 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'; - } -} - -/// 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; - var _sequence = 0; - 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, - ); - }, - ); - } - - 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(); - } - - static DateTime _utcNow() => DateTime.now().toUtc(); -} +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/models/today/required_banner_model.dart b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart new file mode 100644 index 0000000..53ffc08 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart @@ -0,0 +1,20 @@ +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..8a8a6f9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart @@ -0,0 +1,19 @@ +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..722542e --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -0,0 +1,37 @@ +part of '../../today_screen_models.dart'; + +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'; +} 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..4aa6d82 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -0,0 +1,144 @@ +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..3697b4a --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -0,0 +1,50 @@ +part of '../today_screen_models.dart'; + +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(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', + }; +} + +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..2b48a20 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart @@ -0,0 +1,16 @@ +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..4e35ca8 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -0,0 +1,59 @@ +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 fd18048..ac68893 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -3,340 +3,10 @@ library; import 'package:scheduler_core/scheduler_core.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; -} - -/// 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; -} - -/// 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, -} - -/// 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; -} - -/// 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', - }; - } -} - -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(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', - }; -} - -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; -} - -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/widgets/task_selection/action_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart new file mode 100644 index 0000000..7e75afd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -0,0 +1,32 @@ +part of '../task_selection_modal.dart'; + +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, + ), + ), + ); + } +} 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..2a5c5c2 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart @@ -0,0 +1,35 @@ +part of '../task_selection_modal.dart'; + +class _HeaderMetadata extends StatelessWidget { + const _HeaderMetadata({required this.card}); + + final TimelineCardModel card; + + @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..9314e7a --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -0,0 +1,38 @@ +part of '../task_selection_modal.dart'; + +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, + ), + ), + ), + ], + ), + ); + } +} 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..29e7c5e --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart @@ -0,0 +1,11 @@ +part of '../task_selection_modal.dart'; + +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..78b6a03 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -0,0 +1,54 @@ +part of '../task_selection_modal.dart'; + +class _ModalStatusButton extends StatelessWidget { + const _ModalStatusButton({ + required this.card, + required this.color, + required this.onPressed, + }); + + final TimelineCardModel card; + final Color color; + final VoidCallback? onPressed; + + @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 df6c5fb..b26aee9 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -8,6 +8,12 @@ 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]. @@ -131,168 +137,3 @@ class TaskSelectionModal extends StatelessWidget { ); } } - -class _ModalStatusButton extends StatelessWidget { - const _ModalStatusButton({ - required this.card, - required this.color, - required this.onPressed, - }); - - final TimelineCardModel card; - final Color color; - final VoidCallback? onPressed; - - @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, - ), - ), - ), - ); - } -} - -class _HeaderMetadata extends StatelessWidget { - const _HeaderMetadata({required this.card}); - - final TimelineCardModel card; - - @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), - ), - ], - ); - } -} - -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..748c9b3 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart @@ -0,0 +1,21 @@ +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; + + @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..f9b31e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart @@ -0,0 +1,30 @@ +part of '../timeline_axis.dart'; + +class _TimelineGridPainter extends CustomPainter { + const _TimelineGridPainter({required this.geometry, required this.topInset}); + + final TimelineGeometry geometry; + final double topInset; + + @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, + ); + } + } + + @override + bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { + return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; + } +} 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..98447b1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart @@ -0,0 +1,43 @@ +part of '../task_timeline_card.dart'; + +class _CardColors { + const _CardColors({ + required this.accent, + required this.background, + required this.reward, + }); + + final Color accent; + final Color background; + final Color reward; + + 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..2fa9a45 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -0,0 +1,77 @@ +part of '../task_timeline_card.dart'; + +class _CardMetrics { + const _CardMetrics({required this.scale, required this.height}); + + static const _referenceWidth = 640.0; + static const _referenceHeight = 88.0; + static const _freeSlotReferenceHeight = 108.0; + static const _compactHeightThreshold = 56.0; + static const _minimumScale = 0.16; + + 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); + } + + final double scale; + final double height; + + bool get isCompact => height < _compactHeightThreshold; + + double get cardRadius => + math.min(FocusFlowTokens.cardRadius * scale, height / 2); + double get borderWidth => math.max(0.75, 1.2 * scale); + double get shadowBlur => isCompact ? 0 : 18 * scale; + double get shadowSpread => isCompact ? 0 : scale; + double get statusRingSize { + final availableHeight = math.max(8, height - padding.vertical); + return math.min(24, availableHeight * 0.78).toDouble(); + } + + double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08); + double get statusIconSize => statusRingSize * 0.58; + double get statusGap => isCompact ? 7 : 22 * scale; + double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); + double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); + double get actionGap => math.max(3, 5 * scale); + double get actionsWidth => actionButtonSize * 4 + actionGap; + double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); + double get indicatorGap => math.max(3, 5 * scale); + double get rewardIconSize => 14; + double get titleSize => 10; + double get metaSize => 7; + double get titleMetaGap => 4 * scale; + double get freeSlotTimeGap => 10 * scale; + double get compactTextGap => math.max(3, 8 * scale); + double get indicatorWidth => + rewardIconSize + indicatorGap + difficultySize.width; + double get expandedTrailingReserve => + indicatorWidth + math.max(8, 12 * scale); + double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); + Size get difficultySize => Size(26, 14); + 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..8a579c2 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart @@ -0,0 +1,79 @@ +part of '../task_timeline_card.dart'; + +class _CardText extends StatelessWidget { + const _CardText({ + required this.card, + required this.color, + required this.metrics, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + + @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..dbc906b --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -0,0 +1,96 @@ +part of '../task_timeline_card.dart'; + +class _CompactCardText extends StatelessWidget { + const _CompactCardText({ + required this.card, + required this.color, + required this.metrics, + required this.onStatusPressed, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + final VoidCallback? onStatusPressed; + + @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), + ); + }, + ); + } + + 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..21740aa --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -0,0 +1,42 @@ +part of '../task_timeline_card.dart'; + +class _ExpandedCardContent extends StatelessWidget { + const _ExpandedCardContent({ + required this.card, + required this.colors, + required this.metrics, + required this.onStatusPressed, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final VoidCallback? onStatusPressed; + + @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..983c6ea --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -0,0 +1,28 @@ +part of '../task_timeline_card.dart'; + +class _NoOpIcon extends StatelessWidget { + const _NoOpIcon({ + required this.icon, + required this.color, + required this.metrics, + }); + + final IconData icon; + final Color color; + final _CardMetrics metrics; + + @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..b42338d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -0,0 +1,70 @@ +part of '../task_timeline_card.dart'; + +class _QuickActions extends StatelessWidget { + const _QuickActions({ + required this.color, + required this.backgroundColor, + required this.metrics, + required this.visible, + }); + + final Color color; + final Color backgroundColor; + final _CardMetrics metrics; + final bool visible; + + @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..6d31f11 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -0,0 +1,57 @@ +part of '../task_timeline_card.dart'; + +class _StatusRing extends StatelessWidget { + const _StatusRing({ + required this.card, + required this.color, + required this.metrics, + this.onPressed, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + final VoidCallback? onPressed; + + @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..4660beb --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -0,0 +1,90 @@ +part of '../task_timeline_card.dart'; + +class _TaskTimelineCardState extends State { + bool _isHovered = false; + + @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..a6aff39 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -0,0 +1,38 @@ +part of '../task_timeline_card.dart'; + +class _TrailingControls extends StatelessWidget { + const _TrailingControls({ + required this.card, + required this.colors, + required this.metrics, + required this.actionsVisible, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final bool actionsVisible; + + @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 46053e2..96b48bf 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 @@ -10,6 +10,17 @@ import '../../theme/focus_flow_tokens.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; +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'; + /// Interactive card that renders a scheduled task on the compact timeline. class TaskTimelineCard extends StatefulWidget { /// Creates a task timeline card. @@ -32,613 +43,3 @@ class TaskTimelineCard extends StatefulWidget { @override State createState() => _TaskTimelineCardState(); } - -class _TaskTimelineCardState extends State { - bool _isHovered = false; - - @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, - ), - ), - ), - ); - }, - ); - } -} - -class _CardMetrics { - const _CardMetrics({required this.scale, required this.height}); - - static const _referenceWidth = 640.0; - static const _referenceHeight = 88.0; - static const _freeSlotReferenceHeight = 108.0; - static const _compactHeightThreshold = 56.0; - static const _minimumScale = 0.16; - - 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); - } - - final double scale; - final double height; - - bool get isCompact => height < _compactHeightThreshold; - - double get cardRadius => - math.min(FocusFlowTokens.cardRadius * scale, height / 2); - double get borderWidth => math.max(0.75, 1.2 * scale); - double get shadowBlur => isCompact ? 0 : 18 * scale; - double get shadowSpread => isCompact ? 0 : scale; - double get statusRingSize { - final availableHeight = math.max(8, height - padding.vertical); - return math.min(24, availableHeight * 0.78).toDouble(); - } - - double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08); - double get statusIconSize => statusRingSize * 0.58; - double get statusGap => isCompact ? 7 : 22 * scale; - double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); - double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); - double get actionGap => math.max(3, 5 * scale); - double get actionsWidth => actionButtonSize * 4 + actionGap; - double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); - double get indicatorGap => math.max(3, 5 * scale); - double get rewardIconSize => 14; - double get titleSize => 10; - double get metaSize => 7; - double get titleMetaGap => 4 * scale; - double get freeSlotTimeGap => 10 * scale; - double get compactTextGap => math.max(3, 8 * scale); - double get indicatorWidth => - rewardIconSize + indicatorGap + difficultySize.width; - double get expandedTrailingReserve => - indicatorWidth + math.max(8, 12 * scale); - double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); - Size get difficultySize => Size(26, 14); - 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); - } -} - -class _ExpandedCardContent extends StatelessWidget { - const _ExpandedCardContent({ - required this.card, - required this.colors, - required this.metrics, - required this.onStatusPressed, - }); - - final TimelineCardModel card; - final _CardColors colors; - final _CardMetrics metrics; - final VoidCallback? onStatusPressed; - - @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, - ), - ), - ), - ], - ); - } -} - -class _StatusRing extends StatelessWidget { - const _StatusRing({ - required this.card, - required this.color, - required this.metrics, - this.onPressed, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - final VoidCallback? onPressed; - - @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, - ), - ), - ), - ); - } -} - -class _CardText extends StatelessWidget { - const _CardText({ - required this.card, - required this.color, - required this.metrics, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - - @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), - ); - }, - ); - } -} - -class _CompactCardText extends StatelessWidget { - const _CompactCardText({ - required this.card, - required this.color, - required this.metrics, - required this.onStatusPressed, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - final VoidCallback? onStatusPressed; - - @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), - ); - }, - ); - } - - 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; - } -} - -class _TrailingControls extends StatelessWidget { - const _TrailingControls({ - required this.card, - required this.colors, - required this.metrics, - required this.actionsVisible, - }); - - final TimelineCardModel card; - final _CardColors colors; - final _CardMetrics metrics; - final bool actionsVisible; - - @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, - ), - ], - ); - } -} - -class _QuickActions extends StatelessWidget { - const _QuickActions({ - required this.color, - required this.backgroundColor, - required this.metrics, - required this.visible, - }); - - final Color color; - final Color backgroundColor; - final _CardMetrics metrics; - final bool visible; - - @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), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _NoOpIcon extends StatelessWidget { - const _NoOpIcon({ - required this.icon, - required this.color, - required this.metrics, - }); - - final IconData icon; - final Color color; - final _CardMetrics metrics; - - @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), - ), - ), - ); - } -} - -class _CardColors { - const _CardColors({ - required this.accent, - required this.background, - required this.reward, - }); - - final Color accent; - final Color background; - final Color reward; - - 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/timeline_axis.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart index a00aa8a..8d795f9 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -6,6 +6,9 @@ import 'package:flutter/material.dart'; import '../../theme/focus_flow_tokens.dart'; import 'timeline_geometry.dart'; +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]. @@ -68,52 +71,3 @@ class TimelineAxis extends StatelessWidget { ); } } - -/// 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; - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), - size: Size.infinite, - ); - } -} - -class _TimelineGridPainter extends CustomPainter { - const _TimelineGridPainter({required this.geometry, required this.topInset}); - - final TimelineGeometry geometry; - final double topInset; - - @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, - ); - } - } - - @override - bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { - return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; - } -} 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 fb1d50e..e6a0a7a 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -10,6 +10,10 @@ import 'task_timeline_card.dart'; import 'timeline_axis.dart'; import 'timeline_geometry.dart'; +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. @@ -48,374 +52,3 @@ class TimelineView extends StatefulWidget { @override State createState() => _TimelineViewState(); } - -class _TimelineViewState extends State { - static const _topInset = 12.0; - - final _scrollController = ScrollController(); - bool _didSetInitialScroll = false; - var _handledScrollRequest = 0; - late TimelineGeometry _geometry; - late double _contentHeight; - late List<_TimelineCardPlacement> _placements; - late int _cardsLayoutHash; - - @override - void initState() { - super.initState(); - _updateLayoutCache(); - } - - @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(); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @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, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - ); - } - - 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); - } - - int _layoutHashFor(List cards) { - return Object.hashAll( - cards.map( - (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), - ), - ); - } - - 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), - ]; - } - - 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; - }); - } - - 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; - }); - } -} - -class _TimelineScrollBehavior extends MaterialScrollBehavior { - const _TimelineScrollBehavior(); - - @override - Set get dragDevices { - return {...super.dragDevices, PointerDeviceKind.mouse}; - } -} - -class _TimelineCardPlacement { - const _TimelineCardPlacement({ - required this.card, - required this.lane, - required this.laneCount, - required this.top, - required this.height, - }); - - static const _laneGap = 8.0; - - final TimelineCardModel card; - final int lane; - final int laneCount; - final double top; - final double height; - - _TimelineCardPlacement withCard(TimelineCardModel nextCard) { - if (identical(card, nextCard)) { - return this; - } - return _TimelineCardPlacement( - card: nextCard, - lane: lane, - laneCount: laneCount, - top: top, - height: height, - ); - } - - 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; - } - - 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, - ), - ), - ]; - } - - static double _visualStart( - TimelineCardModel card, - TimelineGeometry geometry, - ) { - return geometry.yForMinutesSinceMidnight(card.startMinutes); - } - - static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { - final duration = card.endMinutes - card.startMinutes; - return _visualStart(card, geometry) + geometry.heightForDuration(duration); - } - - double left(double trackWidth) { - final laneWidth = _laneWidth(trackWidth); - final laneGap = _laneGapForWidth(trackWidth); - return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); - } - - double width(double trackWidth) { - return _laneWidth(trackWidth); - } - - double _availableWidth(double trackWidth) { - final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; - if (availableWidth < 0) { - return 0; - } - return availableWidth; - } - - double _laneWidth(double trackWidth) { - final availableWidth = _availableWidth(trackWidth); - final laneGap = _laneGapForWidth(trackWidth); - return (availableWidth - laneGap * (laneCount - 1)) / laneCount; - } - - 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_card_placement.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart new file mode 100644 index 0000000..aecc8cd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart @@ -0,0 +1,163 @@ +part of '../timeline_view.dart'; + +class _TimelineCardPlacement { + const _TimelineCardPlacement({ + required this.card, + required this.lane, + required this.laneCount, + required this.top, + required this.height, + }); + + static const _laneGap = 8.0; + + final TimelineCardModel card; + final int lane; + final int laneCount; + final double top; + final double height; + + _TimelineCardPlacement withCard(TimelineCardModel nextCard) { + if (identical(card, nextCard)) { + return this; + } + return _TimelineCardPlacement( + card: nextCard, + lane: lane, + laneCount: laneCount, + top: top, + height: height, + ); + } + + 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; + } + + 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, + ), + ), + ]; + } + + static double _visualStart( + TimelineCardModel card, + TimelineGeometry geometry, + ) { + return geometry.yForMinutesSinceMidnight(card.startMinutes); + } + + static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { + final duration = card.endMinutes - card.startMinutes; + return _visualStart(card, geometry) + geometry.heightForDuration(duration); + } + + double left(double trackWidth) { + final laneWidth = _laneWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); + } + + double width(double trackWidth) { + return _laneWidth(trackWidth); + } + + double _availableWidth(double trackWidth) { + final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; + if (availableWidth < 0) { + return 0; + } + return availableWidth; + } + + double _laneWidth(double trackWidth) { + final availableWidth = _availableWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return (availableWidth - laneGap * (laneCount - 1)) / laneCount; + } + + 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..46472e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart @@ -0,0 +1,10 @@ +part of '../timeline_view.dart'; + +class _TimelineScrollBehavior extends MaterialScrollBehavior { + const _TimelineScrollBehavior(); + + @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..e2eca0c --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -0,0 +1,201 @@ +part of '../timeline_view.dart'; + +class _TimelineViewState extends State { + static const _topInset = 12.0; + + final _scrollController = ScrollController(); + bool _didSetInitialScroll = false; + var _handledScrollRequest = 0; + late TimelineGeometry _geometry; + late double _contentHeight; + late List<_TimelineCardPlacement> _placements; + late int _cardsLayoutHash; + + @override + void initState() { + super.initState(); + _updateLayoutCache(); + } + + @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(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @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, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + 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); + } + + int _layoutHashFor(List cards) { + return Object.hashAll( + cards.map( + (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), + ), + ); + } + + 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), + ]; + } + + 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; + }); + } + + 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/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index 2e6c7af..98c5059 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -5,6 +5,11 @@ 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]. @@ -79,177 +84,3 @@ class TopBar extends StatelessWidget { ); } } - -class _TopBarMetrics { - const _TopBarMetrics(this.scale); - - static const referenceHeight = 48.0; - static const _referenceWidth = 900.0; - static const _minimumScale = 0.44; - - factory _TopBarMetrics.fromWidth(double width) { - final safeWidth = width.isFinite ? width : _referenceWidth; - final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); - return _TopBarMetrics(scale.toDouble()); - } - - final double scale; - - double get titleWidth => 112 * scale; - double get titleSize => FocusFlowTokens.pageTitleSize * scale; - double get titleGap => 30 * scale; - double get controlHeight => referenceHeight * scale; - double get iconButtonSize => referenceHeight * scale; - double get iconSize => 24 * scale; - double get dateWidth => 144 * scale; - double get dateSize => 17 * scale; - double get modeWidth => 220 * scale; - double get modeHeight => referenceHeight * scale; - double get modeLabelSize => 17 * scale; - double get settingsGap => 28 * scale; - double get settingsWidth => 136 * scale; - double get settingsHeight => referenceHeight * scale; - double get settingsIconSize => 20 * scale; - double get settingsTextSize => 15 * scale; - double get settingsTextGap => 8 * scale; - double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; - double get borderWidth => scale < 0.7 ? 0.8 : 1; - EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); -} - -class _SettingsButton extends StatelessWidget { - const _SettingsButton({required this.metrics}); - - final _TopBarMetrics metrics; - - @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, - ), - ), - ], - ), - ), - ); - } -} - -class _IconButton extends StatelessWidget { - const _IconButton({ - required this.icon, - required this.metrics, - required this.onPressed, - }); - - final IconData icon; - final _TopBarMetrics metrics; - final VoidCallback onPressed; - - @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, - ); - } -} - -class _ModeToggle extends StatelessWidget { - const _ModeToggle({required this.metrics}); - - final _TopBarMetrics metrics; - - @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_icon_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart new file mode 100644 index 0000000..f0bf92c --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart @@ -0,0 +1,29 @@ +part of '../top_bar.dart'; + +class _IconButton extends StatelessWidget { + const _IconButton({ + required this.icon, + required this.metrics, + required this.onPressed, + }); + + final IconData icon; + final _TopBarMetrics metrics; + final VoidCallback onPressed; + + @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..37afaf9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart @@ -0,0 +1,38 @@ +part of '../top_bar.dart'; + +class _TopBarMetrics { + const _TopBarMetrics(this.scale); + + static const referenceHeight = 48.0; + static const _referenceWidth = 900.0; + static const _minimumScale = 0.44; + + factory _TopBarMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _TopBarMetrics(scale.toDouble()); + } + + final double scale; + + double get titleWidth => 112 * scale; + double get titleSize => FocusFlowTokens.pageTitleSize * scale; + double get titleGap => 30 * scale; + double get controlHeight => referenceHeight * scale; + double get iconButtonSize => referenceHeight * scale; + double get iconSize => 24 * scale; + double get dateWidth => 144 * scale; + double get dateSize => 17 * scale; + double get modeWidth => 220 * scale; + double get modeHeight => referenceHeight * scale; + double get modeLabelSize => 17 * scale; + double get settingsGap => 28 * scale; + double get settingsWidth => 136 * scale; + double get settingsHeight => referenceHeight * scale; + double get settingsIconSize => 20 * scale; + double get settingsTextSize => 15 * scale; + double get settingsTextGap => 8 * scale; + double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + 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..a5def8d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart @@ -0,0 +1,66 @@ +part of '../top_bar.dart'; + +class _ModeToggle extends StatelessWidget { + const _ModeToggle({required this.metrics}); + + final _TopBarMetrics metrics; + + @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..2a3166a --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart @@ -0,0 +1,45 @@ +part of '../top_bar.dart'; + +class _SettingsButton extends StatelessWidget { + const _SettingsButton({required this.metrics}); + + final _TopBarMetrics metrics; + + @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, + ), + ), + ], + ), + ), + ); + } +} From 891bbec1fdbf6033e211a5ebb085464b437fff55 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 12:46:45 -0700 Subject: [PATCH 13/16] refactor: split package source files --- .../lib/src/encrypted_backup.dart | 244 +- .../backup_decryption_exception.dart | 15 + .../backup_encryption_codec.dart | 79 + .../encrypted_backup/backup_exception.dart | 13 + .../encrypted_backup/backup_file_names.dart | 30 + .../src/encrypted_backup/backup_paths.dart | 43 + .../encrypted_backup_operations.dart | 64 + .../lib/src/application_commands.dart | 1489 +----------- .../application_child_task_draft.dart | 31 + .../application_command_code.dart | 22 + .../application_command_read_hint.dart | 30 + .../application_command_result.dart | 69 + .../application_commands/planning_state.dart | 252 ++ .../v1_application_command_use_cases.dart | 1085 +++++++++ .../lib/src/application_layer.dart | 1997 +--------------- .../application_failure.dart | 19 + .../application_failure_code.dart | 13 + .../application_failure_exception.dart | 9 + .../application_operation_context.dart | 44 + .../application_operation_record.dart | 25 + .../application_operation_repository.dart | 17 + .../application_persistence_exception.dart | 6 + .../application_layer/application_result.dart | 40 + .../application_scheduling_loader.dart | 30 + .../application_unit_of_work.dart | 18 + ...application_unit_of_work_repositories.dart | 22 + .../in_memory_application_repositories.dart | 72 + .../in_memory_application_state.dart | 106 + .../in_memory_application_unit_of_work.dart | 286 +++ .../notice_acknowledgement_record.dart | 20 + .../notice_acknowledgement_repository.dart | 22 + .../src/application_layer/owner_settings.dart | 62 + .../owner_settings_repository.dart | 15 + .../owner_time_zone_context.dart | 16 + .../project_statistics_repository.dart | 20 + .../task_activity_repository.dart | 28 + .../unit_of_work_locked_block_repository.dart | 254 ++ ...ork_notice_acknowledgement_repository.dart | 74 + .../unit_of_work_operation_repository.dart | 154 ++ ...nit_of_work_owner_settings_repository.dart | 70 + .../unit_of_work_project_repository.dart | 140 ++ ...of_work_project_statistics_repository.dart | 83 + ...f_work_scheduling_snapshot_repository.dart | 29 + ...unit_of_work_task_activity_repository.dart | 91 + .../unit_of_work_task_repository.dart | 212 ++ .../lib/src/application_management.dart | 778 +----- .../application_management_command_code.dart | 16 + .../backlog_item_read_model.dart | 15 + .../backlog_query_result.dart | 19 + .../get_backlog_request.dart | 19 + .../get_project_defaults_request.dart | 15 + .../locked_block_draft.dart | 24 + .../locked_block_update.dart | 22 + .../notice_acknowledgement_result.dart | 15 + .../owner_settings_update.dart | 18 + .../owner_settings_update_result.dart | 16 + .../owner_settings_warning_code.dart | 7 + .../parsed_notice_id.dart | 11 + .../project_defaults_query_result.dart | 15 + .../project_profile_draft.dart | 24 + .../project_profile_update.dart | 24 + .../v1_application_management_use_cases.dart | 518 ++++ .../lib/src/application_recovery.dart | 399 +--- .../app_open_recovery_outcome.dart | 8 + .../app_open_recovery_result.dart | 42 + .../run_app_open_recovery_request.dart | 22 + .../v1_app_open_recovery_use_cases.dart | 327 +++ packages/scheduler_core/lib/src/backlog.dart | 294 +-- .../lib/src/backlog/backlog_filter.dart | 27 + .../lib/src/backlog/backlog_sort_key.dart | 24 + .../src/backlog/backlog_staleness_marker.dart | 17 + .../backlog/backlog_staleness_settings.dart | 42 + .../lib/src/backlog/backlog_view.dart | 172 ++ .../lib/src/backlog/indexed_task.dart | 12 + .../scheduler_core/lib/src/child_tasks.dart | 752 +----- .../child_task_break_up_request.dart | 21 + .../child_task_break_up_result.dart | 37 + .../child_task_break_up_service.dart | 74 + .../child_task_completion_result.dart | 50 + .../child_task_completion_service.dart | 267 +++ .../lib/src/child_tasks/child_task_entry.dart | 72 + .../src/child_tasks/child_task_summary.dart | 86 + .../lib/src/child_tasks/child_task_view.dart | 87 + .../lib/src/child_tasks/indexed_child.dart | 58 + .../lib/src/document_mapping.dart | 2097 +---------------- ...pplication_operation_document_mapping.dart | 49 + .../backlog_staleness_document_mapping.dart | 36 + .../document_mapping_exception.dart | 25 + .../document_mapping_failure_code.dart | 11 + .../document_mapping/document_metadata.dart | 18 + .../locked_block_document_mapping.dart | 81 + ...ocked_block_override_document_mapping.dart | 81 + ...ked_block_recurrence_document_mapping.dart | 42 + ...tice_acknowledgement_document_mapping.dart | 44 + .../owner_settings_document_mapping.dart | 60 + .../persistence_enum_mapping.dart | 288 +++ .../project_document_mapping.dart | 72 + ...project_statistics_document_extension.dart | 577 +++++ .../project_statistics_document_mapping.dart | 101 + .../scheduling_change_document_mapping.dart | 42 + .../scheduling_notice_document_mapping.dart | 67 + .../scheduling_overlap_document_mapping.dart | 32 + .../scheduling_snapshot_document_mapping.dart | 110 + .../scheduling_window_document_mapping.dart | 23 + .../task_activity_document_mapping.dart | 56 + .../task_document_extension.dart | 21 + .../task_document_mapping.dart | 136 ++ .../task_statistics_document_extension.dart | 8 + .../task_statistics_document_mapping.dart | 90 + .../time_interval_document_mapping.dart | 27 + .../lib/src/document_migration.dart | 1142 +-------- .../document_migration_entity.dart | 9 + .../document_migration_failure_code.dart | 9 + .../document_migration_note.dart | 14 + .../document_migration_note_code.dart | 7 + .../document_migration_provenance.dart | 6 + .../document_migration_report.dart | 30 + .../document_migration_result.dart | 19 + .../document_migration_status.dart | 8 + .../legacy_document_exception.dart | 11 + .../legacy_metadata_instants.dart | 602 +++++ .../schema_version_read.dart | 9 + .../src/document_migration/v0_migrator.dart | 6 + .../v1_document_migration_service.dart | 412 ++++ .../scheduler_core/lib/src/free_slots.dart | 322 +-- .../lib/src/free_slots/free_slot_service.dart | 256 ++ .../protected_free_slot_conflict.dart | 23 + .../required_commitment_schedule_result.dart | 27 + .../required_commitment_schedule_status.dart | 16 + .../scheduler_core/lib/src/locked_time.dart | 958 +------- .../lib/src/locked_time/clock_time.dart | 4 + .../lib/src/locked_time/locked_block.dart | 121 + .../locked_time/locked_block_occurrence.dart | 48 + .../locked_time/locked_block_override.dart | 189 ++ .../locked_block_override_type.dart | 16 + .../locked_time/locked_block_recurrence.dart | 30 + .../locked_schedule_expansion.dart | 529 +++++ .../lib/src/locked_time/locked_weekday.dart | 21 + packages/scheduler_core/lib/src/models.dart | 786 +----- .../lib/src/models/backlog_tag.dart | 11 + .../lib/src/models/difficulty_level.dart | 27 + .../src/models/domain_validation_code.dart | 31 + .../models/domain_validation_exception.dart | 14 + .../lib/src/models/priority_level.dart | 25 + .../lib/src/models/project_profile.dart | 156 ++ .../lib/src/models/reminder_profile.dart | 20 + .../lib/src/models/reward_level.dart | 26 + .../scheduler_core/lib/src/models/task.dart | 288 +++ .../lib/src/models/task_status.dart | 31 + .../lib/src/models/task_type.dart | 31 + .../lib/src/models/time_interval.dart | 126 + .../lib/src/occupancy_policy.dart | 339 +-- .../occupancy_policy/occupancy_category.dart | 28 + .../src/occupancy_policy/occupancy_entry.dart | 51 + .../occupancy_policy/occupancy_policy.dart | 247 ++ .../occupancy_policy/occupancy_source.dart | 13 + .../lib/src/persistence_contract.dart | 1079 +-------- ...application_operation_document_fields.dart | 21 + .../backlog_staleness_document_fields.dart | 12 + .../clock_time_document_fields.dart | 10 + .../persistence_contract/document_fields.dart | 31 + .../locked_block_document_fields.dart | 31 + ...locked_block_override_document_fields.dart | 31 + ...cked_block_recurrence_document_fields.dart | 12 + ...otice_acknowledgement_document_fields.dart | 19 + .../owner_settings_document_fields.dart | 25 + .../persistence_civil_date_convention.dart | 18 + .../persistence_collections.dart | 31 + .../persistence_date_time_convention.dart | 23 + .../persistence_document_field_sets.dart | 27 + .../persistence_enum_name.dart | 10 + .../persistence_guard_result.dart | 33 + .../persistence_index_catalog.dart | 300 +++ .../persistence_index_direction.dart | 7 + .../persistence_index_field.dart | 12 + .../persistence_index_spec.dart | 20 + .../persistence_payload_guard.dart | 35 + .../persistence_payload_limits.dart | 13 + .../persistence_wall_time_convention.dart | 18 + .../project_document_fields.dart | 31 + .../project_statistics_document_fields.dart | 35 + .../repository_query_names.dart | 37 + .../scheduling_change_document_fields.dart | 18 + .../scheduling_notice_document_fields.dart | 22 + .../scheduling_overlap_document_fields.dart | 14 + .../scheduling_snapshot_document_fields.dart | 41 + .../scheduling_window_document_fields.dart | 12 + .../task_activity_document_fields.dart | 27 + .../task_document_fields.dart | 53 + .../task_statistics_document_fields.dart | 36 + .../time_interval_document_fields.dart | 14 + .../lib/src/project_statistics.dart | 700 +----- .../project_completion_time_bucket.dart | 10 + .../project_default_resolution.dart | 21 + .../project_statistics.dart | 207 ++ ...roject_statistics_aggregation_service.dart | 59 + ...project_statistics_application_result.dart | 15 + .../project_suggestion.dart | 31 + .../project_suggestion_confidence.dart | 8 + .../project_suggestion_policy.dart | 18 + .../project_suggestion_service.dart | 301 +++ .../project_suggestion_set.dart | 20 + .../project_suggestion_type.dart | 10 + .../scheduler_core/lib/src/quick_capture.dart | 283 +-- .../quick_capture/quick_capture_request.dart | 57 + .../quick_capture/quick_capture_result.dart | 31 + .../quick_capture/quick_capture_service.dart | 177 ++ .../quick_capture/quick_capture_status.dart | 18 + .../lib/src/reminder_policy.dart | 332 +-- .../effective_reminder_profile.dart | 15 + .../effective_reminder_profile_source.dart | 8 + .../reminder_policy/reminder_directive.dart | 47 + .../reminder_directive_action.dart | 9 + .../reminder_directive_reason.dart | 16 + .../reminder_policy_service.dart | 237 ++ .../scheduler_core/lib/src/repositories.dart | 1215 +--------- .../repositories/backlog_candidate_query.dart | 16 + .../in_memory_locked_block_repository.dart | 302 +++ .../in_memory_project_repository.dart | 166 ++ ...memory_scheduling_snapshot_repository.dart | 108 + .../in_memory_task_repository.dart | 244 ++ .../repositories/locked_block_repository.dart | 73 + .../src/repositories/project_repository.dart | 44 + .../src/repositories/repository_failure.dart | 16 + .../repositories/repository_failure_code.dart | 11 + .../repository_mutation_result.dart | 22 + .../lib/src/repositories/repository_page.dart | 14 + .../repositories/repository_page_request.dart | 15 + .../src/repositories/repository_record.dart | 18 + .../scheduling_snapshot_repository.dart | 13 + .../scheduling_state_snapshot.dart | 74 + .../lib/src/repositories/task_repository.dart | 79 + .../lib/src/repository_values.dart | 89 +- .../lib/src/repository_values/owner_id.dart | 20 + .../lib/src/repository_values/page.dart | 26 + .../src/repository_values/page_request.dart | 15 + .../lib/src/repository_values/revision.dart | 29 + .../lib/src/scheduling_engine.dart | 1752 +------------- .../backlog_insertion_plan.dart | 13 + .../src/scheduling_engine/placement_item.dart | 23 + .../scheduling_engine/scheduling_change.dart | 31 + .../scheduling_conflict_code.dart | 8 + .../scheduling_engine/scheduling_engine.dart | 1309 ++++++++++ .../scheduling_engine/scheduling_input.dart | 97 + .../scheduling_issue_code.dart | 14 + .../scheduling_movement_code.dart | 12 + .../scheduling_engine/scheduling_notice.dart | 40 + .../scheduling_notice_type.dart | 23 + .../scheduling_operation_code.dart | 31 + .../scheduling_outcome_code.dart | 25 + .../scheduling_engine/scheduling_overlap.dart | 23 + .../scheduling_engine/scheduling_result.dart | 57 + .../scheduling_engine/scheduling_window.dart | 46 + .../scheduler_core/lib/src/task_actions.dart | 1097 +-------- .../flexible_task_action_result.dart | 38 + .../flexible_task_action_service.dart | 264 +++ .../flexible_task_quick_action.dart | 20 + .../src/task_actions/push_destination.dart | 17 + .../task_actions/push_destination_result.dart | 29 + .../task_actions/required_task_action.dart | 20 + .../required_task_action_result.dart | 31 + .../required_task_action_service.dart | 81 + .../surprise_task_log_request.dart | 49 + .../surprise_task_log_result.dart | 31 + .../surprise_task_log_service.dart | 517 ++++ .../lib/src/task_lifecycle.dart | 959 +------- .../lib/src/task_lifecycle/task_activity.dart | 43 + .../task_activity_accounting_service.dart | 43 + .../task_activity_application_result.dart | 15 + .../task_lifecycle/task_activity_code.dart | 14 + .../task_lifecycle/task_transition_code.dart | 14 + .../task_transition_outcome_code.dart | 9 + .../task_transition_result.dart | 30 + .../task_transition_service.dart | 791 +++++++ .../lib/src/time_contracts.dart | 306 +-- .../lib/src/time_contracts/civil_date.dart | 83 + .../lib/src/time_contracts/clock.dart | 6 + .../lib/src/time_contracts/fixed_clock.dart | 11 + .../fixed_offset_time_zone_resolver.dart | 44 + .../lib/src/time_contracts/id_generator.dart | 6 + .../time_contracts/local_time_resolution.dart | 8 + .../nonexistent_local_time_policy.dart | 9 + .../repeated_local_time_policy.dart | 12 + .../resolved_local_date_time.dart | 19 + .../sequential_id_generator.dart | 19 + .../lib/src/time_contracts/system_clock.dart | 10 + .../time_zone_resolution_options.dart | 11 + .../time_contracts/time_zone_resolver.dart | 11 + .../lib/src/time_contracts/wall_time.dart | 57 + .../lib/src/timeline_state.dart | 461 +--- .../compact_timeline_state.dart | 48 + .../timeline_background_token.dart | 11 + .../timeline_difficulty_icon_token.dart | 11 + .../lib/src/timeline_state/timeline_item.dart | 63 + .../timeline_item_category.dart | 10 + .../timeline_state/timeline_item_mapper.dart | 295 +++ .../timeline_state/timeline_quick_action.dart | 12 + .../timeline_reward_icon_token.dart | 11 + .../scheduler_core/lib/src/today_state.dart | 648 +---- .../today_state/get_today_state_query.dart | 372 +++ .../today_state/get_today_state_request.dart | 28 + .../src/today_state/today_compact_state.dart | 49 + .../src/today_state/today_pending_notice.dart | 47 + .../lib/src/today_state/today_state.dart | 60 + .../src/today_state/today_timeline_item.dart | 85 + .../today_timeline_item_source.dart | 7 + .../lib/src/export_controller.dart | 311 +-- .../export_controller/csv_cell_encoder.dart | 8 + .../export_controller/csv_export_writer.dart | 62 + .../src/export_controller/export_context.dart | 20 + .../export_controller/export_controller.dart | 58 + .../export_controller/export_exception.dart | 13 + .../export_format_normalizer.dart | 13 + .../export_json_context_encoder.dart | 9 + .../export_repository_exception.dart | 11 + .../src/export_controller/export_writer.dart | 13 + .../export_writer_factory.dart | 4 + .../export_writer_registry.dart | 44 + .../export_controller/json_export_writer.dart | 56 + .../lib/src/json_csv_writers.dart | 136 +- .../json_csv_writers/csv_cell_encoder.dart | 8 + .../json_csv_writers/csv_export_writer.dart | 62 + .../export_json_context_encoder.dart | 9 + .../json_csv_writers/json_export_writer.dart | 57 + .../lib/src/notification_adapter.dart | 155 +- .../fake_notification_adapter.dart | 50 + .../notification_adapter.dart | 13 + .../notification_feedback.dart | 24 + .../notification_feedback_type.dart | 13 + .../notification_request.dart | 44 + .../notification_request_impl.dart | 11 + .../src/desktop_notification_adapter_io.dart | 234 +- .../apple_script_string_encoder.dart | 5 + .../desktop_notification_adapter.dart | 38 + .../desktop_notification_backend.dart | 13 + .../desktop_notification_backend_factory.dart | 28 + .../desktop_notification_log_sink.dart | 4 + .../desktop_notification_platform.dart | 16 + ...esktop_notification_platform_detector.dart | 8 + .../desktop_process_runner.dart | 7 + .../linux_notify_send_backend.dart | 48 + .../mac_os_notification_backend.dart | 36 + .../stdout_notification_backend.dart | 31 + .../desktop_notification_adapter_stub.dart | 121 +- .../default_log_sink.dart | 6 + .../desktop_notification_adapter.dart | 38 + .../desktop_notification_backend.dart | 13 + .../desktop_notification_backend_factory.dart | 13 + .../desktop_notification_log_sink.dart | 4 + .../desktop_notification_platform.dart | 16 + .../stdout_notification_backend.dart | 31 + .../lib/persistence.dart | 332 +-- .../lib/persistence/either.dart | 24 + .../lib/persistence/left.dart | 10 + .../persistence/locked_block_repository.dart | 64 + .../lib/persistence/project_repository.dart | 38 + .../lib/persistence/repo_result.dart | 4 + .../persistence/repository_duplicate_id.dart | 7 + .../lib/persistence/repository_failure.dart | 23 + .../repository_invalid_revision.dart | 11 + .../lib/persistence/repository_not_found.dart | 7 + .../repository_owner_mismatch.dart | 7 + .../repository_stale_revision.dart | 12 + .../repository_storage_failure.dart | 7 + .../lib/persistence/right.dart | 10 + .../schedule_snapshot_repository.dart | 29 + .../lib/persistence/settings_repository.dart | 22 + .../lib/persistence/task_repository.dart | 57 + .../lib/persistence_memory.dart | 845 +------ .../persistence_memory/identified_record.dart | 5 + .../in_memory_locked_block_repository.dart | 191 ++ .../in_memory_project_repository.dart | 125 + ...n_memory_schedule_snapshot_repository.dart | 251 ++ .../in_memory_settings_repository.dart | 78 + .../in_memory_task_repository.dart | 141 ++ .../lib/persistence_memory/record.dart | 36 + .../persistence_memory/snapshot_record.dart | 18 + .../lib/src/scheduler_db.dart | 234 +- .../src/scheduler_db/locked_block_dao.dart | 8 + .../lib/src/scheduler_db/locked_blocks.dart | 23 + .../src/scheduler_db/locked_overrides.dart | 22 + .../lib/src/scheduler_db/project_dao.dart | 7 + .../lib/src/scheduler_db/projects.dart | 25 + .../lib/src/scheduler_db/scheduler_db.dart | 33 + .../lib/src/scheduler_db/settings_dao.dart | 8 + .../lib/src/scheduler_db/settings_table.dart | 22 + .../lib/src/scheduler_db/snapshot_dao.dart | 8 + .../lib/src/scheduler_db/snapshots.dart | 29 + .../lib/src/scheduler_db/task_dao.dart | 7 + .../lib/src/scheduler_db/tasks.dart | 42 + .../lib/src/sqlite_repositories.dart | 1357 +---------- .../sqlite_locked_block_repository.dart | 304 +++ .../sqlite_project_repository.dart | 184 ++ .../sqlite_schedule_snapshot_repository.dart | 541 +++++ .../sqlite_settings_repository.dart | 113 + .../sqlite_task_repository.dart | 215 ++ 396 files changed, 24606 insertions(+), 23881 deletions(-) create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart create mode 100644 packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_code.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/application_command_result.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/planning_state.dart create mode 100644 packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_context.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_record.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_result.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_settings.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart create mode 100644 packages/scheduler_core/lib/src/application_management/application_management_command_code.dart create mode 100644 packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart create mode 100644 packages/scheduler_core/lib/src/application_management/backlog_query_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/get_backlog_request.dart create mode 100644 packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart create mode 100644 packages/scheduler_core/lib/src/application_management/locked_block_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_management/locked_block_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart create mode 100644 packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_profile_draft.dart create mode 100644 packages/scheduler_core/lib/src/application_management/project_profile_update.dart create mode 100644 packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart create mode 100644 packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_filter.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart create mode 100644 packages/scheduler_core/lib/src/backlog/backlog_view.dart create mode 100644 packages/scheduler_core/lib/src/backlog/indexed_task.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/child_task_view.dart create mode 100644 packages/scheduler_core/lib/src/child_tasks/indexed_child.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/document_metadata.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_note.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_report.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_result.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/document_migration_status.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/schema_version_read.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/v0_migrator.dart create mode 100644 packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/free_slot_service.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart create mode 100644 packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/clock_time.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_override.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart create mode 100644 packages/scheduler_core/lib/src/locked_time/locked_weekday.dart create mode 100644 packages/scheduler_core/lib/src/models/backlog_tag.dart create mode 100644 packages/scheduler_core/lib/src/models/difficulty_level.dart create mode 100644 packages/scheduler_core/lib/src/models/domain_validation_code.dart create mode 100644 packages/scheduler_core/lib/src/models/domain_validation_exception.dart create mode 100644 packages/scheduler_core/lib/src/models/priority_level.dart create mode 100644 packages/scheduler_core/lib/src/models/project_profile.dart create mode 100644 packages/scheduler_core/lib/src/models/reminder_profile.dart create mode 100644 packages/scheduler_core/lib/src/models/reward_level.dart create mode 100644 packages/scheduler_core/lib/src/models/task.dart create mode 100644 packages/scheduler_core/lib/src/models/task_status.dart create mode 100644 packages/scheduler_core/lib/src/models/task_type.dart create mode 100644 packages/scheduler_core/lib/src/models/time_interval.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart create mode 100644 packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart create mode 100644 packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart create mode 100644 packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart create mode 100644 packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart create mode 100644 packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/locked_block_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/project_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_failure.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_failure_code.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_page.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_page_request.dart create mode 100644 packages/scheduler_core/lib/src/repositories/repository_record.dart create mode 100644 packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart create mode 100644 packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart create mode 100644 packages/scheduler_core/lib/src/repositories/task_repository.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/owner_id.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/page.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/page_request.dart create mode 100644 packages/scheduler_core/lib/src/repository_values/revision.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart create mode 100644 packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/push_destination.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/push_destination_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart create mode 100644 packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart create mode 100644 packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/civil_date.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/id_generator.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/system_clock.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart create mode 100644 packages/scheduler_core/lib/src/time_contracts/wall_time.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart create mode 100644 packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart create mode 100644 packages/scheduler_core/lib/src/today_state/get_today_state_query.dart create mode 100644 packages/scheduler_core/lib/src/today_state/get_today_state_request.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_compact_state.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_pending_notice.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_state.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_timeline_item.dart create mode 100644 packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_context.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_controller.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_exception.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart create mode 100644 packages/scheduler_export/lib/src/export_controller/json_export_writer.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart create mode 100644 packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart create mode 100644 packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart create mode 100644 packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart create mode 100644 packages/scheduler_persistence/lib/persistence/either.dart create mode 100644 packages/scheduler_persistence/lib/persistence/left.dart create mode 100644 packages/scheduler_persistence/lib/persistence/locked_block_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/project_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repo_result.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_failure.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_not_found.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart create mode 100644 packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart create mode 100644 packages/scheduler_persistence/lib/persistence/right.dart create mode 100644 packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/settings_repository.dart create mode 100644 packages/scheduler_persistence/lib/persistence/task_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/record.dart create mode 100644 packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart create mode 100644 packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart diff --git a/packages/scheduler_backup/lib/src/encrypted_backup.dart b/packages/scheduler_backup/lib/src/encrypted_backup.dart index 1bee79f..706f997 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup.dart @@ -7,6 +7,12 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; +part 'encrypted_backup/backup_decryption_exception.dart'; +part 'encrypted_backup/backup_encryption_codec.dart'; +part 'encrypted_backup/backup_exception.dart'; +part 'encrypted_backup/backup_file_names.dart'; +part 'encrypted_backup/backup_paths.dart'; +part 'encrypted_backup/encrypted_backup_operations.dart'; /// Default scheduler data directory name under the user's home directory. const defaultSchedulerDirectoryName = 'ADHD_Scheduler'; @@ -25,241 +31,3 @@ const _nonceLength = 12; const _keyLength = 32; 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/backup_decryption_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart new file mode 100644 index 0000000..31fd2c8 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart @@ -0,0 +1,15 @@ +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; + + @override + String toString() => 'BackupDecryptionException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart new file mode 100644 index 0000000..32d3c6f --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart @@ -0,0 +1,79 @@ +part of '../encrypted_backup.dart'; + +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)), + ); +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart new file mode 100644 index 0000000..1a8b466 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart @@ -0,0 +1,13 @@ +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; + + @override + String toString() => 'BackupException: $message'; +} diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart new file mode 100644 index 0000000..4a010ae --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart @@ -0,0 +1,30 @@ +part of '../encrypted_backup.dart'; + +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'); diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart b/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart new file mode 100644 index 0000000..7890550 --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart @@ -0,0 +1,43 @@ +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, + )); +} + +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/encrypted_backup_operations.dart b/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart new file mode 100644 index 0000000..b5bb58e --- /dev/null +++ b/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart @@ -0,0 +1,64 @@ +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_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index cf39363..f731a88 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -18,1486 +18,9 @@ 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, - uncompleteTask, - 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; -} - -/// UI-facing V1 command use cases. -class V1ApplicationCommandUseCases { - const V1ApplicationCommandUseCases({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - this.quickCaptureService = const QuickCaptureService(), - this.flexibleTaskActionService = const FlexibleTaskActionService(), - this.requiredTaskActionService = const RequiredTaskActionService(), - this.surpriseTaskLogService = const SurpriseTaskLogService(), - this.freeSlotService = const FreeSlotService(), - this.childTaskBreakUpService = const ChildTaskBreakUpService(), - this.childTaskCompletionService = const ChildTaskCompletionService(), - this.projectStatisticsAggregationService = - const ProjectStatisticsAggregationService(), - }); - - /// Atomic repository boundary. - final ApplicationUnitOfWork applicationStore; - - /// Explicit local-time resolver for planning windows and locked expansion. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - final QuickCaptureService quickCaptureService; - final FlexibleTaskActionService flexibleTaskActionService; - final RequiredTaskActionService requiredTaskActionService; - final SurpriseTaskLogService surpriseTaskLogService; - final FreeSlotService freeSlotService; - final ChildTaskBreakUpService childTaskBreakUpService; - final ChildTaskCompletionService childTaskCompletionService; - final ProjectStatisticsAggregationService projectStatisticsAggregationService; - - /// Quick capture a task into backlog. - Future> quickCaptureToBacklog({ - required ApplicationOperationContext context, - required String title, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - TaskType type = TaskType.flexible, - int? durationMinutes, - Set backlogTags = const {}, - }) { - const commandCode = ApplicationCommandCode.quickCaptureToBacklog; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final result = quickCaptureService.capture( - QuickCaptureRequest( - id: context.idGenerator.nextId(), - title: title, - createdAt: context.now, - addToNextAvailableSlot: false, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - type: type, - durationMinutes: durationMinutes, - backlogTags: backlogTags, - ), - updatedAt: context.now, - ); - if (!result.isValid) { - return _failure( - ApplicationFailureCode.validation, - detailCode: QuickCaptureStatus.validationError.name, - ); - } - - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: const [], - afterTasks: [result.task], - activities: const [], - readHint: ApplicationCommandReadHint( - affectedTaskIds: [result.task.id], - refreshToday: false, - ), - ); - }, - ); - } - - /// Quick capture a flexible task and schedule it into the next available slot. - Future> - quickCaptureToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - required int durationMinutes, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - }) { - const commandCode = ApplicationCommandCode.quickCaptureToNextAvailableSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final taskId = context.idGenerator.nextId(); - final result = quickCaptureService.capture( - QuickCaptureRequest( - id: taskId, - title: title, - createdAt: context.now, - addToNextAvailableSlot: true, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - durationMinutes: durationMinutes, - ), - schedulingInput: state.input, - updatedAt: context.now, - ); - if (!result.isValid) { - return _failureForQuickCapture(result); - } - - final activities = _activitiesForSchedulingChanges( - result: result.schedulingResult, - beforeTasks: [result.task, ...state.tasks], - operationId: context.operationId, - occurredAt: context.now, - selectedTaskId: result.task.id, - selectedActivityCode: TaskActivityCode.restoredFromBacklog, - movedActivityCode: TaskActivityCode.automaticallyPushed, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: result.schedulingResult?.tasks ?? [result.task], - activities: activities, - schedulingResult: result.schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _taskIdsFromTasks([result.task]), - ), - ); - }, - ); - } - - /// Schedule an existing backlog item into the next available flexible slot. - Future> - scheduleBacklogItemToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - int? durationMinutes, - DateTime? expectedUpdatedAt, - }) { - const commandCode = - ApplicationCommandCode.scheduleBacklogItemToNextAvailableSlot; - 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 (durationMinutes != null && durationMinutes <= 0) { - return _failure( - ApplicationFailureCode.validation, - entityId: taskId, - detailCode: DomainValidationCode.nonPositiveDuration.name, - ); - } - if (durationMinutes != null) { - task = task.copyWith( - durationMinutes: durationMinutes, - updatedAt: context.now, - ); - state = state.replaceTask(task); - } - - final schedulingResult = - const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( - input: state.input, - taskId: taskId, - updatedAt: context.now, - ); - final schedulingFailure = _failureForSchedulingResult( - schedulingResult, - entityId: taskId, - ); - if (schedulingFailure != null) { - return ApplicationResult.failure(schedulingFailure); - } - - final activities = _activitiesForSchedulingChanges( - result: schedulingResult, - beforeTasks: state.tasks, - operationId: context.operationId, - occurredAt: context.now, - selectedTaskId: taskId, - selectedActivityCode: TaskActivityCode.restoredFromBacklog, - movedActivityCode: TaskActivityCode.automaticallyPushed, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: schedulingResult.tasks, - activities: activities, - schedulingResult: schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), - ), - ); - }, - ); - } - - /// Push a planned flexible task later in the same local day. - Future> - pushFlexibleToNextAvailableSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - return _pushFlexible( - commandCode: ApplicationCommandCode.pushFlexibleToNextAvailableSlot, - context: context, - localDate: localDate, - taskId: taskId, - destination: PushDestination.nextAvailableSlot, - expectedUpdatedAt: expectedUpdatedAt, - ); - } - - /// Push a planned flexible task to the top of a future/tomorrow queue. - Future> - pushFlexibleToTomorrowTopOfQueue({ - required ApplicationOperationContext context, - required CivilDate targetDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - return _pushFlexible( - commandCode: ApplicationCommandCode.pushFlexibleToTomorrowTopOfQueue, - context: context, - localDate: targetDate, - taskId: taskId, - destination: PushDestination.tomorrowTopOfQueue, - expectedUpdatedAt: expectedUpdatedAt, - ); - } - - /// Move a planned flexible task to backlog. - Future> moveFlexibleToBacklog({ - required ApplicationOperationContext context, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.moveFlexibleToBacklog; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = flexibleTaskActionService.apply( - task: task, - action: FlexibleTaskQuickAction.backlog, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [actionResult.task], - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - affectedTaskIds: [taskId], - refreshToday: true, - ), - ); - }, - ); - } - - /// Complete a flexible task. - Future> completeFlexibleTask({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - DateTime? actualStart, - DateTime? actualEnd, - }) { - const commandCode = ApplicationCommandCode.completeFlexibleTask; - 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); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = flexibleTaskActionService.apply( - task: task, - action: FlexibleTaskQuickAction.done, - updatedAt: context.now, - operationId: context.operationId, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: state.lockedIntervals, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: _replaceTask(state.tasks, actionResult.task), - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// 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, - required CivilDate localDate, - required String taskId, - required RequiredTaskAction action, - DateTime? expectedUpdatedAt, - DateTime? actualStart, - DateTime? actualEnd, - }) { - const commandCode = ApplicationCommandCode.applyRequiredTaskAction; - 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); - } - final existingActivities = await repositories.taskActivities.findAll(); - final actionResult = requiredTaskActionService.apply( - task: task, - action: action, - updatedAt: context.now, - operationId: context.operationId, - actualStart: actualStart, - actualEnd: actualEnd, - lockedIntervals: state.lockedIntervals, - existingActivities: existingActivities, - ); - if (actionResult.transitionOutcomeCode == - TaskTransitionOutcomeCode.invalidState) { - return _failure( - ApplicationFailureCode.conflict, - entityId: taskId, - detailCode: TaskTransitionOutcomeCode.invalidState.name, - ); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: _replaceTask(state.tasks, actionResult.task), - activities: actionResult.activities, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Log completed surprise work and repair overlapping flexible work. - Future> logSurpriseTask({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - DateTime? startedAt, - int? timeUsedMinutes, - String projectId = 'inbox', - PriorityLevel? priority, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - bool revealHiddenLockedDetails = false, - }) { - const commandCode = ApplicationCommandCode.logSurpriseTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final logResult = surpriseTaskLogService.log( - request: SurpriseTaskLogRequest( - id: context.operationId, - title: title, - createdAt: context.now, - startedAt: startedAt, - timeUsedMinutes: timeUsedMinutes, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - ), - input: state.input, - updatedAt: context.now, - revealHiddenLockedDetails: revealHiddenLockedDetails, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: logResult.schedulingResult.tasks, - activities: logResult.activities, - schedulingResult: logResult.schedulingResult, - protectedFreeSlotConflicts: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: _changedTaskIds( - state.tasks, - logResult.schedulingResult.tasks, - ), - ), - ); - }, - ); - } - - /// Create a protected Free Slot task. - Future> createProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String title, - required DateTime start, - required DateTime end, - String projectId = 'inbox', - }) { - const commandCode = ApplicationCommandCode.createProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final freeSlot = freeSlotService.create( - id: context.idGenerator.nextId(), - title: title, - start: start, - end: end, - createdAt: context.now, - updatedAt: context.now, - projectId: projectId, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: const [], - afterTasks: [freeSlot], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [freeSlot.id], - ), - ); - }, - ); - } - - /// Update a protected Free Slot task. - Future> updateProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - required DateTime start, - required DateTime end, - DateTime? expectedUpdatedAt, - String? title, - String? projectId, - }) { - const commandCode = ApplicationCommandCode.updateProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final updated = freeSlotService.update( - freeSlot: task, - start: start, - end: end, - updatedAt: context.now, - title: title, - projectId: projectId, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [updated], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Remove a protected Free Slot from the active timeline. - Future> removeProtectedFreeSlot({ - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.removeProtectedFreeSlot; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final task = await repositories.tasks.findById(taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - if (task.type != TaskType.freeSlot) { - return _failure( - ApplicationFailureCode.validation, - entityId: taskId, - detailCode: 'invalidFreeSlotType', - ); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final removed = task.copyWith( - status: TaskStatus.cancelled, - updatedAt: context.now, - clearSchedule: true, - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: [task], - afterTasks: [removed], - activities: const [], - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: [taskId], - ), - ); - }, - ); - } - - /// Break a parent task into direct child tasks. - Future> breakUpTask({ - required ApplicationOperationContext context, - required String parentTaskId, - required List children, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.breakUpTask; - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final parent = _taskById(tasks, parentTaskId); - if (parent == null) { - return _failure( - ApplicationFailureCode.notFound, - entityId: parentTaskId, - ); - } - final staleFailure = _staleFailure(parent, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final entries = children.map((draft) { - return ChildTaskEntry( - id: context.idGenerator.nextId(), - title: draft.title, - createdAt: context.now, - priority: draft.priority, - reward: draft.reward, - difficulty: draft.difficulty, - durationMinutes: draft.durationMinutes, - projectId: draft.projectId, - updatedAt: context.now, - ); - }).toList(growable: false); - final breakUpResult = childTaskBreakUpService.breakUp( - tasks: tasks, - request: ChildTaskBreakUpRequest( - parentTaskId: parentTaskId, - entries: entries, - operationId: context.operationId, - ), - ); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: tasks, - afterTasks: breakUpResult.tasks, - activities: breakUpResult.activities, - childTaskIds: breakUpResult.createdChildTaskIds, - readHint: ApplicationCommandReadHint( - affectedTaskIds: [ - parentTaskId, - ...breakUpResult.createdChildTaskIds - ], - refreshToday: false, - ), - ); - }, - ); - } - - /// Complete one child task and auto-complete its parent if appropriate. - Future> completeChildTask({ - required ApplicationOperationContext context, - required String childTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeChildTask; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: childTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeChild( - tasks: tasks, - childTaskId: childTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - /// Complete a parent and force-complete unfinished direct children. - Future> completeParentTask({ - required ApplicationOperationContext context, - required String parentTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeParentTask; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: parentTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeParent( - tasks: tasks, - parentTaskId: parentTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - /// Complete a parent from one of its direct children. - Future> completeParentFromChild({ - required ApplicationOperationContext context, - required String childTaskId, - DateTime? expectedUpdatedAt, - }) { - const commandCode = ApplicationCommandCode.completeParentFromChild; - return _completeChildOrParent( - commandCode: commandCode, - context: context, - taskId: childTaskId, - expectedUpdatedAt: expectedUpdatedAt, - apply: (tasks, existingActivities) => - childTaskCompletionService.completeParentFromChild( - tasks: tasks, - childTaskId: childTaskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ), - ); - } - - Future> _pushFlexible({ - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required CivilDate localDate, - required String taskId, - required PushDestination destination, - DateTime? expectedUpdatedAt, - }) { - 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); - } - final existingActivities = await repositories.taskActivities.findAll(); - final pushResult = flexibleTaskActionService.applyPushDestination( - destination: destination, - input: state.input, - taskId: taskId, - updatedAt: context.now, - operationId: context.operationId, - existingActivities: existingActivities, - ); - final schedulingFailure = _failureForSchedulingResult( - pushResult.schedulingResult, - entityId: taskId, - ); - if (schedulingFailure != null) { - return ApplicationResult.failure(schedulingFailure); - } - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: state.tasks, - afterTasks: pushResult.schedulingResult.tasks, - activities: pushResult.activities, - schedulingResult: pushResult.schedulingResult, - readHint: ApplicationCommandReadHint( - localDate: localDate, - affectedTaskIds: - _taskIdsFromChanges(pushResult.schedulingResult.changes), - ), - ); - }, - ); - } - - Future> _completeChildOrParent({ - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required String taskId, - required ChildTaskCompletionResult Function( - List tasks, - List existingActivities, - ) apply, - DateTime? expectedUpdatedAt, - }) { - return applicationStore.run( - context: context, - operationName: commandCode.name, - action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final task = _taskById(tasks, taskId); - if (task == null) { - return _failure(ApplicationFailureCode.notFound, entityId: taskId); - } - final staleFailure = _staleFailure(task, expectedUpdatedAt); - if (staleFailure != null) { - return ApplicationResult.failure(staleFailure); - } - final existingActivities = await repositories.taskActivities.findAll(); - final result = apply(tasks, existingActivities); - return _persist( - repositories: repositories, - commandCode: commandCode, - context: context, - beforeTasks: tasks, - afterTasks: result.tasks, - activities: result.activities, - childTaskIds: [ - if (result.completedChildId != null) result.completedChildId!, - ...result.forceCompletedChildIds, - ], - readHint: ApplicationCommandReadHint( - affectedTaskIds: result.changedTaskIds, - refreshToday: true, - ), - ); - }, - ); - } - - Future<_PlanningState> _loadPlanningState( - ApplicationUnitOfWorkRepositories repositories, - ApplicationOperationContext context, - CivilDate localDate, - ) async { - final ownerId = context.ownerTimeZone.ownerId; - final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? - OwnerSettings( - ownerId: ownerId, - timeZoneId: context.ownerTimeZone.timeZoneId, - ); - final dayStart = _resolve( - date: localDate, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, - ); - final dayEnd = _resolve( - date: localDate, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, - ); - final window = SchedulingWindow(start: dayStart, end: dayEnd); - final tasks = (await repositories.tasks.findForLocalDay( - ownerId: ownerId, - localDate: localDate, - window: window, - )) - .items; - final lockedExpansion = expandLockedBlocksForDay( - blocks: (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: ownerId, - )) - .items, - overrides: (await repositories.lockedBlocks.findOverridesForDate( - ownerId: ownerId, - date: localDate, - )) - .items, - date: localDate, - timeZoneId: settings.timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - - return _PlanningState( - tasks: tasks, - window: window, - lockedIntervals: lockedExpansion.schedulingIntervals, - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } - - Future> _persist({ - required ApplicationUnitOfWorkRepositories repositories, - required ApplicationCommandCode commandCode, - required ApplicationOperationContext context, - required List beforeTasks, - required List afterTasks, - required List activities, - required ApplicationCommandReadHint readHint, - SchedulingResult? schedulingResult, - List protectedFreeSlotConflicts = - const [], - List childTaskIds = const [], - }) async { - final changedTasks = _changedTasks(beforeTasks, afterTasks); - await repositories.tasks.saveAll(changedTasks); - await repositories.taskActivities.saveAll(activities); - final projectStats = await _saveProjectStatistics( - repositories: repositories, - activities: activities, - tasks: afterTasks, - ); - - return ApplicationResult.success( - ApplicationCommandResult( - commandCode: commandCode, - operationId: context.operationId, - changedTasks: changedTasks, - activities: activities, - projectStatistics: projectStats, - notices: schedulingResult?.notices ?? const [], - changes: schedulingResult?.changes ?? const [], - overlaps: schedulingResult?.overlaps ?? const [], - protectedFreeSlotConflicts: protectedFreeSlotConflicts, - childTaskIds: childTaskIds, - schedulingResult: schedulingResult, - readHint: readHint, - ), - ); - } - - Future> _saveProjectStatistics({ - required ApplicationUnitOfWorkRepositories repositories, - required List activities, - required List tasks, - }) async { - final projectIds = activities - .where((activity) => activity.code == TaskActivityCode.completed) - .map((activity) => activity.projectId) - .toSet(); - final updated = []; - - for (final projectId in projectIds) { - final current = await repositories.projectStatistics.findByProjectId( - projectId, - ) ?? - ProjectStatistics(projectId: projectId); - final result = projectStatisticsAggregationService.apply( - statistics: current, - activities: activities, - tasks: tasks, - ); - if (result.appliedActivityIds.isEmpty) { - continue; - } - await repositories.projectStatistics.save(result.statistics); - updated.add(result.statistics); - } - - 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; -} +part 'application_commands/application_command_code.dart'; +part 'application_commands/application_child_task_draft.dart'; +part 'application_commands/application_command_read_hint.dart'; +part 'application_commands/application_command_result.dart'; +part 'application_commands/v1_application_command_use_cases.dart'; +part 'application_commands/planning_state.dart'; diff --git a/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart new file mode 100644 index 0000000..d469da0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart @@ -0,0 +1,31 @@ +part of '../application_commands.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_code.dart b/packages/scheduler_core/lib/src/application_commands/application_command_code.dart new file mode 100644 index 0000000..94b498a --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_code.dart @@ -0,0 +1,22 @@ +part of '../application_commands.dart'; + +/// Stable V1 application command identifiers. +enum ApplicationCommandCode { + quickCaptureToBacklog, + quickCaptureToNextAvailableSlot, + scheduleBacklogItemToNextAvailableSlot, + pushFlexibleToNextAvailableSlot, + pushFlexibleToTomorrowTopOfQueue, + moveFlexibleToBacklog, + completeFlexibleTask, + uncompleteTask, + applyRequiredTaskAction, + logSurpriseTask, + createProtectedFreeSlot, + updateProtectedFreeSlot, + removeProtectedFreeSlot, + breakUpTask, + completeChildTask, + completeParentTask, + completeParentFromChild, +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart new file mode 100644 index 0000000..06102d7 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart @@ -0,0 +1,30 @@ +part of '../application_commands.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_result.dart b/packages/scheduler_core/lib/src/application_commands/application_command_result.dart new file mode 100644 index 0000000..adb8fca --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/application_command_result.dart @@ -0,0 +1,69 @@ +part of '../application_commands.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_commands/planning_state.dart b/packages/scheduler_core/lib/src/application_commands/planning_state.dart new file mode 100644 index 0000000..51288b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/planning_state.dart @@ -0,0 +1,252 @@ +part of '../application_commands.dart'; + +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_commands/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart new file mode 100644 index 0000000..1338fa1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart @@ -0,0 +1,1085 @@ +part of '../application_commands.dart'; + +/// UI-facing V1 command use cases. +class V1ApplicationCommandUseCases { + const V1ApplicationCommandUseCases({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.quickCaptureService = const QuickCaptureService(), + this.flexibleTaskActionService = const FlexibleTaskActionService(), + this.requiredTaskActionService = const RequiredTaskActionService(), + this.surpriseTaskLogService = const SurpriseTaskLogService(), + this.freeSlotService = const FreeSlotService(), + this.childTaskBreakUpService = const ChildTaskBreakUpService(), + this.childTaskCompletionService = const ChildTaskCompletionService(), + this.projectStatisticsAggregationService = + const ProjectStatisticsAggregationService(), + }); + + /// Atomic repository boundary. + final ApplicationUnitOfWork applicationStore; + + /// Explicit local-time resolver for planning windows and locked expansion. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + final QuickCaptureService quickCaptureService; + final FlexibleTaskActionService flexibleTaskActionService; + final RequiredTaskActionService requiredTaskActionService; + final SurpriseTaskLogService surpriseTaskLogService; + final FreeSlotService freeSlotService; + final ChildTaskBreakUpService childTaskBreakUpService; + final ChildTaskCompletionService childTaskCompletionService; + final ProjectStatisticsAggregationService projectStatisticsAggregationService; + + /// Quick capture a task into backlog. + Future> quickCaptureToBacklog({ + required ApplicationOperationContext context, + required String title, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + TaskType type = TaskType.flexible, + int? durationMinutes, + Set backlogTags = const {}, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: context.idGenerator.nextId(), + title: title, + createdAt: context.now, + addToNextAvailableSlot: false, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + type: type, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + ), + updatedAt: context.now, + ); + if (!result.isValid) { + return _failure( + ApplicationFailureCode.validation, + detailCode: QuickCaptureStatus.validationError.name, + ); + } + + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: const [], + afterTasks: [result.task], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [result.task.id], + refreshToday: false, + ), + ); + }, + ); + } + + /// Quick capture a flexible task and schedule it into the next available slot. + Future> + quickCaptureToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required int durationMinutes, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToNextAvailableSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final taskId = context.idGenerator.nextId(); + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: taskId, + title: title, + createdAt: context.now, + addToNextAvailableSlot: true, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + ), + schedulingInput: state.input, + updatedAt: context.now, + ); + if (!result.isValid) { + return _failureForQuickCapture(result); + } + + final activities = _activitiesForSchedulingChanges( + result: result.schedulingResult, + beforeTasks: [result.task, ...state.tasks], + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: result.task.id, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: result.schedulingResult?.tasks ?? [result.task], + activities: activities, + schedulingResult: result.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromTasks([result.task]), + ), + ); + }, + ); + } + + /// Schedule an existing backlog item into the next available flexible slot. + Future> + scheduleBacklogItemToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + int? durationMinutes, + DateTime? expectedUpdatedAt, + }) { + const commandCode = + ApplicationCommandCode.scheduleBacklogItemToNextAvailableSlot; + 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 (durationMinutes != null && durationMinutes <= 0) { + return _failure( + ApplicationFailureCode.validation, + entityId: taskId, + detailCode: DomainValidationCode.nonPositiveDuration.name, + ); + } + if (durationMinutes != null) { + task = task.copyWith( + durationMinutes: durationMinutes, + updatedAt: context.now, + ); + state = state.replaceTask(task); + } + + final schedulingResult = + const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( + input: state.input, + taskId: taskId, + updatedAt: context.now, + ); + final schedulingFailure = _failureForSchedulingResult( + schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + + final activities = _activitiesForSchedulingChanges( + result: schedulingResult, + beforeTasks: state.tasks, + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: taskId, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: schedulingResult.tasks, + activities: activities, + schedulingResult: schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), + ), + ); + }, + ); + } + + /// Push a planned flexible task later in the same local day. + Future> + pushFlexibleToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToNextAvailableSlot, + context: context, + localDate: localDate, + taskId: taskId, + destination: PushDestination.nextAvailableSlot, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Push a planned flexible task to the top of a future/tomorrow queue. + Future> + pushFlexibleToTomorrowTopOfQueue({ + required ApplicationOperationContext context, + required CivilDate targetDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToTomorrowTopOfQueue, + context: context, + localDate: targetDate, + taskId: taskId, + destination: PushDestination.tomorrowTopOfQueue, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Move a planned flexible task to backlog. + Future> moveFlexibleToBacklog({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.moveFlexibleToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.backlog, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [actionResult.task], + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: true, + ), + ); + }, + ); + } + + /// Complete a flexible task. + Future> completeFlexibleTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.completeFlexibleTask; + 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); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.done, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// 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, + required CivilDate localDate, + required String taskId, + required RequiredTaskAction action, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.applyRequiredTaskAction; + 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); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = requiredTaskActionService.apply( + task: task, + action: action, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Log completed surprise work and repair overlapping flexible work. + Future> logSurpriseTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + DateTime? startedAt, + int? timeUsedMinutes, + String projectId = 'inbox', + PriorityLevel? priority, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + bool revealHiddenLockedDetails = false, + }) { + const commandCode = ApplicationCommandCode.logSurpriseTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final logResult = surpriseTaskLogService.log( + request: SurpriseTaskLogRequest( + id: context.operationId, + title: title, + createdAt: context.now, + startedAt: startedAt, + timeUsedMinutes: timeUsedMinutes, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + ), + input: state.input, + updatedAt: context.now, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: logResult.schedulingResult.tasks, + activities: logResult.activities, + schedulingResult: logResult.schedulingResult, + protectedFreeSlotConflicts: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _changedTaskIds( + state.tasks, + logResult.schedulingResult.tasks, + ), + ), + ); + }, + ); + } + + /// Create a protected Free Slot task. + Future> createProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required DateTime start, + required DateTime end, + String projectId = 'inbox', + }) { + const commandCode = ApplicationCommandCode.createProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final freeSlot = freeSlotService.create( + id: context.idGenerator.nextId(), + title: title, + start: start, + end: end, + createdAt: context.now, + updatedAt: context.now, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: const [], + afterTasks: [freeSlot], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [freeSlot.id], + ), + ); + }, + ); + } + + /// Update a protected Free Slot task. + Future> updateProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required DateTime start, + required DateTime end, + DateTime? expectedUpdatedAt, + String? title, + String? projectId, + }) { + const commandCode = ApplicationCommandCode.updateProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final updated = freeSlotService.update( + freeSlot: task, + start: start, + end: end, + updatedAt: context.now, + title: title, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [updated], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Remove a protected Free Slot from the active timeline. + Future> removeProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.removeProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + if (task.type != TaskType.freeSlot) { + return _failure( + ApplicationFailureCode.validation, + entityId: taskId, + detailCode: 'invalidFreeSlotType', + ); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final removed = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: context.now, + clearSchedule: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [removed], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Break a parent task into direct child tasks. + Future> breakUpTask({ + required ApplicationOperationContext context, + required String parentTaskId, + required List children, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.breakUpTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final parent = _taskById(tasks, parentTaskId); + if (parent == null) { + return _failure( + ApplicationFailureCode.notFound, + entityId: parentTaskId, + ); + } + final staleFailure = _staleFailure(parent, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final entries = children.map((draft) { + return ChildTaskEntry( + id: context.idGenerator.nextId(), + title: draft.title, + createdAt: context.now, + priority: draft.priority, + reward: draft.reward, + difficulty: draft.difficulty, + durationMinutes: draft.durationMinutes, + projectId: draft.projectId, + updatedAt: context.now, + ); + }).toList(growable: false); + final breakUpResult = childTaskBreakUpService.breakUp( + tasks: tasks, + request: ChildTaskBreakUpRequest( + parentTaskId: parentTaskId, + entries: entries, + operationId: context.operationId, + ), + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: breakUpResult.tasks, + activities: breakUpResult.activities, + childTaskIds: breakUpResult.createdChildTaskIds, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [ + parentTaskId, + ...breakUpResult.createdChildTaskIds + ], + refreshToday: false, + ), + ); + }, + ); + } + + /// Complete one child task and auto-complete its parent if appropriate. + Future> completeChildTask({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeChildTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent and force-complete unfinished direct children. + Future> completeParentTask({ + required ApplicationOperationContext context, + required String parentTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: parentTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParent( + tasks: tasks, + parentTaskId: parentTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent from one of its direct children. + Future> completeParentFromChild({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentFromChild; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParentFromChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + Future> _pushFlexible({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required PushDestination destination, + DateTime? expectedUpdatedAt, + }) { + 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); + } + final existingActivities = await repositories.taskActivities.findAll(); + final pushResult = flexibleTaskActionService.applyPushDestination( + destination: destination, + input: state.input, + taskId: taskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + final schedulingFailure = _failureForSchedulingResult( + pushResult.schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: pushResult.schedulingResult.tasks, + activities: pushResult.activities, + schedulingResult: pushResult.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: + _taskIdsFromChanges(pushResult.schedulingResult.changes), + ), + ); + }, + ); + } + + Future> _completeChildOrParent({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required String taskId, + required ChildTaskCompletionResult Function( + List tasks, + List existingActivities, + ) apply, + DateTime? expectedUpdatedAt, + }) { + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final result = apply(tasks, existingActivities); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: result.tasks, + activities: result.activities, + childTaskIds: [ + if (result.completedChildId != null) result.completedChildId!, + ...result.forceCompletedChildIds, + ], + readHint: ApplicationCommandReadHint( + affectedTaskIds: result.changedTaskIds, + refreshToday: true, + ), + ); + }, + ); + } + + Future<_PlanningState> _loadPlanningState( + ApplicationUnitOfWorkRepositories repositories, + ApplicationOperationContext context, + CivilDate localDate, + ) async { + final ownerId = context.ownerTimeZone.ownerId; + final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? + OwnerSettings( + ownerId: ownerId, + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final dayStart = _resolve( + date: localDate, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ); + final dayEnd = _resolve( + date: localDate, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ); + final window = SchedulingWindow(start: dayStart, end: dayEnd); + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: localDate, + window: window, + )) + .items; + final lockedExpansion = expandLockedBlocksForDay( + blocks: (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items, + overrides: (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: localDate, + )) + .items, + date: localDate, + timeZoneId: settings.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + + return _PlanningState( + tasks: tasks, + window: window, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + + Future> _persist({ + required ApplicationUnitOfWorkRepositories repositories, + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required List beforeTasks, + required List afterTasks, + required List activities, + required ApplicationCommandReadHint readHint, + SchedulingResult? schedulingResult, + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + }) async { + final changedTasks = _changedTasks(beforeTasks, afterTasks); + await repositories.tasks.saveAll(changedTasks); + await repositories.taskActivities.saveAll(activities); + final projectStats = await _saveProjectStatistics( + repositories: repositories, + activities: activities, + tasks: afterTasks, + ); + + return ApplicationResult.success( + ApplicationCommandResult( + commandCode: commandCode, + operationId: context.operationId, + changedTasks: changedTasks, + activities: activities, + projectStatistics: projectStats, + notices: schedulingResult?.notices ?? const [], + changes: schedulingResult?.changes ?? const [], + overlaps: schedulingResult?.overlaps ?? const [], + protectedFreeSlotConflicts: protectedFreeSlotConflicts, + childTaskIds: childTaskIds, + schedulingResult: schedulingResult, + readHint: readHint, + ), + ); + } + + Future> _saveProjectStatistics({ + required ApplicationUnitOfWorkRepositories repositories, + required List activities, + required List tasks, + }) async { + final projectIds = activities + .where((activity) => activity.code == TaskActivityCode.completed) + .map((activity) => activity.projectId) + .toSet(); + final updated = []; + + for (final projectId in projectIds) { + final current = await repositories.projectStatistics.findByProjectId( + projectId, + ) ?? + ProjectStatistics(projectId: projectId); + final result = projectStatisticsAggregationService.apply( + statistics: current, + activities: activities, + tasks: tasks, + ); + if (result.appliedActivityIds.isEmpty) { + continue; + } + await repositories.projectStatistics.save(result.statistics); + updated.add(result.statistics); + } + + return List.unmodifiable(updated); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application_layer.dart index c40e9c9..c62bae6 100644 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ b/packages/scheduler_core/lib/src/application_layer.dart @@ -15,1970 +15,33 @@ 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; -} +part 'application_layer/owner_time_zone_context.dart'; +part 'application_layer/application_operation_context.dart'; +part 'application_layer/application_failure_code.dart'; +part 'application_layer/application_failure.dart'; +part 'application_layer/application_result.dart'; +part 'application_layer/application_failure_exception.dart'; +part 'application_layer/application_persistence_exception.dart'; +part 'application_layer/application_operation_record.dart'; +part 'application_layer/notice_acknowledgement_record.dart'; +part 'application_layer/owner_settings.dart'; +part 'application_layer/task_activity_repository.dart'; +part 'application_layer/project_statistics_repository.dart'; +part 'application_layer/owner_settings_repository.dart'; +part 'application_layer/notice_acknowledgement_repository.dart'; +part 'application_layer/application_operation_repository.dart'; +part 'application_layer/application_unit_of_work_repositories.dart'; +part 'application_layer/application_unit_of_work.dart'; +part 'application_layer/application_scheduling_loader.dart'; +part 'application_layer/in_memory_application_unit_of_work.dart'; +part 'application_layer/in_memory_application_state.dart'; +part 'application_layer/in_memory_application_repositories.dart'; +part 'application_layer/unit_of_work_task_repository.dart'; +part 'application_layer/unit_of_work_project_repository.dart'; +part 'application_layer/unit_of_work_locked_block_repository.dart'; +part 'application_layer/unit_of_work_scheduling_snapshot_repository.dart'; +part 'application_layer/unit_of_work_task_activity_repository.dart'; +part 'application_layer/unit_of_work_project_statistics_repository.dart'; +part 'application_layer/unit_of_work_owner_settings_repository.dart'; +part 'application_layer/unit_of_work_notice_acknowledgement_repository.dart'; +part 'application_layer/unit_of_work_operation_repository.dart'; diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure.dart b/packages/scheduler_core/lib/src/application_layer/application_failure.dart new file mode 100644 index 0000000..1beed99 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure.dart @@ -0,0 +1,19 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart b/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart new file mode 100644 index 0000000..db9eee3 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart @@ -0,0 +1,13 @@ +part of '../application_layer.dart'; + +/// Stable application-layer result categories. +enum ApplicationFailureCode { + validation, + notFound, + conflict, + staleRevision, + noSlot, + duplicateOperation, + persistenceFailure, + unexpected, +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart b/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart new file mode 100644 index 0000000..dee317f --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart @@ -0,0 +1,9 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart new file mode 100644 index 0000000..aa9d187 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart @@ -0,0 +1,44 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart new file mode 100644 index 0000000..f8fe4aa --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart @@ -0,0 +1,25 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart new file mode 100644 index 0000000..31bb9b0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart @@ -0,0 +1,17 @@ +part of '../application_layer.dart'; + +/// 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, + ); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart new file mode 100644 index 0000000..e1f7e5a --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart @@ -0,0 +1,6 @@ +part of '../application_layer.dart'; + +/// Exception representing persistence/driver failures at repository boundaries. +class ApplicationPersistenceException implements Exception { + const ApplicationPersistenceException(); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_result.dart b/packages/scheduler_core/lib/src/application_layer/application_result.dart new file mode 100644 index 0000000..881c807 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_result.dart @@ -0,0 +1,40 @@ +part of '../application_layer.dart'; + +/// 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; + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart new file mode 100644 index 0000000..fc0f3dc --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart @@ -0,0 +1,30 @@ +part of '../application_layer.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart new file mode 100644 index 0000000..c8b1a58 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart @@ -0,0 +1,18 @@ +part of '../application_layer.dart'; + +/// 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, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart new file mode 100644 index 0000000..cb77ce9 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart @@ -0,0 +1,22 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart new file mode 100644 index 0000000..c7a44c2 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart @@ -0,0 +1,72 @@ +part of '../application_layer.dart'; + +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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart new file mode 100644 index 0000000..0c68d39 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart @@ -0,0 +1,106 @@ +part of '../application_layer.dart'; + +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, + ), + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart new file mode 100644 index 0000000..7861618 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart @@ -0,0 +1,286 @@ +part of '../application_layer.dart'; + +/// 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), + ); + } + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart new file mode 100644 index 0000000..4e5f1c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart @@ -0,0 +1,20 @@ +part of '../application_layer.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart new file mode 100644 index 0000000..7711608 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart @@ -0,0 +1,22 @@ +part of '../application_layer.dart'; + +/// 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, + ); +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings.dart b/packages/scheduler_core/lib/src/application_layer/owner_settings.dart new file mode 100644 index 0000000..dca2743 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_settings.dart @@ -0,0 +1,62 @@ +part of '../application_layer.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart new file mode 100644 index 0000000..990174e --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart @@ -0,0 +1,15 @@ +part of '../application_layer.dart'; + +/// 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, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart new file mode 100644 index 0000000..7e26a6c --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart @@ -0,0 +1,16 @@ +part of '../application_layer.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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart new file mode 100644 index 0000000..b2656ea --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart @@ -0,0 +1,20 @@ +part of '../application_layer.dart'; + +/// 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, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart new file mode 100644 index 0000000..139927d --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart @@ -0,0 +1,28 @@ +part of '../application_layer.dart'; + +/// 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, + }); +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart new file mode 100644 index 0000000..854ae7c --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart @@ -0,0 +1,254 @@ +part of '../application_layer.dart'; + +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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart new file mode 100644 index 0000000..e8cbeee --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart @@ -0,0 +1,74 @@ +part of '../application_layer.dart'; + +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); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart new file mode 100644 index 0000000..9c2bda6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart @@ -0,0 +1,154 @@ +part of '../application_layer.dart'; + +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/application_layer/unit_of_work_owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart new file mode 100644 index 0000000..2665551 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart @@ -0,0 +1,70 @@ +part of '../application_layer.dart'; + +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); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart new file mode 100644 index 0000000..1ead197 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart @@ -0,0 +1,140 @@ +part of '../application_layer.dart'; + +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; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart new file mode 100644 index 0000000..89a4382 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart @@ -0,0 +1,83 @@ +part of '../application_layer.dart'; + +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); + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..bb2c427 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart @@ -0,0 +1,29 @@ +part of '../application_layer.dart'; + +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; + } +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart new file mode 100644 index 0000000..61412f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart @@ -0,0 +1,91 @@ +part of '../application_layer.dart'; + +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'; +} diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart new file mode 100644 index 0000000..743916f --- /dev/null +++ b/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart @@ -0,0 +1,212 @@ +part of '../application_layer.dart'; + +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; +} diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application_management.dart index 26bcda1..5aecbbb 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application_management.dart @@ -14,765 +14,19 @@ 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; -} - -/// V1 management facade used by future Flutter state management. -class V1ApplicationManagementUseCases { - const V1ApplicationManagementUseCases({ - required this.applicationStore, - this.projectSuggestionService = const ProjectSuggestionService(), - }); - - /// Atomic application boundary. - final ApplicationUnitOfWork applicationStore; - - /// Learned project suggestion policy. - final ProjectSuggestionService projectSuggestionService; - - /// Load backlog candidates through the repository status filter, then apply - /// existing filter/sort/staleness policies in memory. - Future> getBacklog( - GetBacklogRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final ownerId = request.context.ownerTimeZone.ownerId; - final settings = await _ownerSettingsOrDefault( - repositories, - request.context.ownerTimeZone, - ); - final candidates = await repositories.tasks.findByStatus( - TaskStatus.backlog, - ); - final filtered = request.filter == null - ? candidates - : BacklogView( - tasks: candidates, - now: request.context.now, - stalenessSettings: settings.backlogStalenessSettings, - ).filter(request.filter!); - final view = BacklogView( - tasks: filtered, - now: request.context.now, - stalenessSettings: settings.backlogStalenessSettings, - ); - final sorted = view.sorted(request.sortKey); - - return ApplicationResult.success( - BacklogQueryResult( - ownerId: ownerId, - settings: settings, - items: [ - for (final task in sorted) - BacklogItemReadModel( - task: task, - stalenessMarker: view.stalenessMarkerFor(task), - ), - ], - ), - ); - }, - ); - } - - /// Load configured project defaults with learned suggestions kept separate. - Future> getProjectDefaults( - GetProjectDefaultsRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final projects = (await repositories.projects.findByOwner( - ownerId: request.context.ownerTimeZone.ownerId, - includeArchived: request.includeArchived, - )) - .items; - final resolutions = []; - final sortedProjects = [...projects] - ..sort((a, b) => a.name.compareTo(b.name)); - - for (final project in sortedProjects) { - if (project.isArchived && !request.includeArchived) { - continue; - } - final statistics = - await repositories.projectStatistics.findByProjectId(project.id); - resolutions.add( - projectSuggestionService.resolveDefaults( - project: project, - statistics: - statistics ?? ProjectStatistics(projectId: project.id), - ), - ); - } - - return ApplicationResult.success( - ProjectDefaultsQueryResult( - ownerId: request.context.ownerTimeZone.ownerId, - projects: resolutions, - ), - ); - }, - ); - } - - Future> createProject({ - required ApplicationOperationContext context, - required ProjectProfileDraft draft, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.createProject.name, - action: (repositories) async { - final id = draft.id ?? context.idGenerator.nextId(); - final existing = await repositories.projects.findById(id); - if (existing != null) { - return _failure( - ApplicationFailureCode.conflict, - entityId: id, - detailCode: 'projectAlreadyExists', - ); - } - final project = ProjectProfile( - id: id, - name: draft.name, - colorKey: draft.colorKey, - defaultPriority: draft.defaultPriority, - defaultReward: draft.defaultReward, - defaultDifficulty: draft.defaultDifficulty, - defaultReminderProfile: draft.defaultReminderProfile, - defaultDurationMinutes: draft.defaultDurationMinutes, - ); - await repositories.projects.save(project); - return ApplicationResult.success(project); - }, - ); - } - - Future> updateProject({ - required ApplicationOperationContext context, - required String projectId, - required ProjectProfileUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateProject.name, - action: (repositories) async { - final project = await _requireProject(repositories, projectId); - final updated = project.copyWith( - name: update.name, - colorKey: update.colorKey, - defaultPriority: update.defaultPriority, - defaultReward: update.defaultReward, - defaultDifficulty: update.defaultDifficulty, - defaultReminderProfile: update.defaultReminderProfile, - defaultDurationMinutes: update.defaultDurationMinutes, - clearDefaultDuration: update.clearDefaultDuration, - ); - await repositories.projects.save(updated); - return ApplicationResult.success(updated); - }, - ); - } - - Future> archiveProject({ - required ApplicationOperationContext context, - required String projectId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.archiveProject.name, - action: (repositories) async { - final project = await _requireProject(repositories, projectId); - final archived = project.copyWith(archivedAt: context.now); - await repositories.projects.save(archived); - return ApplicationResult.success(archived); - }, - ); - } - - Future> createLockedBlock({ - required ApplicationOperationContext context, - required LockedBlockDraft draft, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.createLockedBlock.name, - action: (repositories) async { - final id = draft.id ?? context.idGenerator.nextId(); - final existing = await repositories.lockedBlocks.findBlockById(id); - if (existing != null) { - return _failure( - ApplicationFailureCode.conflict, - entityId: id, - detailCode: 'lockedBlockAlreadyExists', - ); - } - final block = LockedBlock( - id: id, - name: draft.name, - startTime: draft.startTime, - endTime: draft.endTime, - date: draft.date, - recurrence: draft.recurrence, - hiddenByDefault: draft.hiddenByDefault, - projectId: draft.projectId, - createdAt: context.now, - updatedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(block); - return ApplicationResult.success(block); - }, - ); - } - - Future> updateLockedBlock({ - required ApplicationOperationContext context, - required String lockedBlockId, - required LockedBlockUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateLockedBlock.name, - action: (repositories) async { - final block = await _requireLockedBlock(repositories, lockedBlockId); - final updated = block.copyWith( - name: update.name, - startTime: update.startTime, - endTime: update.endTime, - date: update.date, - recurrence: update.recurrence, - hiddenByDefault: update.hiddenByDefault, - projectId: update.projectId, - updatedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(updated); - return ApplicationResult.success(updated); - }, - ); - } - - Future> archiveLockedBlock({ - required ApplicationOperationContext context, - required String lockedBlockId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, - action: (repositories) async { - final block = await _requireLockedBlock(repositories, lockedBlockId); - final archived = block.copyWith( - updatedAt: context.now, - archivedAt: context.now, - ); - await repositories.lockedBlocks.saveBlock(archived); - return ApplicationResult.success(archived); - }, - ); - } - - Future> addOneDayLockedOverride({ - required ApplicationOperationContext context, - required CivilDate date, - required String name, - required ClockTime startTime, - required ClockTime endTime, - bool hiddenByDefault = true, - String? projectId, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.addLockedBlockOverride.name, - action: (repositories) async { - final override = LockedBlockOverride.add( - id: context.idGenerator.nextId(), - date: date, - name: name, - startTime: startTime, - endTime: endTime, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> removeOneDayLockedOccurrence({ - required ApplicationOperationContext context, - required String lockedBlockId, - required CivilDate date, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, - action: (repositories) async { - await _requireLockedBlock(repositories, lockedBlockId); - final override = LockedBlockOverride.remove( - id: context.idGenerator.nextId(), - lockedBlockId: lockedBlockId, - date: date, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> replaceOneDayLockedOccurrence({ - required ApplicationOperationContext context, - required String lockedBlockId, - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - String? name, - bool hiddenByDefault = true, - String? projectId, - }) { - return applicationStore.run( - context: context, - operationName: - ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, - action: (repositories) async { - await _requireLockedBlock(repositories, lockedBlockId); - final override = LockedBlockOverride.replace( - id: context.idGenerator.nextId(), - lockedBlockId: lockedBlockId, - date: date, - startTime: startTime, - endTime: endTime, - name: name, - hiddenByDefault: hiddenByDefault, - projectId: projectId, - createdAt: context.now, - ); - await repositories.lockedBlocks.saveOverride(override); - return ApplicationResult.success(override); - }, - ); - } - - Future> updateOwnerSettings({ - required ApplicationOperationContext context, - required OwnerSettingsUpdate update, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, - action: (repositories) async { - final current = await _ownerSettingsOrDefault( - repositories, - context.ownerTimeZone, - ); - final stalenessSettings = update.backlogStalenessSettings; - if (stalenessSettings != null && - (stalenessSettings.greenMaxAge.isNegative || - stalenessSettings.blueMaxAge.isNegative || - stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { - return _failure( - ApplicationFailureCode.validation, - detailCode: 'invalidBacklogStalenessThresholds', - ); - } - final updated = current.copyWith( - timeZoneId: update.timeZoneId, - dayStart: update.dayStart, - dayEnd: update.dayEnd, - compactModeEnabled: update.compactModeEnabled, - backlogStalenessSettings: update.backlogStalenessSettings, - ); - final warnings = [ - if (update.timeZoneId != null && - update.timeZoneId != current.timeZoneId) - OwnerSettingsWarningCode.timeZoneChanged, - if ((update.dayStart != null && - update.dayStart != current.dayStart) || - (update.dayEnd != null && update.dayEnd != current.dayEnd)) - OwnerSettingsWarningCode.planningWindowChanged, - ]; - await repositories.ownerSettings.save(updated); - return ApplicationResult.success( - OwnerSettingsUpdateResult(settings: updated, warnings: warnings), - ); - }, - ); - } - - Future> acknowledgeNotice({ - required ApplicationOperationContext context, - required String noticeId, - }) { - return applicationStore.run( - context: context, - operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, - action: (repositories) async { - final ownerId = context.ownerTimeZone.ownerId; - final parsed = _parseNoticeId(noticeId); - if (parsed == null) { - return _failure( - ApplicationFailureCode.validation, - entityId: noticeId, - detailCode: 'invalidNoticeId', - ); - } - final snapshot = - await repositories.schedulingSnapshots.findById(parsed.snapshotId); - if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { - return _failure( - ApplicationFailureCode.notFound, - entityId: noticeId, - detailCode: 'noticeNotFound', - ); - } - final existing = await repositories.noticeAcknowledgements.findById( - ownerId: ownerId, - noticeId: noticeId, - ); - if (existing != null) { - return ApplicationResult.success( - NoticeAcknowledgementResult( - record: existing, - alreadyAcknowledged: true, - ), - ); - } - - final record = NoticeAcknowledgementRecord( - noticeId: noticeId, - ownerId: ownerId, - acknowledgedAt: context.now, - ); - await repositories.noticeAcknowledgements.save(record); - return ApplicationResult.success( - NoticeAcknowledgementResult( - record: record, - alreadyAcknowledged: false, - ), - ); - }, - ); - } - - Future _requireProject( - ApplicationUnitOfWorkRepositories repositories, - String projectId, - ) async { - final project = await repositories.projects.findById(projectId); - if (project == null) { - throw ApplicationFailureException( - ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: projectId, - detailCode: 'projectNotFound', - ), - ); - } - return project; - } - - Future _requireLockedBlock( - ApplicationUnitOfWorkRepositories repositories, - String lockedBlockId, - ) async { - final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); - if (block == null) { - throw ApplicationFailureException( - ApplicationFailure( - code: ApplicationFailureCode.notFound, - entityId: lockedBlockId, - detailCode: 'lockedBlockNotFound', - ), - ); - } - return block; - } -} - -Future _ownerSettingsOrDefault( - ApplicationUnitOfWorkRepositories repositories, - OwnerTimeZoneContext ownerTimeZone, -) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); -} - -ApplicationResult _failure( - ApplicationFailureCode code, { - String? entityId, - String? detailCode, -}) { - return ApplicationResult.failure( - ApplicationFailure( - code: code, - entityId: entityId, - detailCode: detailCode, - ), - ); -} - -_ParsedNoticeId? _parseNoticeId(String noticeId) { - final separator = noticeId.lastIndexOf(':'); - if (separator <= 0 || separator == noticeId.length - 1) { - return null; - } - final noticeIndex = int.tryParse(noticeId.substring(separator + 1)); - if (noticeIndex == null || noticeIndex < 0) { - return null; - } - return _ParsedNoticeId( - snapshotId: noticeId.substring(0, separator), - noticeIndex: noticeIndex, - ); -} - -class _ParsedNoticeId { - const _ParsedNoticeId({ - required this.snapshotId, - required this.noticeIndex, - }); - - final String snapshotId; - final int noticeIndex; -} +part 'application_management/application_management_command_code.dart'; +part 'application_management/owner_settings_warning_code.dart'; +part 'application_management/get_backlog_request.dart'; +part 'application_management/backlog_item_read_model.dart'; +part 'application_management/backlog_query_result.dart'; +part 'application_management/get_project_defaults_request.dart'; +part 'application_management/project_defaults_query_result.dart'; +part 'application_management/project_profile_draft.dart'; +part 'application_management/project_profile_update.dart'; +part 'application_management/locked_block_draft.dart'; +part 'application_management/locked_block_update.dart'; +part 'application_management/owner_settings_update_result.dart'; +part 'application_management/owner_settings_update.dart'; +part 'application_management/notice_acknowledgement_result.dart'; +part 'application_management/v1_application_management_use_cases.dart'; +part 'application_management/parsed_notice_id.dart'; diff --git a/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart b/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart new file mode 100644 index 0000000..ffcf978 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart @@ -0,0 +1,16 @@ +part of '../application_management.dart'; + +/// Stable management command identifiers. +enum ApplicationManagementCommandCode { + createProject, + updateProject, + archiveProject, + createLockedBlock, + updateLockedBlock, + archiveLockedBlock, + addLockedBlockOverride, + removeLockedBlockOccurrence, + replaceLockedBlockOccurrence, + updateOwnerSettings, + acknowledgeNotice, +} diff --git a/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart b/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart new file mode 100644 index 0000000..6fa8906 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart b/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart new file mode 100644 index 0000000..921971e --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart @@ -0,0 +1,19 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart b/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart new file mode 100644 index 0000000..3892b02 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart @@ -0,0 +1,19 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart new file mode 100644 index 0000000..d5490aa --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart b/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart new file mode 100644 index 0000000..33bc3b3 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_update.dart b/packages/scheduler_core/lib/src/application_management/locked_block_update.dart new file mode 100644 index 0000000..46f8f0b --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/locked_block_update.dart @@ -0,0 +1,22 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart new file mode 100644 index 0000000..2c5b300 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart new file mode 100644 index 0000000..1631108 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart @@ -0,0 +1,18 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart new file mode 100644 index 0000000..22fa941 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart @@ -0,0 +1,16 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart new file mode 100644 index 0000000..fbac4b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart @@ -0,0 +1,7 @@ +part of '../application_management.dart'; + +/// Typed warning codes for settings changes that reinterpret local dates/times. +enum OwnerSettingsWarningCode { + timeZoneChanged, + planningWindowChanged, +} diff --git a/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart new file mode 100644 index 0000000..d187142 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart @@ -0,0 +1,11 @@ +part of '../application_management.dart'; + +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_management/project_defaults_query_result.dart b/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart new file mode 100644 index 0000000..9960b65 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart @@ -0,0 +1,15 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart b/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart new file mode 100644 index 0000000..99c7f83 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_update.dart b/packages/scheduler_core/lib/src/application_management/project_profile_update.dart new file mode 100644 index 0000000..bf5c898 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/project_profile_update.dart @@ -0,0 +1,24 @@ +part of '../application_management.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart new file mode 100644 index 0000000..5ad379b --- /dev/null +++ b/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart @@ -0,0 +1,518 @@ +part of '../application_management.dart'; + +/// V1 management facade used by future Flutter state management. +class V1ApplicationManagementUseCases { + const V1ApplicationManagementUseCases({ + required this.applicationStore, + this.projectSuggestionService = const ProjectSuggestionService(), + }); + + /// Atomic application boundary. + final ApplicationUnitOfWork applicationStore; + + /// Learned project suggestion policy. + final ProjectSuggestionService projectSuggestionService; + + /// Load backlog candidates through the repository status filter, then apply + /// existing filter/sort/staleness policies in memory. + Future> getBacklog( + GetBacklogRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final ownerId = request.context.ownerTimeZone.ownerId; + final settings = await _ownerSettingsOrDefault( + repositories, + request.context.ownerTimeZone, + ); + final candidates = await repositories.tasks.findByStatus( + TaskStatus.backlog, + ); + final filtered = request.filter == null + ? candidates + : BacklogView( + tasks: candidates, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ).filter(request.filter!); + final view = BacklogView( + tasks: filtered, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ); + final sorted = view.sorted(request.sortKey); + + return ApplicationResult.success( + BacklogQueryResult( + ownerId: ownerId, + settings: settings, + items: [ + for (final task in sorted) + BacklogItemReadModel( + task: task, + stalenessMarker: view.stalenessMarkerFor(task), + ), + ], + ), + ); + }, + ); + } + + /// Load configured project defaults with learned suggestions kept separate. + Future> getProjectDefaults( + GetProjectDefaultsRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final projects = (await repositories.projects.findByOwner( + ownerId: request.context.ownerTimeZone.ownerId, + includeArchived: request.includeArchived, + )) + .items; + final resolutions = []; + final sortedProjects = [...projects] + ..sort((a, b) => a.name.compareTo(b.name)); + + for (final project in sortedProjects) { + if (project.isArchived && !request.includeArchived) { + continue; + } + final statistics = + await repositories.projectStatistics.findByProjectId(project.id); + resolutions.add( + projectSuggestionService.resolveDefaults( + project: project, + statistics: + statistics ?? ProjectStatistics(projectId: project.id), + ), + ); + } + + return ApplicationResult.success( + ProjectDefaultsQueryResult( + ownerId: request.context.ownerTimeZone.ownerId, + projects: resolutions, + ), + ); + }, + ); + } + + Future> createProject({ + required ApplicationOperationContext context, + required ProjectProfileDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createProject.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.projects.findById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'projectAlreadyExists', + ); + } + final project = ProjectProfile( + id: id, + name: draft.name, + colorKey: draft.colorKey, + defaultPriority: draft.defaultPriority, + defaultReward: draft.defaultReward, + defaultDifficulty: draft.defaultDifficulty, + defaultReminderProfile: draft.defaultReminderProfile, + defaultDurationMinutes: draft.defaultDurationMinutes, + ); + await repositories.projects.save(project); + return ApplicationResult.success(project); + }, + ); + } + + Future> updateProject({ + required ApplicationOperationContext context, + required String projectId, + required ProjectProfileUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final updated = project.copyWith( + name: update.name, + colorKey: update.colorKey, + defaultPriority: update.defaultPriority, + defaultReward: update.defaultReward, + defaultDifficulty: update.defaultDifficulty, + defaultReminderProfile: update.defaultReminderProfile, + defaultDurationMinutes: update.defaultDurationMinutes, + clearDefaultDuration: update.clearDefaultDuration, + ); + await repositories.projects.save(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveProject({ + required ApplicationOperationContext context, + required String projectId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final archived = project.copyWith(archivedAt: context.now); + await repositories.projects.save(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> createLockedBlock({ + required ApplicationOperationContext context, + required LockedBlockDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createLockedBlock.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.lockedBlocks.findBlockById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'lockedBlockAlreadyExists', + ); + } + final block = LockedBlock( + id: id, + name: draft.name, + startTime: draft.startTime, + endTime: draft.endTime, + date: draft.date, + recurrence: draft.recurrence, + hiddenByDefault: draft.hiddenByDefault, + projectId: draft.projectId, + createdAt: context.now, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(block); + return ApplicationResult.success(block); + }, + ); + } + + Future> updateLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + required LockedBlockUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final updated = block.copyWith( + name: update.name, + startTime: update.startTime, + endTime: update.endTime, + date: update.date, + recurrence: update.recurrence, + hiddenByDefault: update.hiddenByDefault, + projectId: update.projectId, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final archived = block.copyWith( + updatedAt: context.now, + archivedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> addOneDayLockedOverride({ + required ApplicationOperationContext context, + required CivilDate date, + required String name, + required ClockTime startTime, + required ClockTime endTime, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.addLockedBlockOverride.name, + action: (repositories) async { + final override = LockedBlockOverride.add( + id: context.idGenerator.nextId(), + date: date, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> removeOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.remove( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> replaceOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + String? name, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.replace( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + startTime: startTime, + endTime: endTime, + name: name, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> updateOwnerSettings({ + required ApplicationOperationContext context, + required OwnerSettingsUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, + action: (repositories) async { + final current = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final stalenessSettings = update.backlogStalenessSettings; + if (stalenessSettings != null && + (stalenessSettings.greenMaxAge.isNegative || + stalenessSettings.blueMaxAge.isNegative || + stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { + return _failure( + ApplicationFailureCode.validation, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + final updated = current.copyWith( + timeZoneId: update.timeZoneId, + dayStart: update.dayStart, + dayEnd: update.dayEnd, + compactModeEnabled: update.compactModeEnabled, + backlogStalenessSettings: update.backlogStalenessSettings, + ); + final warnings = [ + if (update.timeZoneId != null && + update.timeZoneId != current.timeZoneId) + OwnerSettingsWarningCode.timeZoneChanged, + if ((update.dayStart != null && + update.dayStart != current.dayStart) || + (update.dayEnd != null && update.dayEnd != current.dayEnd)) + OwnerSettingsWarningCode.planningWindowChanged, + ]; + await repositories.ownerSettings.save(updated); + return ApplicationResult.success( + OwnerSettingsUpdateResult(settings: updated, warnings: warnings), + ); + }, + ); + } + + Future> acknowledgeNotice({ + required ApplicationOperationContext context, + required String noticeId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, + action: (repositories) async { + final ownerId = context.ownerTimeZone.ownerId; + final parsed = _parseNoticeId(noticeId); + if (parsed == null) { + return _failure( + ApplicationFailureCode.validation, + entityId: noticeId, + detailCode: 'invalidNoticeId', + ); + } + final snapshot = + await repositories.schedulingSnapshots.findById(parsed.snapshotId); + if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { + return _failure( + ApplicationFailureCode.notFound, + entityId: noticeId, + detailCode: 'noticeNotFound', + ); + } + final existing = await repositories.noticeAcknowledgements.findById( + ownerId: ownerId, + noticeId: noticeId, + ); + if (existing != null) { + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: existing, + alreadyAcknowledged: true, + ), + ); + } + + final record = NoticeAcknowledgementRecord( + noticeId: noticeId, + ownerId: ownerId, + acknowledgedAt: context.now, + ); + await repositories.noticeAcknowledgements.save(record); + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: record, + alreadyAcknowledged: false, + ), + ); + }, + ); + } + + Future _requireProject( + ApplicationUnitOfWorkRepositories repositories, + String projectId, + ) async { + final project = await repositories.projects.findById(projectId); + if (project == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: projectId, + detailCode: 'projectNotFound', + ), + ); + } + return project; + } + + Future _requireLockedBlock( + ApplicationUnitOfWorkRepositories repositories, + String lockedBlockId, + ) async { + final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); + if (block == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: lockedBlockId, + detailCode: 'lockedBlockNotFound', + ), + ); + } + return block; + } +} + +Future _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +_ParsedNoticeId? _parseNoticeId(String noticeId) { + final separator = noticeId.lastIndexOf(':'); + if (separator <= 0 || separator == noticeId.length - 1) { + return null; + } + final noticeIndex = int.tryParse(noticeId.substring(separator + 1)); + if (noticeIndex == null || noticeIndex < 0) { + return null; + } + return _ParsedNoticeId( + snapshotId: noticeId.substring(0, separator), + noticeIndex: noticeIndex, + ); +} diff --git a/packages/scheduler_core/lib/src/application_recovery.dart b/packages/scheduler_core/lib/src/application_recovery.dart index daf37ce..2e7a53a 100644 --- a/packages/scheduler_core/lib/src/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application_recovery.dart @@ -14,398 +14,7 @@ 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; -} - -/// Explicit app-open recovery facade. -class V1AppOpenRecoveryUseCases { - const V1AppOpenRecoveryUseCases({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - this.schedulingEngine = const SchedulingEngine(), - }); - - /// Atomic application boundary. - final ApplicationUnitOfWork applicationStore; - - /// Explicit owner-local date resolver. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - /// Pure scheduling engine. - final SchedulingEngine schedulingEngine; - - /// Run source-day rollover at most once for one owner/source-date key. - Future> runAppOpenRecovery( - RunAppOpenRecoveryRequest request, - ) { - return applicationStore.run( - context: request.context, - operationName: 'appOpenRecovery', - action: (repositories) async { - final context = request.context; - final ownerId = context.ownerTimeZone.ownerId; - final sourceDate = request.sourceDate; - final targetDate = request.resolvedTargetDate; - final snapshotId = _rolloverSnapshotId( - ownerId: ownerId, - sourceDate: sourceDate, - ); - final existingSnapshot = - await repositories.schedulingSnapshots.findById(snapshotId); - if (existingSnapshot != null) { - return ApplicationResult.success( - AppOpenRecoveryResult( - outcome: AppOpenRecoveryOutcome.alreadyProcessed, - sourceDate: sourceDate, - targetDate: targetDate, - snapshot: existingSnapshot, - changedTasks: const [], - activities: const [], - notices: existingSnapshot.notices, - changes: existingSnapshot.changes, - ), - ); - } - - final settings = await _ownerSettingsOrDefault( - repositories, - context.ownerTimeZone, - ); - final sourceWindow = _windowForDate(sourceDate, settings); - final targetWindow = _windowForDate(targetDate, settings); - final tasks = await _loadSourceAndTargetTasks( - repositories, - sourceWindow: sourceWindow, - targetWindow: targetWindow, - ); - final blocks = (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: context.ownerTimeZone.ownerId, - )) - .items; - final overrides = (await repositories.lockedBlocks.findOverridesForDate( - ownerId: context.ownerTimeZone.ownerId, - date: targetDate, - )) - .items; - final lockedExpansion = expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: targetDate, - timeZoneId: settings.timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final input = SchedulingInput( - tasks: tasks, - window: targetWindow, - lockedIntervals: lockedExpansion.schedulingIntervals, - ); - final schedulingResult = - schedulingEngine.rollOverUnfinishedFlexibleTasks( - input: input, - sourceWindow: sourceWindow, - updatedAt: context.now, - ); - - if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || - schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { - return ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.noSlot, - entityId: snapshotId, - detailCode: schedulingResult.outcomeCode.name, - ), - ); - } - - final changedTasks = _changedTasks(tasks, schedulingResult.tasks); - final activities = _activitiesForRolloverChanges( - result: schedulingResult, - beforeTasks: tasks, - afterTasks: schedulingResult.tasks, - operationId: context.operationId, - occurredAt: context.now, - ); - final rolloverNotice = _rolloverNotice( - sourceWindow: sourceWindow, - result: schedulingResult, - ); - final snapshot = SchedulingStateSnapshot( - id: snapshotId, - capturedAt: context.now, - window: targetWindow, - tasks: schedulingResult.tasks, - lockedIntervals: input.lockedIntervals, - notices: [ - if (rolloverNotice != null) rolloverNotice, - ], - changes: schedulingResult.changes, - operationName: 'appOpenRecovery', - ); - - await repositories.tasks.saveAll(changedTasks); - await repositories.taskActivities.saveAll(activities); - await repositories.schedulingSnapshots.save(snapshot); - - return ApplicationResult.success( - AppOpenRecoveryResult( - outcome: rolloverNotice == null - ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks - : AppOpenRecoveryOutcome.rolledOver, - sourceDate: sourceDate, - targetDate: targetDate, - snapshot: snapshot, - changedTasks: changedTasks, - activities: activities, - notices: snapshot.notices, - changes: schedulingResult.changes, - ), - ); - }, - ); - } - - SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) { - return SchedulingWindow( - start: _resolve( - date: date, - wallTime: settings.dayStart, - timeZoneId: settings.timeZoneId, - ), - end: _resolve( - date: date, - wallTime: settings.dayEnd, - timeZoneId: settings.timeZoneId, - ), - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } -} - -Future _ownerSettingsOrDefault( - ApplicationUnitOfWorkRepositories repositories, - OwnerTimeZoneContext ownerTimeZone, -) async { - return await repositories.ownerSettings - .findByOwnerId(ownerTimeZone.ownerId) ?? - OwnerSettings( - ownerId: ownerTimeZone.ownerId, - timeZoneId: ownerTimeZone.timeZoneId, - ); -} - -Future> _loadSourceAndTargetTasks( - ApplicationUnitOfWorkRepositories repositories, { - required SchedulingWindow sourceWindow, - required SchedulingWindow targetWindow, -}) async { - final byId = {}; - for (final task in await repositories.tasks.findScheduledInWindow( - sourceWindow, - )) { - byId[task.id] = task; - } - for (final task in await repositories.tasks.findScheduledInWindow( - targetWindow, - )) { - byId[task.id] = task; - } - return List.unmodifiable(byId.values); -} - -SchedulingNotice? _rolloverNotice({ - required SchedulingWindow sourceWindow, - required SchedulingResult result, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return null; - } - final rolledTaskIds = result.changes - .where( - (change) => - change.previousStart != null && - change.previousEnd != null && - sourceWindow.contains( - TimeInterval( - start: change.previousStart!, end: change.previousEnd!), - ), - ) - .map((change) => change.taskId) - .toSet(); - if (rolledTaskIds.isEmpty) { - return null; - } - - return SchedulingNotice( - '${rolledTaskIds.length} unfinished flexible tasks were moved to today.', - type: SchedulingNoticeType.moved, - movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - parameters: { - 'count': rolledTaskIds.length, - 'taskIds': List.unmodifiable(rolledTaskIds), - }, - ); -} - -List _activitiesForRolloverChanges({ - required SchedulingResult result, - required List beforeTasks, - required List afterTasks, - required String operationId, - required DateTime occurredAt, -}) { - if (result.outcomeCode != SchedulingOutcomeCode.success) { - return const []; - } - final beforeById = { - for (final task in beforeTasks) task.id: task, - }; - final afterById = { - for (final task in afterTasks) task.id: task, - }; - final activities = []; - - for (final change in result.changes) { - final before = beforeById[change.taskId]; - final after = afterById[change.taskId]; - if (before == null || after == null) { - continue; - } - activities.add( - TaskActivity( - id: '$operationId:${change.taskId}:automaticPush', - operationId: operationId, - code: TaskActivityCode.automaticallyPushed, - taskId: change.taskId, - projectId: before.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': before.status.name, - 'nextStatus': after.status.name, - 'previousStart': change.previousStart, - 'previousEnd': change.previousEnd, - 'nextStart': change.nextStart, - 'nextEnd': change.nextEnd, - }, - ), - ); - } - - return List.unmodifiable(activities); -} - -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); -} - -bool _taskChanged(Task before, Task after) { - return before.status != after.status || - before.scheduledStart != after.scheduledStart || - before.scheduledEnd != after.scheduledEnd || - before.updatedAt != after.updatedAt || - before.stats != after.stats; -} - -String _rolloverSnapshotId({ - required String ownerId, - required CivilDate sourceDate, -}) { - return 'rollover:$ownerId:${sourceDate.toIsoString()}'; -} +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_recovery/app_open_recovery_outcome.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart new file mode 100644 index 0000000..23db761 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart @@ -0,0 +1,8 @@ +part of '../application_recovery.dart'; + +/// Stable app-open recovery outcomes. +enum AppOpenRecoveryOutcome { + rolledOver, + noUnfinishedFlexibleTasks, + alreadyProcessed, +} diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart new file mode 100644 index 0000000..b0c30e4 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart @@ -0,0 +1,42 @@ +part of '../application_recovery.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart new file mode 100644 index 0000000..b79ad6d --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart @@ -0,0 +1,22 @@ +part of '../application_recovery.dart'; + +/// 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); +} diff --git a/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart new file mode 100644 index 0000000..f214d23 --- /dev/null +++ b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart @@ -0,0 +1,327 @@ +part of '../application_recovery.dart'; + +/// Explicit app-open recovery facade. +class V1AppOpenRecoveryUseCases { + const V1AppOpenRecoveryUseCases({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.schedulingEngine = const SchedulingEngine(), + }); + + /// Atomic application boundary. + final ApplicationUnitOfWork applicationStore; + + /// Explicit owner-local date resolver. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + /// Pure scheduling engine. + final SchedulingEngine schedulingEngine; + + /// Run source-day rollover at most once for one owner/source-date key. + Future> runAppOpenRecovery( + RunAppOpenRecoveryRequest request, + ) { + return applicationStore.run( + context: request.context, + operationName: 'appOpenRecovery', + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + final sourceDate = request.sourceDate; + final targetDate = request.resolvedTargetDate; + final snapshotId = _rolloverSnapshotId( + ownerId: ownerId, + sourceDate: sourceDate, + ); + final existingSnapshot = + await repositories.schedulingSnapshots.findById(snapshotId); + if (existingSnapshot != null) { + return ApplicationResult.success( + AppOpenRecoveryResult( + outcome: AppOpenRecoveryOutcome.alreadyProcessed, + sourceDate: sourceDate, + targetDate: targetDate, + snapshot: existingSnapshot, + changedTasks: const [], + activities: const [], + notices: existingSnapshot.notices, + changes: existingSnapshot.changes, + ), + ); + } + + final settings = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final sourceWindow = _windowForDate(sourceDate, settings); + final targetWindow = _windowForDate(targetDate, settings); + final tasks = await _loadSourceAndTargetTasks( + repositories, + sourceWindow: sourceWindow, + targetWindow: targetWindow, + ); + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: context.ownerTimeZone.ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: context.ownerTimeZone.ownerId, + date: targetDate, + )) + .items; + final lockedExpansion = expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: targetDate, + timeZoneId: settings.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final input = SchedulingInput( + tasks: tasks, + window: targetWindow, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + final schedulingResult = + schedulingEngine.rollOverUnfinishedFlexibleTasks( + input: input, + sourceWindow: sourceWindow, + updatedAt: context.now, + ); + + if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow || + schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.noSlot, + entityId: snapshotId, + detailCode: schedulingResult.outcomeCode.name, + ), + ); + } + + final changedTasks = _changedTasks(tasks, schedulingResult.tasks); + final activities = _activitiesForRolloverChanges( + result: schedulingResult, + beforeTasks: tasks, + afterTasks: schedulingResult.tasks, + operationId: context.operationId, + occurredAt: context.now, + ); + final rolloverNotice = _rolloverNotice( + sourceWindow: sourceWindow, + result: schedulingResult, + ); + final snapshot = SchedulingStateSnapshot( + id: snapshotId, + capturedAt: context.now, + window: targetWindow, + tasks: schedulingResult.tasks, + lockedIntervals: input.lockedIntervals, + notices: [ + if (rolloverNotice != null) rolloverNotice, + ], + changes: schedulingResult.changes, + operationName: 'appOpenRecovery', + ); + + await repositories.tasks.saveAll(changedTasks); + await repositories.taskActivities.saveAll(activities); + await repositories.schedulingSnapshots.save(snapshot); + + return ApplicationResult.success( + AppOpenRecoveryResult( + outcome: rolloverNotice == null + ? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks + : AppOpenRecoveryOutcome.rolledOver, + sourceDate: sourceDate, + targetDate: targetDate, + snapshot: snapshot, + changedTasks: changedTasks, + activities: activities, + notices: snapshot.notices, + changes: schedulingResult.changes, + ), + ); + }, + ); + } + + SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) { + return SchedulingWindow( + start: _resolve( + date: date, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ), + end: _resolve( + date: date, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ), + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } +} + +Future _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +Future> _loadSourceAndTargetTasks( + ApplicationUnitOfWorkRepositories repositories, { + required SchedulingWindow sourceWindow, + required SchedulingWindow targetWindow, +}) async { + final byId = {}; + for (final task in await repositories.tasks.findScheduledInWindow( + sourceWindow, + )) { + byId[task.id] = task; + } + for (final task in await repositories.tasks.findScheduledInWindow( + targetWindow, + )) { + byId[task.id] = task; + } + return List.unmodifiable(byId.values); +} + +SchedulingNotice? _rolloverNotice({ + required SchedulingWindow sourceWindow, + required SchedulingResult result, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return null; + } + final rolledTaskIds = result.changes + .where( + (change) => + change.previousStart != null && + change.previousEnd != null && + sourceWindow.contains( + TimeInterval( + start: change.previousStart!, end: change.previousEnd!), + ), + ) + .map((change) => change.taskId) + .toSet(); + if (rolledTaskIds.isEmpty) { + return null; + } + + return SchedulingNotice( + '${rolledTaskIds.length} unfinished flexible tasks were moved to today.', + type: SchedulingNoticeType.moved, + movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: { + 'count': rolledTaskIds.length, + 'taskIds': List.unmodifiable(rolledTaskIds), + }, + ); +} + +List _activitiesForRolloverChanges({ + required SchedulingResult result, + required List beforeTasks, + required List afterTasks, + required String operationId, + required DateTime occurredAt, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final afterById = { + for (final task in afterTasks) task.id: task, + }; + final activities = []; + + for (final change in result.changes) { + final before = beforeById[change.taskId]; + final after = afterById[change.taskId]; + if (before == null || after == null) { + continue; + } + activities.add( + TaskActivity( + id: '$operationId:${change.taskId}:automaticPush', + operationId: operationId, + code: TaskActivityCode.automaticallyPushed, + taskId: change.taskId, + projectId: before.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': before.status.name, + 'nextStatus': after.status.name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +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); +} + +bool _taskChanged(Task before, Task after) { + return before.status != after.status || + before.scheduledStart != after.scheduledStart || + before.scheduledEnd != after.scheduledEnd || + before.updatedAt != after.updatedAt || + before.stats != after.stats; +} + +String _rolloverSnapshotId({ + required String ownerId, + required CivilDate sourceDate, +}) { + return 'rollover:$ownerId:${sourceDate.toIsoString()}'; +} diff --git a/packages/scheduler_core/lib/src/backlog.dart b/packages/scheduler_core/lib/src/backlog.dart index fbda319..3bc4a7f 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/backlog.dart @@ -9,291 +9,9 @@ library; // 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; - } -} - -/// Read-only backlog projection over the unified task list. -/// -/// [BacklogView] is a query/helper object. It does not mutate tasks or own data; -/// it receives the current task list and exposes common backlog slices for UI. -/// That keeps backlog display logic out of widgets and avoids duplicating the -/// same filtering rules in multiple screens. -class BacklogView { - BacklogView({ - required List tasks, - required this.now, - this.staleAfter = const Duration(days: 31), - this.stalenessSettings = const BacklogStalenessSettings(), - }) : tasks = List.unmodifiable(tasks); - - /// Master task list supplied by the caller. Only `status == backlog` items are - /// shown by this view. - final List tasks; - - /// Clock value supplied by the caller so age/staleness behavior is testable. - final DateTime now; - - /// Age since [Task.createdAt] that qualifies for the `stale` filter. - /// - /// V1 does not yet store a separate "entered backlog at" timestamp. Until - /// persistence adds that field, both stale filtering and visual staleness use - /// task creation age so they do not disagree after a task edit. - final Duration staleAfter; - - /// Color-bucket threshold configuration for backlog aging indicators. - final BacklogStalenessSettings stalenessSettings; - - /// All tasks currently in backlog status. - /// - /// The returned list is a snapshot. It is not intended to be modified and then - /// written back; state changes should go through scheduling/action services. - List get backlogTasks { - return tasks.where((task) => task.isBacklog).toList(growable: false); - } - - /// Return backlog tasks matching a single user-facing filter. - /// - /// Filtering always starts from [backlogTasks], so a completed or planned task - /// will never appear here even if it has matching statistics. - List filter(BacklogFilter filter) { - return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( - growable: false, - ); - } - - /// Return all backlog tasks sorted by a user-facing ordering. - /// - /// A new list is created before sorting so the original [tasks] list is never - /// reordered by a read operation. The final list is unmodifiable to make that - /// intent explicit to callers. - List sorted(BacklogSortKey sortKey) { - final sortedTasks = backlogTasks - .asMap() - .entries - .map( - (entry) => _IndexedTask( - task: entry.value, - originalIndex: entry.key, - ), - ) - .toList(growable: false); - sortedTasks.sort((a, b) { - final comparison = _compareTasks(a.task, b.task, sortKey); - if (comparison != 0) { - return comparison; - } - - return a.originalIndex.compareTo(b.originalIndex); - }); - - return List.unmodifiable( - sortedTasks.map((indexedTask) => indexedTask.task), - ); - } - - /// Return the green/blue/purple marker for one task. - BacklogStalenessMarker stalenessMarkerFor(Task task) { - return stalenessSettings.markerFor(task: task, now: now); - } - - /// Private predicate implementing every [BacklogFilter] option. - /// - /// Keeping this as a switch expression makes new filters obvious: add the enum - /// value and the compiler forces this method to handle it. - bool _matchesFilter(Task task, BacklogFilter filter) { - return switch (filter) { - BacklogFilter.inbox => task.projectId == 'inbox', - BacklogFilter.pushed => - task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0, - BacklogFilter.criticalMissed => - task.type == TaskType.critical && task.stats.missedCount > 0, - BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), - BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, - BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, - }; - } - - /// Comparison callback used by [sorted]. - /// - /// Sort directions are encoded here. Higher priority/reward/push counts should - /// appear earlier, while older age uses the earliest [Task.createdAt] first. - int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { - return switch (sortKey) { - BacklogSortKey.priority => - _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), - BacklogSortKey.rewardVsEffort => - _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), - BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), - BacklogSortKey.project => a.projectId.compareTo(b.projectId), - BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), - }; - } -} - -/// Convert nullable priority into a stable numeric rank for sorting. -/// -/// Null priority is treated like medium so partially imported data behaves like -/// normal starter tasks instead of sinking to the bottom. -int _priorityRank(PriorityLevel? priority) { - return switch (priority) { - PriorityLevel.veryLow => 0, - PriorityLevel.low => 1, - PriorityLevel.medium || null => 2, - PriorityLevel.high => 3, - PriorityLevel.veryHigh => 4, - }; -} - -/// Convert reward enum values to numeric ranks for derived scoring. -int _rewardRank(RewardLevel reward) { - return switch (reward) { - RewardLevel.notSet => 0, - RewardLevel.veryLow => 1, - RewardLevel.low => 2, - RewardLevel.medium => 3, - RewardLevel.high => 4, - RewardLevel.veryHigh => 5, - }; -} - -/// Convert difficulty enum values to numeric ranks for derived scoring. -int _difficultyRank(DifficultyLevel difficulty) { - return switch (difficulty) { - DifficultyLevel.notSet => 0, - DifficultyLevel.veryEasy => 1, - DifficultyLevel.easy => 2, - DifficultyLevel.medium => 3, - DifficultyLevel.hard => 4, - DifficultyLevel.veryHard => 5, - }; -} - -/// Simple motivation score: reward minus difficulty. -/// -/// Positive scores suggest high payoff for lower activation cost. Negative scores -/// suggest high effort for lower payoff. This is deliberately simple for V1 and -/// can be replaced by richer heuristics later without changing the public sort -/// key. -int _rewardVsEffortScore(Task task) { - return _rewardRank(task.reward) - _difficultyRank(task.difficulty); -} - -/// Total manual and automatic pushes recorded on the 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; -} +part 'backlog/backlog_filter.dart'; +part 'backlog/backlog_sort_key.dart'; +part 'backlog/backlog_staleness_marker.dart'; +part 'backlog/backlog_staleness_settings.dart'; +part 'backlog/backlog_view.dart'; +part 'backlog/indexed_task.dart'; diff --git a/packages/scheduler_core/lib/src/backlog/backlog_filter.dart b/packages/scheduler_core/lib/src/backlog/backlog_filter.dart new file mode 100644 index 0000000..f36ce61 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_filter.dart @@ -0,0 +1,27 @@ +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/backlog/backlog_sort_key.dart b/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart new file mode 100644 index 0000000..1269794 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart @@ -0,0 +1,24 @@ +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/backlog/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart new file mode 100644 index 0000000..10f22f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart @@ -0,0 +1,17 @@ +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/backlog/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart new file mode 100644 index 0000000..2229ee7 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart @@ -0,0 +1,42 @@ +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 { + 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/backlog_view.dart b/packages/scheduler_core/lib/src/backlog/backlog_view.dart new file mode 100644 index 0000000..61b948d --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/backlog_view.dart @@ -0,0 +1,172 @@ +part of '../backlog.dart'; + +/// Read-only backlog projection over the unified task list. +/// +/// [BacklogView] is a query/helper object. It does not mutate tasks or own data; +/// it receives the current task list and exposes common backlog slices for UI. +/// That keeps backlog display logic out of widgets and avoids duplicating the +/// same filtering rules in multiple screens. +class BacklogView { + BacklogView({ + required List tasks, + required this.now, + this.staleAfter = const Duration(days: 31), + this.stalenessSettings = const BacklogStalenessSettings(), + }) : tasks = List.unmodifiable(tasks); + + /// Master task list supplied by the caller. Only `status == backlog` items are + /// shown by this view. + final List tasks; + + /// Clock value supplied by the caller so age/staleness behavior is testable. + final DateTime now; + + /// Age since [Task.createdAt] that qualifies for the `stale` filter. + /// + /// V1 does not yet store a separate "entered backlog at" timestamp. Until + /// persistence adds that field, both stale filtering and visual staleness use + /// task creation age so they do not disagree after a task edit. + final Duration staleAfter; + + /// Color-bucket threshold configuration for backlog aging indicators. + final BacklogStalenessSettings stalenessSettings; + + /// All tasks currently in backlog status. + /// + /// The returned list is a snapshot. It is not intended to be modified and then + /// written back; state changes should go through scheduling/action services. + List get backlogTasks { + return tasks.where((task) => task.isBacklog).toList(growable: false); + } + + /// Return backlog tasks matching a single user-facing filter. + /// + /// Filtering always starts from [backlogTasks], so a completed or planned task + /// will never appear here even if it has matching statistics. + List filter(BacklogFilter filter) { + return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( + growable: false, + ); + } + + /// Return all backlog tasks sorted by a user-facing ordering. + /// + /// A new list is created before sorting so the original [tasks] list is never + /// reordered by a read operation. The final list is unmodifiable to make that + /// intent explicit to callers. + List sorted(BacklogSortKey sortKey) { + final sortedTasks = backlogTasks + .asMap() + .entries + .map( + (entry) => _IndexedTask( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + sortedTasks.sort((a, b) { + final comparison = _compareTasks(a.task, b.task, sortKey); + if (comparison != 0) { + return comparison; + } + + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + sortedTasks.map((indexedTask) => indexedTask.task), + ); + } + + /// Return the green/blue/purple marker for one task. + BacklogStalenessMarker stalenessMarkerFor(Task task) { + return stalenessSettings.markerFor(task: task, now: now); + } + + /// Private predicate implementing every [BacklogFilter] option. + /// + /// Keeping this as a switch expression makes new filters obvious: add the enum + /// value and the compiler forces this method to handle it. + bool _matchesFilter(Task task, BacklogFilter filter) { + return switch (filter) { + BacklogFilter.inbox => task.projectId == 'inbox', + BacklogFilter.pushed => + task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0, + BacklogFilter.criticalMissed => + task.type == TaskType.critical && task.stats.missedCount > 0, + BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), + BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, + BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, + }; + } + + /// Comparison callback used by [sorted]. + /// + /// Sort directions are encoded here. Higher priority/reward/push counts should + /// appear earlier, while older age uses the earliest [Task.createdAt] first. + int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { + return switch (sortKey) { + BacklogSortKey.priority => + _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), + BacklogSortKey.rewardVsEffort => + _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), + BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), + BacklogSortKey.project => a.projectId.compareTo(b.projectId), + BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), + }; + } +} + +/// Convert nullable priority into a stable numeric rank for sorting. +/// +/// Null priority is treated like medium so partially imported data behaves like +/// normal starter tasks instead of sinking to the bottom. +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium || null => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + }; +} + +/// Convert reward enum values to numeric ranks for derived scoring. +int _rewardRank(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 0, + RewardLevel.veryLow => 1, + RewardLevel.low => 2, + RewardLevel.medium => 3, + RewardLevel.high => 4, + RewardLevel.veryHigh => 5, + }; +} + +/// Convert difficulty enum values to numeric ranks for derived scoring. +int _difficultyRank(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => 0, + DifficultyLevel.veryEasy => 1, + DifficultyLevel.easy => 2, + DifficultyLevel.medium => 3, + DifficultyLevel.hard => 4, + DifficultyLevel.veryHard => 5, + }; +} + +/// Simple motivation score: reward minus difficulty. +/// +/// Positive scores suggest high payoff for lower activation cost. Negative scores +/// suggest high effort for lower payoff. This is deliberately simple for V1 and +/// can be replaced by richer heuristics later without changing the public sort +/// key. +int _rewardVsEffortScore(Task task) { + return _rewardRank(task.reward) - _difficultyRank(task.difficulty); +} + +/// Total manual and automatic pushes recorded on the task. +int _timesPushed(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} diff --git a/packages/scheduler_core/lib/src/backlog/indexed_task.dart b/packages/scheduler_core/lib/src/backlog/indexed_task.dart new file mode 100644 index 0000000..ded17d1 --- /dev/null +++ b/packages/scheduler_core/lib/src/backlog/indexed_task.dart @@ -0,0 +1,12 @@ +part of '../backlog.dart'; + +/// 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/child_tasks.dart b/packages/scheduler_core/lib/src/child_tasks.dart index 8f23fb8..6041b64 100644 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ b/packages/scheduler_core/lib/src/child_tasks.dart @@ -10,746 +10,12 @@ library; 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; -} +part 'child_tasks/child_task_entry.dart'; +part 'child_tasks/child_task_break_up_request.dart'; +part 'child_tasks/child_task_break_up_result.dart'; +part 'child_tasks/child_task_break_up_service.dart'; +part 'child_tasks/child_task_view.dart'; +part 'child_tasks/child_task_completion_result.dart'; +part 'child_tasks/child_task_completion_service.dart'; +part 'child_tasks/child_task_summary.dart'; +part 'child_tasks/indexed_child.dart'; diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart new file mode 100644 index 0000000..37c26d4 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart @@ -0,0 +1,21 @@ +part of '../child_tasks.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart new file mode 100644 index 0000000..21280ae --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart @@ -0,0 +1,37 @@ +part of '../child_tasks.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart new file mode 100644 index 0000000..2391462 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart @@ -0,0 +1,74 @@ +part of '../child_tasks.dart'; + +/// 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), + ); + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart new file mode 100644 index 0000000..e50bf2d --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart @@ -0,0 +1,50 @@ +part of '../child_tasks.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart new file mode 100644 index 0000000..2976786 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart @@ -0,0 +1,267 @@ +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 { + 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart new file mode 100644 index 0000000..0bb6e1d --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart @@ -0,0 +1,72 @@ +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 { + 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/child_tasks/child_task_summary.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart new file mode 100644 index 0000000..faf1fc2 --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart @@ -0,0 +1,86 @@ +part of '../child_tasks.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart b/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart new file mode 100644 index 0000000..f25e3cf --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart @@ -0,0 +1,87 @@ +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 { + 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/child_tasks/indexed_child.dart b/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart new file mode 100644 index 0000000..790926e --- /dev/null +++ b/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart @@ -0,0 +1,58 @@ +part of '../child_tasks.dart'; + +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 index b084b1e..1638bf4 100644 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping.dart @@ -17,479 +17,31 @@ 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(); - } -} +part 'document_mapping/document_mapping_failure_code.dart'; +part 'document_mapping/document_mapping_exception.dart'; +part 'document_mapping/document_metadata.dart'; +part 'document_mapping/persistence_enum_mapping.dart'; +part 'document_mapping/task_document_mapping.dart'; +part 'document_mapping/task_statistics_document_mapping.dart'; +part 'document_mapping/project_document_mapping.dart'; +part 'document_mapping/project_statistics_document_mapping.dart'; +part 'document_mapping/locked_block_recurrence_document_mapping.dart'; +part 'document_mapping/locked_block_document_mapping.dart'; +part 'document_mapping/locked_block_override_document_mapping.dart'; +part 'document_mapping/task_activity_document_mapping.dart'; +part 'document_mapping/owner_settings_document_mapping.dart'; +part 'document_mapping/backlog_staleness_document_mapping.dart'; +part 'document_mapping/notice_acknowledgement_document_mapping.dart'; +part 'document_mapping/application_operation_document_mapping.dart'; +part 'document_mapping/scheduling_snapshot_document_mapping.dart'; +part 'document_mapping/scheduling_window_document_mapping.dart'; +part 'document_mapping/time_interval_document_mapping.dart'; +part 'document_mapping/scheduling_notice_document_mapping.dart'; +part 'document_mapping/scheduling_change_document_mapping.dart'; +part 'document_mapping/scheduling_overlap_document_mapping.dart'; +part 'document_mapping/task_document_extension.dart'; +part 'document_mapping/task_statistics_document_extension.dart'; +part 'document_mapping/project_statistics_document_extension.dart'; extension on Task { void _validateTaskDocumentMetadata(Map document) { @@ -501,266 +53,6 @@ extension on Task { } } -/// 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, @@ -779,556 +71,6 @@ extension on ProjectStatistics { } } -/// 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, @@ -1348,792 +90,3 @@ extension on SchedulingStateSnapshot { _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_mapping/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart new file mode 100644 index 0000000..4e37ca4 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart @@ -0,0 +1,49 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart new file mode 100644 index 0000000..0d39b1a --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart @@ -0,0 +1,36 @@ +part of '../document_mapping.dart'; + +/// 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), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart b/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart new file mode 100644 index 0000000..5440dac --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart @@ -0,0 +1,25 @@ +part of '../document_mapping.dart'; + +/// 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)'; + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart new file mode 100644 index 0000000..061275b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart @@ -0,0 +1,11 @@ +part of '../document_mapping.dart'; + +/// Stable document mapping failure categories. +enum DocumentMappingFailureCode { + missingField, + wrongType, + unknownCode, + invalidSchemaVersion, + invalidRevision, + invalidValue, +} diff --git a/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart b/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart new file mode 100644 index 0000000..9f48044 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart @@ -0,0 +1,18 @@ +part of '../document_mapping.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart new file mode 100644 index 0000000..3dd6323 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart @@ -0,0 +1,81 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart new file mode 100644 index 0000000..7525bd1 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart @@ -0,0 +1,81 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart new file mode 100644 index 0000000..043e45d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart @@ -0,0 +1,42 @@ +part of '../document_mapping.dart'; + +/// 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); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart new file mode 100644 index 0000000..22a51d4 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart @@ -0,0 +1,44 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart new file mode 100644 index 0000000..854a49a --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart @@ -0,0 +1,60 @@ +part of '../document_mapping.dart'; + +/// 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), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart new file mode 100644 index 0000000..65cdccd --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart @@ -0,0 +1,288 @@ +part of '../document_mapping.dart'; + +/// 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', + ); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart new file mode 100644 index 0000000..93d4792 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart @@ -0,0 +1,72 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart new file mode 100644 index 0000000..6b86bb5 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart @@ -0,0 +1,577 @@ +part of '../document_mapping.dart'; + +/// 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_mapping/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart new file mode 100644 index 0000000..606fe6d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart @@ -0,0 +1,101 @@ +part of '../document_mapping.dart'; + +/// 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); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart new file mode 100644 index 0000000..b6f5714 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart @@ -0,0 +1,42 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart new file mode 100644 index 0000000..4096b74 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart @@ -0,0 +1,67 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart new file mode 100644 index 0000000..e2dc7e6 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart @@ -0,0 +1,32 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart new file mode 100644 index 0000000..7f47067 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart @@ -0,0 +1,110 @@ +part of '../document_mapping.dart'; + +/// 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); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart new file mode 100644 index 0000000..06a5c8f --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart @@ -0,0 +1,23 @@ +part of '../document_mapping.dart'; + +/// 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), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart new file mode 100644 index 0000000..df9fdc8 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart @@ -0,0 +1,56 @@ +part of '../document_mapping.dart'; + +/// 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), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart new file mode 100644 index 0000000..1830657 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart @@ -0,0 +1,21 @@ +part of '../document_mapping.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart new file mode 100644 index 0000000..4b51d0f --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart @@ -0,0 +1,136 @@ +part of '../document_mapping.dart'; + +/// 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(); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart new file mode 100644 index 0000000..fabd306 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart @@ -0,0 +1,8 @@ +part of '../document_mapping.dart'; + +/// Convenience extension for converting [TaskStatistics] into an embedded map. +extension TaskStatisticsDocumentExtension on TaskStatistics { + Map toDocument() { + return TaskStatisticsDocumentMapping.toDocument(this); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart new file mode 100644 index 0000000..b503c61 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart @@ -0,0 +1,90 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart new file mode 100644 index 0000000..1817f7d --- /dev/null +++ b/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart @@ -0,0 +1,27 @@ +part of '../document_mapping.dart'; + +/// 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, + ), + ); + }); + } +} diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/document_migration.dart index 8dcc6fa..c5c172e 100644 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ b/packages/scheduler_core/lib/src/document_migration.dart @@ -10,1132 +10,16 @@ library; 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; -} +part 'document_migration/document_migration_entity.dart'; +part 'document_migration/document_migration_status.dart'; +part 'document_migration/document_migration_failure_code.dart'; +part 'document_migration/document_migration_note_code.dart'; +part 'document_migration/document_migration_provenance.dart'; +part 'document_migration/document_migration_note.dart'; +part 'document_migration/document_migration_report.dart'; +part 'document_migration/document_migration_result.dart'; +part 'document_migration/v1_document_migration_service.dart'; +part 'document_migration/v0_migrator.dart'; +part 'document_migration/schema_version_read.dart'; +part 'document_migration/legacy_document_exception.dart'; +part 'document_migration/legacy_metadata_instants.dart'; diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart new file mode 100644 index 0000000..9730c47 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +/// Entity shape being migrated. +enum DocumentMigrationEntity { + task, + project, + lockedBlock, + lockedBlockOverride, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart new file mode 100644 index 0000000..688b5b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +/// Typed categories for migration failures. +enum DocumentMigrationFailureCode { + invalidSchemaVersion, + unsupportedSchemaVersion, + legacyDocumentFailure, + currentDocumentFailure, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart new file mode 100644 index 0000000..b0b3770 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart @@ -0,0 +1,14 @@ +part of '../document_migration.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart new file mode 100644 index 0000000..09da94b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart @@ -0,0 +1,7 @@ +part of '../document_migration.dart'; + +/// Non-fatal migration notes surfaced to callers during dry-runs. +enum DocumentMigrationNoteCode { + approximatedBacklogEnteredAt, + synthesizedMetadataTimestamp, +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart new file mode 100644 index 0000000..68be7a7 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart @@ -0,0 +1,6 @@ +part of '../document_migration.dart'; + +/// Provenance labels written by migrations. +abstract final class DocumentMigrationProvenance { + static const approximatedFromCreatedAt = 'approximated_from_created_at'; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart new file mode 100644 index 0000000..27b36e6 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart @@ -0,0 +1,30 @@ +part of '../document_migration.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart new file mode 100644 index 0000000..4e5b08b --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart @@ -0,0 +1,19 @@ +part of '../document_migration.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart b/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart new file mode 100644 index 0000000..4e953e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart @@ -0,0 +1,8 @@ +part of '../document_migration.dart'; + +/// Result state for a migration attempt. +enum DocumentMigrationStatus { + migrated, + alreadyCurrent, + failed, +} diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart b/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart new file mode 100644 index 0000000..4370a09 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart @@ -0,0 +1,11 @@ +part of '../document_migration.dart'; + +class _LegacyDocumentException implements Exception { + const _LegacyDocumentException({ + required this.fieldName, + required this.detailCode, + }); + + final String fieldName; + final String detailCode; +} diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart new file mode 100644 index 0000000..6b1b590 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart @@ -0,0 +1,602 @@ +part of '../document_migration.dart'; + +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/document_migration/schema_version_read.dart b/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart new file mode 100644 index 0000000..81d7f05 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart @@ -0,0 +1,9 @@ +part of '../document_migration.dart'; + +class _SchemaVersionRead { + const _SchemaVersionRead.version(this.version) : failure = null; + const _SchemaVersionRead.failure(this.failure) : version = 0; + + final int version; + final String? failure; +} diff --git a/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart b/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart new file mode 100644 index 0000000..ac2e172 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart @@ -0,0 +1,6 @@ +part of '../document_migration.dart'; + +typedef _V0Migrator = Map Function( + Map document, + List notes, +); diff --git a/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart new file mode 100644 index 0000000..adcbbc9 --- /dev/null +++ b/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart @@ -0,0 +1,412 @@ +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 { + 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/free_slots.dart b/packages/scheduler_core/lib/src/free_slots.dart index 4d681ea..1325118 100644 --- a/packages/scheduler_core/lib/src/free_slots.dart +++ b/packages/scheduler_core/lib/src/free_slots.dart @@ -10,321 +10,7 @@ library; 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; - } -} - -/// Creates and updates protected Free Slot task records. -class FreeSlotService { - const FreeSlotService(); - - /// Create a scheduled Free Slot record. - Task create({ - required String id, - required String title, - required DateTime start, - required DateTime end, - required DateTime createdAt, - String projectId = 'inbox', - DateTime? updatedAt, - }) { - final interval = TimeInterval(start: start, end: end, label: id); - - return Task( - id: id, - title: title, - projectId: projectId, - type: TaskType.freeSlot, - status: TaskStatus.planned, - priority: PriorityLevel.medium, - durationMinutes: interval.duration.inMinutes, - scheduledStart: interval.start, - scheduledEnd: interval.end, - createdAt: createdAt, - updatedAt: updatedAt ?? createdAt, - ); - } - - /// Update an existing Free Slot record's schedule and optional metadata. - Task update({ - required Task freeSlot, - required DateTime start, - required DateTime end, - DateTime? updatedAt, - String? title, - String? projectId, - }) { - if (freeSlot.type != TaskType.freeSlot) { - throw ArgumentError.value( - freeSlot.type, - 'freeSlot.type', - 'Task must be a Free Slot.', - ); - } - - final interval = TimeInterval(start: start, end: end, label: freeSlot.id); - - return freeSlot.copyWith( - title: title, - projectId: projectId, - status: TaskStatus.planned, - durationMinutes: interval.duration.inMinutes, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt ?? freeSlot.updatedAt, - ); - } - - /// Explicitly place a critical or inflexible task. - /// - /// This operation is allowed to overlap protected Free Slots because required - /// commitments can interrupt rest. It does not move the Free Slot. Instead it - /// returns typed conflict data for the application layer to surface calmly. - RequiredCommitmentScheduleResult scheduleRequiredCommitment({ - required SchedulingInput input, - required String taskId, - required DateTime start, - required DateTime end, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedRequiredResult( - input: input, - status: RequiredCommitmentScheduleStatus.taskNotFound, - notice: SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isRequiredVisible) { - return _unchangedRequiredResult( - input: input, - status: RequiredCommitmentScheduleStatus.invalidTaskType, - notice: SchedulingNotice( - 'Only critical or inflexible tasks can be explicitly scheduled here.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final requiredInterval = - TimeInterval(start: start, end: end, label: taskId); - final conflicts = _protectedFreeSlotConflicts( - input: input, - requiredTaskId: task.id, - requiredInterval: requiredInterval, - ); - final updatedTask = task.copyWith( - status: TaskStatus.planned, - durationMinutes: requiredInterval.duration.inMinutes, - scheduledStart: requiredInterval.start, - scheduledEnd: requiredInterval.end, - updatedAt: updatedAt ?? task.updatedAt, - ); - final scheduleChanged = !_sameDateTime(task.scheduledStart, start) || - !_sameDateTime(task.scheduledEnd, end) || - task.status != TaskStatus.planned; - final updatedTasks = input.tasks.map((current) { - if (current.id == task.id) { - return updatedTask; - } - return current; - }).toList(growable: false); - final overlapDetails = conflicts.map((conflict) { - return SchedulingOverlap( - taskId: conflict.requiredTaskId, - taskInterval: conflict.requiredInterval, - blockedInterval: conflict.freeSlotInterval, - ); - }).toList(growable: false); - final notices = [ - SchedulingNotice( - conflicts.isEmpty - ? 'Required commitment scheduled.' - : 'Required commitment overlaps protected rest.', - type: conflicts.isEmpty - ? SchedulingNoticeType.moved - : SchedulingNoticeType.overlap, - taskId: task.id, - movementCode: conflicts.isEmpty - ? SchedulingMovementCode.requiredCommitmentScheduled - : null, - conflictCode: conflicts.isEmpty - ? null - : SchedulingConflictCode - .requiredCommitmentOverlapsProtectedFreeSlot, - parameters: { - 'requiredStart': requiredInterval.start, - 'requiredEnd': requiredInterval.end, - if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length, - }, - ), - ]; - final changes = [ - if (scheduleChanged) - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: requiredInterval.start, - nextEnd: requiredInterval.end, - ), - ]; - - return RequiredCommitmentScheduleResult( - status: conflicts.isEmpty - ? RequiredCommitmentScheduleStatus.scheduled - : RequiredCommitmentScheduleStatus - .scheduledWithProtectedFreeSlotConflict, - protectedFreeSlotConflicts: conflicts, - schedulingResult: SchedulingResult( - tasks: updatedTasks, - operationCode: SchedulingOperationCode.scheduleRequiredCommitment, - outcomeCode: conflicts.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: notices, - changes: changes, - overlaps: overlapDetails, - ), - ); - } -} - -RequiredCommitmentScheduleResult _unchangedRequiredResult({ - required SchedulingInput input, - required RequiredCommitmentScheduleStatus status, - required SchedulingNotice notice, - required SchedulingOutcomeCode outcomeCode, -}) { - return RequiredCommitmentScheduleResult( - status: status, - protectedFreeSlotConflicts: const [], - schedulingResult: SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.scheduleRequiredCommitment, - outcomeCode: outcomeCode, - notices: [notice], - ), - ); -} - -List _protectedFreeSlotConflicts({ - required SchedulingInput input, - required String requiredTaskId, - required TimeInterval requiredInterval, -}) { - final conflicts = []; - - for (final entry in input.occupancyEntries) { - if (entry.category != OccupancyCategory.protectedFreeSlot) { - continue; - } - - final freeSlot = entry.task; - final freeSlotInterval = entry.interval; - if (freeSlot == null || - freeSlotInterval == null || - !requiredInterval.overlaps(freeSlotInterval)) { - continue; - } - - conflicts.add( - ProtectedFreeSlotConflict( - requiredTaskId: requiredTaskId, - freeSlotTaskId: freeSlot.id, - requiredInterval: requiredInterval, - freeSlotInterval: freeSlotInterval, - ), - ); - } - - return List.unmodifiable(conflicts); -} - -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -bool _sameDateTime(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} +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/free_slot_service.dart b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart new file mode 100644 index 0000000..8f8566a --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart @@ -0,0 +1,256 @@ +part of '../free_slots.dart'; + +/// Creates and updates protected Free Slot task records. +class FreeSlotService { + const FreeSlotService(); + + /// Create a scheduled Free Slot record. + Task create({ + required String id, + required String title, + required DateTime start, + required DateTime end, + required DateTime createdAt, + String projectId = 'inbox', + DateTime? updatedAt, + }) { + final interval = TimeInterval(start: start, end: end, label: id); + + return Task( + id: id, + title: title, + projectId: projectId, + type: TaskType.freeSlot, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + durationMinutes: interval.duration.inMinutes, + scheduledStart: interval.start, + scheduledEnd: interval.end, + createdAt: createdAt, + updatedAt: updatedAt ?? createdAt, + ); + } + + /// Update an existing Free Slot record's schedule and optional metadata. + Task update({ + required Task freeSlot, + required DateTime start, + required DateTime end, + DateTime? updatedAt, + String? title, + String? projectId, + }) { + if (freeSlot.type != TaskType.freeSlot) { + throw ArgumentError.value( + freeSlot.type, + 'freeSlot.type', + 'Task must be a Free Slot.', + ); + } + + final interval = TimeInterval(start: start, end: end, label: freeSlot.id); + + return freeSlot.copyWith( + title: title, + projectId: projectId, + status: TaskStatus.planned, + durationMinutes: interval.duration.inMinutes, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt ?? freeSlot.updatedAt, + ); + } + + /// Explicitly place a critical or inflexible task. + /// + /// This operation is allowed to overlap protected Free Slots because required + /// commitments can interrupt rest. It does not move the Free Slot. Instead it + /// returns typed conflict data for the application layer to surface calmly. + RequiredCommitmentScheduleResult scheduleRequiredCommitment({ + required SchedulingInput input, + required String taskId, + required DateTime start, + required DateTime end, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedRequiredResult( + input: input, + status: RequiredCommitmentScheduleStatus.taskNotFound, + notice: SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isRequiredVisible) { + return _unchangedRequiredResult( + input: input, + status: RequiredCommitmentScheduleStatus.invalidTaskType, + notice: SchedulingNotice( + 'Only critical or inflexible tasks can be explicitly scheduled here.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final requiredInterval = + TimeInterval(start: start, end: end, label: taskId); + final conflicts = _protectedFreeSlotConflicts( + input: input, + requiredTaskId: task.id, + requiredInterval: requiredInterval, + ); + final updatedTask = task.copyWith( + status: TaskStatus.planned, + durationMinutes: requiredInterval.duration.inMinutes, + scheduledStart: requiredInterval.start, + scheduledEnd: requiredInterval.end, + updatedAt: updatedAt ?? task.updatedAt, + ); + final scheduleChanged = !_sameDateTime(task.scheduledStart, start) || + !_sameDateTime(task.scheduledEnd, end) || + task.status != TaskStatus.planned; + final updatedTasks = input.tasks.map((current) { + if (current.id == task.id) { + return updatedTask; + } + return current; + }).toList(growable: false); + final overlapDetails = conflicts.map((conflict) { + return SchedulingOverlap( + taskId: conflict.requiredTaskId, + taskInterval: conflict.requiredInterval, + blockedInterval: conflict.freeSlotInterval, + ); + }).toList(growable: false); + final notices = [ + SchedulingNotice( + conflicts.isEmpty + ? 'Required commitment scheduled.' + : 'Required commitment overlaps protected rest.', + type: conflicts.isEmpty + ? SchedulingNoticeType.moved + : SchedulingNoticeType.overlap, + taskId: task.id, + movementCode: conflicts.isEmpty + ? SchedulingMovementCode.requiredCommitmentScheduled + : null, + conflictCode: conflicts.isEmpty + ? null + : SchedulingConflictCode + .requiredCommitmentOverlapsProtectedFreeSlot, + parameters: { + 'requiredStart': requiredInterval.start, + 'requiredEnd': requiredInterval.end, + if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length, + }, + ), + ]; + final changes = [ + if (scheduleChanged) + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: requiredInterval.start, + nextEnd: requiredInterval.end, + ), + ]; + + return RequiredCommitmentScheduleResult( + status: conflicts.isEmpty + ? RequiredCommitmentScheduleStatus.scheduled + : RequiredCommitmentScheduleStatus + .scheduledWithProtectedFreeSlotConflict, + protectedFreeSlotConflicts: conflicts, + schedulingResult: SchedulingResult( + tasks: updatedTasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: conflicts.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: notices, + changes: changes, + overlaps: overlapDetails, + ), + ); + } +} + +RequiredCommitmentScheduleResult _unchangedRequiredResult({ + required SchedulingInput input, + required RequiredCommitmentScheduleStatus status, + required SchedulingNotice notice, + required SchedulingOutcomeCode outcomeCode, +}) { + return RequiredCommitmentScheduleResult( + status: status, + protectedFreeSlotConflicts: const [], + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: outcomeCode, + notices: [notice], + ), + ); +} + +List _protectedFreeSlotConflicts({ + required SchedulingInput input, + required String requiredTaskId, + required TimeInterval requiredInterval, +}) { + final conflicts = []; + + for (final entry in input.occupancyEntries) { + if (entry.category != OccupancyCategory.protectedFreeSlot) { + continue; + } + + final freeSlot = entry.task; + final freeSlotInterval = entry.interval; + if (freeSlot == null || + freeSlotInterval == null || + !requiredInterval.overlaps(freeSlotInterval)) { + continue; + } + + conflicts.add( + ProtectedFreeSlotConflict( + requiredTaskId: requiredTaskId, + freeSlotTaskId: freeSlot.id, + requiredInterval: requiredInterval, + freeSlotInterval: freeSlotInterval, + ), + ); + } + + return List.unmodifiable(conflicts); +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +bool _sameDateTime(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} diff --git a/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart new file mode 100644 index 0000000..26062ab --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart @@ -0,0 +1,23 @@ +part of '../free_slots.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart new file mode 100644 index 0000000..ab0c89e --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart @@ -0,0 +1,27 @@ +part of '../free_slots.dart'; + +/// 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; + } +} diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart new file mode 100644 index 0000000..7c64690 --- /dev/null +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart @@ -0,0 +1,16 @@ +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/locked_time.dart b/packages/scheduler_core/lib/src/locked_time.dart index e31a3d8..b3fc34a 100644 --- a/packages/scheduler_core/lib/src/locked_time.dart +++ b/packages/scheduler_core/lib/src/locked_time.dart @@ -10,953 +10,11 @@ library; 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, - ); - } -} - -/// Expands locked blocks and one-day overrides into concrete intervals. -/// -/// This object is the boundary between human-friendly locked block definitions -/// and scheduler-friendly intervals. It retains the occurrence details for UI -/// while exposing [schedulingIntervals] for placement algorithms. -class LockedScheduleExpansion { - const LockedScheduleExpansion({ - required this.date, - required this.occurrences, - }); - - /// Calendar date represented by this expansion, normalized to year/month/day. - final CivilDate date; - - /// Concrete locked occurrences active on [date]. - final List occurrences; - - /// Just the intervals the scheduler needs to avoid. - List get schedulingIntervals { - return List.unmodifiable( - occurrences.map((occurrence) => occurrence.schedulingInterval), - ); - } -} - -/// Returns concrete locked-time occurrences for [date]. -/// -/// Expansion order: -/// 1. Sort overrides deterministically by creation time, then id. -/// 2. For each base block that occurs on the date, collect its overrides. -/// 3. Skip the occurrence if any remove override applies. -/// 4. Use the latest replacement override if one exists. -/// 5. Add one-off `add` overrides for the date. -/// 6. Sort occurrences by start time for predictable UI/scheduler behavior. -LockedScheduleExpansion expandLockedBlocksForDay({ - required List blocks, - required List overrides, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final occurrences = []; - final sortedOverrides = [...overrides]..sort((a, b) { - final createdComparison = a.createdAt.compareTo(b.createdAt); - if (createdComparison != 0) { - return createdComparison; - } - - return a.id.compareTo(b.id); - }); - - for (final block in blocks) { - if (block.isArchived) { - continue; - } - if (!_blockOccursOn(block, date)) { - continue; - } - - final blockOverrides = sortedOverrides - .where( - (override) => - override.appliesTo(blockId: block.id, occurrenceDate: date), - ) - .toList(growable: false); - - if (blockOverrides.any( - (override) => override.type == LockedBlockOverrideType.remove, - )) { - continue; - } - - final replacement = _lastReplacementOverride(blockOverrides); - - occurrences.add( - replacement == null - ? _occurrenceFromBlock( - block, - date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ) - : _occurrenceFromReplacement( - block: block, - override: replacement, - date: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - ); - } - - for (final override in sortedOverrides) { - if (override.type != LockedBlockOverrideType.add || - !_sameDate(override.date, date)) { - continue; - } - - final occurrence = _occurrenceFromAddedOverride( - override, - date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - if (occurrence != null) { - occurrences.add(occurrence); - } - } - - occurrences.sort((a, b) { - final startComparison = a.interval.start.compareTo(b.interval.start); - if (startComparison != 0) { - return startComparison; - } - - return a.name.compareTo(b.name); - }); - - return LockedScheduleExpansion( - date: date, - occurrences: List.unmodifiable(occurrences), - ); -} - -/// Return the last replacement override from an already sorted override list. -/// -/// Later-created replacements win, which lets a user correct a one-day override -/// without deleting older history first. -LockedBlockOverride? _lastReplacementOverride( - List overrides, -) { - for (final override in overrides.reversed) { - if (override.type == LockedBlockOverrideType.replace) { - return override; - } - } - - return null; -} - -/// Convenience wrapper when callers only need scheduler intervals. -List lockedSchedulingIntervalsForDay({ - required List blocks, - required List overrides, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - return expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ).schedulingIntervals; -} - -/// Returns [task] with locked-hour completion statistics applied when relevant. -/// -/// This does not decide whether completion is allowed. It only records that a -/// completion overlapped locked time, which future reports can surface as a -/// boundary-leak signal. -Task trackCompletedDuringLockedHours({ - required Task task, - required List lockedIntervals, -}) { - final overlapMinutes = completedDuringLockedHoursMinutes( - task: task, - lockedIntervals: lockedIntervals, - ); - - if (overlapMinutes == 0) { - return task; - } - - return task.copyWith( - stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes), - ); -} - -/// Calculates how many scheduled task minutes overlap locked intervals. -/// -/// Multiple locked intervals may overlap each other. To avoid double-counting, -/// intersections are merged before minutes are summed. -int completedDuringLockedHoursMinutes({ - required Task task, - required List lockedIntervals, -}) { - if (!_shouldTrackLockedHourCompletion(task)) { - return 0; - } - - final taskInterval = _scheduledIntervalForTask(task); - if (taskInterval == null) { - return 0; - } - - final intersections = []; - for (final lockedInterval in lockedIntervals) { - if (!taskInterval.overlaps(lockedInterval)) { - continue; - } - - intersections.add( - TimeInterval( - start: _latest(taskInterval.start, lockedInterval.start), - end: _earliest(taskInterval.end, lockedInterval.end), - ), - ); - } - - if (intersections.isEmpty) { - return 0; - } - - intersections.sort((a, b) => a.start.compareTo(b.start)); - - var total = Duration.zero; - var current = intersections.first; - for (final interval in intersections.skip(1)) { - if (interval.start.isAfter(current.end)) { - total += current.duration; - current = interval; - continue; - } - - current = TimeInterval( - start: current.start, - end: _latest(current.end, interval.end), - ); - } - - total += current.duration; - - return total.inMinutes; -} - -/// Whether a base block should produce an occurrence on [date]. -bool _blockOccursOn(LockedBlock block, CivilDate date) { - final recurrence = block.recurrence; - if (recurrence != null) { - return recurrence.occursOn(date); - } - - return _sameDate(block.date!, date); -} - -/// Convert a base block rule into a concrete occurrence for [date]. -LockedBlockOccurrence _occurrenceFromBlock( - LockedBlock block, - CivilDate date, { - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - return LockedBlockOccurrence( - lockedBlockId: block.id, - name: block.name, - interval: _resolvedInterval( - date: date, - startTime: block.startTime, - endTime: block.endTime, - label: block.name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - hiddenByDefault: block.hiddenByDefault, - projectId: block.projectId, - ); -} - -/// Convert a replacement override into a concrete occurrence. -/// -/// Missing override fields fall back to the original block, so a one-day change -/// can replace only the time, only the name, or only the project association. -LockedBlockOccurrence _occurrenceFromReplacement({ - required LockedBlock block, - required LockedBlockOverride override, - required CivilDate date, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final startTime = override.startTime ?? block.startTime; - final endTime = override.endTime ?? block.endTime; - final name = override.name ?? block.name; - - return LockedBlockOccurrence( - lockedBlockId: block.id, - overrideId: override.id, - name: name, - interval: _resolvedInterval( - date: date, - startTime: startTime, - endTime: endTime, - label: name, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ), - hiddenByDefault: override.hiddenByDefault, - projectId: override.projectId ?? block.projectId, - ); -} - -/// Convert an add override into a concrete occurrence when it is complete. -LockedBlockOccurrence? _occurrenceFromAddedOverride( - LockedBlockOverride override, - CivilDate date, { - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - TimeZoneResolutionOptions resolutionOptions = - const TimeZoneResolutionOptions(), -}) { - final interval = override.intervalForDate( - targetDate: date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final name = override.name; - - if (interval == null || name == null) { - return null; - } - - return LockedBlockOccurrence( - overrideId: override.id, - name: name, - interval: TimeInterval( - start: interval.start, - end: interval.end, - label: name, - ), - hiddenByDefault: override.hiddenByDefault, - projectId: override.projectId, - ); -} - -/// Whether this task state/type should be checked for locked-hour completion. -bool _shouldTrackLockedHourCompletion(Task task) { - return task.status == TaskStatus.completed || task.type == TaskType.surprise; -} - -/// Build a valid scheduled interval for a task, or null if the task is unplaced. -TimeInterval? _scheduledIntervalForTask(Task task) { - 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); -} - -/// Return whichever timestamp is earlier. -DateTime _earliest(DateTime first, DateTime second) { - return first.isBefore(second) ? first : second; -} - -/// Return whichever timestamp is later. -DateTime _latest(DateTime first, DateTime second) { - return first.isAfter(second) ? first : second; -} - -TimeInterval _resolvedInterval({ - required CivilDate date, - required ClockTime startTime, - required ClockTime endTime, - required String? label, - required String timeZoneId, - required TimeZoneResolver timeZoneResolver, - required TimeZoneResolutionOptions resolutionOptions, -}) { - final start = timeZoneResolver.resolve( - date: date, - wallTime: startTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ); - final end = timeZoneResolver.resolve( - date: date, - wallTime: endTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ); - - return TimeInterval( - start: start.instant, - end: end.instant, - label: label, - ); -} - -bool _sameDate(CivilDate first, CivilDate second) => first == second; - -String _requiredLockedString( - String value, - String name, - DomainValidationCode code, -) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: '$name cannot be blank.', - ); - } - return trimmed; -} - -String? _nullableLockedString( - String? value, - String name, - DomainValidationCode code, -) { - if (value == null) { - return null; - } - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw DomainValidationException( - code: code, - invalidValue: value, - name: name, - message: '$name cannot be blank.', - ); - } - return trimmed; -} - -bool _clockTimeIsBefore(ClockTime start, ClockTime end) { - if (start.hour != end.hour) { - return start.hour < end.hour; - } - return start.minute < end.minute; -} - -void _validateOverrideShape({ - required LockedBlockOverrideType type, - required String? lockedBlockId, - required String? name, - required ClockTime? startTime, - required ClockTime? endTime, -}) { - switch (type) { - case LockedBlockOverrideType.remove: - if (lockedBlockId == null || - name != null || - startTime != null || - endTime != null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'LockedBlockOverride.remove', - message: 'Remove overrides only reference a locked block and date.', - ); - } - case LockedBlockOverrideType.replace: - if (lockedBlockId == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'lockedBlockId', - message: 'Replace overrides must reference a locked block.', - ); - } - _validateOverrideInterval(startTime: startTime, endTime: endTime); - case LockedBlockOverrideType.add: - if (lockedBlockId != null || name == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'LockedBlockOverride.add', - message: - 'Add overrides need a name and cannot reference a base block.', - ); - } - if (startTime == null || endTime == null) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: type, - name: 'startTime/endTime', - message: 'Add overrides need both start and end time.', - ); - } - _validateOverrideInterval(startTime: startTime, endTime: endTime); - } -} - -void _validateOverrideInterval({ - required ClockTime? startTime, - required ClockTime? endTime, -}) { - if ((startTime == null) != (endTime == null)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Override start and end time must both be present or absent.', - ); - } - if (startTime != null && - endTime != null && - !_clockTimeIsBefore(startTime, endTime)) { - throw DomainValidationException( - code: DomainValidationCode.invalidLockedOverride, - invalidValue: {'startTime': startTime, 'endTime': endTime}, - name: 'startTime/endTime', - message: 'Override end time must be after start time.', - ); - } -} +part 'locked_time/locked_weekday.dart'; +part 'locked_time/clock_time.dart'; +part 'locked_time/locked_block_recurrence.dart'; +part 'locked_time/locked_block.dart'; +part 'locked_time/locked_block_occurrence.dart'; +part 'locked_time/locked_block_override_type.dart'; +part 'locked_time/locked_block_override.dart'; +part 'locked_time/locked_schedule_expansion.dart'; diff --git a/packages/scheduler_core/lib/src/locked_time/clock_time.dart b/packages/scheduler_core/lib/src/locked_time/clock_time.dart new file mode 100644 index 0000000..b3e9d08 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/clock_time.dart @@ -0,0 +1,4 @@ +part of '../locked_time.dart'; + +/// Backwards-compatible name for explicit wall-clock time. +typedef ClockTime = WallTime; diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block.dart b/packages/scheduler_core/lib/src/locked_time/locked_block.dart new file mode 100644 index 0000000..ffe0c7c --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block.dart @@ -0,0 +1,121 @@ +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 { + 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/locked_time/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart new file mode 100644 index 0000000..7419e53 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart @@ -0,0 +1,48 @@ +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 { + 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/locked_time/locked_block_override.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart new file mode 100644 index 0000000..8979839 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart @@ -0,0 +1,189 @@ +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 { + 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/locked_time/locked_block_override_type.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart new file mode 100644 index 0000000..dfed29e --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart @@ -0,0 +1,16 @@ +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/locked_time/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart new file mode 100644 index 0000000..de6b8a2 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart @@ -0,0 +1,30 @@ +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 { + 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/locked_time/locked_schedule_expansion.dart b/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart new file mode 100644 index 0000000..4d6ad7e --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart @@ -0,0 +1,529 @@ +part of '../locked_time.dart'; + +/// Expands locked blocks and one-day overrides into concrete intervals. +/// +/// This object is the boundary between human-friendly locked block definitions +/// and scheduler-friendly intervals. It retains the occurrence details for UI +/// while exposing [schedulingIntervals] for placement algorithms. +class LockedScheduleExpansion { + const LockedScheduleExpansion({ + required this.date, + required this.occurrences, + }); + + /// Calendar date represented by this expansion, normalized to year/month/day. + final CivilDate date; + + /// Concrete locked occurrences active on [date]. + final List occurrences; + + /// Just the intervals the scheduler needs to avoid. + List get schedulingIntervals { + return List.unmodifiable( + occurrences.map((occurrence) => occurrence.schedulingInterval), + ); + } +} + +/// Returns concrete locked-time occurrences for [date]. +/// +/// Expansion order: +/// 1. Sort overrides deterministically by creation time, then id. +/// 2. For each base block that occurs on the date, collect its overrides. +/// 3. Skip the occurrence if any remove override applies. +/// 4. Use the latest replacement override if one exists. +/// 5. Add one-off `add` overrides for the date. +/// 6. Sort occurrences by start time for predictable UI/scheduler behavior. +LockedScheduleExpansion expandLockedBlocksForDay({ + required List blocks, + required List overrides, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final occurrences = []; + final sortedOverrides = [...overrides]..sort((a, b) { + final createdComparison = a.createdAt.compareTo(b.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + + return a.id.compareTo(b.id); + }); + + for (final block in blocks) { + if (block.isArchived) { + continue; + } + if (!_blockOccursOn(block, date)) { + continue; + } + + final blockOverrides = sortedOverrides + .where( + (override) => + override.appliesTo(blockId: block.id, occurrenceDate: date), + ) + .toList(growable: false); + + if (blockOverrides.any( + (override) => override.type == LockedBlockOverrideType.remove, + )) { + continue; + } + + final replacement = _lastReplacementOverride(blockOverrides); + + occurrences.add( + replacement == null + ? _occurrenceFromBlock( + block, + date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ) + : _occurrenceFromReplacement( + block: block, + override: replacement, + date: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + ); + } + + for (final override in sortedOverrides) { + if (override.type != LockedBlockOverrideType.add || + !_sameDate(override.date, date)) { + continue; + } + + final occurrence = _occurrenceFromAddedOverride( + override, + date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + if (occurrence != null) { + occurrences.add(occurrence); + } + } + + occurrences.sort((a, b) { + final startComparison = a.interval.start.compareTo(b.interval.start); + if (startComparison != 0) { + return startComparison; + } + + return a.name.compareTo(b.name); + }); + + return LockedScheduleExpansion( + date: date, + occurrences: List.unmodifiable(occurrences), + ); +} + +/// Return the last replacement override from an already sorted override list. +/// +/// Later-created replacements win, which lets a user correct a one-day override +/// without deleting older history first. +LockedBlockOverride? _lastReplacementOverride( + List overrides, +) { + for (final override in overrides.reversed) { + if (override.type == LockedBlockOverrideType.replace) { + return override; + } + } + + return null; +} + +/// Convenience wrapper when callers only need scheduler intervals. +List lockedSchedulingIntervalsForDay({ + required List blocks, + required List overrides, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + return expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ).schedulingIntervals; +} + +/// Returns [task] with locked-hour completion statistics applied when relevant. +/// +/// This does not decide whether completion is allowed. It only records that a +/// completion overlapped locked time, which future reports can surface as a +/// boundary-leak signal. +Task trackCompletedDuringLockedHours({ + required Task task, + required List lockedIntervals, +}) { + final overlapMinutes = completedDuringLockedHoursMinutes( + task: task, + lockedIntervals: lockedIntervals, + ); + + if (overlapMinutes == 0) { + return task; + } + + return task.copyWith( + stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes), + ); +} + +/// Calculates how many scheduled task minutes overlap locked intervals. +/// +/// Multiple locked intervals may overlap each other. To avoid double-counting, +/// intersections are merged before minutes are summed. +int completedDuringLockedHoursMinutes({ + required Task task, + required List lockedIntervals, +}) { + if (!_shouldTrackLockedHourCompletion(task)) { + return 0; + } + + final taskInterval = _scheduledIntervalForTask(task); + if (taskInterval == null) { + return 0; + } + + final intersections = []; + for (final lockedInterval in lockedIntervals) { + if (!taskInterval.overlaps(lockedInterval)) { + continue; + } + + intersections.add( + TimeInterval( + start: _latest(taskInterval.start, lockedInterval.start), + end: _earliest(taskInterval.end, lockedInterval.end), + ), + ); + } + + if (intersections.isEmpty) { + return 0; + } + + intersections.sort((a, b) => a.start.compareTo(b.start)); + + var total = Duration.zero; + var current = intersections.first; + for (final interval in intersections.skip(1)) { + if (interval.start.isAfter(current.end)) { + total += current.duration; + current = interval; + continue; + } + + current = TimeInterval( + start: current.start, + end: _latest(current.end, interval.end), + ); + } + + total += current.duration; + + return total.inMinutes; +} + +/// Whether a base block should produce an occurrence on [date]. +bool _blockOccursOn(LockedBlock block, CivilDate date) { + final recurrence = block.recurrence; + if (recurrence != null) { + return recurrence.occursOn(date); + } + + return _sameDate(block.date!, date); +} + +/// Convert a base block rule into a concrete occurrence for [date]. +LockedBlockOccurrence _occurrenceFromBlock( + LockedBlock block, + CivilDate date, { + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + return LockedBlockOccurrence( + lockedBlockId: block.id, + name: block.name, + interval: _resolvedInterval( + date: date, + startTime: block.startTime, + endTime: block.endTime, + label: block.name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + hiddenByDefault: block.hiddenByDefault, + projectId: block.projectId, + ); +} + +/// Convert a replacement override into a concrete occurrence. +/// +/// Missing override fields fall back to the original block, so a one-day change +/// can replace only the time, only the name, or only the project association. +LockedBlockOccurrence _occurrenceFromReplacement({ + required LockedBlock block, + required LockedBlockOverride override, + required CivilDate date, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final startTime = override.startTime ?? block.startTime; + final endTime = override.endTime ?? block.endTime; + final name = override.name ?? block.name; + + return LockedBlockOccurrence( + lockedBlockId: block.id, + overrideId: override.id, + name: name, + interval: _resolvedInterval( + date: date, + startTime: startTime, + endTime: endTime, + label: name, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ), + hiddenByDefault: override.hiddenByDefault, + projectId: override.projectId ?? block.projectId, + ); +} + +/// Convert an add override into a concrete occurrence when it is complete. +LockedBlockOccurrence? _occurrenceFromAddedOverride( + LockedBlockOverride override, + CivilDate date, { + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + TimeZoneResolutionOptions resolutionOptions = + const TimeZoneResolutionOptions(), +}) { + final interval = override.intervalForDate( + targetDate: date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final name = override.name; + + if (interval == null || name == null) { + return null; + } + + return LockedBlockOccurrence( + overrideId: override.id, + name: name, + interval: TimeInterval( + start: interval.start, + end: interval.end, + label: name, + ), + hiddenByDefault: override.hiddenByDefault, + projectId: override.projectId, + ); +} + +/// Whether this task state/type should be checked for locked-hour completion. +bool _shouldTrackLockedHourCompletion(Task task) { + return task.status == TaskStatus.completed || task.type == TaskType.surprise; +} + +/// Build a valid scheduled interval for a task, or null if the task is unplaced. +TimeInterval? _scheduledIntervalForTask(Task task) { + 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); +} + +/// Return whichever timestamp is earlier. +DateTime _earliest(DateTime first, DateTime second) { + return first.isBefore(second) ? first : second; +} + +/// Return whichever timestamp is later. +DateTime _latest(DateTime first, DateTime second) { + return first.isAfter(second) ? first : second; +} + +TimeInterval _resolvedInterval({ + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + required String? label, + required String timeZoneId, + required TimeZoneResolver timeZoneResolver, + required TimeZoneResolutionOptions resolutionOptions, +}) { + final start = timeZoneResolver.resolve( + date: date, + wallTime: startTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ); + final end = timeZoneResolver.resolve( + date: date, + wallTime: endTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ); + + return TimeInterval( + start: start.instant, + end: end.instant, + label: label, + ); +} + +bool _sameDate(CivilDate first, CivilDate second) => first == second; + +String _requiredLockedString( + String value, + String name, + DomainValidationCode code, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +String? _nullableLockedString( + String? value, + String name, + DomainValidationCode code, +) { + if (value == null) { + return null; + } + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw DomainValidationException( + code: code, + invalidValue: value, + name: name, + message: '$name cannot be blank.', + ); + } + return trimmed; +} + +bool _clockTimeIsBefore(ClockTime start, ClockTime end) { + if (start.hour != end.hour) { + return start.hour < end.hour; + } + return start.minute < end.minute; +} + +void _validateOverrideShape({ + required LockedBlockOverrideType type, + required String? lockedBlockId, + required String? name, + required ClockTime? startTime, + required ClockTime? endTime, +}) { + switch (type) { + case LockedBlockOverrideType.remove: + if (lockedBlockId == null || + name != null || + startTime != null || + endTime != null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.remove', + message: 'Remove overrides only reference a locked block and date.', + ); + } + case LockedBlockOverrideType.replace: + if (lockedBlockId == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'lockedBlockId', + message: 'Replace overrides must reference a locked block.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + case LockedBlockOverrideType.add: + if (lockedBlockId != null || name == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'LockedBlockOverride.add', + message: + 'Add overrides need a name and cannot reference a base block.', + ); + } + if (startTime == null || endTime == null) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: type, + name: 'startTime/endTime', + message: 'Add overrides need both start and end time.', + ); + } + _validateOverrideInterval(startTime: startTime, endTime: endTime); + } +} + +void _validateOverrideInterval({ + required ClockTime? startTime, + required ClockTime? endTime, +}) { + if ((startTime == null) != (endTime == null)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override start and end time must both be present or absent.', + ); + } + if (startTime != null && + endTime != null && + !_clockTimeIsBefore(startTime, endTime)) { + throw DomainValidationException( + code: DomainValidationCode.invalidLockedOverride, + invalidValue: {'startTime': startTime, 'endTime': endTime}, + name: 'startTime/endTime', + message: 'Override end time must be after start time.', + ); + } +} diff --git a/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart b/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart new file mode 100644 index 0000000..04c9565 --- /dev/null +++ b/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart @@ -0,0 +1,21 @@ +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 { + 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; +} diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/models.dart index b21ba4b..18d4c22 100644 --- a/packages/scheduler_core/lib/src/models.dart +++ b/packages/scheduler_core/lib/src/models.dart @@ -15,777 +15,15 @@ library; // 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, - ); - } -} +part 'models/domain_validation_code.dart'; +part 'models/domain_validation_exception.dart'; +part 'models/task_type.dart'; +part 'models/task_status.dart'; +part 'models/priority_level.dart'; +part 'models/reward_level.dart'; +part 'models/difficulty_level.dart'; +part 'models/reminder_profile.dart'; +part 'models/backlog_tag.dart'; +part 'models/task.dart'; +part 'models/project_profile.dart'; +part 'models/time_interval.dart'; diff --git a/packages/scheduler_core/lib/src/models/backlog_tag.dart b/packages/scheduler_core/lib/src/models/backlog_tag.dart new file mode 100644 index 0000000..cf697fd --- /dev/null +++ b/packages/scheduler_core/lib/src/models/backlog_tag.dart @@ -0,0 +1,11 @@ +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/models/difficulty_level.dart b/packages/scheduler_core/lib/src/models/difficulty_level.dart new file mode 100644 index 0000000..2ea21b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/difficulty_level.dart @@ -0,0 +1,27 @@ +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/models/domain_validation_code.dart b/packages/scheduler_core/lib/src/models/domain_validation_code.dart new file mode 100644 index 0000000..0da2848 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/domain_validation_code.dart @@ -0,0 +1,31 @@ +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 { + blankStableId, + blankTitle, + blankProjectId, + blankName, + blankColorKey, + blankParentTaskId, + selfParent, + nonPositiveDuration, + partialScheduledInterval, + invalidScheduledInterval, + partialActualInterval, + invalidActualInterval, + invalidTimeInterval, + invalidSchedulingWindow, + invalidCivilDate, + invalidClockTime, + blankTimeZoneId, + nonexistentLocalTime, + ambiguousLocalTime, + emptyRecurrence, + invalidLockedBlock, + invalidLockedOverride, +} diff --git a/packages/scheduler_core/lib/src/models/domain_validation_exception.dart b/packages/scheduler_core/lib/src/models/domain_validation_exception.dart new file mode 100644 index 0000000..9b90db6 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/domain_validation_exception.dart @@ -0,0 +1,14 @@ +part of '../models.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/models/priority_level.dart b/packages/scheduler_core/lib/src/models/priority_level.dart new file mode 100644 index 0000000..a29a39e --- /dev/null +++ b/packages/scheduler_core/lib/src/models/priority_level.dart @@ -0,0 +1,25 @@ +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/models/project_profile.dart b/packages/scheduler_core/lib/src/models/project_profile.dart new file mode 100644 index 0000000..a1480d5 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/project_profile.dart @@ -0,0 +1,156 @@ +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 { + 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/models/reminder_profile.dart b/packages/scheduler_core/lib/src/models/reminder_profile.dart new file mode 100644 index 0000000..edf1deb --- /dev/null +++ b/packages/scheduler_core/lib/src/models/reminder_profile.dart @@ -0,0 +1,20 @@ +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/models/reward_level.dart b/packages/scheduler_core/lib/src/models/reward_level.dart new file mode 100644 index 0000000..489ea4f --- /dev/null +++ b/packages/scheduler_core/lib/src/models/reward_level.dart @@ -0,0 +1,26 @@ +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/models/task.dart b/packages/scheduler_core/lib/src/models/task.dart new file mode 100644 index 0000000..ff9c1e2 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task.dart @@ -0,0 +1,288 @@ +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 { + 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/models/task_status.dart b/packages/scheduler_core/lib/src/models/task_status.dart new file mode 100644 index 0000000..6c92737 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task_status.dart @@ -0,0 +1,31 @@ +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/models/task_type.dart b/packages/scheduler_core/lib/src/models/task_type.dart new file mode 100644 index 0000000..1d5c86f --- /dev/null +++ b/packages/scheduler_core/lib/src/models/task_type.dart @@ -0,0 +1,31 @@ +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/models/time_interval.dart b/packages/scheduler_core/lib/src/models/time_interval.dart new file mode 100644 index 0000000..2338b08 --- /dev/null +++ b/packages/scheduler_core/lib/src/models/time_interval.dart @@ -0,0 +1,126 @@ +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 { + 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/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy.dart index 4cf99ec..523ee56 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy.dart @@ -8,338 +8,7 @@ library; // 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; - } -} - -/// UI-independent V1 occupancy policy. -class OccupancyPolicy { - const OccupancyPolicy(); - - /// Classify all task and external interval inputs for a scheduling operation. - List classify({ - required Iterable tasks, - Iterable lockedIntervals = const [], - Iterable requiredVisibleIntervals = const [], - }) { - return List.unmodifiable([ - ...tasks.map(classifyTask), - ...lockedIntervals.map(classifyLockedInterval), - ...requiredVisibleIntervals.map(classifyRequiredVisibleInterval), - ]); - } - - /// Classify one task record. - OccupancyEntry classifyTask(Task task) { - final scheduledInterval = _scheduledIntervalFor(task); - final actualInterval = _actualIntervalFor(task); - - if (_isNonOccupyingStatus(task.status)) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - if (task.status == TaskStatus.completed) { - final completedInterval = actualInterval ?? - (task.type == TaskType.surprise ? scheduledInterval : null); - - if (completedInterval == null) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - return _taskEntry( - task: task, - category: OccupancyCategory.completedActualOccupancy, - interval: completedInterval, - ); - } - - if (task.status == TaskStatus.missed) { - if (scheduledInterval == null) { - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - return _taskEntry( - task: task, - category: OccupancyCategory.retainedMissedHistoricalInterval, - interval: scheduledInterval, - ); - } - - if (task.type == TaskType.locked) { - return _taskEntry( - task: task, - category: OccupancyCategory.hiddenLockedConstraint, - interval: scheduledInterval, - ); - } - - if (task.type == TaskType.freeSlot) { - return _taskEntry( - task: task, - category: OccupancyCategory.protectedFreeSlot, - interval: scheduledInterval, - ); - } - - if (task.status == TaskStatus.active) { - return _taskEntry( - task: task, - category: OccupancyCategory.activeWork, - interval: actualInterval ?? scheduledInterval, - ); - } - - if (task.isRequiredVisible) { - return _taskEntry( - task: task, - category: OccupancyCategory.requiredVisibleCommitment, - interval: scheduledInterval, - ); - } - - if (task.isFlexible && task.status == TaskStatus.planned) { - return _taskEntry( - task: task, - category: OccupancyCategory.movablePlannedFlexible, - interval: scheduledInterval, - ); - } - - // Surprise tasks should normally be completed records. If an unsupported - // lifecycle combination appears, keep it out of automatic placement instead - // of inventing movement behavior for it. - return _taskEntry( - task: task, - category: OccupancyCategory.nonOccupyingRecord, - interval: null, - ); - } - - /// Classify one hidden locked interval supplied outside the task list. - OccupancyEntry classifyLockedInterval(TimeInterval interval) { - return _entry( - source: OccupancySource.lockedInterval, - category: OccupancyCategory.hiddenLockedConstraint, - interval: interval, - task: null, - ); - } - - /// Classify one visible required interval supplied outside the task list. - OccupancyEntry classifyRequiredVisibleInterval(TimeInterval interval) { - return _entry( - source: OccupancySource.requiredVisibleInterval, - category: OccupancyCategory.requiredVisibleCommitment, - interval: interval, - task: null, - ); - } - - OccupancyEntry _taskEntry({ - required Task task, - required OccupancyCategory category, - required TimeInterval? interval, - }) { - return _entry( - source: OccupancySource.task, - category: category, - interval: interval, - task: task, - ); - } - - OccupancyEntry _entry({ - required OccupancySource source, - required OccupancyCategory category, - required TimeInterval? interval, - required Task? task, - }) { - final hasInterval = interval != null; - - return switch (category) { - OccupancyCategory.nonOccupyingRecord => OccupancyEntry( - source: source, - category: category, - interval: null, - task: task, - isImmovable: false, - blocksAutomaticFlexiblePlacement: false, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: false, - hiddenByDefault: false, - ), - OccupancyCategory.movablePlannedFlexible => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: false, - blocksAutomaticFlexiblePlacement: false, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: false, - hiddenByDefault: false, - ), - OccupancyCategory.protectedFreeSlot => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: hasInterval, - reportsConflict: hasInterval, - hiddenByDefault: false, - ), - OccupancyCategory.hiddenLockedConstraint => OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: hasInterval, - hiddenByDefault: true, - ), - OccupancyCategory.requiredVisibleCommitment || - OccupancyCategory.activeWork || - OccupancyCategory.completedActualOccupancy || - OccupancyCategory.retainedMissedHistoricalInterval => - OccupancyEntry( - source: source, - category: category, - interval: interval, - task: task, - isImmovable: true, - blocksAutomaticFlexiblePlacement: hasInterval, - mayBeExplicitlyOverlappedByRequiredCommitment: false, - reportsConflict: hasInterval, - hiddenByDefault: false, - ), - }; - } -} - -bool _isNonOccupyingStatus(TaskStatus status) { - return status == TaskStatus.backlog || - status == TaskStatus.cancelled || - status == TaskStatus.noLongerRelevant; -} - -TimeInterval? _scheduledIntervalFor(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -TimeInterval? _actualIntervalFor(Task task) { - final start = task.actualStart; - final end = task.actualEnd; - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} +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/occupancy_policy/occupancy_category.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart new file mode 100644 index 0000000..ec69b2f --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart @@ -0,0 +1,28 @@ +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/occupancy_policy/occupancy_entry.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart new file mode 100644 index 0000000..5d67009 --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart @@ -0,0 +1,51 @@ +part of '../occupancy_policy.dart'; + +/// 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; + } +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart new file mode 100644 index 0000000..d94798e --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart @@ -0,0 +1,247 @@ +part of '../occupancy_policy.dart'; + +/// UI-independent V1 occupancy policy. +class OccupancyPolicy { + const OccupancyPolicy(); + + /// Classify all task and external interval inputs for a scheduling operation. + List classify({ + required Iterable tasks, + Iterable lockedIntervals = const [], + Iterable requiredVisibleIntervals = const [], + }) { + return List.unmodifiable([ + ...tasks.map(classifyTask), + ...lockedIntervals.map(classifyLockedInterval), + ...requiredVisibleIntervals.map(classifyRequiredVisibleInterval), + ]); + } + + /// Classify one task record. + OccupancyEntry classifyTask(Task task) { + final scheduledInterval = _scheduledIntervalFor(task); + final actualInterval = _actualIntervalFor(task); + + if (_isNonOccupyingStatus(task.status)) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + if (task.status == TaskStatus.completed) { + final completedInterval = actualInterval ?? + (task.type == TaskType.surprise ? scheduledInterval : null); + + if (completedInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.completedActualOccupancy, + interval: completedInterval, + ); + } + + if (task.status == TaskStatus.missed) { + if (scheduledInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.retainedMissedHistoricalInterval, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.locked) { + return _taskEntry( + task: task, + category: OccupancyCategory.hiddenLockedConstraint, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.freeSlot) { + return _taskEntry( + task: task, + category: OccupancyCategory.protectedFreeSlot, + interval: scheduledInterval, + ); + } + + if (task.status == TaskStatus.active) { + return _taskEntry( + task: task, + category: OccupancyCategory.activeWork, + interval: actualInterval ?? scheduledInterval, + ); + } + + if (task.isRequiredVisible) { + return _taskEntry( + task: task, + category: OccupancyCategory.requiredVisibleCommitment, + interval: scheduledInterval, + ); + } + + if (task.isFlexible && task.status == TaskStatus.planned) { + return _taskEntry( + task: task, + category: OccupancyCategory.movablePlannedFlexible, + interval: scheduledInterval, + ); + } + + // Surprise tasks should normally be completed records. If an unsupported + // lifecycle combination appears, keep it out of automatic placement instead + // of inventing movement behavior for it. + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + /// Classify one hidden locked interval supplied outside the task list. + OccupancyEntry classifyLockedInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.lockedInterval, + category: OccupancyCategory.hiddenLockedConstraint, + interval: interval, + task: null, + ); + } + + /// Classify one visible required interval supplied outside the task list. + OccupancyEntry classifyRequiredVisibleInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.requiredVisibleInterval, + category: OccupancyCategory.requiredVisibleCommitment, + interval: interval, + task: null, + ); + } + + OccupancyEntry _taskEntry({ + required Task task, + required OccupancyCategory category, + required TimeInterval? interval, + }) { + return _entry( + source: OccupancySource.task, + category: category, + interval: interval, + task: task, + ); + } + + OccupancyEntry _entry({ + required OccupancySource source, + required OccupancyCategory category, + required TimeInterval? interval, + required Task? task, + }) { + final hasInterval = interval != null; + + return switch (category) { + OccupancyCategory.nonOccupyingRecord => OccupancyEntry( + source: source, + category: category, + interval: null, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.movablePlannedFlexible => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.protectedFreeSlot => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: hasInterval, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + OccupancyCategory.hiddenLockedConstraint => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: true, + ), + OccupancyCategory.requiredVisibleCommitment || + OccupancyCategory.activeWork || + OccupancyCategory.completedActualOccupancy || + OccupancyCategory.retainedMissedHistoricalInterval => + OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + }; + } +} + +bool _isNonOccupyingStatus(TaskStatus status) { + return status == TaskStatus.backlog || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +TimeInterval? _scheduledIntervalFor(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +TimeInterval? _actualIntervalFor(Task task) { + final start = task.actualStart; + final end = task.actualEnd; + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart new file mode 100644 index 0000000..cf86143 --- /dev/null +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart @@ -0,0 +1,13 @@ +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/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence_contract.dart index d7286f1..6e11363 100644 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ b/packages/scheduler_core/lib/src/persistence_contract.dart @@ -8,1051 +8,40 @@ library; // domain models or import a database client. import 'time_contracts.dart'; +part 'persistence_contract/persistence_collections.dart'; +part 'persistence_contract/persistence_index_direction.dart'; +part 'persistence_contract/persistence_index_field.dart'; +part 'persistence_contract/persistence_index_spec.dart'; +part 'persistence_contract/repository_query_names.dart'; +part 'persistence_contract/persistence_index_catalog.dart'; +part 'persistence_contract/persistence_payload_limits.dart'; +part 'persistence_contract/persistence_guard_result.dart'; +part 'persistence_contract/persistence_payload_guard.dart'; +part 'persistence_contract/persistence_date_time_convention.dart'; +part 'persistence_contract/persistence_civil_date_convention.dart'; +part 'persistence_contract/persistence_wall_time_convention.dart'; +part 'persistence_contract/persistence_enum_name.dart'; +part 'persistence_contract/document_fields.dart'; +part 'persistence_contract/task_document_fields.dart'; +part 'persistence_contract/task_activity_document_fields.dart'; +part 'persistence_contract/task_statistics_document_fields.dart'; +part 'persistence_contract/project_document_fields.dart'; +part 'persistence_contract/project_statistics_document_fields.dart'; +part 'persistence_contract/clock_time_document_fields.dart'; +part 'persistence_contract/locked_block_recurrence_document_fields.dart'; +part 'persistence_contract/locked_block_document_fields.dart'; +part 'persistence_contract/locked_block_override_document_fields.dart'; +part 'persistence_contract/owner_settings_document_fields.dart'; +part 'persistence_contract/backlog_staleness_document_fields.dart'; +part 'persistence_contract/notice_acknowledgement_document_fields.dart'; +part 'persistence_contract/application_operation_document_fields.dart'; +part 'persistence_contract/scheduling_snapshot_document_fields.dart'; +part 'persistence_contract/scheduling_window_document_fields.dart'; +part 'persistence_contract/time_interval_document_fields.dart'; +part 'persistence_contract/scheduling_notice_document_fields.dart'; +part 'persistence_contract/scheduling_change_document_fields.dart'; +part 'persistence_contract/scheduling_overlap_document_fields.dart'; +part 'persistence_contract/persistence_document_field_sets.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/persistence_contract/application_operation_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart new file mode 100644 index 0000000..eaf96eb --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart @@ -0,0 +1,21 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart new file mode 100644 index 0000000..67a4c71 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for backlog staleness settings. +abstract final class BacklogStalenessDocumentFields { + static const greenMaxAgeDays = 'greenMaxAgeDays'; + static const blueMaxAgeDays = 'blueMaxAgeDays'; + + static const all = { + greenMaxAgeDays, + blueMaxAgeDays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart new file mode 100644 index 0000000..e309815 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart @@ -0,0 +1,10 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [ClockTime] embedded documents. +abstract final class ClockTimeDocumentFields { + static const value = 'value'; + + static const all = { + value, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart new file mode 100644 index 0000000..8d3ba5c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart @@ -0,0 +1,31 @@ +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'; + + static const common = { + schemaVersion, + id, + ownerId, + revision, + createdAt, + updatedAt, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart new file mode 100644 index 0000000..8b3e6b2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart new file mode 100644 index 0000000..2cd83f0 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart new file mode 100644 index 0000000..c26ec8b --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [LockedBlockRecurrence] embedded documents. +abstract final class LockedBlockRecurrenceDocumentFields { + static const type = 'type'; + static const weekdays = 'weekdays'; + + static const all = { + type, + weekdays, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart new file mode 100644 index 0000000..4785fd0 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart @@ -0,0 +1,19 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart new file mode 100644 index 0000000..49e8783 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart @@ -0,0 +1,25 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart new file mode 100644 index 0000000..f110715 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart @@ -0,0 +1,18 @@ +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_contract/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart new file mode 100644 index 0000000..10e1bf2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart new file mode 100644 index 0000000..8de931a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart @@ -0,0 +1,23 @@ +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_contract/persistence_document_field_sets.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart new file mode 100644 index 0000000..37c66ce --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart @@ -0,0 +1,27 @@ +part of '../persistence_contract.dart'; + +/// 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/persistence_contract/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart new file mode 100644 index 0000000..a31b3f2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart @@ -0,0 +1,10 @@ +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_contract/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart new file mode 100644 index 0000000..983bc01 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart @@ -0,0 +1,33 @@ +part of '../persistence_contract.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart new file mode 100644 index 0000000..2b4b4b6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart @@ -0,0 +1,300 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart new file mode 100644 index 0000000..422612a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart @@ -0,0 +1,7 @@ +part of '../persistence_contract.dart'; + +/// Sort direction in an adapter-neutral index specification. +enum PersistenceIndexDirection { + ascending, + descending, +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart new file mode 100644 index 0000000..a56897c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// One field in a declarative index key. +class PersistenceIndexField { + const PersistenceIndexField( + this.fieldName, { + this.direction = PersistenceIndexDirection.ascending, + }); + + final String fieldName; + final PersistenceIndexDirection direction; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart new file mode 100644 index 0000000..1076fe2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart @@ -0,0 +1,20 @@ +part of '../persistence_contract.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart new file mode 100644 index 0000000..f8b5e2d --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart @@ -0,0 +1,35 @@ +part of '../persistence_contract.dart'; + +/// 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(); + } +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart new file mode 100644 index 0000000..0a3fa51 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart @@ -0,0 +1,13 @@ +part of '../persistence_contract.dart'; + +/// 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); +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart new file mode 100644 index 0000000..4fa094c --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart @@ -0,0 +1,18 @@ +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_contract/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart new file mode 100644 index 0000000..53d99e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart @@ -0,0 +1,31 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart new file mode 100644 index 0000000..ac1dce6 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart @@ -0,0 +1,35 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart new file mode 100644 index 0000000..f4a8995 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart @@ -0,0 +1,37 @@ +part of '../persistence_contract.dart'; + +/// 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'; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart new file mode 100644 index 0000000..c3ca256 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart @@ -0,0 +1,18 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart new file mode 100644 index 0000000..c843f55 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart @@ -0,0 +1,22 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart new file mode 100644 index 0000000..71718df --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart @@ -0,0 +1,14 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart new file mode 100644 index 0000000..3e6573a --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart @@ -0,0 +1,41 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart new file mode 100644 index 0000000..8e8db94 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart @@ -0,0 +1,12 @@ +part of '../persistence_contract.dart'; + +/// Document field names for [SchedulingWindow] embedded documents. +abstract final class SchedulingWindowDocumentFields { + static const start = 'start'; + static const end = 'end'; + + static const all = { + start, + end, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart new file mode 100644 index 0000000..10ef992 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart @@ -0,0 +1,27 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart new file mode 100644 index 0000000..921ffc2 --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart @@ -0,0 +1,53 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart new file mode 100644 index 0000000..9f2734f --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart @@ -0,0 +1,36 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart new file mode 100644 index 0000000..e861bbd --- /dev/null +++ b/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart @@ -0,0 +1,14 @@ +part of '../persistence_contract.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics.dart index 30fdbdc..44e9c76 100644 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics.dart @@ -9,692 +9,14 @@ library; 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; -} +part 'project_statistics/project_completion_time_bucket.dart'; +part 'project_statistics/project_suggestion_type.dart'; +part 'project_statistics/project_suggestion_confidence.dart'; +part 'project_statistics/project_statistics.dart'; +part 'project_statistics/project_statistics_application_result.dart'; +part 'project_statistics/project_statistics_aggregation_service.dart'; +part 'project_statistics/project_suggestion_policy.dart'; +part 'project_statistics/project_suggestion.dart'; +part 'project_statistics/project_suggestion_set.dart'; +part 'project_statistics/project_default_resolution.dart'; +part 'project_statistics/project_suggestion_service.dart'; diff --git a/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart new file mode 100644 index 0000000..ee998c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart @@ -0,0 +1,10 @@ +part of '../project_statistics.dart'; + +/// Coarse completion-time buckets used for learned project suggestions. +enum ProjectCompletionTimeBucket { + overnight, + morning, + afternoon, + evening, + night, +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart b/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart new file mode 100644 index 0000000..642c57e --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart @@ -0,0 +1,21 @@ +part of '../project_statistics.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart new file mode 100644 index 0000000..0221848 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart @@ -0,0 +1,207 @@ +part of '../project_statistics.dart'; + +/// 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; + } +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart new file mode 100644 index 0000000..4c58643 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart @@ -0,0 +1,59 @@ +part of '../project_statistics.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart new file mode 100644 index 0000000..7bb0b3c --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart @@ -0,0 +1,15 @@ +part of '../project_statistics.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart new file mode 100644 index 0000000..1ac0d55 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart @@ -0,0 +1,31 @@ +part of '../project_statistics.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart new file mode 100644 index 0000000..b86c0f1 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart @@ -0,0 +1,8 @@ +part of '../project_statistics.dart'; + +/// How concentrated the supporting observations are for a suggestion. +enum ProjectSuggestionConfidence { + low, + medium, + high, +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart new file mode 100644 index 0000000..7078beb --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart @@ -0,0 +1,18 @@ +part of '../project_statistics.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart new file mode 100644 index 0000000..afbe56c --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart @@ -0,0 +1,301 @@ +part of '../project_statistics.dart'; + +/// 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/project_statistics/project_suggestion_set.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart new file mode 100644 index 0000000..5b89f92 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart @@ -0,0 +1,20 @@ +part of '../project_statistics.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart new file mode 100644 index 0000000..7ecaf20 --- /dev/null +++ b/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart @@ -0,0 +1,10 @@ +part of '../project_statistics.dart'; + +/// Suggestion categories derived from project observations. +enum ProjectSuggestionType { + durationMinutes, + completionTimeBucket, + reward, + difficulty, + reminderProfile, +} diff --git a/packages/scheduler_core/lib/src/quick_capture.dart b/packages/scheduler_core/lib/src/quick_capture.dart index c9ccdbc..34a3078 100644 --- a/packages/scheduler_core/lib/src/quick_capture.dart +++ b/packages/scheduler_core/lib/src/quick_capture.dart @@ -10,282 +10,7 @@ library; 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; -} - -/// Coordinates quick capture defaults and optional scheduling. -/// -/// This service is intentionally thin: it builds a [Task], validates the extra -/// requirements for immediate scheduling, and delegates placement to -/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand -/// every scheduler precondition. -class QuickCaptureService { - const QuickCaptureService({ - this.engine = const SchedulingEngine(), - this.clock = const SystemClock(), - this.idGenerator, - }); - - /// Scheduling dependency. Defaults to the starter engine but can be swapped in - /// tests or future implementations. - final SchedulingEngine engine; - - /// Clock boundary used by convenience capture entry points. - final Clock clock; - - /// Optional ID boundary for convenience capture entry points. - final IdGenerator? idGenerator; - - /// Capture a task and optionally place it into the next available slot. - /// - /// Flow: - /// 1. Build the task using lightweight defaults. - /// 2. If immediate scheduling was not requested, return a backlog success. - /// 3. Validate the requirements for immediate scheduling. - /// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot]. - /// 5. Convert the lower-level scheduling result into a capture result. - QuickCaptureResult capture( - QuickCaptureRequest request, { - SchedulingInput? schedulingInput, - DateTime? updatedAt, - }) { - final capturedTaskWithoutDuration = Task.quickCapture( - id: request.id, - title: request.title, - createdAt: request.createdAt, - projectId: request.projectId, - type: request.type, - priority: request.priority, - reward: request.reward, - difficulty: request.difficulty, - backlogTags: request.backlogTags, - updatedAt: updatedAt, - ); - - if (request.durationMinutes != null && request.durationMinutes! <= 0) { - return QuickCaptureResult( - task: capturedTaskWithoutDuration, - status: QuickCaptureStatus.validationError, - messages: const ['Duration must be positive when provided.'], - ); - } - - final capturedTask = capturedTaskWithoutDuration.copyWith( - durationMinutes: request.durationMinutes, - ); - - if (!request.addToNextAvailableSlot) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.addedToBacklog, - ); - } - - if (request.durationMinutes == null || request.durationMinutes! <= 0) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Duration is required to schedule quick capture.'], - ); - } - - if (request.type != TaskType.flexible) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Scheduled quick capture must be flexible.'], - ); - } - - if (schedulingInput == null) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - messages: const ['Scheduling input is required.'], - ); - } - - final result = engine.insertBacklogTaskIntoNextAvailableSlot( - input: SchedulingInput( - tasks: [capturedTask, ...schedulingInput.tasks], - window: schedulingInput.window, - lockedIntervals: schedulingInput.lockedIntervals, - requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals, - ), - taskId: capturedTask.id, - updatedAt: updatedAt, - ); - final scheduledTask = _taskById(result.tasks, capturedTask.id); - - if (scheduledTask == null || scheduledTask.isBacklog) { - return QuickCaptureResult( - task: capturedTask, - status: QuickCaptureStatus.validationError, - schedulingResult: result, - messages: result.notices.map((notice) => notice.message).toList(), - ); - } - - return QuickCaptureResult( - task: scheduledTask, - status: QuickCaptureStatus.scheduled, - schedulingResult: result, - ); - } - - /// Convenience outer-boundary wrapper that supplies id and creation time. - QuickCaptureResult captureNew({ - required String title, - bool addToNextAvailableSlot = false, - String projectId = 'inbox', - PriorityLevel priority = PriorityLevel.medium, - RewardLevel reward = RewardLevel.notSet, - DifficultyLevel difficulty = DifficultyLevel.notSet, - TaskType type = TaskType.flexible, - int? durationMinutes, - Set backlogTags = const {}, - SchedulingInput? schedulingInput, - DateTime? createdAt, - DateTime? updatedAt, - }) { - final generator = idGenerator; - if (generator == null) { - throw StateError('Quick capture needs an id generator.'); - } - - final now = createdAt ?? updatedAt ?? clock.now(); - - return capture( - QuickCaptureRequest( - id: generator.nextId(), - title: title, - createdAt: now, - addToNextAvailableSlot: addToNextAvailableSlot, - projectId: projectId, - priority: priority, - reward: reward, - difficulty: difficulty, - type: type, - durationMinutes: durationMinutes, - backlogTags: backlogTags, - ), - schedulingInput: schedulingInput, - updatedAt: updatedAt ?? now, - ); - } -} - -/// Find a task in a returned scheduling result. -/// -/// This duplicates a small helper rather than exposing scheduler internals. The -/// capture service only needs to retrieve the newly created task after placement. -Task? _taskById(List tasks, String id) { - for (final task in tasks) { - if (task.id == id) { - return task; - } - } - - return null; -} +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/quick_capture/quick_capture_request.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart new file mode 100644 index 0000000..f854260 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart @@ -0,0 +1,57 @@ +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 { + 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/quick_capture/quick_capture_result.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart new file mode 100644 index 0000000..65ea65c --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart @@ -0,0 +1,31 @@ +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 { + 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/quick_capture_service.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart new file mode 100644 index 0000000..92f1129 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart @@ -0,0 +1,177 @@ +part of '../quick_capture.dart'; + +/// Coordinates quick capture defaults and optional scheduling. +/// +/// This service is intentionally thin: it builds a [Task], validates the extra +/// requirements for immediate scheduling, and delegates placement to +/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand +/// every scheduler precondition. +class QuickCaptureService { + const QuickCaptureService({ + this.engine = const SchedulingEngine(), + this.clock = const SystemClock(), + this.idGenerator, + }); + + /// Scheduling dependency. Defaults to the starter engine but can be swapped in + /// tests or future implementations. + final SchedulingEngine engine; + + /// Clock boundary used by convenience capture entry points. + final Clock clock; + + /// Optional ID boundary for convenience capture entry points. + final IdGenerator? idGenerator; + + /// Capture a task and optionally place it into the next available slot. + /// + /// Flow: + /// 1. Build the task using lightweight defaults. + /// 2. If immediate scheduling was not requested, return a backlog success. + /// 3. Validate the requirements for immediate scheduling. + /// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot]. + /// 5. Convert the lower-level scheduling result into a capture result. + QuickCaptureResult capture( + QuickCaptureRequest request, { + SchedulingInput? schedulingInput, + DateTime? updatedAt, + }) { + final capturedTaskWithoutDuration = Task.quickCapture( + id: request.id, + title: request.title, + createdAt: request.createdAt, + projectId: request.projectId, + type: request.type, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + backlogTags: request.backlogTags, + updatedAt: updatedAt, + ); + + if (request.durationMinutes != null && request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTaskWithoutDuration, + status: QuickCaptureStatus.validationError, + messages: const ['Duration must be positive when provided.'], + ); + } + + final capturedTask = capturedTaskWithoutDuration.copyWith( + durationMinutes: request.durationMinutes, + ); + + if (!request.addToNextAvailableSlot) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.addedToBacklog, + ); + } + + if (request.durationMinutes == null || request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Duration is required to schedule quick capture.'], + ); + } + + if (request.type != TaskType.flexible) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduled quick capture must be flexible.'], + ); + } + + if (schedulingInput == null) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduling input is required.'], + ); + } + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [capturedTask, ...schedulingInput.tasks], + window: schedulingInput.window, + lockedIntervals: schedulingInput.lockedIntervals, + requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals, + ), + taskId: capturedTask.id, + updatedAt: updatedAt, + ); + final scheduledTask = _taskById(result.tasks, capturedTask.id); + + if (scheduledTask == null || scheduledTask.isBacklog) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + schedulingResult: result, + messages: result.notices.map((notice) => notice.message).toList(), + ); + } + + return QuickCaptureResult( + task: scheduledTask, + status: QuickCaptureStatus.scheduled, + schedulingResult: result, + ); + } + + /// Convenience outer-boundary wrapper that supplies id and creation time. + QuickCaptureResult captureNew({ + required String title, + bool addToNextAvailableSlot = false, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + TaskType type = TaskType.flexible, + int? durationMinutes, + Set backlogTags = const {}, + SchedulingInput? schedulingInput, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final generator = idGenerator; + if (generator == null) { + throw StateError('Quick capture needs an id generator.'); + } + + final now = createdAt ?? updatedAt ?? clock.now(); + + return capture( + QuickCaptureRequest( + id: generator.nextId(), + title: title, + createdAt: now, + addToNextAvailableSlot: addToNextAvailableSlot, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + type: type, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + ), + schedulingInput: schedulingInput, + updatedAt: updatedAt ?? now, + ); + } +} + +/// Find a task in a returned scheduling result. +/// +/// This duplicates a small helper rather than exposing scheduler internals. The +/// capture service only needs to retrieve the newly created task after placement. +Task? _taskById(List tasks, String id) { + for (final task in tasks) { + if (task.id == id) { + return task; + } + } + + return null; +} diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart new file mode 100644 index 0000000..423d0c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart @@ -0,0 +1,18 @@ +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/reminder_policy.dart b/packages/scheduler_core/lib/src/reminder_policy.dart index 0e9e380..8cbfd1b 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/reminder_policy.dart @@ -9,329 +9,9 @@ library; 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; -} - -/// Resolves reminder profiles and reminder directives. -class ReminderPolicyService { - const ReminderPolicyService({ - this.fallbackProfile = ReminderProfile.gentle, - this.occupancyPolicy = const OccupancyPolicy(), - }); - - /// Fallback used when no task override or project profile is available. - final ReminderProfile fallbackProfile; - - /// Occupancy classifier used to detect active protected Free Slots. - final OccupancyPolicy occupancyPolicy; - - /// Resolve effective profile without considering learned suggestions. - EffectiveReminderProfile resolveEffectiveProfile({ - required Task task, - ProjectProfile? project, - }) { - final override = task.reminderOverride; - if (override != null) { - return EffectiveReminderProfile( - profile: override, - source: EffectiveReminderProfileSource.taskOverride, - ); - } - - if (project != null) { - return EffectiveReminderProfile( - profile: project.defaultReminderProfile, - source: EffectiveReminderProfileSource.projectDefault, - ); - } - - return EffectiveReminderProfile( - profile: fallbackProfile, - source: EffectiveReminderProfileSource.fallback, - ); - } - - /// Resolve one reminder directive at [now]. - ReminderDirective directiveForTask({ - required Task task, - required DateTime now, - ProjectProfile? project, - Iterable contextTasks = const [], - }) { - final effective = resolveEffectiveProfile(task: task, project: project); - final activeFreeSlot = _activeProtectedFreeSlot( - now: now, - contextTasks: contextTasks, - ); - - return _directiveForTask( - task: task, - now: now, - effective: effective, - activeFreeSlot: activeFreeSlot, - ); - } - - ReminderDirective _directiveForTask({ - required Task task, - required DateTime now, - required EffectiveReminderProfile effective, - required OccupancyEntry? activeFreeSlot, - }) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - ReminderDirective build({ - required ReminderDirectiveAction action, - required ReminderDirectiveReason reason, - DateTime? nextEligibleAt, - }) { - return ReminderDirective( - taskId: task.id, - action: action, - reason: reason, - effectiveProfile: effective.profile, - effectiveProfileSource: effective.source, - evaluatedAt: now, - scheduledStart: start, - scheduledEnd: end, - protectedFreeSlotTaskId: activeFreeSlot?.taskId, - nextEligibleAt: nextEligibleAt, - ); - } - - if (effective.profile == ReminderProfile.silent) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressSilentProfile, - ); - } - - if (task.type == TaskType.locked) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressHiddenLockedTime, - ); - } - - if (task.type == TaskType.freeSlot) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressFreeSlotRecord, - ); - } - - if (!_isReminderEligibleStatus(task.status)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressInactiveStatus, - ); - } - - if (!_isReminderEligibleType(task.type)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressUnsupportedTaskType, - ); - } - - if (start == null || end == null || !start.isBefore(end)) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressNoScheduledWindow, - ); - } - - if (now.isBefore(start)) { - return build( - action: ReminderDirectiveAction.defer, - reason: ReminderDirectiveReason.deferUntilTaskWindow, - nextEligibleAt: start, - ); - } - - final protectedRestActive = activeFreeSlot != null; - if (protectedRestActive && task.isFlexible) { - return build( - action: ReminderDirectiveAction.suppress, - reason: ReminderDirectiveReason.suppressProtectedRest, - nextEligibleAt: activeFreeSlot.interval?.end, - ); - } - - if (protectedRestActive && task.isRequiredVisible) { - return _requiredDuringProtectedRest( - task: task, - effective: effective, - build: build, - freeSlotEnd: activeFreeSlot.interval?.end, - ); - } - - if (task.type == TaskType.critical && - effective.profile == ReminderProfile.strict) { - return build( - action: ReminderDirectiveAction.requireAcknowledgement, - reason: ReminderDirectiveReason.requireRequiredAcknowledgement, - ); - } - - return build( - action: ReminderDirectiveAction.deliver, - reason: ReminderDirectiveReason.deliverNormal, - ); - } - - ReminderDirective _requiredDuringProtectedRest({ - required Task task, - required EffectiveReminderProfile effective, - required ReminderDirective Function({ - required ReminderDirectiveAction action, - required ReminderDirectiveReason reason, - DateTime? nextEligibleAt, - }) build, - required DateTime? freeSlotEnd, - }) { - if (task.type == TaskType.critical && - effective.profile == ReminderProfile.strict) { - return build( - action: ReminderDirectiveAction.requireAcknowledgement, - reason: ReminderDirectiveReason.requireRequiredAcknowledgement, - ); - } - - if (task.type == TaskType.inflexible && - effective.profile == ReminderProfile.gentle) { - return build( - action: ReminderDirectiveAction.defer, - reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, - nextEligibleAt: freeSlotEnd, - ); - } - - return build( - action: ReminderDirectiveAction.deliver, - reason: ReminderDirectiveReason.deliverNormal, - ); - } - - OccupancyEntry? _activeProtectedFreeSlot({ - required DateTime now, - required Iterable contextTasks, - }) { - for (final entry in occupancyPolicy.classify(tasks: contextTasks)) { - final interval = entry.interval; - if (entry.category != OccupancyCategory.protectedFreeSlot || - interval == null) { - continue; - } - - final startsAtOrBeforeNow = - interval.start.isBefore(now) || interval.start.isAtSameMomentAs(now); - final endsAfterNow = interval.end.isAfter(now); - if (startsAtOrBeforeNow && endsAfterNow) { - return entry; - } - } - - return null; - } -} - -bool _isReminderEligibleStatus(TaskStatus status) { - return status == TaskStatus.planned || status == TaskStatus.active; -} - -bool _isReminderEligibleType(TaskType type) { - return type == TaskType.flexible || - type == TaskType.critical || - type == TaskType.inflexible; -} +part 'reminder_policy/reminder_directive_action.dart'; +part 'reminder_policy/reminder_directive_reason.dart'; +part 'reminder_policy/effective_reminder_profile.dart'; +part 'reminder_policy/effective_reminder_profile_source.dart'; +part 'reminder_policy/reminder_directive.dart'; +part 'reminder_policy/reminder_policy_service.dart'; diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart new file mode 100644 index 0000000..c0eae9a --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart @@ -0,0 +1,15 @@ +part of '../reminder_policy.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart new file mode 100644 index 0000000..56ad447 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart @@ -0,0 +1,8 @@ +part of '../reminder_policy.dart'; + +/// Source for an effective reminder profile. +enum EffectiveReminderProfileSource { + taskOverride, + projectDefault, + fallback, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart new file mode 100644 index 0000000..f350145 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart @@ -0,0 +1,47 @@ +part of '../reminder_policy.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart new file mode 100644 index 0000000..bffc11b --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart @@ -0,0 +1,9 @@ +part of '../reminder_policy.dart'; + +/// High-level reminder directive for application/platform layers. +enum ReminderDirectiveAction { + deliver, + suppress, + defer, + requireAcknowledgement, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart new file mode 100644 index 0000000..b7b77c5 --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart @@ -0,0 +1,16 @@ +part of '../reminder_policy.dart'; + +/// Stable reason codes for reminder directives. +enum ReminderDirectiveReason { + deliverNormal, + requireRequiredAcknowledgement, + deferUntilTaskWindow, + deferRequiredDuringProtectedRest, + suppressSilentProfile, + suppressProtectedRest, + suppressNoScheduledWindow, + suppressUnsupportedTaskType, + suppressInactiveStatus, + suppressHiddenLockedTime, + suppressFreeSlotRecord, +} diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart b/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart new file mode 100644 index 0000000..9409a2d --- /dev/null +++ b/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart @@ -0,0 +1,237 @@ +part of '../reminder_policy.dart'; + +/// Resolves reminder profiles and reminder directives. +class ReminderPolicyService { + const ReminderPolicyService({ + this.fallbackProfile = ReminderProfile.gentle, + this.occupancyPolicy = const OccupancyPolicy(), + }); + + /// Fallback used when no task override or project profile is available. + final ReminderProfile fallbackProfile; + + /// Occupancy classifier used to detect active protected Free Slots. + final OccupancyPolicy occupancyPolicy; + + /// Resolve effective profile without considering learned suggestions. + EffectiveReminderProfile resolveEffectiveProfile({ + required Task task, + ProjectProfile? project, + }) { + final override = task.reminderOverride; + if (override != null) { + return EffectiveReminderProfile( + profile: override, + source: EffectiveReminderProfileSource.taskOverride, + ); + } + + if (project != null) { + return EffectiveReminderProfile( + profile: project.defaultReminderProfile, + source: EffectiveReminderProfileSource.projectDefault, + ); + } + + return EffectiveReminderProfile( + profile: fallbackProfile, + source: EffectiveReminderProfileSource.fallback, + ); + } + + /// Resolve one reminder directive at [now]. + ReminderDirective directiveForTask({ + required Task task, + required DateTime now, + ProjectProfile? project, + Iterable contextTasks = const [], + }) { + final effective = resolveEffectiveProfile(task: task, project: project); + final activeFreeSlot = _activeProtectedFreeSlot( + now: now, + contextTasks: contextTasks, + ); + + return _directiveForTask( + task: task, + now: now, + effective: effective, + activeFreeSlot: activeFreeSlot, + ); + } + + ReminderDirective _directiveForTask({ + required Task task, + required DateTime now, + required EffectiveReminderProfile effective, + required OccupancyEntry? activeFreeSlot, + }) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + ReminderDirective build({ + required ReminderDirectiveAction action, + required ReminderDirectiveReason reason, + DateTime? nextEligibleAt, + }) { + return ReminderDirective( + taskId: task.id, + action: action, + reason: reason, + effectiveProfile: effective.profile, + effectiveProfileSource: effective.source, + evaluatedAt: now, + scheduledStart: start, + scheduledEnd: end, + protectedFreeSlotTaskId: activeFreeSlot?.taskId, + nextEligibleAt: nextEligibleAt, + ); + } + + if (effective.profile == ReminderProfile.silent) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressSilentProfile, + ); + } + + if (task.type == TaskType.locked) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressHiddenLockedTime, + ); + } + + if (task.type == TaskType.freeSlot) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressFreeSlotRecord, + ); + } + + if (!_isReminderEligibleStatus(task.status)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressInactiveStatus, + ); + } + + if (!_isReminderEligibleType(task.type)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressUnsupportedTaskType, + ); + } + + if (start == null || end == null || !start.isBefore(end)) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressNoScheduledWindow, + ); + } + + if (now.isBefore(start)) { + return build( + action: ReminderDirectiveAction.defer, + reason: ReminderDirectiveReason.deferUntilTaskWindow, + nextEligibleAt: start, + ); + } + + final protectedRestActive = activeFreeSlot != null; + if (protectedRestActive && task.isFlexible) { + return build( + action: ReminderDirectiveAction.suppress, + reason: ReminderDirectiveReason.suppressProtectedRest, + nextEligibleAt: activeFreeSlot.interval?.end, + ); + } + + if (protectedRestActive && task.isRequiredVisible) { + return _requiredDuringProtectedRest( + task: task, + effective: effective, + build: build, + freeSlotEnd: activeFreeSlot.interval?.end, + ); + } + + if (task.type == TaskType.critical && + effective.profile == ReminderProfile.strict) { + return build( + action: ReminderDirectiveAction.requireAcknowledgement, + reason: ReminderDirectiveReason.requireRequiredAcknowledgement, + ); + } + + return build( + action: ReminderDirectiveAction.deliver, + reason: ReminderDirectiveReason.deliverNormal, + ); + } + + ReminderDirective _requiredDuringProtectedRest({ + required Task task, + required EffectiveReminderProfile effective, + required ReminderDirective Function({ + required ReminderDirectiveAction action, + required ReminderDirectiveReason reason, + DateTime? nextEligibleAt, + }) build, + required DateTime? freeSlotEnd, + }) { + if (task.type == TaskType.critical && + effective.profile == ReminderProfile.strict) { + return build( + action: ReminderDirectiveAction.requireAcknowledgement, + reason: ReminderDirectiveReason.requireRequiredAcknowledgement, + ); + } + + if (task.type == TaskType.inflexible && + effective.profile == ReminderProfile.gentle) { + return build( + action: ReminderDirectiveAction.defer, + reason: ReminderDirectiveReason.deferRequiredDuringProtectedRest, + nextEligibleAt: freeSlotEnd, + ); + } + + return build( + action: ReminderDirectiveAction.deliver, + reason: ReminderDirectiveReason.deliverNormal, + ); + } + + OccupancyEntry? _activeProtectedFreeSlot({ + required DateTime now, + required Iterable contextTasks, + }) { + for (final entry in occupancyPolicy.classify(tasks: contextTasks)) { + final interval = entry.interval; + if (entry.category != OccupancyCategory.protectedFreeSlot || + interval == null) { + continue; + } + + final startsAtOrBeforeNow = + interval.start.isBefore(now) || interval.start.isAtSameMomentAs(now); + final endsAfterNow = interval.end.isAfter(now); + if (startsAtOrBeforeNow && endsAfterNow) { + return entry; + } + } + + return null; + } +} + +bool _isReminderEligibleStatus(TaskStatus status) { + return status == TaskStatus.planned || status == TaskStatus.active; +} + +bool _isReminderEligibleType(TaskType type) { + return type == TaskType.flexible || + type == TaskType.critical || + type == TaskType.inflexible; +} diff --git a/packages/scheduler_core/lib/src/repositories.dart b/packages/scheduler_core/lib/src/repositories.dart index 882a031..a80222f 100644 --- a/packages/scheduler_core/lib/src/repositories.dart +++ b/packages/scheduler_core/lib/src/repositories.dart @@ -10,1202 +10,19 @@ 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); -} +part 'repositories/repository_failure_code.dart'; +part 'repositories/repository_failure.dart'; +part 'repositories/repository_mutation_result.dart'; +part 'repositories/repository_record.dart'; +part 'repositories/repository_page_request.dart'; +part 'repositories/repository_page.dart'; +part 'repositories/backlog_candidate_query.dart'; +part 'repositories/task_repository.dart'; +part 'repositories/project_repository.dart'; +part 'repositories/locked_block_repository.dart'; +part 'repositories/scheduling_state_snapshot.dart'; +part 'repositories/scheduling_snapshot_repository.dart'; +part 'repositories/in_memory_task_repository.dart'; +part 'repositories/in_memory_project_repository.dart'; +part 'repositories/in_memory_locked_block_repository.dart'; +part 'repositories/in_memory_scheduling_snapshot_repository.dart'; diff --git a/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart new file mode 100644 index 0000000..3387e76 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart @@ -0,0 +1,16 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..ce52887 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart @@ -0,0 +1,302 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart new file mode 100644 index 0000000..48bad78 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart @@ -0,0 +1,166 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart new file mode 100644 index 0000000..7b145c0 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart @@ -0,0 +1,108 @@ +part of '../repositories.dart'; + +/// 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/repositories/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart new file mode 100644 index 0000000..5618f83 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart @@ -0,0 +1,244 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart new file mode 100644 index 0000000..fcf0a80 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart @@ -0,0 +1,73 @@ +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); + + 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, + }); +} diff --git a/packages/scheduler_core/lib/src/repositories/project_repository.dart b/packages/scheduler_core/lib/src/repositories/project_repository.dart new file mode 100644 index 0000000..8f2c9bd --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/project_repository.dart @@ -0,0 +1,44 @@ +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/repositories/repository_failure.dart b/packages/scheduler_core/lib/src/repositories/repository_failure.dart new file mode 100644 index 0000000..0535908 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_failure.dart @@ -0,0 +1,16 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart b/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart new file mode 100644 index 0000000..35a36aa --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart @@ -0,0 +1,11 @@ +part of '../repositories.dart'; + +/// Stable repository mutation failure categories. +enum RepositoryFailureCode { + notFound, + staleRevision, + invalidRevision, + ownerMismatch, + duplicateId, + duplicateOperation, +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart b/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart new file mode 100644 index 0000000..661e1b0 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart @@ -0,0 +1,22 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_page.dart b/packages/scheduler_core/lib/src/repositories/repository_page.dart new file mode 100644 index 0000000..121e373 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_page.dart @@ -0,0 +1,14 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_page_request.dart b/packages/scheduler_core/lib/src/repositories/repository_page_request.dart new file mode 100644 index 0000000..6e2e18c --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_page_request.dart @@ -0,0 +1,15 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/repository_record.dart b/packages/scheduler_core/lib/src/repositories/repository_record.dart new file mode 100644 index 0000000..b89b357 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/repository_record.dart @@ -0,0 +1,18 @@ +part of '../repositories.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart new file mode 100644 index 0000000..6ad760e --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart @@ -0,0 +1,13 @@ +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/repositories/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart new file mode 100644 index 0000000..366e6ce --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart @@ -0,0 +1,74 @@ +part of '../repositories.dart'; + +/// 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), + ); + } +} diff --git a/packages/scheduler_core/lib/src/repositories/task_repository.dart b/packages/scheduler_core/lib/src/repositories/task_repository.dart new file mode 100644 index 0000000..b6ce2d9 --- /dev/null +++ b/packages/scheduler_core/lib/src/repositories/task_repository.dart @@ -0,0 +1,79 @@ +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/repository_values.dart b/packages/scheduler_core/lib/src/repository_values.dart index c86ca60..95a523a 100644 --- a/packages/scheduler_core/lib/src/repository_values.dart +++ b/packages/scheduler_core/lib/src/repository_values.dart @@ -5,88 +5,7 @@ /// 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; -} +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/repository_values/owner_id.dart b/packages/scheduler_core/lib/src/repository_values/owner_id.dart new file mode 100644 index 0000000..dddd783 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/owner_id.dart @@ -0,0 +1,20 @@ +part of '../repository_values.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repository_values/page.dart b/packages/scheduler_core/lib/src/repository_values/page.dart new file mode 100644 index 0000000..fa35079 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/page.dart @@ -0,0 +1,26 @@ +part of '../repository_values.dart'; + +/// 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/repository_values/page_request.dart b/packages/scheduler_core/lib/src/repository_values/page_request.dart new file mode 100644 index 0000000..d249de2 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/page_request.dart @@ -0,0 +1,15 @@ +part of '../repository_values.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/repository_values/revision.dart b/packages/scheduler_core/lib/src/repository_values/revision.dart new file mode 100644 index 0000000..aebda11 --- /dev/null +++ b/packages/scheduler_core/lib/src/repository_values/revision.dart @@ -0,0 +1,29 @@ +part of '../repository_values.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine.dart index 287239d..193af5d 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine.dart @@ -19,1745 +19,23 @@ import 'models.dart'; import 'occupancy_policy.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; +part 'scheduling_engine/scheduling_notice_type.dart'; +part 'scheduling_engine/scheduling_operation_code.dart'; +part 'scheduling_engine/scheduling_outcome_code.dart'; +part 'scheduling_engine/scheduling_issue_code.dart'; +part 'scheduling_engine/scheduling_movement_code.dart'; +part 'scheduling_engine/scheduling_conflict_code.dart'; +part 'scheduling_engine/scheduling_window.dart'; +part 'scheduling_engine/scheduling_input.dart'; +part 'scheduling_engine/scheduling_change.dart'; +part 'scheduling_engine/scheduling_overlap.dart'; +part 'scheduling_engine/scheduling_notice.dart'; +part 'scheduling_engine/scheduling_result.dart'; +part 'scheduling_engine/scheduling_engine.dart'; +part 'scheduling_engine/placement_item.dart'; +part 'scheduling_engine/backlog_insertion_plan.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; -} - -/// Starter scheduling engine. -/// -/// The engine is a pure domain service: it receives immutable-ish input values -/// and returns new values. It does not persist data, render UI, send reminders, -/// or read the clock except where an optional [updatedAt] timestamp is omitted. -/// -/// Current V1 responsibilities: -/// - insert backlog tasks into the earliest available flexible slot; -/// - push flexible tasks later today; -/// - move flexible tasks to tomorrow's queue; -/// - roll unfinished flexible tasks into a new planning window; -/// - analyze overlaps against locked/required time; -/// - perform small state transitions such as missed/backlog handling. -/// -/// Important rule vocabulary: -/// - `fixedBlocks` are intervals the engine will not move. -/// - `queue` is the ordered set of flexible tasks that may be placed or shifted. -/// - `placement` is a map from task id to the interval chosen by the planner. -class SchedulingEngine { - const SchedulingEngine({ - this.clock = const SystemClock(), - }); - - /// Clock boundary used when an operation timestamp is not supplied. - final Clock clock; - - /// Insert a backlog task into the earliest available slot today. - /// - /// Locked, inflexible, and critical time is treated as fixed. Planned - /// flexible tasks at or after the insertion point may shift later, preserving - /// their relative order. - /// - /// The selected task must already exist in `input.tasks`, be flexible, be in - /// backlog status, and have a positive duration. If any precondition fails, the - /// original task list is returned with a no-fit/overflow notice. - SchedulingResult insertBacklogTaskIntoNextAvailableSlot({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - // Step 1: resolve and validate the task. The engine does not create tasks; - // quick capture or persistence code is responsible for adding it to the list. - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || !task.isBacklog) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only backlog flexible tasks can be inserted.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final taskDuration = _durationFromMinutes(task.durationMinutes); - if (taskDuration == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive duration before scheduling.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingDuration, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - // Step 2: compute placements without mutating any task. Planning returns null - // if the inserted task and shifted queue cannot fit inside the window. - final placement = _planBacklogInsertion( - input: input, - task: task, - taskDuration: taskDuration, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot today.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPlacement( - input: input, - insertedTask: task, - placement: placement, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Push a planned flexible task to the next available slot today. - /// - /// The selected task moves after its current slot. Planned flexible tasks - /// after it may shift later, preserving their relative order. - /// - /// This is the "not now, later today" action. Anything before the pushed - /// task's current end time becomes fixed for this operation, while later - /// planned flexible tasks may be shifted if necessary. - SchedulingResult pushFlexibleTaskToNextAvailableSlot({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - bool countAsManualPush = true, - }) { - // Resolve the selected task by id so UI code only needs to pass a stable - // identifier, not object references. - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only planned flexible tasks can be pushed.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final currentInterval = _scheduledIntervalFor(task); - if (currentInterval == null || !input.window.contains(currentInterval)) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a current scheduled slot before pushing.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingScheduledSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - if (currentInterval.duration.inMicroseconds <= 0) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive scheduled duration before pushing.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.nonPositiveDuration, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final placement = _planFlexiblePush( - input: input, - task: task, - currentInterval: currentInterval, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot today.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPushPlacement( - input: input, - pushedTask: task, - placement: placement, - pushedTaskMessage: 'Flexible task pushed to next available slot.', - pushedTaskMovementCode: - SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, - operationCode: - SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, - updatedAt: updatedAt ?? clock.now(), - countPushedTaskAsManual: countAsManualPush, - ); - } - - /// Move a planned flexible task to the top of tomorrow's flexible queue. - /// - /// The input window represents tomorrow's scheduling window. Existing planned - /// flexible tasks in that window may shift later, preserving their order. - /// - /// The method name says "tomorrow" because that is the product action, but the - /// engine only trusts `input.window`. Tests can pass any future window. - SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ - required SchedulingInput input, - required String taskId, - DateTime? updatedAt, - }) { - final task = _taskById(input.tasks, taskId); - if (task == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task was not found.', - type: SchedulingNoticeType.noFit, - taskId: taskId, - issueCode: SchedulingIssueCode.taskNotFound, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.notFound, - ); - } - - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _unchangedResult( - input, - SchedulingNotice( - 'Only planned flexible tasks can be moved to tomorrow.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.invalidTaskState, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final taskDuration = _scheduledIntervalFor(task)?.duration ?? - _durationFromMinutes(task.durationMinutes); - if (taskDuration == null || taskDuration.inMicroseconds <= 0) { - return _unchangedResult( - input, - SchedulingNotice( - 'Task needs a positive duration before moving to tomorrow.', - type: SchedulingNoticeType.noFit, - taskId: task.id, - issueCode: SchedulingIssueCode.missingDuration, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.invalidState, - ); - } - - final placement = _planTomorrowQueueInsertion( - input: input, - task: task, - taskDuration: taskDuration, - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'No available flexible slot tomorrow.', - type: SchedulingNoticeType.overflow, - taskId: task.id, - issueCode: SchedulingIssueCode.noAvailableSlot, - ), - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - outcomeCode: SchedulingOutcomeCode.noSlot, - ); - } - - return _applyPushPlacement( - input: input, - pushedTask: task, - placement: placement, - pushedTaskMessage: 'Flexible task moved to tomorrow.', - pushedTaskMovementCode: - SchedulingMovementCode.flexibleTaskMovedToTomorrow, - operationCode: - SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Move unfinished flexible tasks to the top of tomorrow's flexible queue. - /// - /// The input window represents tomorrow's scheduling window. Only planned and - /// active flexible tasks are rolled; required, locked, completed, and - /// cancelled tasks remain unchanged. - /// - /// Rollover is bulk push behavior for day-end recovery. It collects unfinished - /// flexible tasks outside the target window, preserves their relative order, - /// then places them at the start of the new window while shifting already - /// planned flexible tasks as needed. When [sourceWindow] is supplied, only - /// unfinished flexible tasks from that source window are rolled over. This - /// prevents future planned tasks from being pulled into tomorrow accidentally. - SchedulingResult rollOverUnfinishedFlexibleTasks({ - required SchedulingInput input, - SchedulingWindow? sourceWindow, - DateTime? updatedAt, - }) { - // Build the explicit queue of tasks to roll before asking the planner to - // place anything. This keeps selection separate from placement. - final rolledItems = <_PlacementItem>[]; - final rolloverTasks = input.flexibleTasks - .where((task) => _shouldRollOver( - task: task, - targetWindow: input.window, - sourceWindow: sourceWindow, - )) - .toList() - ..sort((a, b) { - final aStart = - a.scheduledStart ?? sourceWindow?.start ?? input.window.start; - final bStart = - b.scheduledStart ?? sourceWindow?.start ?? input.window.start; - - return aStart.compareTo(bStart); - }); - - for (final task in rolloverTasks) { - final duration = _scheduledIntervalFor(task)?.duration ?? - _durationFromMinutes(task.durationMinutes); - if (duration == null || duration.inMicroseconds <= 0) { - continue; - } - - rolledItems.add( - _PlacementItem( - task: task, - duration: duration, - earliestStart: input.window.start, - ), - ); - } - - if (rolledItems.isEmpty) { - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.noOp, - notices: [ - SchedulingNotice( - 'No unfinished flexible tasks to roll over.', - issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks, - ), - ], - ); - } - - final placement = _planQueueAtWindowStart( - input: input, - queue: rolledItems, - excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(), - ); - - if (placement == null) { - return _unchangedResult( - input, - SchedulingNotice( - 'Unfinished flexible tasks could not fit tomorrow.', - type: SchedulingNoticeType.overflow, - issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit, - ), - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.overflow, - ); - } - - return _applyRolloverPlacement( - input: input, - rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(), - placement: placement, - updatedAt: updatedAt ?? clock.now(), - ); - } - - /// Analyze the current in-memory schedule without moving tasks. - /// - /// This is a validation/debugging helper. It scans scheduled flexible tasks and - /// reports any overlap with blocked intervals. It deliberately returns the - /// original task list unchanged. - SchedulingResult analyzeSchedule(SchedulingInput input) { - final overlaps = []; - final notices = []; - final conflictOccupancies = input.conflictReportingOccupancies; - - for (final task in input.movablePlannedFlexibleTasks) { - final taskInterval = _scheduledIntervalFor(task); - if (taskInterval == null || !input.window.contains(taskInterval)) { - continue; - } - - for (final occupancy in conflictOccupancies) { - final blockedInterval = occupancy.interval; - if (blockedInterval == null) { - continue; - } - - if (!taskInterval.overlaps(blockedInterval)) { - continue; - } - - overlaps.add( - SchedulingOverlap( - taskId: task.id, - taskInterval: taskInterval, - blockedInterval: blockedInterval, - ), - ); - notices.add( - SchedulingNotice( - 'Flexible task overlaps blocked time.', - type: SchedulingNoticeType.overlap, - taskId: task.id, - conflictCode: - SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, - parameters: { - 'blockedStart': blockedInterval.start, - 'blockedEnd': blockedInterval.end, - }, - ), - ); - } - } - - return SchedulingResult( - tasks: input.tasks, - operationCode: SchedulingOperationCode.analyzeSchedule, - outcomeCode: overlaps.isEmpty - ? SchedulingOutcomeCode.success - : SchedulingOutcomeCode.conflict, - notices: List.unmodifiable(notices), - overlaps: List.unmodifiable(overlaps), - ); - } - - /// Move a task to backlog. - /// - /// Backlog does not preserve original schedule/order placement. The task's - /// schedule is cleared and its moved-to-backlog counter is incremented so - /// reports can distinguish this from a task that was never scheduled. - Task moveToBacklog(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.moveToBacklog, - operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}', - activityId: - 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Mark a flexible task pushed manually. - /// - /// This updates statistics only. Use the push methods above when the task's - /// actual scheduled slot should change. - Task markManuallyPushed(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.manualPush, - operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}', - activityId: - 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Mark missed according to the current MVP rules. - /// - /// Critical missed tasks go to backlog so they remain actionable. Inflexible - /// missed tasks stay in place as missed because they represented a fixed event - /// or time block that cannot simply be rescheduled automatically. - Task markMissed(Task task, {DateTime? updatedAt}) { - final now = updatedAt ?? clock.now(); - final result = _transitionService.apply( - task: task, - transitionCode: TaskTransitionCode.miss, - operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}', - activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', - occurredAt: now, - ); - return result.applied ? result.task : task; - } - - /// Finds the first interval that can fit the requested duration while avoiding - /// blocked intervals. - /// - /// This public helper is deliberately simple and does not shift existing - /// flexible tasks. It is useful for UI previews or tests that only need to know - /// the first open gap. Full bump/queue behavior lives in the private planning - /// helpers below. - TimeInterval? findFirstOpenInterval({ - required DateTime windowStart, - required DateTime windowEnd, - required Duration duration, - required List blocked, - }) { - final sortedBlocked = [...blocked] - ..sort((a, b) => a.start.compareTo(b.start)); - var cursor = windowStart; - - for (final interval in sortedBlocked) { - if (cursor.add(duration).isBefore(interval.start) || - cursor.add(duration).isAtSameMomentAs(interval.start)) { - return TimeInterval(start: cursor, end: cursor.add(duration)); - } - - if (interval.end.isAfter(cursor)) { - cursor = interval.end; - } - } - - final candidateEnd = cursor.add(duration); - if (candidateEnd.isBefore(windowEnd) || - candidateEnd.isAtSameMomentAs(windowEnd)) { - return TimeInterval(start: cursor, end: candidateEnd); - } - - return null; - } -} - -/// Convert a scheduled task into an interval, or null if it is unplaced. -/// -/// This helper does not validate positive duration; callers that require a valid -/// duration check that separately. -TimeInterval? _scheduledIntervalFor(Task task) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - - if (start == null || end == null) { - return null; - } - - return TimeInterval(start: start, end: end, label: task.id); -} - -/// Convert all placed tasks in [tasks] into intervals. -List _scheduledIntervalsFor(Iterable tasks) { - final intervals = []; - - for (final task in tasks) { - final interval = _scheduledIntervalFor(task); - if (interval != null) { - intervals.add(interval); - } - } - - return List.unmodifiable(intervals); -} - -/// Return the original task list with one explanatory notice. -/// -/// Most validation failures use this so callers can keep rendering the existing -/// schedule while showing why the requested action did not apply. -SchedulingResult _unchangedResult( - SchedulingInput input, - SchedulingNotice notice, { - SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified, - SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState, -}) { - return SchedulingResult( - tasks: input.tasks, - operationCode: operationCode, - outcomeCode: outcomeCode, - notices: [notice], - ); -} - -/// Find a task by stable id. -Task? _taskById(List tasks, String taskId) { - for (final task in tasks) { - if (task.id == taskId) { - return task; - } - } - - return null; -} - -/// Convert a nullable minute estimate into a positive [Duration]. -Duration? _durationFromMinutes(int? minutes) { - if (minutes == null || minutes <= 0) { - return null; - } - - return Duration(minutes: minutes); -} - -/// Plan insertion of a backlog task plus any flexible tasks that must shift. -/// -/// This function only calculates intervals. It does not update task objects. The -/// returned plan is later applied by [_applyPlacement], which creates notices, -/// changes, and updated task copies. -_BacklogInsertionPlan? _planBacklogInsertion({ - required SchedulingInput input, - required Task task, - required Duration taskDuration, -}) { - // Start with intervals that the algorithm is not allowed to move. - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: taskDuration, - earliestStart: input.window.start, - ), - ]; - - // Existing flexible tasks are inspected in timeline order. Planned tasks inside - // the movable portion become part of the placement queue; everything else is - // treated as fixed. - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => flexibleTask.id != task.id) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final startsBeforeWindow = interval.start.isBefore(input.window.start); - final startsAfterWindow = interval.start.isAfter(input.window.end) || - interval.start.isAtSameMomentAs(input.window.end); - - if (startsBeforeWindow || startsAfterWindow) { - fixedBlocks.add(interval); - continue; - } - - queue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - // The cursor tracks the earliest point after the previously placed queue item. - // Each queued task is placed no earlier than both the cursor and its own - // original earliest start. - var cursor = input.window.start; - final placements = {}; - - for (final item in queue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Plan the "push later today" behavior for one flexible task. -/// -/// Items before the pushed task's current end are fixed. The pushed task starts -/// the queue at its current end, followed by later planned flexible tasks that -/// may need to move to preserve order. -_BacklogInsertionPlan? _planFlexiblePush({ - required SchedulingInput input, - required Task task, - required TimeInterval currentInterval, -}) { - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: currentInterval.duration, - earliestStart: currentInterval.end, - ), - ]; - - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => flexibleTask.id != task.id) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); - final startsAfterWindow = interval.start.isAfter(input.window.end) || - interval.start.isAtSameMomentAs(input.window.end); - - if (startsBeforePushPoint || startsAfterWindow) { - fixedBlocks.add(interval); - continue; - } - - queue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - var cursor = currentInterval.end; - final placements = {}; - - for (final item in queue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Plan putting a single task at the start of the supplied future window. -_BacklogInsertionPlan? _planTomorrowQueueInsertion({ - required SchedulingInput input, - required Task task, - required Duration taskDuration, -}) { - final queue = <_PlacementItem>[ - _PlacementItem( - task: task, - duration: taskDuration, - earliestStart: input.window.start, - ), - ]; - - return _planQueueAtWindowStart( - input: input, - queue: queue, - excludeTaskIds: {task.id}, - ); -} - -/// Plan a queue of flexible tasks at the beginning of [input.window]. -/// -/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies -/// tasks already represented in the incoming [queue] so they are not also pulled -/// from existing scheduled flexible tasks. -_BacklogInsertionPlan? _planQueueAtWindowStart({ - required SchedulingInput input, - required List<_PlacementItem> queue, - required Set excludeTaskIds, -}) { - final fixedBlocks = [ - ...input.blockedIntervals, - ]; - final placementQueue = <_PlacementItem>[ - ...queue, - ]; - - final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks - .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) - .toList(growable: false) - ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.end; - final bStart = b.scheduledStart ?? input.window.end; - - return aStart.compareTo(bStart); - }); - - for (final flexibleTask in scheduledFlexibleTasks) { - final interval = _scheduledIntervalFor(flexibleTask); - if (interval == null) { - continue; - } - - final overlapsWindow = interval.overlaps(input.window.interval); - if (!overlapsWindow) { - continue; - } - - if (!input.window.contains(interval)) { - fixedBlocks.add(interval); - continue; - } - - placementQueue.add( - _PlacementItem( - task: flexibleTask, - duration: interval.duration, - earliestStart: interval.start, - ), - ); - } - - fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - - var cursor = input.window.start; - final placements = {}; - - for (final item in placementQueue) { - final earliestStart = _laterOf(cursor, item.earliestStart); - final interval = _firstOpenIntervalFrom( - earliestStart: earliestStart, - windowEnd: input.window.end, - duration: item.duration, - blocked: fixedBlocks, - ); - - if (interval == null) { - return null; - } - - placements[item.task.id] = interval; - cursor = interval.end; - } - - return _BacklogInsertionPlan(placements: placements); -} - -/// Apply a backlog insertion plan to the task list. -/// -/// The inserted backlog task becomes planned and increments -/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room -/// increment `autoPushedCount`. -SchedulingResult _applyPlacement({ - required SchedulingInput input, - required Task insertedTask, - required _BacklogInsertionPlan placement, - required DateTime updatedAt, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final isInsertedTask = task.id == insertedTask.id; - final moved = isInsertedTask || - !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final placedTask = task.copyWith( - status: isInsertedTask ? TaskStatus.planned : task.status, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - activityCode: isInsertedTask - ? TaskActivityCode.restoredFromBacklog - : TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - notices.add( - SchedulingNotice( - isInsertedTask - ? 'Backlog task inserted into schedule.' - : 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: isInsertedTask - ? SchedulingMovementCode.backlogTaskInserted - : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Apply a push/tomorrow placement plan to the task list. -/// -/// The explicitly pushed task increments `manuallyPushedCount`; other moved -/// flexible tasks increment `autoPushedCount` because the scheduler moved them as -/// a side effect. -SchedulingResult _applyPushPlacement({ - required SchedulingInput input, - required Task pushedTask, - required _BacklogInsertionPlan placement, - required String pushedTaskMessage, - required SchedulingMovementCode pushedTaskMovementCode, - required SchedulingOperationCode operationCode, - required DateTime updatedAt, - bool countPushedTaskAsManual = true, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final moved = !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final isPushedTask = task.id == pushedTask.id; - final placedTask = task.copyWith( - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: operationCode, - activityCode: isPushedTask && countPushedTaskAsManual - ? TaskActivityCode.manuallyPushed - : TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - notices.add( - SchedulingNotice( - isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: isPushedTask - ? pushedTaskMovementCode - : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: operationCode, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Apply a bulk rollover placement plan. -/// -/// Rolled tasks are set back to planned status in the target window. Existing -/// tasks moved to make room receive normal movement notices. -SchedulingResult _applyRolloverPlacement({ - required SchedulingInput input, - required Set rolledTaskIds, - required _BacklogInsertionPlan placement, - required DateTime updatedAt, -}) { - final changes = []; - final notices = []; - final updatedTasks = []; - var rolledCount = 0; - - for (final task in input.tasks) { - final interval = placement.placements[task.id]; - if (interval == null) { - updatedTasks.add(task); - continue; - } - - final moved = !_sameDateTime(task.scheduledStart, interval.start) || - !_sameDateTime(task.scheduledEnd, interval.end); - - if (!moved) { - updatedTasks.add(task); - continue; - } - - final isRolledTask = rolledTaskIds.contains(task.id); - final placedTask = task.copyWith( - status: isRolledTask ? TaskStatus.planned : task.status, - scheduledStart: interval.start, - scheduledEnd: interval.end, - updatedAt: updatedAt, - ); - final updatedTask = _applySchedulingActivity( - originalTask: task, - updatedTask: placedTask, - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - activityCode: TaskActivityCode.automaticallyPushed, - occurredAt: updatedAt, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ); - - updatedTasks.add(updatedTask); - changes.add( - SchedulingChange( - taskId: task.id, - previousStart: task.scheduledStart, - previousEnd: task.scheduledEnd, - nextStart: interval.start, - nextEnd: interval.end, - ), - ); - - if (isRolledTask) { - rolledCount += 1; - } else { - notices.add( - SchedulingNotice( - 'Flexible task moved to make room.', - type: SchedulingNoticeType.moved, - taskId: task.id, - movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom, - parameters: { - 'previousStart': task.scheduledStart, - 'previousEnd': task.scheduledEnd, - 'nextStart': interval.start, - 'nextEnd': interval.end, - }, - ), - ); - } - } - - notices.insert( - 0, - SchedulingNotice( - '$rolledCount unfinished flexible tasks were moved to tomorrow.', - type: SchedulingNoticeType.moved, - movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, - parameters: { - 'rolledCount': rolledCount, - }, - ), - ); - - return SchedulingResult( - tasks: List.unmodifiable(updatedTasks), - operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, - outcomeCode: SchedulingOutcomeCode.success, - notices: List.unmodifiable(notices), - changes: List.unmodifiable(changes), - ); -} - -/// Whether [task] belongs in the rollover queue. -/// -/// Planned/active flexible tasks already inside the target window are not rolled -/// again; they are handled as existing tasks that may shift to make room. -bool _shouldRollOver({ - required Task task, - required SchedulingWindow targetWindow, - SchedulingWindow? sourceWindow, -}) { - final interval = _scheduledIntervalFor(task); - final isTomorrowTask = - interval != null && interval.overlaps(targetWindow.interval); - - final isInSourceWindow = sourceWindow == null || - (interval != null && sourceWindow.contains(interval)); - - return task.isFlexible && - !isTomorrowTask && - isInSourceWindow && - (task.status == TaskStatus.planned || task.status == TaskStatus.active); -} - -/// Return whichever timestamp is later. -DateTime _laterOf(DateTime first, DateTime second) { - if (first.isAfter(second)) { - return first; - } - - return second; -} - -/// Null-safe exact timestamp comparison. -bool _sameDateTime(DateTime? first, DateTime? second) { - if (first == null || second == null) { - return first == null && second == null; - } - - return first.isAtSameMomentAs(second); -} - -Task _applySchedulingActivity({ - required Task originalTask, - required Task updatedTask, - required SchedulingOperationCode operationCode, - required TaskActivityCode activityCode, - required DateTime occurredAt, - required DateTime? previousStart, - required DateTime? previousEnd, - required DateTime? nextStart, - required DateTime? nextEnd, -}) { - final operationId = - '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}'; - final activity = TaskActivity( - id: '$operationId:${activityCode.name}', - operationId: operationId, - code: activityCode, - taskId: originalTask.id, - projectId: originalTask.projectId, - occurredAt: occurredAt, - metadata: { - 'previousStatus': originalTask.status.name, - 'nextStatus': updatedTask.status.name, - 'previousStart': previousStart, - 'previousEnd': previousEnd, - 'nextStart': nextStart, - 'nextEnd': nextEnd, - }, - ); - - return _activityAccountingService - .apply(task: updatedTask, activities: [activity]).task; -} - -/// Find the first candidate interval at or after [earliestStart]. -/// -/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps -/// a blocked interval, the cursor jumps to that blocked interval's end and tries -/// again. This makes the algorithm easy to follow and adequate for the starter -/// in-memory engine. -TimeInterval? _firstOpenIntervalFrom({ - required DateTime earliestStart, - required DateTime windowEnd, - required Duration duration, - required List blocked, -}) { - var cursor = earliestStart; - - while (true) { - final candidateEnd = cursor.add(duration); - if (candidateEnd.isAfter(windowEnd)) { - return null; - } - - final candidate = TimeInterval(start: cursor, end: candidateEnd); - TimeInterval? overlappingBlock; - - for (final block in blocked) { - if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) { - continue; - } - - if (block.start.isAfter(candidate.end) || - block.start.isAtSameMomentAs(candidate.end)) { - break; - } - - if (candidate.overlaps(block)) { - overlappingBlock = block; - break; - } - } - - if (overlappingBlock == null) { - return candidate; - } - - cursor = overlappingBlock.end; - } -} - -/// 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_engine/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart new file mode 100644 index 0000000..7f63ed8 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart @@ -0,0 +1,13 @@ +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 { + 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_engine/placement_item.dart b/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart new file mode 100644 index 0000000..c0e8580 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart @@ -0,0 +1,23 @@ +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 { + 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_engine/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart new file mode 100644 index 0000000..04dc40a --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart @@ -0,0 +1,31 @@ +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 { + 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_engine/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart new file mode 100644 index 0000000..d2a529c --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart @@ -0,0 +1,8 @@ +part of '../scheduling_engine.dart'; + +/// Stable conflict identifiers for overlap/interrupt notices. +enum SchedulingConflictCode { + flexibleTaskOverlapsBlockedTime, + surpriseTaskOverlapsRequiredVisibleTime, + requiredCommitmentOverlapsProtectedFreeSlot, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart new file mode 100644 index 0000000..9de4ccb --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart @@ -0,0 +1,1309 @@ +part of '../scheduling_engine.dart'; + +/// Starter scheduling engine. +/// +/// The engine is a pure domain service: it receives immutable-ish input values +/// and returns new values. It does not persist data, render UI, send reminders, +/// or read the clock except where an optional [updatedAt] timestamp is omitted. +/// +/// Current V1 responsibilities: +/// - insert backlog tasks into the earliest available flexible slot; +/// - push flexible tasks later today; +/// - move flexible tasks to tomorrow's queue; +/// - roll unfinished flexible tasks into a new planning window; +/// - analyze overlaps against locked/required time; +/// - perform small state transitions such as missed/backlog handling. +/// +/// Important rule vocabulary: +/// - `fixedBlocks` are intervals the engine will not move. +/// - `queue` is the ordered set of flexible tasks that may be placed or shifted. +/// - `placement` is a map from task id to the interval chosen by the planner. +class SchedulingEngine { + const SchedulingEngine({ + this.clock = const SystemClock(), + }); + + /// Clock boundary used when an operation timestamp is not supplied. + final Clock clock; + + /// Insert a backlog task into the earliest available slot today. + /// + /// Locked, inflexible, and critical time is treated as fixed. Planned + /// flexible tasks at or after the insertion point may shift later, preserving + /// their relative order. + /// + /// The selected task must already exist in `input.tasks`, be flexible, be in + /// backlog status, and have a positive duration. If any precondition fails, the + /// original task list is returned with a no-fit/overflow notice. + SchedulingResult insertBacklogTaskIntoNextAvailableSlot({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + // Step 1: resolve and validate the task. The engine does not create tasks; + // quick capture or persistence code is responsible for adding it to the list. + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || !task.isBacklog) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only backlog flexible tasks can be inserted.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final taskDuration = _durationFromMinutes(task.durationMinutes); + if (taskDuration == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before scheduling.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + // Step 2: compute placements without mutating any task. Planning returns null + // if the inserted task and shifted queue cannot fit inside the window. + final placement = _planBacklogInsertion( + input: input, + task: task, + taskDuration: taskDuration, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot today.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPlacement( + input: input, + insertedTask: task, + placement: placement, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Push a planned flexible task to the next available slot today. + /// + /// The selected task moves after its current slot. Planned flexible tasks + /// after it may shift later, preserving their relative order. + /// + /// This is the "not now, later today" action. Anything before the pushed + /// task's current end time becomes fixed for this operation, while later + /// planned flexible tasks may be shifted if necessary. + SchedulingResult pushFlexibleTaskToNextAvailableSlot({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + bool countAsManualPush = true, + }) { + // Resolve the selected task by id so UI code only needs to pass a stable + // identifier, not object references. + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only planned flexible tasks can be pushed.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final currentInterval = _scheduledIntervalFor(task); + if (currentInterval == null || !input.window.contains(currentInterval)) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a current scheduled slot before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingScheduledSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + if (currentInterval.duration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive scheduled duration before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.nonPositiveDuration, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final placement = _planFlexiblePush( + input: input, + task: task, + currentInterval: currentInterval, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot today.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + pushedTaskMessage: 'Flexible task pushed to next available slot.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + updatedAt: updatedAt ?? clock.now(), + countPushedTaskAsManual: countAsManualPush, + ); + } + + /// Move a planned flexible task to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Existing planned + /// flexible tasks in that window may shift later, preserving their order. + /// + /// The method name says "tomorrow" because that is the product action, but the + /// engine only trusts `input.window`. Tests can pass any future window. + SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only planned flexible tasks can be moved to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final taskDuration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (taskDuration == null || taskDuration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before moving to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final placement = _planTomorrowQueueInsertion( + input: input, + task: task, + taskDuration: taskDuration, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot tomorrow.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + pushedTaskMessage: 'Flexible task moved to tomorrow.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Move unfinished flexible tasks to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Only planned and + /// active flexible tasks are rolled; required, locked, completed, and + /// cancelled tasks remain unchanged. + /// + /// Rollover is bulk push behavior for day-end recovery. It collects unfinished + /// flexible tasks outside the target window, preserves their relative order, + /// then places them at the start of the new window while shifting already + /// planned flexible tasks as needed. When [sourceWindow] is supplied, only + /// unfinished flexible tasks from that source window are rolled over. This + /// prevents future planned tasks from being pulled into tomorrow accidentally. + SchedulingResult rollOverUnfinishedFlexibleTasks({ + required SchedulingInput input, + SchedulingWindow? sourceWindow, + DateTime? updatedAt, + }) { + // Build the explicit queue of tasks to roll before asking the planner to + // place anything. This keeps selection separate from placement. + final rolledItems = <_PlacementItem>[]; + final rolloverTasks = input.flexibleTasks + .where((task) => _shouldRollOver( + task: task, + targetWindow: input.window, + sourceWindow: sourceWindow, + )) + .toList() + ..sort((a, b) { + final aStart = + a.scheduledStart ?? sourceWindow?.start ?? input.window.start; + final bStart = + b.scheduledStart ?? sourceWindow?.start ?? input.window.start; + + return aStart.compareTo(bStart); + }); + + for (final task in rolloverTasks) { + final duration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (duration == null || duration.inMicroseconds <= 0) { + continue; + } + + rolledItems.add( + _PlacementItem( + task: task, + duration: duration, + earliestStart: input.window.start, + ), + ); + } + + if (rolledItems.isEmpty) { + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'No unfinished flexible tasks to roll over.', + issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks, + ), + ], + ); + } + + final placement = _planQueueAtWindowStart( + input: input, + queue: rolledItems, + excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(), + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Unfinished flexible tasks could not fit tomorrow.', + type: SchedulingNoticeType.overflow, + issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit, + ), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.overflow, + ); + } + + return _applyRolloverPlacement( + input: input, + rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(), + placement: placement, + updatedAt: updatedAt ?? clock.now(), + ); + } + + /// Analyze the current in-memory schedule without moving tasks. + /// + /// This is a validation/debugging helper. It scans scheduled flexible tasks and + /// reports any overlap with blocked intervals. It deliberately returns the + /// original task list unchanged. + SchedulingResult analyzeSchedule(SchedulingInput input) { + final overlaps = []; + final notices = []; + final conflictOccupancies = input.conflictReportingOccupancies; + + for (final task in input.movablePlannedFlexibleTasks) { + final taskInterval = _scheduledIntervalFor(task); + if (taskInterval == null || !input.window.contains(taskInterval)) { + continue; + } + + for (final occupancy in conflictOccupancies) { + final blockedInterval = occupancy.interval; + if (blockedInterval == null) { + continue; + } + + if (!taskInterval.overlaps(blockedInterval)) { + continue; + } + + overlaps.add( + SchedulingOverlap( + taskId: task.id, + taskInterval: taskInterval, + blockedInterval: blockedInterval, + ), + ); + notices.add( + SchedulingNotice( + 'Flexible task overlaps blocked time.', + type: SchedulingNoticeType.overlap, + taskId: task.id, + conflictCode: + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + parameters: { + 'blockedStart': blockedInterval.start, + 'blockedEnd': blockedInterval.end, + }, + ), + ); + } + } + + return SchedulingResult( + tasks: input.tasks, + operationCode: SchedulingOperationCode.analyzeSchedule, + outcomeCode: overlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, + notices: List.unmodifiable(notices), + overlaps: List.unmodifiable(overlaps), + ); + } + + /// Move a task to backlog. + /// + /// Backlog does not preserve original schedule/order placement. The task's + /// schedule is cleared and its moved-to-backlog counter is incremented so + /// reports can distinguish this from a task that was never scheduled. + Task moveToBacklog(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Mark a flexible task pushed manually. + /// + /// This updates statistics only. Use the push methods above when the task's + /// actual scheduled slot should change. + Task markManuallyPushed(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.manualPush, + operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Mark missed according to the current MVP rules. + /// + /// Critical missed tasks go to backlog so they remain actionable. Inflexible + /// missed tasks stay in place as missed because they represented a fixed event + /// or time block that cannot simply be rescheduled automatically. + Task markMissed(Task task, {DateTime? updatedAt}) { + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.miss, + operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}', + activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, + ); + return result.applied ? result.task : task; + } + + /// Finds the first interval that can fit the requested duration while avoiding + /// blocked intervals. + /// + /// This public helper is deliberately simple and does not shift existing + /// flexible tasks. It is useful for UI previews or tests that only need to know + /// the first open gap. Full bump/queue behavior lives in the private planning + /// helpers below. + TimeInterval? findFirstOpenInterval({ + required DateTime windowStart, + required DateTime windowEnd, + required Duration duration, + required List blocked, + }) { + final sortedBlocked = [...blocked] + ..sort((a, b) => a.start.compareTo(b.start)); + var cursor = windowStart; + + for (final interval in sortedBlocked) { + if (cursor.add(duration).isBefore(interval.start) || + cursor.add(duration).isAtSameMomentAs(interval.start)) { + return TimeInterval(start: cursor, end: cursor.add(duration)); + } + + if (interval.end.isAfter(cursor)) { + cursor = interval.end; + } + } + + final candidateEnd = cursor.add(duration); + if (candidateEnd.isBefore(windowEnd) || + candidateEnd.isAtSameMomentAs(windowEnd)) { + return TimeInterval(start: cursor, end: candidateEnd); + } + + return null; + } +} + +/// Convert a scheduled task into an interval, or null if it is unplaced. +/// +/// This helper does not validate positive duration; callers that require a valid +/// duration check that separately. +TimeInterval? _scheduledIntervalFor(Task task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} + +/// Convert all placed tasks in [tasks] into intervals. +List _scheduledIntervalsFor(Iterable tasks) { + final intervals = []; + + for (final task in tasks) { + final interval = _scheduledIntervalFor(task); + if (interval != null) { + intervals.add(interval); + } + } + + return List.unmodifiable(intervals); +} + +/// Return the original task list with one explanatory notice. +/// +/// Most validation failures use this so callers can keep rendering the existing +/// schedule while showing why the requested action did not apply. +SchedulingResult _unchangedResult( + SchedulingInput input, + SchedulingNotice notice, { + SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified, + SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState, +}) { + return SchedulingResult( + tasks: input.tasks, + operationCode: operationCode, + outcomeCode: outcomeCode, + notices: [notice], + ); +} + +/// Find a task by stable id. +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +/// Convert a nullable minute estimate into a positive [Duration]. +Duration? _durationFromMinutes(int? minutes) { + if (minutes == null || minutes <= 0) { + return null; + } + + return Duration(minutes: minutes); +} + +/// Plan insertion of a backlog task plus any flexible tasks that must shift. +/// +/// This function only calculates intervals. It does not update task objects. The +/// returned plan is later applied by [_applyPlacement], which creates notices, +/// changes, and updated task copies. +_BacklogInsertionPlan? _planBacklogInsertion({ + required SchedulingInput input, + required Task task, + required Duration taskDuration, +}) { + // Start with intervals that the algorithm is not allowed to move. + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: taskDuration, + earliestStart: input.window.start, + ), + ]; + + // Existing flexible tasks are inspected in timeline order. Planned tasks inside + // the movable portion become part of the placement queue; everything else is + // treated as fixed. + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final startsBeforeWindow = interval.start.isBefore(input.window.start); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + + if (startsBeforeWindow || startsAfterWindow) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + // The cursor tracks the earliest point after the previously placed queue item. + // Each queued task is placed no earlier than both the cursor and its own + // original earliest start. + var cursor = input.window.start; + final placements = {}; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Plan the "push later today" behavior for one flexible task. +/// +/// Items before the pushed task's current end are fixed. The pushed task starts +/// the queue at its current end, followed by later planned flexible tasks that +/// may need to move to preserve order. +_BacklogInsertionPlan? _planFlexiblePush({ + required SchedulingInput input, + required Task task, + required TimeInterval currentInterval, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: currentInterval.duration, + earliestStart: currentInterval.end, + ), + ]; + + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + + if (startsBeforePushPoint || startsAfterWindow) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + var cursor = currentInterval.end; + final placements = {}; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Plan putting a single task at the start of the supplied future window. +_BacklogInsertionPlan? _planTomorrowQueueInsertion({ + required SchedulingInput input, + required Task task, + required Duration taskDuration, +}) { + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: taskDuration, + earliestStart: input.window.start, + ), + ]; + + return _planQueueAtWindowStart( + input: input, + queue: queue, + excludeTaskIds: {task.id}, + ); +} + +/// Plan a queue of flexible tasks at the beginning of [input.window]. +/// +/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies +/// tasks already represented in the incoming [queue] so they are not also pulled +/// from existing scheduled flexible tasks. +_BacklogInsertionPlan? _planQueueAtWindowStart({ + required SchedulingInput input, + required List<_PlacementItem> queue, + required Set excludeTaskIds, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final placementQueue = <_PlacementItem>[ + ...queue, + ]; + + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final overlapsWindow = interval.overlaps(input.window.interval); + if (!overlapsWindow) { + continue; + } + + if (!input.window.contains(interval)) { + fixedBlocks.add(interval); + continue; + } + + placementQueue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + var cursor = input.window.start; + final placements = {}; + + for (final item in placementQueue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + +/// Apply a backlog insertion plan to the task list. +/// +/// The inserted backlog task becomes planned and increments +/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room +/// increment `autoPushedCount`. +SchedulingResult _applyPlacement({ + required SchedulingInput input, + required Task insertedTask, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final isInsertedTask = task.id == insertedTask.id; + final moved = isInsertedTask || + !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final placedTask = task.copyWith( + status: isInsertedTask ? TaskStatus.planned : task.status, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + activityCode: isInsertedTask + ? TaskActivityCode.restoredFromBacklog + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + notices.add( + SchedulingNotice( + isInsertedTask + ? 'Backlog task inserted into schedule.' + : 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: isInsertedTask + ? SchedulingMovementCode.backlogTaskInserted + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Apply a push/tomorrow placement plan to the task list. +/// +/// The explicitly pushed task increments `manuallyPushedCount`; other moved +/// flexible tasks increment `autoPushedCount` because the scheduler moved them as +/// a side effect. +SchedulingResult _applyPushPlacement({ + required SchedulingInput input, + required Task pushedTask, + required _BacklogInsertionPlan placement, + required String pushedTaskMessage, + required SchedulingMovementCode pushedTaskMovementCode, + required SchedulingOperationCode operationCode, + required DateTime updatedAt, + bool countPushedTaskAsManual = true, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final moved = !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final isPushedTask = task.id == pushedTask.id; + final placedTask = task.copyWith( + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: operationCode, + activityCode: isPushedTask && countPushedTaskAsManual + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + notices.add( + SchedulingNotice( + isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: isPushedTask + ? pushedTaskMovementCode + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: operationCode, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Apply a bulk rollover placement plan. +/// +/// Rolled tasks are set back to planned status in the target window. Existing +/// tasks moved to make room receive normal movement notices. +SchedulingResult _applyRolloverPlacement({ + required SchedulingInput input, + required Set rolledTaskIds, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + var rolledCount = 0; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final moved = !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final isRolledTask = rolledTaskIds.contains(task.id); + final placedTask = task.copyWith( + status: isRolledTask ? TaskStatus.planned : task.status, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + activityCode: TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + + if (isRolledTask) { + rolledCount += 1; + } else { + notices.add( + SchedulingNotice( + 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, + ), + ); + } + } + + notices.insert( + 0, + SchedulingNotice( + '$rolledCount unfinished flexible tasks were moved to tomorrow.', + type: SchedulingNoticeType.moved, + movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: { + 'rolledCount': rolledCount, + }, + ), + ); + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.success, + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +/// Whether [task] belongs in the rollover queue. +/// +/// Planned/active flexible tasks already inside the target window are not rolled +/// again; they are handled as existing tasks that may shift to make room. +bool _shouldRollOver({ + required Task task, + required SchedulingWindow targetWindow, + SchedulingWindow? sourceWindow, +}) { + final interval = _scheduledIntervalFor(task); + final isTomorrowTask = + interval != null && interval.overlaps(targetWindow.interval); + + final isInSourceWindow = sourceWindow == null || + (interval != null && sourceWindow.contains(interval)); + + return task.isFlexible && + !isTomorrowTask && + isInSourceWindow && + (task.status == TaskStatus.planned || task.status == TaskStatus.active); +} + +/// Return whichever timestamp is later. +DateTime _laterOf(DateTime first, DateTime second) { + if (first.isAfter(second)) { + return first; + } + + return second; +} + +/// Null-safe exact timestamp comparison. +bool _sameDateTime(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} + +Task _applySchedulingActivity({ + required Task originalTask, + required Task updatedTask, + required SchedulingOperationCode operationCode, + required TaskActivityCode activityCode, + required DateTime occurredAt, + required DateTime? previousStart, + required DateTime? previousEnd, + required DateTime? nextStart, + required DateTime? nextEnd, +}) { + final operationId = + '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}'; + final activity = TaskActivity( + id: '$operationId:${activityCode.name}', + operationId: operationId, + code: activityCode, + taskId: originalTask.id, + projectId: originalTask.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': originalTask.status.name, + 'nextStatus': updatedTask.status.name, + 'previousStart': previousStart, + 'previousEnd': previousEnd, + 'nextStart': nextStart, + 'nextEnd': nextEnd, + }, + ); + + return _activityAccountingService + .apply(task: updatedTask, activities: [activity]).task; +} + +/// Find the first candidate interval at or after [earliestStart]. +/// +/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps +/// a blocked interval, the cursor jumps to that blocked interval's end and tries +/// again. This makes the algorithm easy to follow and adequate for the starter +/// in-memory engine. +TimeInterval? _firstOpenIntervalFrom({ + required DateTime earliestStart, + required DateTime windowEnd, + required Duration duration, + required List blocked, +}) { + var cursor = earliestStart; + + while (true) { + final candidateEnd = cursor.add(duration); + if (candidateEnd.isAfter(windowEnd)) { + return null; + } + + final candidate = TimeInterval(start: cursor, end: candidateEnd); + TimeInterval? overlappingBlock; + + for (final block in blocked) { + if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) { + continue; + } + + if (block.start.isAfter(candidate.end) || + block.start.isAtSameMomentAs(candidate.end)) { + break; + } + + if (candidate.overlaps(block)) { + overlappingBlock = block; + break; + } + } + + if (overlappingBlock == null) { + return candidate; + } + + cursor = overlappingBlock.end; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart new file mode 100644 index 0000000..f5713f0 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart @@ -0,0 +1,97 @@ +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 { + 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_engine/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart new file mode 100644 index 0000000..ac7b318 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart @@ -0,0 +1,14 @@ +part of '../scheduling_engine.dart'; + +/// Stable issue identifiers for validation, no-slot, and no-op notices. +enum SchedulingIssueCode { + taskNotFound, + invalidTaskState, + missingDuration, + missingScheduledSlot, + nonPositiveDuration, + noAvailableSlot, + unfinishedTasksCouldNotFit, + noUnfinishedFlexibleTasks, + duplicateSurpriseLog, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart new file mode 100644 index 0000000..d10016d --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart @@ -0,0 +1,12 @@ +part of '../scheduling_engine.dart'; + +/// Stable movement identifiers for task placement changes. +enum SchedulingMovementCode { + backlogTaskInserted, + flexibleTaskMovedToMakeRoom, + flexibleTaskPushedToNextAvailableSlot, + flexibleTaskMovedToTomorrow, + unfinishedFlexibleTasksRolledOver, + flexibleTaskMovedToBacklog, + requiredCommitmentScheduled, +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart new file mode 100644 index 0000000..5cabf25 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart @@ -0,0 +1,40 @@ +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 { + 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_engine/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart new file mode 100644 index 0000000..1d303b9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart @@ -0,0 +1,23 @@ +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_engine/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart new file mode 100644 index 0000000..1db3a14 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart @@ -0,0 +1,31 @@ +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_engine/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart new file mode 100644 index 0000000..2830814 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart @@ -0,0 +1,25 @@ +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/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart new file mode 100644 index 0000000..434d634 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart @@ -0,0 +1,23 @@ +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 { + 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_engine/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart new file mode 100644 index 0000000..eb3c106 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart @@ -0,0 +1,57 @@ +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 { + 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; +} diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart new file mode 100644 index 0000000..09fa0fa --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart @@ -0,0 +1,46 @@ +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 { + 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/task_actions.dart b/packages/scheduler_core/lib/src/task_actions.dart index 2cf0a4f..53676db 100644 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ b/packages/scheduler_core/lib/src/task_actions.dart @@ -13,1089 +13,14 @@ 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); -} +part 'task_actions/flexible_task_quick_action.dart'; +part 'task_actions/required_task_action.dart'; +part 'task_actions/push_destination.dart'; +part 'task_actions/flexible_task_action_result.dart'; +part 'task_actions/push_destination_result.dart'; +part 'task_actions/required_task_action_result.dart'; +part 'task_actions/surprise_task_log_request.dart'; +part 'task_actions/surprise_task_log_result.dart'; +part 'task_actions/flexible_task_action_service.dart'; +part 'task_actions/required_task_action_service.dart'; +part 'task_actions/surprise_task_log_service.dart'; diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart new file mode 100644 index 0000000..8715749 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart @@ -0,0 +1,38 @@ +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 { + 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/task_actions/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart new file mode 100644 index 0000000..0414933 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart @@ -0,0 +1,264 @@ +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 { + 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/task_actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart new file mode 100644 index 0000000..1fb03b1 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart @@ -0,0 +1,20 @@ +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/task_actions/push_destination.dart b/packages/scheduler_core/lib/src/task_actions/push_destination.dart new file mode 100644 index 0000000..2c6f0c7 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/push_destination.dart @@ -0,0 +1,17 @@ +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/task_actions/push_destination_result.dart b/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart new file mode 100644 index 0000000..d96ad54 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart @@ -0,0 +1,29 @@ +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 { + 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/task_actions/required_task_action.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action.dart new file mode 100644 index 0000000..a66a2f4 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action.dart @@ -0,0 +1,20 @@ +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/task_actions/required_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart new file mode 100644 index 0000000..c8cdc73 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart @@ -0,0 +1,31 @@ +part of '../task_actions.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart new file mode 100644 index 0000000..97ee665 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart @@ -0,0 +1,81 @@ +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 { + 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/task_actions/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart new file mode 100644 index 0000000..d45b69b --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart @@ -0,0 +1,49 @@ +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 { + 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/task_actions/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart new file mode 100644 index 0000000..8a462b4 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart @@ -0,0 +1,31 @@ +part of '../task_actions.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart new file mode 100644 index 0000000..1cdac2f --- /dev/null +++ b/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart @@ -0,0 +1,517 @@ +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 { + 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/task_lifecycle.dart b/packages/scheduler_core/lib/src/task_lifecycle.dart index dbd2c6f..1043c8b 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle.dart @@ -10,954 +10,11 @@ library; 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, - ); - } -} - -/// Canonical lifecycle transition service. -class TaskTransitionService { - const TaskTransitionService({ - this.clock = const SystemClock(), - this.accountingService = const TaskActivityAccountingService(), - }); - - /// Clock boundary used when no explicit occurrence time is supplied. - final Clock clock; - - /// Activity-derived statistics updater. - final TaskActivityAccountingService accountingService; - - /// Apply one transition to [task]. - /// - /// [operationId] is the idempotency key. If an existing activity for the same - /// operation and task is supplied, the command is treated as an exact duplicate - /// and no additional changes are returned. - TaskTransitionResult apply({ - required Task task, - required TaskTransitionCode transitionCode, - required String operationId, - required String activityId, - DateTime? occurredAt, - DateTime? actualStart, - DateTime? actualEnd, - Iterable existingActivities = const [], - Iterable appliedActivityIds = const [], - List lockedIntervals = const [], - Map metadata = const {}, - }) { - final now = occurredAt ?? clock.now(); - final duplicate = _duplicateActivity( - existingActivities: existingActivities, - taskId: task.id, - operationId: operationId, - ); - if (duplicate != null) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, - task: task, - duplicateOfActivityId: duplicate.id, - ); - } - - if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); - } - - if (_isNoOpTerminalRepeat(task, transitionCode)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.noOp, - task: task, - ); - } - - if (_isBlockedByTerminalState(task, transitionCode)) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); - } - - return switch (transitionCode) { - TaskTransitionCode.complete => _complete( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - actualStart: actualStart, - actualEnd: actualEnd, - appliedActivityIds: appliedActivityIds, - lockedIntervals: lockedIntervals, - metadata: metadata, - ), - TaskTransitionCode.miss => _miss( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.cancel => _cancel( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.noLongerRelevant => _noLongerRelevant( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.manualPush => _movementOnly( - task: task, - transitionCode: transitionCode, - activityCode: TaskActivityCode.manuallyPushed, - operationId: operationId, - activityId: activityId, - occurredAt: now, - metadata: metadata, - appliedActivityIds: appliedActivityIds, - ), - TaskTransitionCode.automaticPush => _movementOnly( - task: task, - transitionCode: transitionCode, - activityCode: TaskActivityCode.automaticallyPushed, - operationId: operationId, - activityId: activityId, - occurredAt: now, - metadata: metadata, - appliedActivityIds: appliedActivityIds, - ), - TaskTransitionCode.moveToBacklog => _moveToBacklog( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - TaskTransitionCode.activate => _activate( - task: task, - operationId: operationId, - activityId: activityId, - occurredAt: now, - appliedActivityIds: appliedActivityIds, - metadata: metadata, - ), - }; - } - - TaskTransitionResult _complete({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required DateTime? actualStart, - required DateTime? actualEnd, - required Iterable appliedActivityIds, - required List lockedIntervals, - required Map metadata, - }) { - if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { - return _invalid(task, TaskTransitionCode.complete); - } - - final completed = task.copyWith( - status: TaskStatus.completed, - updatedAt: occurredAt, - completedAt: occurredAt, - actualStart: actualStart, - actualEnd: actualEnd, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.completed, - task: task, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'completedAt': occurredAt, - 'scheduledEnd': task.scheduledEnd, - 'actualStart': completed.actualStart, - 'actualEnd': completed.actualEnd, - 'durationMinutes': _knownDurationMinutes( - completed, - completed.actualStart, - completed.actualEnd, - ), - 'reward': task.reward.name, - 'difficulty': task.difficulty.name, - if (task.reminderOverride != null) - 'reminderProfile': task.reminderOverride!.name, - 'pushesBeforeCompletion': _pushesBeforeCompletion(task), - }, - nextStatus: completed.status, - ), - ]; - final accounted = accountingService.apply( - task: completed, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - lockedIntervals: lockedIntervals, - ); - - return _applied( - transitionCode: TaskTransitionCode.complete, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _miss({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { - return _invalid(task, TaskTransitionCode.miss); - } - if (!task.isFlexible && !task.isRequiredVisible) { - return _invalid(task, TaskTransitionCode.miss); - } - - final updatedTask = task.type == TaskType.critical - ? task.copyWith( - status: TaskStatus.backlog, - updatedAt: occurredAt, - clearSchedule: true, - ) - : task.copyWith( - status: TaskStatus.missed, - updatedAt: occurredAt, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.missed, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: updatedTask.status, - ), - ]; - - if (task.type == TaskType.critical) { - activities.add( - _activity( - id: '$activityId:movedToBacklog', - operationId: operationId, - code: TaskActivityCode.movedToBacklog, - task: task, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'reason': 'criticalMissed', - }, - nextStatus: updatedTask.status, - ), - ); - } - - final accounted = accountingService.apply( - task: updatedTask, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.miss, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _cancel({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status == TaskStatus.cancelled) { - return _noOp(task, TaskTransitionCode.cancel); - } - if (!_canRemoveFromActivePlan(task)) { - return _invalid(task, TaskTransitionCode.cancel); - } - - final cancelled = task.copyWith( - status: TaskStatus.cancelled, - updatedAt: occurredAt, - clearSchedule: true, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.cancelled, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: cancelled.status, - ), - ]; - final accounted = accountingService.apply( - task: cancelled, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.cancel, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _noLongerRelevant({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status == TaskStatus.noLongerRelevant) { - return _noOp(task, TaskTransitionCode.noLongerRelevant); - } - if (!_canRemoveFromActivePlan(task)) { - return _invalid(task, TaskTransitionCode.noLongerRelevant); - } - - final dismissed = task.copyWith( - status: TaskStatus.noLongerRelevant, - updatedAt: occurredAt, - clearSchedule: true, - ); - - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.noLongerRelevant, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: dismissed.status, - ), - ]; - final accounted = accountingService.apply( - task: dismissed, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.noLongerRelevant, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _movementOnly({ - required Task task, - required TaskTransitionCode transitionCode, - required TaskActivityCode activityCode, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Map metadata, - required Iterable appliedActivityIds, - }) { - if (!task.isFlexible || task.status != TaskStatus.planned) { - return _invalid(task, transitionCode); - } - - final updated = task.copyWith(updatedAt: occurredAt); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: activityCode, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: updated.status, - ), - ]; - final accounted = accountingService.apply( - task: updated, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: transitionCode, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _moveToBacklog({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - final canMove = (task.isFlexible || task.type == TaskType.critical) && - !_isTerminal(task.status); - if (!canMove) { - return _invalid(task, TaskTransitionCode.moveToBacklog); - } - - final moved = task.copyWith( - status: TaskStatus.backlog, - updatedAt: occurredAt, - clearSchedule: true, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.movedToBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: moved.status, - ), - ]; - final accounted = accountingService.apply( - task: moved, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.moveToBacklog, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _restoreFromBacklog({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.backlog || - (!task.isFlexible && task.type != TaskType.critical)) { - return _invalid(task, TaskTransitionCode.restoreFromBacklog); - } - - final restored = task.copyWith( - status: TaskStatus.planned, - updatedAt: occurredAt, - ); - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.restoredFromBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: restored.status, - ), - ]; - final accounted = accountingService.apply( - task: restored, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.restoreFromBacklog, - task: accounted.task, - activities: activities, - ); - } - - TaskTransitionResult _activate({ - required Task task, - required String operationId, - required String activityId, - required DateTime occurredAt, - required Iterable appliedActivityIds, - required Map metadata, - }) { - if (task.status != TaskStatus.planned || - task.isLocked || - task.type == TaskType.freeSlot || - task.type == TaskType.surprise) { - return _invalid(task, TaskTransitionCode.activate); - } - - final activated = task.copyWith( - status: TaskStatus.active, - updatedAt: occurredAt, - ); - - final activities = [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.activated, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: activated.status, - ), - ]; - final accounted = accountingService.apply( - task: activated, - activities: activities, - alreadyAppliedActivityIds: appliedActivityIds, - ); - - return _applied( - transitionCode: TaskTransitionCode.activate, - task: accounted.task, - activities: activities, - ); - } -} - -TaskStatistics _applyActivityStats({ - required Task task, - required TaskActivity activity, - required List lockedIntervals, -}) { - return switch (activity.code) { - TaskActivityCode.manuallyPushed => task.stats.incrementManualPush(), - TaskActivityCode.automaticallyPushed => task.stats.incrementAutoPush(), - TaskActivityCode.movedToBacklog => task.stats.incrementMovedToBacklog(), - TaskActivityCode.restoredFromBacklog => - task.stats.incrementRestoredFromBacklog(), - TaskActivityCode.missed => task.stats.incrementMissed(), - TaskActivityCode.cancelled => task.stats.incrementCancelled(), - TaskActivityCode.completed => _applyCompletionStats( - task: task, - activity: activity, - lockedIntervals: lockedIntervals, - ), - TaskActivityCode.noLongerRelevant || - TaskActivityCode.activated => - task.stats, - }; -} - -TaskStatistics _applyCompletionStats({ - required Task task, - required TaskActivity activity, - required List lockedIntervals, -}) { - var stats = task.stats; - final scheduledEnd = task.scheduledEnd; - final completedAt = task.completedAt ?? activity.occurredAt; - - if (scheduledEnd != null && completedAt.isAfter(scheduledEnd)) { - stats = stats.incrementCompletedLate(); - } - - final lockedMinutes = _actualLockedOverlapMinutes( - task: task, - lockedIntervals: lockedIntervals, - ); - if (lockedMinutes > 0) { - stats = stats.incrementCompletedDuringLockedHours(lockedMinutes); - } - - final pushesBeforeCompletion = - _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? - _pushesBeforeCompletion(task); - - return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); -} - -int _actualLockedOverlapMinutes({ - required Task task, - required List lockedIntervals, -}) { - final actualStart = task.actualStart; - final actualEnd = task.actualEnd; - if (actualStart == null || - actualEnd == null || - !actualStart.isBefore(actualEnd) || - lockedIntervals.isEmpty) { - return 0; - } - - final actualInterval = TimeInterval(start: actualStart, end: actualEnd); - final intersections = []; - - for (final lockedInterval in lockedIntervals) { - if (!actualInterval.overlaps(lockedInterval)) { - continue; - } - intersections.add( - TimeInterval( - start: _latest(actualInterval.start, lockedInterval.start), - end: _earliest(actualInterval.end, lockedInterval.end), - ), - ); - } - - if (intersections.isEmpty) { - return 0; - } - - intersections.sort((left, right) => left.start.compareTo(right.start)); - var total = Duration.zero; - var current = intersections.first; - for (final interval in intersections.skip(1)) { - if (interval.start.isAfter(current.end)) { - total += current.duration; - current = interval; - continue; - } - - current = TimeInterval( - start: current.start, - end: _latest(current.end, interval.end), - ); - } - total += current.duration; - - return total.inMinutes; -} - -int _pushesBeforeCompletion(Task task) { - return task.stats.manuallyPushedCount + task.stats.autoPushedCount; -} - -int? _knownDurationMinutes( - Task task, - DateTime? actualStart, - DateTime? actualEnd, -) { - if (actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd)) { - final actualMinutes = actualEnd.difference(actualStart).inMinutes; - if (actualMinutes > 0) { - return actualMinutes; - } - } - - return task.durationMinutes; -} - -int? _intMetadata(Object? value) { - return value is int ? value : null; -} - -DateTime _earliest(DateTime first, DateTime second) { - return first.isBefore(second) ? first : second; -} - -DateTime _latest(DateTime first, DateTime second) { - return first.isAfter(second) ? first : second; -} - -TaskTransitionResult _applied({ - required TaskTransitionCode transitionCode, - required Task task, - required List activities, -}) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.applied, - task: task, - activities: activities, - ); -} - -TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.invalidState, - task: task, - ); -} - -TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { - return TaskTransitionResult( - transitionCode: transitionCode, - outcomeCode: TaskTransitionOutcomeCode.noOp, - task: task, - ); -} - -TaskActivity _activity({ - required String id, - required String operationId, - required TaskActivityCode code, - required Task task, - required DateTime occurredAt, - required Map metadata, - required TaskStatus nextStatus, -}) { - return TaskActivity( - id: id, - operationId: operationId, - code: code, - taskId: task.id, - projectId: task.projectId, - occurredAt: occurredAt, - metadata: { - ...metadata, - 'previousStatus': task.status.name, - 'nextStatus': nextStatus.name, - }, - ); -} - -TaskActivity? _duplicateActivity({ - required Iterable existingActivities, - required String taskId, - required String operationId, -}) { - for (final activity in existingActivities) { - if (activity.taskId == taskId && activity.operationId == operationId) { - return activity; - } - } - - return null; -} - -bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { - if (actualStart == null && actualEnd == null) { - return true; - } - return actualStart != null && - actualEnd != null && - actualStart.isBefore(actualEnd); -} - -bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { - return (task.status == TaskStatus.completed && - transitionCode == TaskTransitionCode.complete) || - (task.status == TaskStatus.cancelled && - transitionCode == TaskTransitionCode.cancel) || - (task.status == TaskStatus.noLongerRelevant && - transitionCode == TaskTransitionCode.noLongerRelevant); -} - -bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { - if (!_isTerminal(task.status)) { - return false; - } - return !_isNoOpTerminalRepeat(task, transitionCode); -} - -bool _isTerminal(TaskStatus status) { - return status == TaskStatus.completed || - status == TaskStatus.cancelled || - status == TaskStatus.noLongerRelevant; -} - -bool _canRemoveFromActivePlan(Task task) { - return task.status == TaskStatus.planned || - task.status == TaskStatus.active || - task.status == TaskStatus.backlog || - task.status == TaskStatus.missed; -} - -String _requiredTrimmed(String value, String name) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw ArgumentError.value(value, name, 'Value is required.'); - } - return trimmed; -} +part 'task_lifecycle/task_transition_code.dart'; +part 'task_lifecycle/task_activity_code.dart'; +part 'task_lifecycle/task_transition_outcome_code.dart'; +part 'task_lifecycle/task_activity.dart'; +part 'task_lifecycle/task_transition_result.dart'; +part 'task_lifecycle/task_activity_application_result.dart'; +part 'task_lifecycle/task_activity_accounting_service.dart'; +part 'task_lifecycle/task_transition_service.dart'; diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart new file mode 100644 index 0000000..66aefb1 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart @@ -0,0 +1,43 @@ +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 { + 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/task_lifecycle/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart new file mode 100644 index 0000000..d9baa38 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart @@ -0,0 +1,43 @@ +part of '../task_lifecycle.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart new file mode 100644 index 0000000..58069e7 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart @@ -0,0 +1,15 @@ +part of '../task_lifecycle.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart new file mode 100644 index 0000000..484d370 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart @@ -0,0 +1,14 @@ +part of '../task_lifecycle.dart'; + +/// Stable internal activity categories derived from transitions. +enum TaskActivityCode { + completed, + missed, + cancelled, + noLongerRelevant, + manuallyPushed, + automaticallyPushed, + movedToBacklog, + restoredFromBacklog, + activated, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart new file mode 100644 index 0000000..4927f18 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart @@ -0,0 +1,14 @@ +part of '../task_lifecycle.dart'; + +/// Canonical task transition commands. +enum TaskTransitionCode { + complete, + miss, + cancel, + noLongerRelevant, + manualPush, + automaticPush, + moveToBacklog, + restoreFromBacklog, + activate, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart new file mode 100644 index 0000000..89e7427 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart @@ -0,0 +1,9 @@ +part of '../task_lifecycle.dart'; + +/// Typed result categories for expected transition states. +enum TaskTransitionOutcomeCode { + applied, + noOp, + duplicateOperation, + invalidState, +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart new file mode 100644 index 0000000..ee58d84 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart @@ -0,0 +1,30 @@ +part of '../task_lifecycle.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart new file mode 100644 index 0000000..a84f941 --- /dev/null +++ b/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart @@ -0,0 +1,791 @@ +part of '../task_lifecycle.dart'; + +/// Canonical lifecycle transition service. +class TaskTransitionService { + const TaskTransitionService({ + this.clock = const SystemClock(), + this.accountingService = const TaskActivityAccountingService(), + }); + + /// Clock boundary used when no explicit occurrence time is supplied. + final Clock clock; + + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + + /// Apply one transition to [task]. + /// + /// [operationId] is the idempotency key. If an existing activity for the same + /// operation and task is supplied, the command is treated as an exact duplicate + /// and no additional changes are returned. + TaskTransitionResult apply({ + required Task task, + required TaskTransitionCode transitionCode, + required String operationId, + required String activityId, + DateTime? occurredAt, + DateTime? actualStart, + DateTime? actualEnd, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + List lockedIntervals = const [], + Map metadata = const {}, + }) { + final now = occurredAt ?? clock.now(); + final duplicate = _duplicateActivity( + existingActivities: existingActivities, + taskId: task.id, + operationId: operationId, + ); + if (duplicate != null) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, + task: task, + duplicateOfActivityId: duplicate.id, + ); + } + + if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + if (_isNoOpTerminalRepeat(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); + } + + if (_isBlockedByTerminalState(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + return switch (transitionCode) { + TaskTransitionCode.complete => _complete( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + appliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, + metadata: metadata, + ), + TaskTransitionCode.miss => _miss( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.cancel => _cancel( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.noLongerRelevant => _noLongerRelevant( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.manualPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.manuallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + appliedActivityIds: appliedActivityIds, + ), + TaskTransitionCode.automaticPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.automaticallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + appliedActivityIds: appliedActivityIds, + ), + TaskTransitionCode.moveToBacklog => _moveToBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + TaskTransitionCode.activate => _activate( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + appliedActivityIds: appliedActivityIds, + metadata: metadata, + ), + }; + } + + TaskTransitionResult _complete({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required DateTime? actualStart, + required DateTime? actualEnd, + required Iterable appliedActivityIds, + required List lockedIntervals, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.complete); + } + + final completed = task.copyWith( + status: TaskStatus.completed, + updatedAt: occurredAt, + completedAt: occurredAt, + actualStart: actualStart, + actualEnd: actualEnd, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.completed, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'completedAt': occurredAt, + 'scheduledEnd': task.scheduledEnd, + 'actualStart': completed.actualStart, + 'actualEnd': completed.actualEnd, + 'durationMinutes': _knownDurationMinutes( + completed, + completed.actualStart, + completed.actualEnd, + ), + 'reward': task.reward.name, + 'difficulty': task.difficulty.name, + if (task.reminderOverride != null) + 'reminderProfile': task.reminderOverride!.name, + 'pushesBeforeCompletion': _pushesBeforeCompletion(task), + }, + nextStatus: completed.status, + ), + ]; + final accounted = accountingService.apply( + task: completed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, + ); + + return _applied( + transitionCode: TaskTransitionCode.complete, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _miss({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.miss); + } + if (!task.isFlexible && !task.isRequiredVisible) { + return _invalid(task, TaskTransitionCode.miss); + } + + final updatedTask = task.type == TaskType.critical + ? task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + clearSchedule: true, + ) + : task.copyWith( + status: TaskStatus.missed, + updatedAt: occurredAt, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.missed, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updatedTask.status, + ), + ]; + + if (task.type == TaskType.critical) { + activities.add( + _activity( + id: '$activityId:movedToBacklog', + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'reason': 'criticalMissed', + }, + nextStatus: updatedTask.status, + ), + ); + } + + final accounted = accountingService.apply( + task: updatedTask, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.miss, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _cancel({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status == TaskStatus.cancelled) { + return _noOp(task, TaskTransitionCode.cancel); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.cancel); + } + + final cancelled = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: occurredAt, + clearSchedule: true, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.cancelled, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: cancelled.status, + ), + ]; + final accounted = accountingService.apply( + task: cancelled, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.cancel, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _noLongerRelevant({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status == TaskStatus.noLongerRelevant) { + return _noOp(task, TaskTransitionCode.noLongerRelevant); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.noLongerRelevant); + } + + final dismissed = task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: occurredAt, + clearSchedule: true, + ); + + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.noLongerRelevant, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: dismissed.status, + ), + ]; + final accounted = accountingService.apply( + task: dismissed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.noLongerRelevant, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _movementOnly({ + required Task task, + required TaskTransitionCode transitionCode, + required TaskActivityCode activityCode, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + required Iterable appliedActivityIds, + }) { + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _invalid(task, transitionCode); + } + + final updated = task.copyWith(updatedAt: occurredAt); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: activityCode, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updated.status, + ), + ]; + final accounted = accountingService.apply( + task: updated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: transitionCode, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _moveToBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + final canMove = (task.isFlexible || task.type == TaskType.critical) && + !_isTerminal(task.status); + if (!canMove) { + return _invalid(task, TaskTransitionCode.moveToBacklog); + } + + final moved = task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + clearSchedule: true, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: moved.status, + ), + ]; + final accounted = accountingService.apply( + task: moved, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.moveToBacklog, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _restoreFromBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.backlog || + (!task.isFlexible && task.type != TaskType.critical)) { + return _invalid(task, TaskTransitionCode.restoreFromBacklog); + } + + final restored = task.copyWith( + status: TaskStatus.planned, + updatedAt: occurredAt, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.restoredFromBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: restored.status, + ), + ]; + final accounted = accountingService.apply( + task: restored, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.restoreFromBacklog, + task: accounted.task, + activities: activities, + ); + } + + TaskTransitionResult _activate({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Iterable appliedActivityIds, + required Map metadata, + }) { + if (task.status != TaskStatus.planned || + task.isLocked || + task.type == TaskType.freeSlot || + task.type == TaskType.surprise) { + return _invalid(task, TaskTransitionCode.activate); + } + + final activated = task.copyWith( + status: TaskStatus.active, + updatedAt: occurredAt, + ); + + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.activated, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: activated.status, + ), + ]; + final accounted = accountingService.apply( + task: activated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + + return _applied( + transitionCode: TaskTransitionCode.activate, + task: accounted.task, + activities: activities, + ); + } +} + +TaskStatistics _applyActivityStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + return switch (activity.code) { + TaskActivityCode.manuallyPushed => task.stats.incrementManualPush(), + TaskActivityCode.automaticallyPushed => task.stats.incrementAutoPush(), + TaskActivityCode.movedToBacklog => task.stats.incrementMovedToBacklog(), + TaskActivityCode.restoredFromBacklog => + task.stats.incrementRestoredFromBacklog(), + TaskActivityCode.missed => task.stats.incrementMissed(), + TaskActivityCode.cancelled => task.stats.incrementCancelled(), + TaskActivityCode.completed => _applyCompletionStats( + task: task, + activity: activity, + lockedIntervals: lockedIntervals, + ), + TaskActivityCode.noLongerRelevant || + TaskActivityCode.activated => + task.stats, + }; +} + +TaskStatistics _applyCompletionStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + var stats = task.stats; + final scheduledEnd = task.scheduledEnd; + final completedAt = task.completedAt ?? activity.occurredAt; + + if (scheduledEnd != null && completedAt.isAfter(scheduledEnd)) { + stats = stats.incrementCompletedLate(); + } + + final lockedMinutes = _actualLockedOverlapMinutes( + task: task, + lockedIntervals: lockedIntervals, + ); + if (lockedMinutes > 0) { + stats = stats.incrementCompletedDuringLockedHours(lockedMinutes); + } + + final pushesBeforeCompletion = + _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? + _pushesBeforeCompletion(task); + + return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); +} + +int _actualLockedOverlapMinutes({ + required Task task, + required List lockedIntervals, +}) { + final actualStart = task.actualStart; + final actualEnd = task.actualEnd; + if (actualStart == null || + actualEnd == null || + !actualStart.isBefore(actualEnd) || + lockedIntervals.isEmpty) { + return 0; + } + + final actualInterval = TimeInterval(start: actualStart, end: actualEnd); + final intersections = []; + + for (final lockedInterval in lockedIntervals) { + if (!actualInterval.overlaps(lockedInterval)) { + continue; + } + intersections.add( + TimeInterval( + start: _latest(actualInterval.start, lockedInterval.start), + end: _earliest(actualInterval.end, lockedInterval.end), + ), + ); + } + + if (intersections.isEmpty) { + return 0; + } + + intersections.sort((left, right) => left.start.compareTo(right.start)); + var total = Duration.zero; + var current = intersections.first; + for (final interval in intersections.skip(1)) { + if (interval.start.isAfter(current.end)) { + total += current.duration; + current = interval; + continue; + } + + current = TimeInterval( + start: current.start, + end: _latest(current.end, interval.end), + ); + } + total += current.duration; + + return total.inMinutes; +} + +int _pushesBeforeCompletion(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} + +int? _knownDurationMinutes( + Task task, + DateTime? actualStart, + DateTime? actualEnd, +) { + if (actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd)) { + final actualMinutes = actualEnd.difference(actualStart).inMinutes; + if (actualMinutes > 0) { + return actualMinutes; + } + } + + return task.durationMinutes; +} + +int? _intMetadata(Object? value) { + return value is int ? value : null; +} + +DateTime _earliest(DateTime first, DateTime second) { + return first.isBefore(second) ? first : second; +} + +DateTime _latest(DateTime first, DateTime second) { + return first.isAfter(second) ? first : second; +} + +TaskTransitionResult _applied({ + required TaskTransitionCode transitionCode, + required Task task, + required List activities, +}) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.applied, + task: task, + activities: activities, + ); +} + +TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); +} + +TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); +} + +TaskActivity _activity({ + required String id, + required String operationId, + required TaskActivityCode code, + required Task task, + required DateTime occurredAt, + required Map metadata, + required TaskStatus nextStatus, +}) { + return TaskActivity( + id: id, + operationId: operationId, + code: code, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'previousStatus': task.status.name, + 'nextStatus': nextStatus.name, + }, + ); +} + +TaskActivity? _duplicateActivity({ + required Iterable existingActivities, + required String taskId, + required String operationId, +}) { + for (final activity in existingActivities) { + if (activity.taskId == taskId && activity.operationId == operationId) { + return activity; + } + } + + return null; +} + +bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { + if (actualStart == null && actualEnd == null) { + return true; + } + return actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd); +} + +bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { + return (task.status == TaskStatus.completed && + transitionCode == TaskTransitionCode.complete) || + (task.status == TaskStatus.cancelled && + transitionCode == TaskTransitionCode.cancel) || + (task.status == TaskStatus.noLongerRelevant && + transitionCode == TaskTransitionCode.noLongerRelevant); +} + +bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { + if (!_isTerminal(task.status)) { + return false; + } + return !_isNoOpTerminalRepeat(task, transitionCode); +} + +bool _isTerminal(TaskStatus status) { + return status == TaskStatus.completed || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +bool _canRemoveFromActivePlan(Task task) { + return task.status == TaskStatus.planned || + task.status == TaskStatus.active || + task.status == TaskStatus.backlog || + task.status == TaskStatus.missed; +} + +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/time_contracts.dart b/packages/scheduler_core/lib/src/time_contracts.dart index 4740589..2f318be 100644 --- a/packages/scheduler_core/lib/src/time_contracts.dart +++ b/packages/scheduler_core/lib/src/time_contracts.dart @@ -2,295 +2,17 @@ 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, - ); - } -} +part 'time_contracts/clock.dart'; +part 'time_contracts/system_clock.dart'; +part 'time_contracts/fixed_clock.dart'; +part 'time_contracts/id_generator.dart'; +part 'time_contracts/sequential_id_generator.dart'; +part 'time_contracts/civil_date.dart'; +part 'time_contracts/wall_time.dart'; +part 'time_contracts/local_time_resolution.dart'; +part 'time_contracts/nonexistent_local_time_policy.dart'; +part 'time_contracts/repeated_local_time_policy.dart'; +part 'time_contracts/time_zone_resolution_options.dart'; +part 'time_contracts/resolved_local_date_time.dart'; +part 'time_contracts/time_zone_resolver.dart'; +part 'time_contracts/fixed_offset_time_zone_resolver.dart'; diff --git a/packages/scheduler_core/lib/src/time_contracts/civil_date.dart b/packages/scheduler_core/lib/src/time_contracts/civil_date.dart new file mode 100644 index 0000000..43cf73c --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/civil_date.dart @@ -0,0 +1,83 @@ +part of '../time_contracts.dart'; + +/// 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(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/clock.dart b/packages/scheduler_core/lib/src/time_contracts/clock.dart new file mode 100644 index 0000000..96d7cce --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/clock.dart @@ -0,0 +1,6 @@ +part of '../time_contracts.dart'; + +/// Deterministic source of the current instant. +abstract interface class Clock { + DateTime now(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart b/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart new file mode 100644 index 0000000..c883a53 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +/// Test/application clock with a fixed instant. +class FixedClock implements Clock { + const FixedClock(this.instant); + + final DateTime instant; + + @override + DateTime now() => instant; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart new file mode 100644 index 0000000..099a02a --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart @@ -0,0 +1,44 @@ +part of '../time_contracts.dart'; + +/// 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/lib/src/time_contracts/id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/id_generator.dart new file mode 100644 index 0000000..b7ba5eb --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/id_generator.dart @@ -0,0 +1,6 @@ +part of '../time_contracts.dart'; + +/// Deterministic ID source for application/domain convenience entry points. +abstract interface class IdGenerator { + String nextId(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart b/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart new file mode 100644 index 0000000..9dbae23 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart @@ -0,0 +1,8 @@ +part of '../time_contracts.dart'; + +enum LocalTimeResolution { + exact, + shiftedForward, + repeatedEarlier, + repeatedLater, +} diff --git a/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart new file mode 100644 index 0000000..c2b39f9 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart @@ -0,0 +1,9 @@ +part of '../time_contracts.dart'; + +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/time_contracts/repeated_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart new file mode 100644 index 0000000..42b5c30 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart @@ -0,0 +1,12 @@ +part of '../time_contracts.dart'; + +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/time_contracts/resolved_local_date_time.dart b/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart new file mode 100644 index 0000000..cc6ba80 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart @@ -0,0 +1,19 @@ +part of '../time_contracts.dart'; + +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; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart new file mode 100644 index 0000000..7ddb40d --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart @@ -0,0 +1,19 @@ +part of '../time_contracts.dart'; + +/// 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; + } +} diff --git a/packages/scheduler_core/lib/src/time_contracts/system_clock.dart b/packages/scheduler_core/lib/src/time_contracts/system_clock.dart new file mode 100644 index 0000000..bc40c42 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/system_clock.dart @@ -0,0 +1,10 @@ +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 { + const SystemClock(); + + @override + DateTime now() => DateTime.now(); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart new file mode 100644 index 0000000..aaf01d9 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +class TimeZoneResolutionOptions { + const TimeZoneResolutionOptions({ + this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward, + this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier, + }); + + final NonexistentLocalTimePolicy nonexistentLocalTimePolicy; + final RepeatedLocalTimePolicy repeatedLocalTimePolicy; +} diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart new file mode 100644 index 0000000..702e2a8 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart @@ -0,0 +1,11 @@ +part of '../time_contracts.dart'; + +/// 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, + }); +} diff --git a/packages/scheduler_core/lib/src/time_contracts/wall_time.dart b/packages/scheduler_core/lib/src/time_contracts/wall_time.dart new file mode 100644 index 0000000..4c2fa64 --- /dev/null +++ b/packages/scheduler_core/lib/src/time_contracts/wall_time.dart @@ -0,0 +1,57 @@ +part of '../time_contracts.dart'; + +/// 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(); +} diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 4f463ab..99ce16b 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -10,456 +10,11 @@ library; 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.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; -} - -/// 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!, - ]; - } -} - -/// Converts domain tasks into Today timeline items. -class TimelineItemMapper { - TimelineItemMapper({ - Map projectColorTokensById = const {}, - }) : projectColorTokensById = - Map.unmodifiable(projectColorTokensById); - - /// Project id to border color token lookup. - final Map projectColorTokensById; - - /// Build a mapper from project profiles. - factory TimelineItemMapper.fromProjects(Iterable projects) { - return TimelineItemMapper( - projectColorTokensById: { - for (final project in projects) project.id: project.colorKey, - }, - ); - } - - /// Convert [task] into framework-neutral timeline view state. - TimelineItem fromTask(Task task) { - return TimelineItem( - id: task.id, - displayTitle: task.title, - taskType: task.type, - projectColorToken: _projectColorTokenFor(task.projectId), - backgroundToken: _backgroundTokenFor(task.type), - rewardIconToken: _rewardIconTokenFor(task.reward), - difficultyIconToken: _difficultyIconTokenFor(task.difficulty), - showsExplicitTime: _showsExplicitTime(task.type), - start: task.scheduledStart, - end: task.scheduledEnd, - completedAt: task.completedAt, - durationMinutes: _durationMinutesFor(task), - quickActions: _quickActionsFor(task.type), - category: task.isLocked - ? TimelineItemCategory.overlay - : TimelineItemCategory.taskCard, - ); - } - - /// Convert a concrete locked-time occurrence into an overlay item. - /// - /// Hidden locked time returns null unless [revealHiddenLockedBlocks] is true. - /// This keeps locked time available to scheduling while leaving UI visibility - /// controlled by an explicit temporary reveal state. - TimelineItem? fromLockedOccurrence( - LockedBlockOccurrence occurrence, { - required bool revealHiddenLockedBlocks, - CivilDate? occurrenceDate, - }) { - if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { - return null; - } - - return TimelineItem( - id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), - displayTitle: occurrence.name, - taskType: TaskType.locked, - projectColorToken: _projectColorTokenFor(occurrence.projectId), - backgroundToken: TimelineBackgroundToken.locked, - rewardIconToken: TimelineRewardIconToken.notSet, - difficultyIconToken: TimelineDifficultyIconToken.notSet, - showsExplicitTime: true, - start: occurrence.interval.start, - end: occurrence.interval.end, - quickActions: const [], - category: TimelineItemCategory.overlay, - ); - } - - /// Build manual compact-mode state from scheduled task data. - CompactTimelineState compactStateForTasks({ - required Iterable tasks, - required DateTime now, - required bool manualCompactModeEnabled, - bool includeNextFlexibleTask = false, - bool fullTimelineExpanded = false, - }) { - if (!manualCompactModeEnabled) { - return CompactTimelineState( - manualCompactModeEnabled: false, - fullTimelineExpanded: fullTimelineExpanded, - ); - } - - final timelineTasks = tasks.where(_canAppearInCompactMode).toList() - ..sort(_compareTasksByStart); - final currentTask = timelineTasks - .where((task) { - return task.status == TaskStatus.active || _containsTime(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ); - final nextRequiredTask = timelineTasks - .where((task) { - return task.isRequiredVisible && _startsAtOrAfter(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ); - final nextFlexibleTask = includeNextFlexibleTask - ? timelineTasks - .where((task) { - return task.isFlexible && - task.id != currentTask?.id && - _startsAtOrAfter(task, now); - }) - .cast() - .firstWhere( - (task) => task != null, - orElse: () => null, - ) - : null; - - return CompactTimelineState( - manualCompactModeEnabled: true, - fullTimelineExpanded: fullTimelineExpanded, - currentItem: currentTask == null ? null : fromTask(currentTask), - nextRequiredItem: - nextRequiredTask == null ? null : fromTask(nextRequiredTask), - nextFlexibleItem: - nextFlexibleTask == null ? null : fromTask(nextFlexibleTask), - ); - } - - bool _canAppearInCompactMode(Task task) { - return !task.isLocked && - task.status != TaskStatus.backlog && - task.status != TaskStatus.completed && - task.status != TaskStatus.cancelled && - task.status != TaskStatus.noLongerRelevant && - task.scheduledStart != null && - task.scheduledEnd != null; - } - - bool _containsTime(Task task, DateTime now) { - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return false; - } - return !now.isBefore(start) && now.isBefore(end); - } - - bool _startsAtOrAfter(Task task, DateTime now) { - final start = task.scheduledStart; - return start != null && !start.isBefore(now); - } - - int _compareTasksByStart(Task left, Task right) { - final leftStart = left.scheduledStart; - final rightStart = right.scheduledStart; - if (leftStart == null && rightStart == null) { - return 0; - } - if (leftStart == null) { - return 1; - } - if (rightStart == null) { - return -1; - } - return leftStart.compareTo(rightStart); - } - - String _lockedOccurrenceId( - LockedBlockOccurrence occurrence, { - CivilDate? occurrenceDate, - }) { - final datePart = - occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}'; - final lockedBlockId = occurrence.lockedBlockId; - if (lockedBlockId != null) { - return 'locked-block:$lockedBlockId${datePart ?? ''}'; - } - final overrideId = occurrence.overrideId; - if (overrideId != null) { - return 'locked-override:$overrideId${datePart ?? ''}'; - } - return 'locked:${occurrence.name}:' - '${occurrence.interval.start.toIso8601String()}'; - } - - String _projectColorTokenFor(String? projectId) { - if (projectId == null) { - return 'locked'; - } - return projectColorTokensById[projectId] ?? projectId; - } - - bool _showsExplicitTime(TaskType type) { - return type == TaskType.inflexible || - type == TaskType.critical || - type == TaskType.locked; - } - - int? _durationMinutesFor(Task task) { - if (!task.isFlexible) { - return null; - } - if (task.durationMinutes != null) { - return task.durationMinutes; - } - final start = task.scheduledStart; - final end = task.scheduledEnd; - if (start == null || end == null) { - return null; - } - return end.difference(start).inMinutes; - } - - List _quickActionsFor(TaskType type) { - switch (type) { - case TaskType.flexible: - return const [ - TimelineQuickAction.done, - TimelineQuickAction.push, - TimelineQuickAction.backlog, - TimelineQuickAction.breakUp, - ]; - case TaskType.inflexible: - case TaskType.critical: - return const [ - TimelineQuickAction.done, - TimelineQuickAction.missed, - TimelineQuickAction.cancel, - TimelineQuickAction.noLongerRelevant, - ]; - case TaskType.locked: - case TaskType.surprise: - case TaskType.freeSlot: - return const []; - } - } - - TimelineBackgroundToken _backgroundTokenFor(TaskType type) { - switch (type) { - case TaskType.flexible: - return TimelineBackgroundToken.flexible; - case TaskType.inflexible: - return TimelineBackgroundToken.inflexible; - case TaskType.critical: - return TimelineBackgroundToken.critical; - case TaskType.locked: - return TimelineBackgroundToken.locked; - case TaskType.surprise: - return TimelineBackgroundToken.surprise; - case TaskType.freeSlot: - return TimelineBackgroundToken.freeSlot; - } - } - - TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) { - switch (reward) { - case RewardLevel.notSet: - return TimelineRewardIconToken.notSet; - case RewardLevel.veryLow: - return TimelineRewardIconToken.veryLow; - case RewardLevel.low: - return TimelineRewardIconToken.low; - case RewardLevel.medium: - return TimelineRewardIconToken.medium; - case RewardLevel.high: - return TimelineRewardIconToken.high; - case RewardLevel.veryHigh: - return TimelineRewardIconToken.veryHigh; - } - } - - TimelineDifficultyIconToken _difficultyIconTokenFor( - DifficultyLevel difficulty, - ) { - switch (difficulty) { - case DifficultyLevel.notSet: - return TimelineDifficultyIconToken.notSet; - case DifficultyLevel.veryEasy: - return TimelineDifficultyIconToken.veryEasy; - case DifficultyLevel.easy: - return TimelineDifficultyIconToken.easy; - case DifficultyLevel.medium: - return TimelineDifficultyIconToken.medium; - case DifficultyLevel.hard: - return TimelineDifficultyIconToken.hard; - case DifficultyLevel.veryHard: - return TimelineDifficultyIconToken.veryHard; - } - } -} +part 'timeline_state/timeline_item_category.dart'; +part 'timeline_state/timeline_background_token.dart'; +part 'timeline_state/timeline_reward_icon_token.dart'; +part 'timeline_state/timeline_difficulty_icon_token.dart'; +part 'timeline_state/timeline_quick_action.dart'; +part 'timeline_state/timeline_item.dart'; +part 'timeline_state/compact_timeline_state.dart'; +part 'timeline_state/timeline_item_mapper.dart'; diff --git a/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart new file mode 100644 index 0000000..16afa31 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart @@ -0,0 +1,48 @@ +part of '../timeline_state.dart'; + +/// 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!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart new file mode 100644 index 0000000..4250b84 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Background style token derived from the scheduling behavior type. +enum TimelineBackgroundToken { + flexible, + inflexible, + critical, + locked, + surprise, + freeSlot, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart new file mode 100644 index 0000000..1b12185 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Difficulty icon token for the second timeline card icon. +enum TimelineDifficultyIconToken { + notSet, + veryEasy, + easy, + medium, + hard, + veryHard, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart new file mode 100644 index 0000000..db8ce65 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart @@ -0,0 +1,63 @@ +part of '../timeline_state.dart'; + +/// 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.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/timeline_state/timeline_item_category.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart new file mode 100644 index 0000000..39e4a49 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart @@ -0,0 +1,10 @@ +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/timeline_state/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart new file mode 100644 index 0000000..ffd9ab6 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart @@ -0,0 +1,295 @@ +part of '../timeline_state.dart'; + +/// Converts domain tasks into Today timeline items. +class TimelineItemMapper { + TimelineItemMapper({ + Map projectColorTokensById = const {}, + }) : projectColorTokensById = + Map.unmodifiable(projectColorTokensById); + + /// Project id to border color token lookup. + final Map projectColorTokensById; + + /// Build a mapper from project profiles. + factory TimelineItemMapper.fromProjects(Iterable projects) { + return TimelineItemMapper( + projectColorTokensById: { + for (final project in projects) project.id: project.colorKey, + }, + ); + } + + /// Convert [task] into framework-neutral timeline view state. + TimelineItem fromTask(Task task) { + return TimelineItem( + id: task.id, + displayTitle: task.title, + taskType: task.type, + projectColorToken: _projectColorTokenFor(task.projectId), + backgroundToken: _backgroundTokenFor(task.type), + rewardIconToken: _rewardIconTokenFor(task.reward), + difficultyIconToken: _difficultyIconTokenFor(task.difficulty), + showsExplicitTime: _showsExplicitTime(task.type), + start: task.scheduledStart, + end: task.scheduledEnd, + completedAt: task.completedAt, + durationMinutes: _durationMinutesFor(task), + quickActions: _quickActionsFor(task.type), + category: task.isLocked + ? TimelineItemCategory.overlay + : TimelineItemCategory.taskCard, + ); + } + + /// Convert a concrete locked-time occurrence into an overlay item. + /// + /// Hidden locked time returns null unless [revealHiddenLockedBlocks] is true. + /// This keeps locked time available to scheduling while leaving UI visibility + /// controlled by an explicit temporary reveal state. + TimelineItem? fromLockedOccurrence( + LockedBlockOccurrence occurrence, { + required bool revealHiddenLockedBlocks, + CivilDate? occurrenceDate, + }) { + if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { + return null; + } + + return TimelineItem( + id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), + displayTitle: occurrence.name, + taskType: TaskType.locked, + projectColorToken: _projectColorTokenFor(occurrence.projectId), + backgroundToken: TimelineBackgroundToken.locked, + rewardIconToken: TimelineRewardIconToken.notSet, + difficultyIconToken: TimelineDifficultyIconToken.notSet, + showsExplicitTime: true, + start: occurrence.interval.start, + end: occurrence.interval.end, + quickActions: const [], + category: TimelineItemCategory.overlay, + ); + } + + /// Build manual compact-mode state from scheduled task data. + CompactTimelineState compactStateForTasks({ + required Iterable tasks, + required DateTime now, + required bool manualCompactModeEnabled, + bool includeNextFlexibleTask = false, + bool fullTimelineExpanded = false, + }) { + if (!manualCompactModeEnabled) { + return CompactTimelineState( + manualCompactModeEnabled: false, + fullTimelineExpanded: fullTimelineExpanded, + ); + } + + final timelineTasks = tasks.where(_canAppearInCompactMode).toList() + ..sort(_compareTasksByStart); + final currentTask = timelineTasks + .where((task) { + return task.status == TaskStatus.active || _containsTime(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextRequiredTask = timelineTasks + .where((task) { + return task.isRequiredVisible && _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ); + final nextFlexibleTask = includeNextFlexibleTask + ? timelineTasks + .where((task) { + return task.isFlexible && + task.id != currentTask?.id && + _startsAtOrAfter(task, now); + }) + .cast() + .firstWhere( + (task) => task != null, + orElse: () => null, + ) + : null; + + return CompactTimelineState( + manualCompactModeEnabled: true, + fullTimelineExpanded: fullTimelineExpanded, + currentItem: currentTask == null ? null : fromTask(currentTask), + nextRequiredItem: + nextRequiredTask == null ? null : fromTask(nextRequiredTask), + nextFlexibleItem: + nextFlexibleTask == null ? null : fromTask(nextFlexibleTask), + ); + } + + bool _canAppearInCompactMode(Task task) { + return !task.isLocked && + task.status != TaskStatus.backlog && + task.status != TaskStatus.completed && + task.status != TaskStatus.cancelled && + task.status != TaskStatus.noLongerRelevant && + task.scheduledStart != null && + task.scheduledEnd != null; + } + + bool _containsTime(Task task, DateTime now) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return !now.isBefore(start) && now.isBefore(end); + } + + bool _startsAtOrAfter(Task task, DateTime now) { + final start = task.scheduledStart; + return start != null && !start.isBefore(now); + } + + int _compareTasksByStart(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart == null && rightStart == null) { + return 0; + } + if (leftStart == null) { + return 1; + } + if (rightStart == null) { + return -1; + } + return leftStart.compareTo(rightStart); + } + + String _lockedOccurrenceId( + LockedBlockOccurrence occurrence, { + CivilDate? occurrenceDate, + }) { + final datePart = + occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}'; + final lockedBlockId = occurrence.lockedBlockId; + if (lockedBlockId != null) { + return 'locked-block:$lockedBlockId${datePart ?? ''}'; + } + final overrideId = occurrence.overrideId; + if (overrideId != null) { + return 'locked-override:$overrideId${datePart ?? ''}'; + } + return 'locked:${occurrence.name}:' + '${occurrence.interval.start.toIso8601String()}'; + } + + String _projectColorTokenFor(String? projectId) { + if (projectId == null) { + return 'locked'; + } + return projectColorTokensById[projectId] ?? projectId; + } + + bool _showsExplicitTime(TaskType type) { + return type == TaskType.inflexible || + type == TaskType.critical || + type == TaskType.locked; + } + + int? _durationMinutesFor(Task task) { + if (!task.isFlexible) { + return null; + } + if (task.durationMinutes != null) { + return task.durationMinutes; + } + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return null; + } + return end.difference(start).inMinutes; + } + + List _quickActionsFor(TaskType type) { + switch (type) { + case TaskType.flexible: + return const [ + TimelineQuickAction.done, + TimelineQuickAction.push, + TimelineQuickAction.backlog, + TimelineQuickAction.breakUp, + ]; + case TaskType.inflexible: + case TaskType.critical: + return const [ + TimelineQuickAction.done, + TimelineQuickAction.missed, + TimelineQuickAction.cancel, + TimelineQuickAction.noLongerRelevant, + ]; + case TaskType.locked: + case TaskType.surprise: + case TaskType.freeSlot: + return const []; + } + } + + TimelineBackgroundToken _backgroundTokenFor(TaskType type) { + switch (type) { + case TaskType.flexible: + return TimelineBackgroundToken.flexible; + case TaskType.inflexible: + return TimelineBackgroundToken.inflexible; + case TaskType.critical: + return TimelineBackgroundToken.critical; + case TaskType.locked: + return TimelineBackgroundToken.locked; + case TaskType.surprise: + return TimelineBackgroundToken.surprise; + case TaskType.freeSlot: + return TimelineBackgroundToken.freeSlot; + } + } + + TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) { + switch (reward) { + case RewardLevel.notSet: + return TimelineRewardIconToken.notSet; + case RewardLevel.veryLow: + return TimelineRewardIconToken.veryLow; + case RewardLevel.low: + return TimelineRewardIconToken.low; + case RewardLevel.medium: + return TimelineRewardIconToken.medium; + case RewardLevel.high: + return TimelineRewardIconToken.high; + case RewardLevel.veryHigh: + return TimelineRewardIconToken.veryHigh; + } + } + + TimelineDifficultyIconToken _difficultyIconTokenFor( + DifficultyLevel difficulty, + ) { + switch (difficulty) { + case DifficultyLevel.notSet: + return TimelineDifficultyIconToken.notSet; + case DifficultyLevel.veryEasy: + return TimelineDifficultyIconToken.veryEasy; + case DifficultyLevel.easy: + return TimelineDifficultyIconToken.easy; + case DifficultyLevel.medium: + return TimelineDifficultyIconToken.medium; + case DifficultyLevel.hard: + return TimelineDifficultyIconToken.hard; + case DifficultyLevel.veryHard: + return TimelineDifficultyIconToken.veryHard; + } + } +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart new file mode 100644 index 0000000..f962616 --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart @@ -0,0 +1,12 @@ +part of '../timeline_state.dart'; + +/// Quick actions the timeline can expose without depending on a UI framework. +enum TimelineQuickAction { + done, + push, + backlog, + breakUp, + missed, + cancel, + noLongerRelevant, +} diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart new file mode 100644 index 0000000..307a6cc --- /dev/null +++ b/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart @@ -0,0 +1,11 @@ +part of '../timeline_state.dart'; + +/// Reward icon token for the first timeline card icon. +enum TimelineRewardIconToken { + notSet, + veryLow, + low, + medium, + high, + veryHigh, +} diff --git a/packages/scheduler_core/lib/src/today_state.dart b/packages/scheduler_core/lib/src/today_state.dart index 6e25d1e..6a6580b 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state.dart @@ -14,644 +14,10 @@ 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; -} - -/// Builds Today state from application repositories. -class GetTodayStateQuery { - const GetTodayStateQuery({ - required this.applicationStore, - required this.timeZoneResolver, - this.resolutionOptions = const TimeZoneResolutionOptions(), - }); - - /// Read boundary for repository access. - final ApplicationUnitOfWork applicationStore; - - /// Explicit local-time resolver. - final TimeZoneResolver timeZoneResolver; - - /// DST/nonexistent/repeated local time policy. - final TimeZoneResolutionOptions resolutionOptions; - - /// Execute the read-only Today query. - Future> execute( - GetTodayStateRequest request, - ) { - return applicationStore.read( - action: (repositories) async { - final context = request.context; - final ownerId = context.ownerTimeZone.ownerId; - final storedSettings = - await repositories.ownerSettings.findByOwnerId(ownerId); - final settings = storedSettings ?? - OwnerSettings( - ownerId: ownerId, - timeZoneId: context.ownerTimeZone.timeZoneId, - ); - final timeZoneId = settings.timeZoneId; - final dayStart = _resolve( - date: request.date, - wallTime: settings.dayStart, - timeZoneId: timeZoneId, - ); - final dayEnd = _resolve( - date: request.date, - wallTime: settings.dayEnd, - timeZoneId: timeZoneId, - ); - final window = SchedulingWindow(start: dayStart, end: dayEnd); - - final projects = (await repositories.projects.findByOwner( - ownerId: ownerId, - includeArchived: true, - )) - .items; - final tasks = (await repositories.tasks.findForLocalDay( - ownerId: ownerId, - localDate: request.date, - window: window, - )) - .items; - final blocks = (await repositories.lockedBlocks.findBlocksByOwner( - ownerId: ownerId, - )) - .items; - final overrides = (await repositories.lockedBlocks.findOverridesForDate( - ownerId: ownerId, - date: request.date, - )) - .items; - final snapshots = - await repositories.schedulingSnapshots.findInWindow(window); - final acknowledgedNoticeIds = - (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) - .map((record) => record.noticeId) - .toSet(); - - final lockedExpansion = expandLockedBlocksForDay( - blocks: blocks, - overrides: overrides, - date: request.date, - timeZoneId: timeZoneId, - timeZoneResolver: timeZoneResolver, - resolutionOptions: resolutionOptions, - ); - final mapper = TimelineItemMapper.fromProjects(projects); - final baseItems = [ - for (final task in tasks) - if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks)) - TodayTimelineItem( - item: mapper.fromTask(task), - source: TodayTimelineItemSource.task, - taskStatus: task.status, - taskId: task.id, - hiddenByDefault: task.isLocked, - ), - for (final occurrence in lockedExpansion.occurrences) - if (mapper.fromLockedOccurrence( - occurrence, - revealHiddenLockedBlocks: request.revealHiddenLockedBlocks, - occurrenceDate: request.date, - ) - case final lockedItem?) - TodayTimelineItem( - item: lockedItem, - source: TodayTimelineItemSource.lockedOccurrence, - lockedBlockId: occurrence.lockedBlockId, - lockedOverrideId: occurrence.overrideId, - hiddenByDefault: occurrence.hiddenByDefault, - ), - ]..sort(_compareTodayItems); - - final current = _currentItem(baseItems, context.now); - final nextRequired = _nextRequiredItem(baseItems, context.now); - final nextFlexible = request.includeNextFlexibleItem - ? _nextFlexibleItem( - baseItems, - context.now, - currentTaskId: current?.taskId, - ) - : null; - final selectedItems = baseItems - .map( - (item) => item.copyWith( - isCurrent: item.id == current?.id, - isNextRequired: item.id == nextRequired?.id, - isNextFlexible: item.id == nextFlexible?.id, - ), - ) - .toList(growable: false); - - final selectedCurrent = _findById(selectedItems, current?.id); - final selectedNextRequired = _findById(selectedItems, nextRequired?.id); - final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); - final pendingNotices = _pendingNoticesFromSnapshots( - snapshots, - acknowledgedNoticeIds: acknowledgedNoticeIds, - ); - - return ApplicationResult.success( - TodayState( - date: request.date, - ownerId: ownerId, - timeZoneId: timeZoneId, - readAt: context.now, - dayStart: dayStart, - dayEnd: dayEnd, - ownerSettings: settings, - timelineItems: selectedItems, - currentItem: selectedCurrent, - nextRequiredItem: selectedNextRequired, - nextFlexibleItem: selectedNextFlexible, - compactState: TodayCompactState( - manualCompactModeEnabled: settings.compactModeEnabled, - fullTimelineExpanded: request.fullTimelineExpanded, - currentItem: selectedCurrent, - nextRequiredItem: selectedNextRequired, - nextFlexibleItem: selectedNextFlexible, - ), - pendingNotices: pendingNotices, - ), - ); - }, - ); - } - - DateTime _resolve({ - required CivilDate date, - required WallTime wallTime, - required String timeZoneId, - }) { - return timeZoneResolver - .resolve( - date: date, - wallTime: wallTime, - timeZoneId: timeZoneId, - options: resolutionOptions, - ) - .instant; - } - - static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) { - if (task.status == TaskStatus.backlog || - task.status == TaskStatus.cancelled || - task.status == TaskStatus.noLongerRelevant) { - return false; - } - if (task.isLocked && !revealHiddenLockedBlocks) { - return false; - } - - return task.scheduledStart != null && task.scheduledEnd != null; - } - - static TodayTimelineItem? _currentItem( - List items, - DateTime now, - ) { - final active = _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskStatus == TaskStatus.active; - }), - ); - if (active != null) { - return active; - } - - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskStatus == TaskStatus.planned && - _containsTime(item, now); - }), - ); - } - - static TodayTimelineItem? _nextRequiredItem( - List items, - DateTime now, - ) { - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - _isActionable(item.taskStatus) && - _isRequired(item.taskType) && - _startsAtOrAfter(item, now); - }), - ); - } - - static TodayTimelineItem? _nextFlexibleItem( - List items, - DateTime now, { - required String? currentTaskId, - }) { - return _firstOrNull( - items.where((item) { - return item.source == TodayTimelineItemSource.task && - item.taskId != currentTaskId && - _isActionable(item.taskStatus) && - item.taskType == TaskType.flexible && - _startsAtOrAfter(item, now); - }), - ); - } - - static bool _isActionable(TaskStatus? status) { - return status == TaskStatus.planned || status == TaskStatus.active; - } - - static bool _isRequired(TaskType type) { - return type == TaskType.inflexible || type == TaskType.critical; - } - - static bool _containsTime(TodayTimelineItem item, DateTime now) { - final start = item.start; - final end = item.end; - if (start == null || end == null) { - return false; - } - - return !now.isBefore(start) && now.isBefore(end); - } - - static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) { - final start = item.start; - return start != null && !start.isBefore(now); - } - - static TodayTimelineItem? _findById( - List items, - String? id, - ) { - if (id == null) { - return null; - } - - return _firstOrNull(items.where((item) => item.id == id)); - } - - static List _pendingNoticesFromSnapshots( - List snapshots, { - Set acknowledgedNoticeIds = const {}, - }) { - final sortedSnapshots = [...snapshots]..sort((a, b) { - final capturedComparison = a.capturedAt.compareTo(b.capturedAt); - if (capturedComparison != 0) { - return capturedComparison; - } - - return a.id.compareTo(b.id); - }); - final notices = []; - - for (final snapshot in sortedSnapshots) { - for (var index = 0; index < snapshot.notices.length; index += 1) { - final noticeId = _noticeId(snapshot.id, index); - if (acknowledgedNoticeIds.contains(noticeId)) { - continue; - } - final notice = snapshot.notices[index]; - notices.add( - TodayPendingNotice( - noticeId: noticeId, - snapshotId: snapshot.id, - capturedAt: snapshot.capturedAt, - message: notice.message, - type: notice.type, - taskId: notice.taskId, - issueCode: notice.issueCode, - movementCode: notice.movementCode, - conflictCode: notice.conflictCode, - parameters: notice.parameters, - ), - ); - } - } - - return List.unmodifiable(notices); - } -} - -String schedulingNoticeId({ - required String snapshotId, - required int noticeIndex, -}) { - return _noticeId(snapshotId, noticeIndex); -} - -String _noticeId(String snapshotId, int noticeIndex) { - return '$snapshotId:$noticeIndex'; -} - -int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { - final startComparison = _compareNullableInstants(left.start, right.start); - if (startComparison != 0) { - return startComparison; - } - - final endComparison = _compareNullableInstants(left.end, right.end); - if (endComparison != 0) { - return endComparison; - } - - final sourceComparison = left.source.index.compareTo(right.source.index); - if (sourceComparison != 0) { - return sourceComparison; - } - - return left.id.compareTo(right.id); -} - -int _compareNullableInstants(DateTime? left, DateTime? right) { - if (left == null && right == null) { - return 0; - } - if (left == null) { - return 1; - } - if (right == null) { - return -1; - } - - return left.compareTo(right); -} - -T? _firstOrNull(Iterable values) { - final iterator = values.iterator; - if (!iterator.moveNext()) { - return null; - } - - return iterator.current; -} +part 'today_state/today_timeline_item_source.dart'; +part 'today_state/get_today_state_request.dart'; +part 'today_state/today_timeline_item.dart'; +part 'today_state/today_compact_state.dart'; +part 'today_state/today_pending_notice.dart'; +part 'today_state/today_state.dart'; +part 'today_state/get_today_state_query.dart'; diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart b/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart new file mode 100644 index 0000000..1483abd --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart @@ -0,0 +1,372 @@ +part of '../today_state.dart'; + +/// Builds Today state from application repositories. +class GetTodayStateQuery { + const GetTodayStateQuery({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + }); + + /// Read boundary for repository access. + final ApplicationUnitOfWork applicationStore; + + /// Explicit local-time resolver. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + /// Execute the read-only Today query. + Future> execute( + GetTodayStateRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + final storedSettings = + await repositories.ownerSettings.findByOwnerId(ownerId); + final settings = storedSettings ?? + OwnerSettings( + ownerId: ownerId, + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final timeZoneId = settings.timeZoneId; + final dayStart = _resolve( + date: request.date, + wallTime: settings.dayStart, + timeZoneId: timeZoneId, + ); + final dayEnd = _resolve( + date: request.date, + wallTime: settings.dayEnd, + timeZoneId: timeZoneId, + ); + final window = SchedulingWindow(start: dayStart, end: dayEnd); + + final projects = (await repositories.projects.findByOwner( + ownerId: ownerId, + includeArchived: true, + )) + .items; + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: request.date, + window: window, + )) + .items; + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: request.date, + )) + .items; + final snapshots = + await repositories.schedulingSnapshots.findInWindow(window); + final acknowledgedNoticeIds = + (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) + .map((record) => record.noticeId) + .toSet(); + + final lockedExpansion = expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: request.date, + timeZoneId: timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final mapper = TimelineItemMapper.fromProjects(projects); + final baseItems = [ + for (final task in tasks) + if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks)) + TodayTimelineItem( + item: mapper.fromTask(task), + source: TodayTimelineItemSource.task, + taskStatus: task.status, + taskId: task.id, + hiddenByDefault: task.isLocked, + ), + for (final occurrence in lockedExpansion.occurrences) + if (mapper.fromLockedOccurrence( + occurrence, + revealHiddenLockedBlocks: request.revealHiddenLockedBlocks, + occurrenceDate: request.date, + ) + case final lockedItem?) + TodayTimelineItem( + item: lockedItem, + source: TodayTimelineItemSource.lockedOccurrence, + lockedBlockId: occurrence.lockedBlockId, + lockedOverrideId: occurrence.overrideId, + hiddenByDefault: occurrence.hiddenByDefault, + ), + ]..sort(_compareTodayItems); + + final current = _currentItem(baseItems, context.now); + final nextRequired = _nextRequiredItem(baseItems, context.now); + final nextFlexible = request.includeNextFlexibleItem + ? _nextFlexibleItem( + baseItems, + context.now, + currentTaskId: current?.taskId, + ) + : null; + final selectedItems = baseItems + .map( + (item) => item.copyWith( + isCurrent: item.id == current?.id, + isNextRequired: item.id == nextRequired?.id, + isNextFlexible: item.id == nextFlexible?.id, + ), + ) + .toList(growable: false); + + final selectedCurrent = _findById(selectedItems, current?.id); + final selectedNextRequired = _findById(selectedItems, nextRequired?.id); + final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); + final pendingNotices = _pendingNoticesFromSnapshots( + snapshots, + acknowledgedNoticeIds: acknowledgedNoticeIds, + ); + + return ApplicationResult.success( + TodayState( + date: request.date, + ownerId: ownerId, + timeZoneId: timeZoneId, + readAt: context.now, + dayStart: dayStart, + dayEnd: dayEnd, + ownerSettings: settings, + timelineItems: selectedItems, + currentItem: selectedCurrent, + nextRequiredItem: selectedNextRequired, + nextFlexibleItem: selectedNextFlexible, + compactState: TodayCompactState( + manualCompactModeEnabled: settings.compactModeEnabled, + fullTimelineExpanded: request.fullTimelineExpanded, + currentItem: selectedCurrent, + nextRequiredItem: selectedNextRequired, + nextFlexibleItem: selectedNextFlexible, + ), + pendingNotices: pendingNotices, + ), + ); + }, + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + + static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) { + if (task.status == TaskStatus.backlog || + task.status == TaskStatus.cancelled || + task.status == TaskStatus.noLongerRelevant) { + return false; + } + if (task.isLocked && !revealHiddenLockedBlocks) { + return false; + } + + return task.scheduledStart != null && task.scheduledEnd != null; + } + + static TodayTimelineItem? _currentItem( + List items, + DateTime now, + ) { + final active = _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskStatus == TaskStatus.active; + }), + ); + if (active != null) { + return active; + } + + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskStatus == TaskStatus.planned && + _containsTime(item, now); + }), + ); + } + + static TodayTimelineItem? _nextRequiredItem( + List items, + DateTime now, + ) { + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + _isActionable(item.taskStatus) && + _isRequired(item.taskType) && + _startsAtOrAfter(item, now); + }), + ); + } + + static TodayTimelineItem? _nextFlexibleItem( + List items, + DateTime now, { + required String? currentTaskId, + }) { + return _firstOrNull( + items.where((item) { + return item.source == TodayTimelineItemSource.task && + item.taskId != currentTaskId && + _isActionable(item.taskStatus) && + item.taskType == TaskType.flexible && + _startsAtOrAfter(item, now); + }), + ); + } + + static bool _isActionable(TaskStatus? status) { + return status == TaskStatus.planned || status == TaskStatus.active; + } + + static bool _isRequired(TaskType type) { + return type == TaskType.inflexible || type == TaskType.critical; + } + + static bool _containsTime(TodayTimelineItem item, DateTime now) { + final start = item.start; + final end = item.end; + if (start == null || end == null) { + return false; + } + + return !now.isBefore(start) && now.isBefore(end); + } + + static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) { + final start = item.start; + return start != null && !start.isBefore(now); + } + + static TodayTimelineItem? _findById( + List items, + String? id, + ) { + if (id == null) { + return null; + } + + return _firstOrNull(items.where((item) => item.id == id)); + } + + static List _pendingNoticesFromSnapshots( + List snapshots, { + Set acknowledgedNoticeIds = const {}, + }) { + final sortedSnapshots = [...snapshots]..sort((a, b) { + final capturedComparison = a.capturedAt.compareTo(b.capturedAt); + if (capturedComparison != 0) { + return capturedComparison; + } + + return a.id.compareTo(b.id); + }); + final notices = []; + + for (final snapshot in sortedSnapshots) { + for (var index = 0; index < snapshot.notices.length; index += 1) { + final noticeId = _noticeId(snapshot.id, index); + if (acknowledgedNoticeIds.contains(noticeId)) { + continue; + } + final notice = snapshot.notices[index]; + notices.add( + TodayPendingNotice( + noticeId: noticeId, + snapshotId: snapshot.id, + capturedAt: snapshot.capturedAt, + message: notice.message, + type: notice.type, + taskId: notice.taskId, + issueCode: notice.issueCode, + movementCode: notice.movementCode, + conflictCode: notice.conflictCode, + parameters: notice.parameters, + ), + ); + } + } + + return List.unmodifiable(notices); + } +} + +String schedulingNoticeId({ + required String snapshotId, + required int noticeIndex, +}) { + return _noticeId(snapshotId, noticeIndex); +} + +String _noticeId(String snapshotId, int noticeIndex) { + return '$snapshotId:$noticeIndex'; +} + +int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { + final startComparison = _compareNullableInstants(left.start, right.start); + if (startComparison != 0) { + return startComparison; + } + + final endComparison = _compareNullableInstants(left.end, right.end); + if (endComparison != 0) { + return endComparison; + } + + final sourceComparison = left.source.index.compareTo(right.source.index); + if (sourceComparison != 0) { + return sourceComparison; + } + + return left.id.compareTo(right.id); +} + +int _compareNullableInstants(DateTime? left, DateTime? right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + + return left.compareTo(right); +} + +T? _firstOrNull(Iterable values) { + final iterator = values.iterator; + if (!iterator.moveNext()) { + return null; + } + + return iterator.current; +} diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart b/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart new file mode 100644 index 0000000..21f8b0a --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart @@ -0,0 +1,28 @@ +part of '../today_state.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_compact_state.dart b/packages/scheduler_core/lib/src/today_state/today_compact_state.dart new file mode 100644 index 0000000..43ecdeb --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_compact_state.dart @@ -0,0 +1,49 @@ +part of '../today_state.dart'; + +/// 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!, + ]; + } +} diff --git a/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart b/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart new file mode 100644 index 0000000..e685ef4 --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart @@ -0,0 +1,47 @@ +part of '../today_state.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_state.dart b/packages/scheduler_core/lib/src/today_state/today_state.dart new file mode 100644 index 0000000..eccd00b --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_state.dart @@ -0,0 +1,60 @@ +part of '../today_state.dart'; + +/// 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; +} diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart b/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart new file mode 100644 index 0000000..adb65cc --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart @@ -0,0 +1,85 @@ +part of '../today_state.dart'; + +/// 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, + ); + } +} diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart new file mode 100644 index 0000000..4c859a7 --- /dev/null +++ b/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart @@ -0,0 +1,7 @@ +part of '../today_state.dart'; + +/// Source category for one Today timeline item. +enum TodayTimelineItemSource { + task, + lockedOccurrence, +} diff --git a/packages/scheduler_export/lib/src/export_controller.dart b/packages/scheduler_export/lib/src/export_controller.dart index e6cfb1e..119cfc2 100644 --- a/packages/scheduler_export/lib/src/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller.dart @@ -5,302 +5,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/export_context.dart'; +part 'export_controller/export_exception.dart'; +part 'export_controller/export_repository_exception.dart'; +part 'export_controller/export_writer.dart'; +part 'export_controller/export_writer_factory.dart'; +part 'export_controller/export_writer_registry.dart'; +part 'export_controller/export_controller.dart'; +part 'export_controller/export_format_normalizer.dart'; +part 'export_controller/export_json_context_encoder.dart'; +part 'export_controller/json_export_writer.dart'; +part 'export_controller/csv_cell_encoder.dart'; +part 'export_controller/csv_export_writer.dart'; diff --git a/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart b/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart new file mode 100644 index 0000000..bc80de2 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart @@ -0,0 +1,8 @@ +part of '../export_controller.dart'; + +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/csv_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart new file mode 100644 index 0000000..64e1960 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart @@ -0,0 +1,62 @@ +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); + + 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.'); + } + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_context.dart b/packages/scheduler_export/lib/src/export_controller/export_context.dart new file mode 100644 index 0000000..ae3453f --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_context.dart @@ -0,0 +1,20 @@ +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/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/export_controller.dart new file mode 100644 index 0000000..eeb5616 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_controller.dart @@ -0,0 +1,58 @@ +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(); + + 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(); + } +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_exception.dart b/packages/scheduler_export/lib/src/export_controller/export_exception.dart new file mode 100644 index 0000000..6e27cdf --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_exception.dart @@ -0,0 +1,13 @@ +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; + + @override + String toString() => 'ExportException: $message'; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart b/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart new file mode 100644 index 0000000..fe42e5e --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart @@ -0,0 +1,13 @@ +part of '../export_controller.dart'; + +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; +} diff --git a/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart b/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart new file mode 100644 index 0000000..bea5baf --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart @@ -0,0 +1,9 @@ +part of '../export_controller.dart'; + +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/export_repository_exception.dart b/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart new file mode 100644 index 0000000..774a2ab --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart @@ -0,0 +1,11 @@ +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/export_writer.dart b/packages/scheduler_export/lib/src/export_controller/export_writer.dart new file mode 100644 index 0000000..14a332a --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer.dart @@ -0,0 +1,13 @@ +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/export_writer_factory.dart b/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart new file mode 100644 index 0000000..5033de4 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart @@ -0,0 +1,4 @@ +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/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart new file mode 100644 index 0000000..432c74a --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart @@ -0,0 +1,44 @@ +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), + ), + ); + + 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/lib/src/export_controller/json_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart new file mode 100644 index 0000000..26caaa3 --- /dev/null +++ b/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart @@ -0,0 +1,56 @@ +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); + + 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.'); + } + } +} 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..3b9c427 100644 --- a/packages/scheduler_export_json/lib/src/json_csv_writers.dart +++ b/packages/scheduler_export_json/lib/src/json_csv_writers.dart @@ -5,6 +5,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 +19,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..b439dd6 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_cell_encoder.dart @@ -0,0 +1,8 @@ +part of '../json_csv_writers.dart'; + +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..4b25b1d --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/csv_export_writer.dart @@ -0,0 +1,62 @@ +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); + + 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.'); + } + } +} 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..98393a4 --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/export_json_context_encoder.dart @@ -0,0 +1,9 @@ +part of '../json_csv_writers.dart'; + +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..df3a45d --- /dev/null +++ b/packages/scheduler_export_json/lib/src/json_csv_writers/json_export_writer.dart @@ -0,0 +1,57 @@ +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); + + 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.'); + } + } +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter.dart index 93b7e68..03efdaa 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -2,152 +2,9 @@ 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/notification_request.dart'; +part 'notification_adapter/notification_request_impl.dart'; +part 'notification_adapter/notification_feedback_type.dart'; +part 'notification_adapter/notification_feedback.dart'; +part 'notification_adapter/notification_adapter.dart'; +part 'notification_adapter/fake_notification_adapter.dart'; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart new file mode 100644 index 0000000..3c7d78d --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart @@ -0,0 +1,50 @@ +part of '../notification_adapter.dart'; + +/// 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; +} diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart new file mode 100644 index 0000000..e1e9573 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart @@ -0,0 +1,13 @@ +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/notification_feedback.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart new file mode 100644 index 0000000..acb691c --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart @@ -0,0 +1,24 @@ +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/notification_feedback_type.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart new file mode 100644 index 0000000..188e536 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart @@ -0,0 +1,13 @@ +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/notification_request.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart new file mode 100644 index 0000000..45a3629 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart @@ -0,0 +1,44 @@ +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, + ); + } + + 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/notification_request_impl.dart b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart new file mode 100644 index 0000000..cc02c40 --- /dev/null +++ b/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart @@ -0,0 +1,11 @@ +part of '../notification_adapter.dart'; + +final class _NotificationRequest extends NotificationRequest { + const _NotificationRequest({ + required super.id, + required super.title, + required super.body, + required super.scheduledDateTimeUtc, + required super.payloadJson, + }) : super._(); +} 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..62ddffc 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 @@ -6,226 +6,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/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_io/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_io/desktop_process_runner.dart'; +part 'desktop_notification_adapter_io/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_io/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_io/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_io/desktop_notification_platform_detector.dart'; +part 'desktop_notification_adapter_io/linux_notify_send_backend.dart'; +part 'desktop_notification_adapter_io/apple_script_string_encoder.dart'; +part 'desktop_notification_adapter_io/mac_os_notification_backend.dart'; +part 'desktop_notification_adapter_io/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart new file mode 100644 index 0000000..f2f98d1 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart @@ -0,0 +1,5 @@ +part of '../desktop_notification_adapter_io.dart'; + +String _appleScriptString(String value) { + return jsonEncode(value); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart new file mode 100644 index 0000000..4b0ef01 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart @@ -0,0 +1,38 @@ +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; + + 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()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart new file mode 100644 index 0000000..d14f54c --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart @@ -0,0 +1,13 @@ +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/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..1d8c0e8 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart @@ -0,0 +1,28 @@ +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/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart new file mode 100644 index 0000000..2ddde83 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart @@ -0,0 +1,4 @@ +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/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart new file mode 100644 index 0000000..4ff6301 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart @@ -0,0 +1,16 @@ +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/desktop_notification_platform_detector.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart new file mode 100644 index 0000000..62e5db8 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart @@ -0,0 +1,8 @@ +part of '../desktop_notification_adapter_io.dart'; + +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_io/desktop_process_runner.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart new file mode 100644 index 0000000..c6cf5e6 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart @@ -0,0 +1,7 @@ +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/linux_notify_send_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart new file mode 100644 index 0000000..98d1270 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart @@ -0,0 +1,48 @@ +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; + + 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); + } + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart new file mode 100644 index 0000000..15bc7ca --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart @@ -0,0 +1,36 @@ +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; + + 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); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart new file mode 100644 index 0000000..b589ef7 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart @@ -0,0 +1,31 @@ +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; + + 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()}'); + } +} 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..d55f233 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 @@ -4,117 +4,10 @@ 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/desktop_notification_platform.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_log_sink.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_backend.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_backend_factory.dart'; +part 'desktop_notification_adapter_stub/desktop_notification_adapter.dart'; +part 'desktop_notification_adapter_stub/default_log_sink.dart'; +part 'desktop_notification_adapter_stub/stdout_notification_backend.dart'; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart new file mode 100644 index 0000000..c976700 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart @@ -0,0 +1,6 @@ +part of '../desktop_notification_adapter_stub.dart'; + +void _defaultLogSink(String line) { + // ignore: avoid_print + print(line); +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart new file mode 100644 index 0000000..2212ef2 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart @@ -0,0 +1,38 @@ +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; + + 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()); + } +} diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart new file mode 100644 index 0000000..29d13b7 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart @@ -0,0 +1,13 @@ +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/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart new file mode 100644 index 0000000..a40b4cd --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart @@ -0,0 +1,13 @@ +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/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart new file mode 100644 index 0000000..6fc66d4 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart @@ -0,0 +1,4 @@ +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/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart new file mode 100644 index 0000000..e18fe86 --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart @@ -0,0 +1,16 @@ +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/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart new file mode 100644 index 0000000..0bd2d9a --- /dev/null +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart @@ -0,0 +1,31 @@ +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; + + 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()}'); + } +} diff --git a/packages/scheduler_persistence/lib/persistence.dart b/packages/scheduler_persistence/lib/persistence.dart index 55ebd67..47b3b22 100644 --- a/packages/scheduler_persistence/lib/persistence.dart +++ b/packages/scheduler_persistence/lib/persistence.dart @@ -7,319 +7,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/repository_failure.dart'; +part 'persistence/repository_not_found.dart'; +part 'persistence/repository_duplicate_id.dart'; +part 'persistence/repository_invalid_revision.dart'; +part 'persistence/repository_stale_revision.dart'; +part 'persistence/repository_owner_mismatch.dart'; +part 'persistence/repository_storage_failure.dart'; +part 'persistence/either.dart'; +part 'persistence/left.dart'; +part 'persistence/right.dart'; +part 'persistence/repo_result.dart'; +part 'persistence/task_repository.dart'; +part 'persistence/project_repository.dart'; +part 'persistence/locked_block_repository.dart'; +part 'persistence/settings_repository.dart'; +part 'persistence/schedule_snapshot_repository.dart'; diff --git a/packages/scheduler_persistence/lib/persistence/either.dart b/packages/scheduler_persistence/lib/persistence/either.dart new file mode 100644 index 0000000..dca6ede --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/either.dart @@ -0,0 +1,24 @@ +part of '../persistence.dart'; + +/// 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, + }; +} diff --git a/packages/scheduler_persistence/lib/persistence/left.dart b/packages/scheduler_persistence/lib/persistence/left.dart new file mode 100644 index 0000000..e045e6f --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/left.dart @@ -0,0 +1,10 @@ +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/locked_block_repository.dart b/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart new file mode 100644 index 0000000..6f958c3 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart @@ -0,0 +1,64 @@ +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/project_repository.dart b/packages/scheduler_persistence/lib/persistence/project_repository.dart new file mode 100644 index 0000000..36c6f09 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/project_repository.dart @@ -0,0 +1,38 @@ +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/repo_result.dart b/packages/scheduler_persistence/lib/persistence/repo_result.dart new file mode 100644 index 0000000..62d9528 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repo_result.dart @@ -0,0 +1,4 @@ +part of '../persistence.dart'; + +/// Repository result wrapper for expected adapter outcomes. +typedef RepoResult = Either; diff --git a/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart b/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart new file mode 100644 index 0000000..2f48537 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart @@ -0,0 +1,7 @@ +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/repository_failure.dart b/packages/scheduler_persistence/lib/persistence/repository_failure.dart new file mode 100644 index 0000000..77e7e23 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_failure.dart @@ -0,0 +1,23 @@ +part of '../persistence.dart'; + +/// 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; +} diff --git a/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart b/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart new file mode 100644 index 0000000..35f0286 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart @@ -0,0 +1,11 @@ +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/repository_not_found.dart b/packages/scheduler_persistence/lib/persistence/repository_not_found.dart new file mode 100644 index 0000000..feab57d --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_not_found.dart @@ -0,0 +1,7 @@ +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/repository_owner_mismatch.dart b/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart new file mode 100644 index 0000000..a57ee20 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart @@ -0,0 +1,7 @@ +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/repository_stale_revision.dart b/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart new file mode 100644 index 0000000..6a532e2 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart @@ -0,0 +1,12 @@ +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/repository_storage_failure.dart b/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart new file mode 100644 index 0000000..0cd1ca7 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart @@ -0,0 +1,7 @@ +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/right.dart b/packages/scheduler_persistence/lib/persistence/right.dart new file mode 100644 index 0000000..8557424 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/right.dart @@ -0,0 +1,10 @@ +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/lib/persistence/schedule_snapshot_repository.dart b/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart new file mode 100644 index 0000000..429d7b1 --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart @@ -0,0 +1,29 @@ +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/settings_repository.dart b/packages/scheduler_persistence/lib/persistence/settings_repository.dart new file mode 100644 index 0000000..a8e480a --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/settings_repository.dart @@ -0,0 +1,22 @@ +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/task_repository.dart b/packages/scheduler_persistence/lib/persistence/task_repository.dart new file mode 100644 index 0000000..dd41bbb --- /dev/null +++ b/packages/scheduler_persistence/lib/persistence/task_repository.dart @@ -0,0 +1,57 @@ +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_memory/lib/persistence_memory.dart b/packages/scheduler_persistence_memory/lib/persistence_memory.dart index 53ecd8e..7ed1daa 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory.dart @@ -9,844 +9,15 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; +part 'persistence_memory/in_memory_task_repository.dart'; +part 'persistence_memory/in_memory_project_repository.dart'; +part 'persistence_memory/in_memory_locked_block_repository.dart'; +part 'persistence_memory/in_memory_settings_repository.dart'; +part 'persistence_memory/in_memory_schedule_snapshot_repository.dart'; +part 'persistence_memory/identified_record.dart'; +part 'persistence_memory/record.dart'; +part 'persistence_memory/snapshot_record.dart'; const _snapshotRetention = Duration(days: 30); 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/identified_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart new file mode 100644 index 0000000..8894632 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart @@ -0,0 +1,5 @@ +part of '../persistence_memory.dart'; + +abstract interface class _IdentifiedRecord { + String get id; +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart new file mode 100644 index 0000000..799c647 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart @@ -0,0 +1,191 @@ +part of '../persistence_memory.dart'; + +/// 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); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart new file mode 100644 index 0000000..c31dfea --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart @@ -0,0 +1,125 @@ +part of '../persistence_memory.dart'; + +/// 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); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart new file mode 100644 index 0000000..f7ce9b7 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart @@ -0,0 +1,251 @@ +part of '../persistence_memory.dart'; + +/// 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, + ); +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart new file mode 100644 index 0000000..9369259 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart @@ -0,0 +1,78 @@ +part of '../persistence_memory.dart'; + +/// 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); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart new file mode 100644 index 0000000..29cec50 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart @@ -0,0 +1,141 @@ +part of '../persistence_memory.dart'; + +/// 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); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart new file mode 100644 index 0000000..25b0b53 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart @@ -0,0 +1,36 @@ +part of '../persistence_memory.dart'; + +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, + ); + } +} diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart new file mode 100644 index 0000000..6e2d5a7 --- /dev/null +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart @@ -0,0 +1,18 @@ +part of '../persistence_memory.dart'; + +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_sqlite/lib/src/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart index 1e063c6..5dfb3a8 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart @@ -7,225 +7,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/tasks.dart'; +part 'scheduler_db/projects.dart'; +part 'scheduler_db/locked_blocks.dart'; +part 'scheduler_db/locked_overrides.dart'; +part 'scheduler_db/settings_table.dart'; +part 'scheduler_db/snapshots.dart'; +part 'scheduler_db/task_dao.dart'; +part 'scheduler_db/project_dao.dart'; +part 'scheduler_db/locked_block_dao.dart'; +part 'scheduler_db/settings_dao.dart'; +part 'scheduler_db/snapshot_dao.dart'; +part 'scheduler_db/scheduler_db.dart'; diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart new file mode 100644 index 0000000..0fea943 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated locked-time accessors for blocks and date overrides. +@DriftAccessor(tables: [LockedBlocks, LockedOverrides]) +class LockedBlockDao extends DatabaseAccessor + with _$LockedBlockDaoMixin { + LockedBlockDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart new file mode 100644 index 0000000..1ddea2b --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart @@ -0,0 +1,23 @@ +part of '../scheduler_db.dart'; + +/// 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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart new file mode 100644 index 0000000..74c20e2 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart @@ -0,0 +1,22 @@ +part of '../scheduler_db.dart'; + +/// 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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart new file mode 100644 index 0000000..73cd834 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart @@ -0,0 +1,7 @@ +part of '../scheduler_db.dart'; + +/// Generated project-row accessor. +@DriftAccessor(tables: [Projects]) +class ProjectDao extends DatabaseAccessor with _$ProjectDaoMixin { + ProjectDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart new file mode 100644 index 0000000..a14561f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart @@ -0,0 +1,25 @@ +part of '../scheduler_db.dart'; + +/// 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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart new file mode 100644 index 0000000..8274ed4 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart @@ -0,0 +1,33 @@ +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 { + SchedulerDb(super.executor); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator migrator) async { + await migrator.createAll(); + }, + ); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart new file mode 100644 index 0000000..c072830 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated owner-settings accessor. +@DriftAccessor(tables: [SettingsTable]) +class SettingsDao extends DatabaseAccessor + with _$SettingsDaoMixin { + SettingsDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart new file mode 100644 index 0000000..6a8115d --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart @@ -0,0 +1,22 @@ +part of '../scheduler_db.dart'; + +/// 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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart new file mode 100644 index 0000000..7b67f2f --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart @@ -0,0 +1,8 @@ +part of '../scheduler_db.dart'; + +/// Generated diagnostic snapshot accessor. +@DriftAccessor(tables: [Snapshots]) +class SnapshotDao extends DatabaseAccessor + with _$SnapshotDaoMixin { + SnapshotDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart new file mode 100644 index 0000000..e4d71ab --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart @@ -0,0 +1,29 @@ +part of '../scheduler_db.dart'; + +/// 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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart new file mode 100644 index 0000000..9eca99c --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart @@ -0,0 +1,7 @@ +part of '../scheduler_db.dart'; + +/// Generated task-row accessor. +@DriftAccessor(tables: [Tasks]) +class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { + TaskDao(super.db); +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart new file mode 100644 index 0000000..9ec3f11 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart @@ -0,0 +1,42 @@ +part of '../scheduler_db.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}; +} diff --git a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart index 574f213..4b5f71e 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart @@ -8,1363 +8,16 @@ 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'; const _snapshotRetention = Duration(days: 30); 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; -} - extension on int { core.Revision next() => core.Revision(this).next(); } 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..2161405 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_locked_block_repository.dart @@ -0,0 +1,304 @@ +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; + + @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), + ); + } +} 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..c2011f7 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_project_repository.dart @@ -0,0 +1,184 @@ +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; + + @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), + ); + } +} 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..edd07f1 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_schedule_snapshot_repository.dart @@ -0,0 +1,541 @@ +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; + + @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; +} 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..be78987 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_settings_repository.dart @@ -0,0 +1,113 @@ +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; + + @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), + ); + } +} 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..40759f7 --- /dev/null +++ b/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart @@ -0,0 +1,215 @@ +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; + + @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), + ); + } +} From 2485e10873caf5ac531639ac0304527c4e6f69fc Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 12:59:23 -0700 Subject: [PATCH 14/16] refactor: subgroup package source folders --- .../lib/src/encrypted_backup.dart | 12 ++-- .../{ => codec}/backup_encryption_codec.dart | 2 +- .../backup_decryption_exception.dart | 2 +- .../{ => errors}/backup_exception.dart | 2 +- .../encrypted_backup_operations.dart | 2 +- .../{ => paths}/backup_file_names.dart | 2 +- .../{ => paths}/backup_paths.dart | 2 +- .../lib/src/application_commands.dart | 12 ++-- .../{ => codes}/application_command_code.dart | 2 +- .../application_child_task_draft.dart | 2 +- .../application_command_read_hint.dart | 2 +- .../application_command_result.dart | 2 +- .../{ => planning}/planning_state.dart | 2 +- .../v1_application_command_use_cases.dart | 2 +- .../lib/src/application_layer.dart | 60 ++++++++--------- .../application_operation_context.dart | 2 +- .../owner_time_zone_context.dart | 2 +- .../application_scheduling_loader.dart | 2 +- .../application_operation_record.dart | 2 +- .../notice_acknowledgement_record.dart | 2 +- .../{ => records}/owner_settings.dart | 2 +- .../in_memory_application_repositories.dart | 2 +- .../in_memory_application_state.dart | 2 +- .../in_memory_application_unit_of_work.dart | 2 +- .../application_operation_repository.dart | 2 +- .../notice_acknowledgement_repository.dart | 2 +- .../owner_settings_repository.dart | 2 +- .../project_statistics_repository.dart | 2 +- .../tasks}/task_activity_repository.dart | 2 +- .../application_unit_of_work.dart | 2 +- ...application_unit_of_work_repositories.dart | 2 +- ...ork_notice_acknowledgement_repository.dart | 2 +- .../unit_of_work_operation_repository.dart | 2 +- ...nit_of_work_owner_settings_repository.dart | 2 +- .../unit_of_work_project_repository.dart | 2 +- ...of_work_project_statistics_repository.dart | 2 +- .../unit_of_work_locked_block_repository.dart | 2 +- ...f_work_scheduling_snapshot_repository.dart | 2 +- ...unit_of_work_task_activity_repository.dart | 2 +- .../tasks}/unit_of_work_task_repository.dart | 2 +- .../{ => results}/application_failure.dart | 2 +- .../application_failure_code.dart | 2 +- .../application_failure_exception.dart | 2 +- .../application_persistence_exception.dart | 2 +- .../{ => results}/application_result.dart | 2 +- .../lib/src/application_management.dart | 32 ++++----- .../application_management_command_code.dart | 2 +- .../owner_settings_warning_code.dart | 2 +- .../{ => drafts}/locked_block_draft.dart | 2 +- .../{ => drafts}/project_profile_draft.dart | 2 +- .../{ => parsing}/parsed_notice_id.dart | 2 +- .../backlog_item_read_model.dart | 2 +- .../backlog_query_result.dart | 2 +- .../project_defaults_query_result.dart | 2 +- .../{ => requests}/get_backlog_request.dart | 2 +- .../get_project_defaults_request.dart | 2 +- .../notice_acknowledgement_result.dart | 2 +- .../owner_settings_update_result.dart | 2 +- .../{ => updates}/locked_block_update.dart | 2 +- .../{ => updates}/owner_settings_update.dart | 2 +- .../{ => updates}/project_profile_update.dart | 2 +- .../v1_application_management_use_cases.dart | 2 +- packages/scheduler_core/lib/src/backlog.dart | 12 ++-- .../backlog/{ => queries}/backlog_filter.dart | 2 +- .../{ => queries}/backlog_sort_key.dart | 2 +- .../backlog_staleness_marker.dart | 2 +- .../backlog_staleness_settings.dart | 2 +- .../src/backlog/{ => views}/backlog_view.dart | 2 +- .../src/backlog/{ => views}/indexed_task.dart | 2 +- .../scheduler_core/lib/src/child_tasks.dart | 18 ++--- .../{ => indexing}/indexed_child.dart | 2 +- .../{ => models}/child_task_entry.dart | 2 +- .../{ => models}/child_task_summary.dart | 2 +- .../{ => models}/child_task_view.dart | 2 +- .../child_task_break_up_request.dart | 2 +- .../child_task_break_up_result.dart | 2 +- .../child_task_completion_result.dart | 2 +- .../child_task_break_up_service.dart | 2 +- .../child_task_completion_service.dart | 2 +- .../lib/src/document_mapping.dart | 50 +++++++------- ...pplication_operation_document_mapping.dart | 2 +- .../backlog_staleness_document_mapping.dart | 2 +- ...tice_acknowledgement_document_mapping.dart | 2 +- .../owner_settings_document_mapping.dart | 2 +- .../{ => enums}/persistence_enum_mapping.dart | 2 +- .../document_mapping_exception.dart | 2 +- .../document_mapping_failure_code.dart | 2 +- .../locked_block_document_mapping.dart | 2 +- ...ocked_block_override_document_mapping.dart | 2 +- ...ked_block_recurrence_document_mapping.dart | 2 +- .../{ => metadata}/document_metadata.dart | 2 +- .../project_document_mapping.dart | 2 +- ...project_statistics_document_extension.dart | 2 +- .../project_statistics_document_mapping.dart | 2 +- .../scheduling_change_document_mapping.dart | 2 +- .../scheduling_notice_document_mapping.dart | 2 +- .../scheduling_overlap_document_mapping.dart | 2 +- .../scheduling_snapshot_document_mapping.dart | 2 +- .../scheduling_window_document_mapping.dart | 2 +- .../task_activity_document_mapping.dart | 2 +- .../{ => tasks}/task_document_extension.dart | 2 +- .../{ => tasks}/task_document_mapping.dart | 2 +- .../task_statistics_document_extension.dart | 2 +- .../task_statistics_document_mapping.dart | 2 +- .../time_interval_document_mapping.dart | 2 +- .../lib/src/document_migration.dart | 26 ++++---- .../document_migration_entity.dart | 2 +- .../document_migration_failure_code.dart | 2 +- .../document_migration_note_code.dart | 2 +- .../document_migration_status.dart | 2 +- .../legacy_document_exception.dart | 2 +- .../legacy_metadata_instants.dart | 2 +- .../document_migration_note.dart | 2 +- .../document_migration_provenance.dart | 2 +- .../document_migration_report.dart | 2 +- .../document_migration_result.dart | 2 +- .../{ => service}/schema_version_read.dart | 2 +- .../{ => service}/v0_migrator.dart | 2 +- .../v1_document_migration_service.dart | 2 +- .../scheduler_core/lib/src/locked_time.dart | 16 ++--- .../locked_time/{ => aliases}/clock_time.dart | 2 +- .../{ => blocks}/locked_block.dart | 2 +- .../{ => blocks}/locked_block_occurrence.dart | 2 +- .../locked_block_override_type.dart | 2 +- .../{ => enums}/locked_weekday.dart | 2 +- .../locked_schedule_expansion.dart | 2 +- .../locked_block_override.dart | 2 +- .../locked_block_recurrence.dart | 2 +- packages/scheduler_core/lib/src/models.dart | 24 +++---- .../{ => entities}/project_profile.dart | 2 +- .../lib/src/models/{ => entities}/task.dart | 2 +- .../models/{ => entities}/time_interval.dart | 2 +- .../{ => enums/effort}/difficulty_level.dart | 2 +- .../{ => enums/effort}/reward_level.dart | 2 +- .../{ => enums/planning}/backlog_tag.dart | 2 +- .../{ => enums/planning}/priority_level.dart | 2 +- .../planning}/reminder_profile.dart | 2 +- .../models/{ => enums/tasks}/task_status.dart | 2 +- .../models/{ => enums/tasks}/task_type.dart | 2 +- .../domain_validation_code.dart | 2 +- .../domain_validation_exception.dart | 2 +- .../lib/src/persistence_contract.dart | 66 +++++++++---------- .../persistence_collections.dart | 2 +- .../persistence_civil_date_convention.dart | 2 +- .../persistence_date_time_convention.dart | 2 +- .../persistence_enum_name.dart | 2 +- .../persistence_wall_time_convention.dart | 2 +- ...application_operation_document_fields.dart | 2 +- .../backlog_staleness_document_fields.dart | 2 +- ...otice_acknowledgement_document_fields.dart | 2 +- .../owner_settings_document_fields.dart | 2 +- .../{ => fields/core}/document_fields.dart | 2 +- .../locked_block_document_fields.dart | 2 +- ...locked_block_override_document_fields.dart | 2 +- ...cked_block_recurrence_document_fields.dart | 2 +- .../projects}/project_document_fields.dart | 2 +- .../project_statistics_document_fields.dart | 2 +- .../scheduling_change_document_fields.dart | 2 +- .../scheduling_notice_document_fields.dart | 2 +- .../scheduling_overlap_document_fields.dart | 2 +- .../scheduling_snapshot_document_fields.dart | 2 +- .../scheduling_window_document_fields.dart | 2 +- .../tasks}/task_activity_document_fields.dart | 2 +- .../tasks}/task_document_fields.dart | 2 +- .../task_statistics_document_fields.dart | 2 +- .../time}/clock_time_document_fields.dart | 2 +- .../time}/time_interval_document_fields.dart | 2 +- .../persistence_index_catalog.dart | 2 +- .../persistence_index_direction.dart | 2 +- .../persistence_index_field.dart | 2 +- .../{ => indexes}/persistence_index_spec.dart | 2 +- .../{ => indexes}/repository_query_names.dart | 2 +- .../persistence_guard_result.dart | 2 +- .../persistence_payload_guard.dart | 2 +- .../persistence_payload_limits.dart | 2 +- .../lib/src/project_statistics.dart | 22 +++---- .../project_completion_time_bucket.dart | 2 +- .../project_suggestion_confidence.dart | 2 +- .../{ => enums}/project_suggestion_type.dart | 2 +- .../project_default_resolution.dart | 2 +- .../{ => models}/project_statistics.dart | 2 +- ...project_statistics_application_result.dart | 2 +- ...roject_statistics_aggregation_service.dart | 2 +- .../{ => suggestions}/project_suggestion.dart | 2 +- .../project_suggestion_policy.dart | 2 +- .../project_suggestion_service.dart | 2 +- .../project_suggestion_set.dart | 2 +- .../lib/src/reminder_policy.dart | 12 ++-- .../{ => directives}/reminder_directive.dart | 2 +- .../reminder_directive_action.dart | 2 +- .../reminder_directive_reason.dart | 2 +- .../effective_reminder_profile.dart | 2 +- .../effective_reminder_profile_source.dart | 2 +- .../reminder_policy_service.dart | 2 +- .../scheduler_core/lib/src/repositories.dart | 32 ++++----- .../{ => failures}/repository_failure.dart | 2 +- .../repository_failure_code.dart | 2 +- .../repository_mutation_result.dart | 2 +- .../in_memory_locked_block_repository.dart | 2 +- .../in_memory_project_repository.dart | 2 +- ...memory_scheduling_snapshot_repository.dart | 2 +- .../in_memory_task_repository.dart | 2 +- .../locked_block_repository.dart | 2 +- .../{ => interfaces}/project_repository.dart | 2 +- .../scheduling_snapshot_repository.dart | 2 +- .../{ => interfaces}/task_repository.dart | 2 +- .../{ => pagination}/repository_page.dart | 2 +- .../repository_page_request.dart | 2 +- .../{ => pagination}/repository_record.dart | 2 +- .../backlog_candidate_query.dart | 2 +- .../scheduling_state_snapshot.dart | 2 +- .../lib/src/scheduling_engine.dart | 30 ++++----- .../conflicts}/scheduling_conflict_code.dart | 2 +- .../notices}/scheduling_issue_code.dart | 2 +- .../notices}/scheduling_notice_type.dart | 2 +- .../operations}/scheduling_movement_code.dart | 2 +- .../scheduling_operation_code.dart | 2 +- .../operations}/scheduling_outcome_code.dart | 2 +- .../{ => engine}/scheduling_engine.dart | 2 +- .../changes}/scheduling_change.dart | 2 +- .../changes}/scheduling_overlap.dart | 2 +- .../{ => models/inputs}/scheduling_input.dart | 2 +- .../inputs}/scheduling_window.dart | 2 +- .../notices}/scheduling_notice.dart | 2 +- .../results}/scheduling_result.dart | 2 +- .../backlog_insertion_plan.dart | 2 +- .../{ => planning}/placement_item.dart | 2 +- .../scheduler_core/lib/src/task_actions.dart | 22 +++---- .../flexible_task_quick_action.dart | 2 +- .../{ => actions}/push_destination.dart | 2 +- .../{ => actions}/required_task_action.dart | 2 +- .../surprise_task_log_request.dart | 2 +- .../flexible_task_action_result.dart | 2 +- .../push_destination_result.dart | 2 +- .../required_task_action_result.dart | 2 +- .../surprise_task_log_result.dart | 2 +- .../flexible_task_action_service.dart | 2 +- .../required_task_action_service.dart | 2 +- .../surprise_task_log_service.dart | 2 +- .../lib/src/task_lifecycle.dart | 16 ++--- .../{ => codes}/task_activity_code.dart | 2 +- .../{ => codes}/task_transition_code.dart | 2 +- .../task_transition_outcome_code.dart | 2 +- .../{ => models}/task_activity.dart | 2 +- .../task_activity_application_result.dart | 2 +- .../{ => models}/task_transition_result.dart | 2 +- .../task_activity_accounting_service.dart | 2 +- .../task_transition_service.dart | 2 +- .../lib/src/time_contracts.dart | 28 ++++---- .../{ => civil}/civil_date.dart | 2 +- .../time_contracts/{ => civil}/wall_time.dart | 2 +- .../time_contracts/{ => clocks}/clock.dart | 2 +- .../{ => clocks}/fixed_clock.dart | 2 +- .../{ => clocks}/system_clock.dart | 2 +- .../{ => ids}/id_generator.dart | 2 +- .../{ => ids}/sequential_id_generator.dart | 2 +- .../nonexistent_local_time_policy.dart | 2 +- .../policies}/repeated_local_time_policy.dart | 2 +- .../time_zone_resolution_options.dart | 2 +- .../resolution}/local_time_resolution.dart | 2 +- .../resolution}/resolved_local_date_time.dart | 2 +- .../fixed_offset_time_zone_resolver.dart | 2 +- .../resolvers}/time_zone_resolver.dart | 2 +- .../lib/src/timeline_state.dart | 16 ++--- .../{ => mapping}/timeline_item_mapper.dart | 2 +- .../{ => models}/compact_timeline_state.dart | 2 +- .../{ => models}/timeline_item.dart | 2 +- .../timeline_background_token.dart | 2 +- .../timeline_difficulty_icon_token.dart | 2 +- .../{ => tokens}/timeline_item_category.dart | 2 +- .../{ => tokens}/timeline_quick_action.dart | 2 +- .../timeline_reward_icon_token.dart | 2 +- .../scheduler_core/lib/src/today_state.dart | 14 ++-- .../{ => models}/today_compact_state.dart | 2 +- .../{ => models}/today_pending_notice.dart | 2 +- .../today_state/{ => models}/today_state.dart | 2 +- .../{ => models}/today_timeline_item.dart | 2 +- .../today_timeline_item_source.dart | 2 +- .../{ => queries}/get_today_state_query.dart | 2 +- .../get_today_state_request.dart | 2 +- .../lib/src/export_controller.dart | 24 +++---- .../{ => context}/export_context.dart | 2 +- .../{ => controller}/export_controller.dart | 2 +- .../{ => errors}/export_exception.dart | 2 +- .../export_repository_exception.dart | 2 +- .../{ => writers/core}/export_writer.dart | 2 +- .../{ => writers/csv}/csv_cell_encoder.dart | 2 +- .../{ => writers/csv}/csv_export_writer.dart | 2 +- .../formats}/export_format_normalizer.dart | 2 +- .../json}/export_json_context_encoder.dart | 2 +- .../json}/json_export_writer.dart | 2 +- .../registry}/export_writer_factory.dart | 2 +- .../registry}/export_writer_registry.dart | 2 +- .../lib/src/notification_adapter.dart | 12 ++-- .../fake_notification_adapter.dart | 2 +- .../{ => adapter}/notification_adapter.dart | 2 +- .../{ => feedback}/notification_feedback.dart | 2 +- .../notification_feedback_type.dart | 2 +- .../{ => requests}/notification_request.dart | 2 +- .../notification_request_impl.dart | 2 +- .../src/desktop_notification_adapter_io.dart | 22 +++---- .../desktop_notification_adapter.dart | 2 +- .../core}/desktop_notification_backend.dart | 2 +- .../desktop_notification_backend_factory.dart | 2 +- .../stdout_notification_backend.dart | 2 +- .../apple_script_string_encoder.dart | 2 +- .../platform}/linux_notify_send_backend.dart | 2 +- .../mac_os_notification_backend.dart | 2 +- .../desktop_notification_log_sink.dart | 2 +- .../desktop_process_runner.dart | 2 +- .../desktop_notification_platform.dart | 2 +- ...esktop_notification_platform_detector.dart | 2 +- .../desktop_notification_adapter_stub.dart | 14 ++-- .../desktop_notification_adapter.dart | 2 +- .../desktop_notification_backend.dart | 2 +- .../desktop_notification_backend_factory.dart | 2 +- .../stdout_notification_backend.dart | 2 +- .../{ => callbacks}/default_log_sink.dart | 2 +- .../desktop_notification_log_sink.dart | 2 +- .../desktop_notification_platform.dart | 2 +- .../lib/persistence.dart | 32 ++++----- .../core}/repository_failure.dart | 2 +- .../identity}/repository_duplicate_id.dart | 2 +- .../identity}/repository_not_found.dart | 2 +- .../identity}/repository_owner_mismatch.dart | 2 +- .../repository_invalid_revision.dart | 2 +- .../revision}/repository_stale_revision.dart | 2 +- .../storage}/repository_storage_failure.dart | 2 +- .../locked_block_repository.dart | 2 +- .../project_repository.dart | 2 +- .../schedule_snapshot_repository.dart | 2 +- .../settings_repository.dart | 2 +- .../{ => repositories}/task_repository.dart | 2 +- .../lib/persistence/{ => results}/either.dart | 2 +- .../lib/persistence/{ => results}/left.dart | 2 +- .../{ => results}/repo_result.dart | 2 +- .../lib/persistence/{ => results}/right.dart | 2 +- .../lib/persistence_memory.dart | 16 ++--- .../{ => records}/identified_record.dart | 2 +- .../{ => records}/record.dart | 2 +- .../{ => records}/snapshot_record.dart | 2 +- .../in_memory_locked_block_repository.dart | 2 +- .../in_memory_project_repository.dart | 2 +- ...n_memory_schedule_snapshot_repository.dart | 2 +- .../in_memory_settings_repository.dart | 2 +- .../in_memory_task_repository.dart | 2 +- .../lib/src/scheduler_db.dart | 24 +++---- .../{ => daos}/locked_block_dao.dart | 2 +- .../scheduler_db/{ => daos}/project_dao.dart | 2 +- .../scheduler_db/{ => daos}/settings_dao.dart | 2 +- .../scheduler_db/{ => daos}/snapshot_dao.dart | 2 +- .../src/scheduler_db/{ => daos}/task_dao.dart | 2 +- .../{ => database}/scheduler_db.dart | 2 +- .../locked_time}/locked_blocks.dart | 2 +- .../locked_time}/locked_overrides.dart | 2 +- .../{ => tables/projects}/projects.dart | 2 +- .../{ => tables/settings}/settings_table.dart | 2 +- .../{ => tables/snapshots}/snapshots.dart | 2 +- .../{ => tables/tasks}/tasks.dart | 2 +- 359 files changed, 664 insertions(+), 664 deletions(-) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => codec}/backup_encryption_codec.dart (98%) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => errors}/backup_decryption_exception.dart (92%) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => errors}/backup_exception.dart (89%) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => operations}/encrypted_backup_operations.dart (98%) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => paths}/backup_file_names.dart (95%) rename packages/scheduler_backup/lib/src/encrypted_backup/{ => paths}/backup_paths.dart (97%) rename packages/scheduler_core/lib/src/application_commands/{ => codes}/application_command_code.dart (92%) rename packages/scheduler_core/lib/src/application_commands/{ => messages}/application_child_task_draft.dart (94%) rename packages/scheduler_core/lib/src/application_commands/{ => messages}/application_command_read_hint.dart (95%) rename packages/scheduler_core/lib/src/application_commands/{ => messages}/application_command_result.dart (98%) rename packages/scheduler_core/lib/src/application_commands/{ => planning}/planning_state.dart (99%) rename packages/scheduler_core/lib/src/application_commands/{ => use_cases}/v1_application_command_use_cases.dart (99%) rename packages/scheduler_core/lib/src/application_layer/{ => context}/application_operation_context.dart (96%) rename packages/scheduler_core/lib/src/application_layer/{ => context}/owner_time_zone_context.dart (92%) rename packages/scheduler_core/lib/src/application_layer/{ => loading}/application_scheduling_loader.dart (96%) rename packages/scheduler_core/lib/src/application_layer/{ => records}/application_operation_record.dart (94%) rename packages/scheduler_core/lib/src/application_layer/{ => records}/notice_acknowledgement_record.dart (93%) rename packages/scheduler_core/lib/src/application_layer/{ => records}/owner_settings.dart (97%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/in_memory}/in_memory_application_repositories.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/in_memory}/in_memory_application_state.dart (99%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/in_memory}/in_memory_application_unit_of_work.dart (99%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/application}/application_operation_repository.dart (91%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/application}/notice_acknowledgement_repository.dart (93%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/application}/owner_settings_repository.dart (89%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/projects}/project_statistics_repository.dart (92%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/tasks}/task_activity_repository.dart (94%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/unit_of_work}/application_unit_of_work.dart (91%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/interfaces/unit_of_work}/application_unit_of_work_repositories.dart (92%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/application}/unit_of_work_notice_acknowledgement_repository.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/application}/unit_of_work_operation_repository.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/application}/unit_of_work_owner_settings_repository.dart (97%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/projects}/unit_of_work_project_repository.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/projects}/unit_of_work_project_statistics_repository.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/scheduling}/unit_of_work_locked_block_repository.dart (99%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/scheduling}/unit_of_work_scheduling_snapshot_repository.dart (94%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/tasks}/unit_of_work_task_activity_repository.dart (98%) rename packages/scheduler_core/lib/src/application_layer/{ => repositories/unit_of_work/tasks}/unit_of_work_task_repository.dart (99%) rename packages/scheduler_core/lib/src/application_layer/{ => results}/application_failure.dart (92%) rename packages/scheduler_core/lib/src/application_layer/{ => results}/application_failure_code.dart (83%) rename packages/scheduler_core/lib/src/application_layer/{ => results}/application_failure_exception.dart (87%) rename packages/scheduler_core/lib/src/application_layer/{ => results}/application_persistence_exception.dart (82%) rename packages/scheduler_core/lib/src/application_layer/{ => results}/application_result.dart (96%) rename packages/scheduler_core/lib/src/application_management/{ => codes}/application_management_command_code.dart (88%) rename packages/scheduler_core/lib/src/application_management/{ => codes}/owner_settings_warning_code.dart (78%) rename packages/scheduler_core/lib/src/application_management/{ => drafts}/locked_block_draft.dart (91%) rename packages/scheduler_core/lib/src/application_management/{ => drafts}/project_profile_draft.dart (93%) rename packages/scheduler_core/lib/src/application_management/{ => parsing}/parsed_notice_id.dart (79%) rename packages/scheduler_core/lib/src/application_management/{ => read_models}/backlog_item_read_model.dart (88%) rename packages/scheduler_core/lib/src/application_management/{ => read_models}/backlog_query_result.dart (91%) rename packages/scheduler_core/lib/src/application_management/{ => read_models}/project_defaults_query_result.dart (90%) rename packages/scheduler_core/lib/src/application_management/{ => requests}/get_backlog_request.dart (90%) rename packages/scheduler_core/lib/src/application_management/{ => requests}/get_project_defaults_request.dart (89%) rename packages/scheduler_core/lib/src/application_management/{ => results}/notice_acknowledgement_result.dart (89%) rename packages/scheduler_core/lib/src/application_management/{ => results}/owner_settings_update_result.dart (91%) rename packages/scheduler_core/lib/src/application_management/{ => updates}/locked_block_update.dart (91%) rename packages/scheduler_core/lib/src/application_management/{ => updates}/owner_settings_update.dart (90%) rename packages/scheduler_core/lib/src/application_management/{ => updates}/project_profile_update.dart (93%) rename packages/scheduler_core/lib/src/application_management/{ => use_cases}/v1_application_management_use_cases.dart (99%) rename packages/scheduler_core/lib/src/backlog/{ => queries}/backlog_filter.dart (96%) rename packages/scheduler_core/lib/src/backlog/{ => queries}/backlog_sort_key.dart (95%) rename packages/scheduler_core/lib/src/backlog/{ => staleness}/backlog_staleness_marker.dart (94%) rename packages/scheduler_core/lib/src/backlog/{ => staleness}/backlog_staleness_settings.dart (97%) rename packages/scheduler_core/lib/src/backlog/{ => views}/backlog_view.dart (99%) rename packages/scheduler_core/lib/src/backlog/{ => views}/indexed_task.dart (88%) rename packages/scheduler_core/lib/src/child_tasks/{ => indexing}/indexed_child.dart (97%) rename packages/scheduler_core/lib/src/child_tasks/{ => models}/child_task_entry.dart (98%) rename packages/scheduler_core/lib/src/child_tasks/{ => models}/child_task_summary.dart (98%) rename packages/scheduler_core/lib/src/child_tasks/{ => models}/child_task_view.dart (98%) rename packages/scheduler_core/lib/src/child_tasks/{ => requests}/child_task_break_up_request.dart (95%) rename packages/scheduler_core/lib/src/child_tasks/{ => results}/child_task_break_up_result.dart (97%) rename packages/scheduler_core/lib/src/child_tasks/{ => results}/child_task_completion_result.dart (98%) rename packages/scheduler_core/lib/src/child_tasks/{ => services}/child_task_break_up_service.dart (98%) rename packages/scheduler_core/lib/src/child_tasks/{ => services}/child_task_completion_service.dart (99%) rename packages/scheduler_core/lib/src/document_mapping/{ => application}/application_operation_document_mapping.dart (97%) rename packages/scheduler_core/lib/src/document_mapping/{ => application}/backlog_staleness_document_mapping.dart (96%) rename packages/scheduler_core/lib/src/document_mapping/{ => application}/notice_acknowledgement_document_mapping.dart (97%) rename packages/scheduler_core/lib/src/document_mapping/{ => application}/owner_settings_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => enums}/persistence_enum_mapping.dart (99%) rename packages/scheduler_core/lib/src/document_mapping/{ => failures}/document_mapping_exception.dart (93%) rename packages/scheduler_core/lib/src/document_mapping/{ => failures}/document_mapping_failure_code.dart (82%) rename packages/scheduler_core/lib/src/document_mapping/{ => locked_time}/locked_block_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => locked_time}/locked_block_override_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => locked_time}/locked_block_recurrence_document_mapping.dart (97%) rename packages/scheduler_core/lib/src/document_mapping/{ => metadata}/document_metadata.dart (90%) rename packages/scheduler_core/lib/src/document_mapping/{ => projects}/project_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => projects}/project_statistics_document_extension.dart (99%) rename packages/scheduler_core/lib/src/document_mapping/{ => projects}/project_statistics_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => scheduling}/scheduling_change_document_mapping.dart (97%) rename packages/scheduler_core/lib/src/document_mapping/{ => scheduling}/scheduling_notice_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => scheduling}/scheduling_overlap_document_mapping.dart (96%) rename packages/scheduler_core/lib/src/document_mapping/{ => scheduling}/scheduling_snapshot_document_mapping.dart (99%) rename packages/scheduler_core/lib/src/document_mapping/{ => scheduling}/scheduling_window_document_mapping.dart (95%) rename packages/scheduler_core/lib/src/document_mapping/{ => tasks}/task_activity_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => tasks}/task_document_extension.dart (93%) rename packages/scheduler_core/lib/src/document_mapping/{ => tasks}/task_document_mapping.dart (99%) rename packages/scheduler_core/lib/src/document_mapping/{ => tasks}/task_statistics_document_extension.dart (86%) rename packages/scheduler_core/lib/src/document_mapping/{ => tasks}/task_statistics_document_mapping.dart (98%) rename packages/scheduler_core/lib/src/document_mapping/{ => time}/time_interval_document_mapping.dart (95%) rename packages/scheduler_core/lib/src/document_migration/{ => codes}/document_migration_entity.dart (75%) rename packages/scheduler_core/lib/src/document_migration/{ => codes}/document_migration_failure_code.dart (82%) rename packages/scheduler_core/lib/src/document_migration/{ => codes}/document_migration_note_code.dart (80%) rename packages/scheduler_core/lib/src/document_migration/{ => codes}/document_migration_status.dart (73%) rename packages/scheduler_core/lib/src/document_migration/{ => legacy}/legacy_document_exception.dart (83%) rename packages/scheduler_core/lib/src/document_migration/{ => legacy}/legacy_metadata_instants.dart (99%) rename packages/scheduler_core/lib/src/document_migration/{ => reports}/document_migration_note.dart (87%) rename packages/scheduler_core/lib/src/document_migration/{ => reports}/document_migration_provenance.dart (80%) rename packages/scheduler_core/lib/src/document_migration/{ => reports}/document_migration_report.dart (95%) rename packages/scheduler_core/lib/src/document_migration/{ => reports}/document_migration_result.dart (93%) rename packages/scheduler_core/lib/src/document_migration/{ => service}/schema_version_read.dart (83%) rename packages/scheduler_core/lib/src/document_migration/{ => service}/v0_migrator.dart (75%) rename packages/scheduler_core/lib/src/document_migration/{ => service}/v1_document_migration_service.dart (99%) rename packages/scheduler_core/lib/src/locked_time/{ => aliases}/clock_time.dart (72%) rename packages/scheduler_core/lib/src/locked_time/{ => blocks}/locked_block.dart (99%) rename packages/scheduler_core/lib/src/locked_time/{ => blocks}/locked_block_occurrence.dart (97%) rename packages/scheduler_core/lib/src/locked_time/{ => enums}/locked_block_override_type.dart (92%) rename packages/scheduler_core/lib/src/locked_time/{ => enums}/locked_weekday.dart (94%) rename packages/scheduler_core/lib/src/locked_time/{ => expansion}/locked_schedule_expansion.dart (99%) rename packages/scheduler_core/lib/src/locked_time/{ => overrides}/locked_block_override.dart (99%) rename packages/scheduler_core/lib/src/locked_time/{ => recurrence}/locked_block_recurrence.dart (96%) rename packages/scheduler_core/lib/src/models/{ => entities}/project_profile.dart (99%) rename packages/scheduler_core/lib/src/models/{ => entities}/task.dart (99%) rename packages/scheduler_core/lib/src/models/{ => entities}/time_interval.dart (99%) rename packages/scheduler_core/lib/src/models/{ => enums/effort}/difficulty_level.dart (95%) rename packages/scheduler_core/lib/src/models/{ => enums/effort}/reward_level.dart (94%) rename packages/scheduler_core/lib/src/models/{ => enums/planning}/backlog_tag.dart (91%) rename packages/scheduler_core/lib/src/models/{ => enums/planning}/priority_level.dart (94%) rename packages/scheduler_core/lib/src/models/{ => enums/planning}/reminder_profile.dart (93%) rename packages/scheduler_core/lib/src/models/{ => enums/tasks}/task_status.dart (96%) rename packages/scheduler_core/lib/src/models/{ => enums/tasks}/task_type.dart (96%) rename packages/scheduler_core/lib/src/models/{ => validation}/domain_validation_code.dart (96%) rename packages/scheduler_core/lib/src/models/{ => validation}/domain_validation_exception.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => collections}/persistence_collections.dart (95%) rename packages/scheduler_core/lib/src/persistence_contract/{ => conventions}/persistence_civil_date_convention.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => conventions}/persistence_date_time_convention.dart (94%) rename packages/scheduler_core/lib/src/persistence_contract/{ => conventions}/persistence_enum_name.dart (86%) rename packages/scheduler_core/lib/src/persistence_contract/{ => conventions}/persistence_wall_time_convention.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/application}/application_operation_document_fields.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/application}/backlog_staleness_document_fields.dart (86%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/application}/notice_acknowledgement_document_fields.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/application}/owner_settings_document_fields.dart (94%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/core}/document_fields.dart (93%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/locked_time}/locked_block_document_fields.dart (95%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/locked_time}/locked_block_override_document_fields.dart (95%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/locked_time}/locked_block_recurrence_document_fields.dart (85%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/projects}/project_document_fields.dart (95%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/projects}/project_statistics_document_fields.dart (96%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/scheduling}/scheduling_change_document_fields.dart (90%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/scheduling}/scheduling_notice_document_fields.dart (92%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/scheduling}/scheduling_overlap_document_fields.dart (88%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/scheduling}/scheduling_snapshot_document_fields.dart (96%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/scheduling}/scheduling_window_document_fields.dart (84%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/tasks}/task_activity_document_fields.dart (94%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/tasks}/task_document_fields.dart (97%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/tasks}/task_statistics_document_fields.dart (96%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/time}/clock_time_document_fields.dart (80%) rename packages/scheduler_core/lib/src/persistence_contract/{ => fields/time}/time_interval_document_fields.dart (85%) rename packages/scheduler_core/lib/src/persistence_contract/{ => indexes}/persistence_index_catalog.dart (99%) rename packages/scheduler_core/lib/src/persistence_contract/{ => indexes}/persistence_index_direction.dart (74%) rename packages/scheduler_core/lib/src/persistence_contract/{ => indexes}/persistence_index_field.dart (85%) rename packages/scheduler_core/lib/src/persistence_contract/{ => indexes}/persistence_index_spec.dart (92%) rename packages/scheduler_core/lib/src/persistence_contract/{ => indexes}/repository_query_names.dart (97%) rename packages/scheduler_core/lib/src/persistence_contract/{ => payloads}/persistence_guard_result.dart (94%) rename packages/scheduler_core/lib/src/persistence_contract/{ => payloads}/persistence_payload_guard.dart (95%) rename packages/scheduler_core/lib/src/persistence_contract/{ => payloads}/persistence_payload_limits.dart (92%) rename packages/scheduler_core/lib/src/project_statistics/{ => enums}/project_completion_time_bucket.dart (80%) rename packages/scheduler_core/lib/src/project_statistics/{ => enums}/project_suggestion_confidence.dart (76%) rename packages/scheduler_core/lib/src/project_statistics/{ => enums}/project_suggestion_type.dart (81%) rename packages/scheduler_core/lib/src/project_statistics/{ => models}/project_default_resolution.dart (94%) rename packages/scheduler_core/lib/src/project_statistics/{ => models}/project_statistics.dart (99%) rename packages/scheduler_core/lib/src/project_statistics/{ => models}/project_statistics_application_result.dart (92%) rename packages/scheduler_core/lib/src/project_statistics/{ => services}/project_statistics_aggregation_service.dart (98%) rename packages/scheduler_core/lib/src/project_statistics/{ => suggestions}/project_suggestion.dart (94%) rename packages/scheduler_core/lib/src/project_statistics/{ => suggestions}/project_suggestion_policy.dart (92%) rename packages/scheduler_core/lib/src/project_statistics/{ => suggestions}/project_suggestion_service.dart (99%) rename packages/scheduler_core/lib/src/project_statistics/{ => suggestions}/project_suggestion_set.dart (93%) rename packages/scheduler_core/lib/src/reminder_policy/{ => directives}/reminder_directive.dart (97%) rename packages/scheduler_core/lib/src/reminder_policy/{ => directives}/reminder_directive_action.dart (80%) rename packages/scheduler_core/lib/src/reminder_policy/{ => directives}/reminder_directive_reason.dart (90%) rename packages/scheduler_core/lib/src/reminder_policy/{ => profiles}/effective_reminder_profile.dart (90%) rename packages/scheduler_core/lib/src/reminder_policy/{ => profiles}/effective_reminder_profile_source.dart (77%) rename packages/scheduler_core/lib/src/reminder_policy/{ => services}/reminder_policy_service.dart (99%) rename packages/scheduler_core/lib/src/repositories/{ => failures}/repository_failure.dart (91%) rename packages/scheduler_core/lib/src/repositories/{ => failures}/repository_failure_code.dart (84%) rename packages/scheduler_core/lib/src/repositories/{ => failures}/repository_mutation_result.dart (94%) rename packages/scheduler_core/lib/src/repositories/{ => in_memory}/in_memory_locked_block_repository.dart (99%) rename packages/scheduler_core/lib/src/repositories/{ => in_memory}/in_memory_project_repository.dart (99%) rename packages/scheduler_core/lib/src/repositories/{ => in_memory}/in_memory_scheduling_snapshot_repository.dart (98%) rename packages/scheduler_core/lib/src/repositories/{ => in_memory}/in_memory_task_repository.dart (99%) rename packages/scheduler_core/lib/src/repositories/{ => interfaces}/locked_block_repository.dart (98%) rename packages/scheduler_core/lib/src/repositories/{ => interfaces}/project_repository.dart (97%) rename packages/scheduler_core/lib/src/repositories/{ => interfaces}/scheduling_snapshot_repository.dart (93%) rename packages/scheduler_core/lib/src/repositories/{ => interfaces}/task_repository.dart (98%) rename packages/scheduler_core/lib/src/repositories/{ => pagination}/repository_page.dart (87%) rename packages/scheduler_core/lib/src/repositories/{ => pagination}/repository_page_request.dart (90%) rename packages/scheduler_core/lib/src/repositories/{ => pagination}/repository_record.dart (91%) rename packages/scheduler_core/lib/src/repositories/{ => queries}/backlog_candidate_query.dart (91%) rename packages/scheduler_core/lib/src/repositories/{ => queries}/scheduling_state_snapshot.dart (98%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/conflicts}/scheduling_conflict_code.dart (83%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/notices}/scheduling_issue_code.dart (87%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/notices}/scheduling_notice_type.dart (93%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/operations}/scheduling_movement_code.dart (88%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/operations}/scheduling_operation_code.dart (95%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => codes/operations}/scheduling_outcome_code.dart (92%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => engine}/scheduling_engine.dart (99%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/changes}/scheduling_change.dart (95%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/changes}/scheduling_overlap.dart (93%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/inputs}/scheduling_input.dart (98%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/inputs}/scheduling_window.dart (97%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/notices}/scheduling_notice.dart (96%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => models/results}/scheduling_result.dart (97%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => planning}/backlog_insertion_plan.dart (92%) rename packages/scheduler_core/lib/src/scheduling_engine/{ => planning}/placement_item.dart (93%) rename packages/scheduler_core/lib/src/task_actions/{ => actions}/flexible_task_quick_action.dart (94%) rename packages/scheduler_core/lib/src/task_actions/{ => actions}/push_destination.dart (94%) rename packages/scheduler_core/lib/src/task_actions/{ => actions}/required_task_action.dart (94%) rename packages/scheduler_core/lib/src/task_actions/{ => requests}/surprise_task_log_request.dart (97%) rename packages/scheduler_core/lib/src/task_actions/{ => results}/flexible_task_action_result.dart (97%) rename packages/scheduler_core/lib/src/task_actions/{ => results}/push_destination_result.dart (96%) rename packages/scheduler_core/lib/src/task_actions/{ => results}/required_task_action_result.dart (96%) rename packages/scheduler_core/lib/src/task_actions/{ => results}/surprise_task_log_result.dart (96%) rename packages/scheduler_core/lib/src/task_actions/{ => services}/flexible_task_action_service.dart (99%) rename packages/scheduler_core/lib/src/task_actions/{ => services}/required_task_action_service.dart (98%) rename packages/scheduler_core/lib/src/task_actions/{ => services}/surprise_task_log_service.dart (99%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => codes}/task_activity_code.dart (86%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => codes}/task_transition_code.dart (84%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => codes}/task_transition_outcome_code.dart (80%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => models}/task_activity.dart (97%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => models}/task_activity_application_result.dart (92%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => models}/task_transition_result.dart (96%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => services}/task_activity_accounting_service.dart (97%) rename packages/scheduler_core/lib/src/task_lifecycle/{ => services}/task_transition_service.dart (99%) rename packages/scheduler_core/lib/src/time_contracts/{ => civil}/civil_date.dart (98%) rename packages/scheduler_core/lib/src/time_contracts/{ => civil}/wall_time.dart (97%) rename packages/scheduler_core/lib/src/time_contracts/{ => clocks}/clock.dart (73%) rename packages/scheduler_core/lib/src/time_contracts/{ => clocks}/fixed_clock.dart (83%) rename packages/scheduler_core/lib/src/time_contracts/{ => clocks}/system_clock.dart (86%) rename packages/scheduler_core/lib/src/time_contracts/{ => ids}/id_generator.dart (78%) rename packages/scheduler_core/lib/src/time_contracts/{ => ids}/sequential_id_generator.dart (90%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/policies}/nonexistent_local_time_policy.dart (84%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/policies}/repeated_local_time_policy.dart (88%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/policies}/time_zone_resolution_options.dart (89%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/resolution}/local_time_resolution.dart (69%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/resolution}/resolved_local_date_time.dart (91%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/resolvers}/fixed_offset_time_zone_resolver.dart (96%) rename packages/scheduler_core/lib/src/time_contracts/{ => zones/resolvers}/time_zone_resolver.dart (87%) rename packages/scheduler_core/lib/src/timeline_state/{ => mapping}/timeline_item_mapper.dart (99%) rename packages/scheduler_core/lib/src/timeline_state/{ => models}/compact_timeline_state.dart (97%) rename packages/scheduler_core/lib/src/timeline_state/{ => models}/timeline_item.dart (97%) rename packages/scheduler_core/lib/src/timeline_state/{ => tokens}/timeline_background_token.dart (82%) rename packages/scheduler_core/lib/src/timeline_state/{ => tokens}/timeline_difficulty_icon_token.dart (81%) rename packages/scheduler_core/lib/src/timeline_state/{ => tokens}/timeline_item_category.dart (84%) rename packages/scheduler_core/lib/src/timeline_state/{ => tokens}/timeline_quick_action.dart (83%) rename packages/scheduler_core/lib/src/timeline_state/{ => tokens}/timeline_reward_icon_token.dart (80%) rename packages/scheduler_core/lib/src/today_state/{ => models}/today_compact_state.dart (97%) rename packages/scheduler_core/lib/src/today_state/{ => models}/today_pending_notice.dart (97%) rename packages/scheduler_core/lib/src/today_state/{ => models}/today_state.dart (98%) rename packages/scheduler_core/lib/src/today_state/{ => models}/today_timeline_item.dart (98%) rename packages/scheduler_core/lib/src/today_state/{ => models}/today_timeline_item_source.dart (76%) rename packages/scheduler_core/lib/src/today_state/{ => queries}/get_today_state_query.dart (99%) rename packages/scheduler_core/lib/src/today_state/{ => requests}/get_today_state_request.dart (96%) rename packages/scheduler_export/lib/src/export_controller/{ => context}/export_context.dart (92%) rename packages/scheduler_export/lib/src/export_controller/{ => controller}/export_controller.dart (97%) rename packages/scheduler_export/lib/src/export_controller/{ => errors}/export_exception.dart (89%) rename packages/scheduler_export/lib/src/export_controller/{ => errors}/export_repository_exception.dart (90%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/core}/export_writer.dart (87%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/csv}/csv_cell_encoder.dart (83%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/csv}/csv_export_writer.dart (97%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/formats}/export_format_normalizer.dart (87%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/json}/export_json_context_encoder.dart (82%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/json}/json_export_writer.dart (96%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/registry}/export_writer_factory.dart (74%) rename packages/scheduler_export/lib/src/export_controller/{ => writers/registry}/export_writer_registry.dart (96%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => adapter}/fake_notification_adapter.dart (97%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => adapter}/notification_adapter.dart (90%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => feedback}/notification_feedback.dart (93%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => feedback}/notification_feedback_type.dart (85%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => requests}/notification_request.dart (96%) rename packages/scheduler_notifications/lib/src/notification_adapter/{ => requests}/notification_request_impl.dart (85%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => adapter}/desktop_notification_adapter.dart (95%) rename packages/scheduler_notifications_desktop/lib/src/{desktop_notification_adapter_stub => desktop_notification_adapter_io/backends/core}/desktop_notification_backend.dart (87%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => backends/core}/desktop_notification_backend_factory.dart (94%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => backends/fallback}/stdout_notification_backend.dart (93%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => backends/platform}/apple_script_string_encoder.dart (56%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => backends/platform}/linux_notify_send_backend.dart (95%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => backends/platform}/mac_os_notification_backend.dart (94%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => callbacks}/desktop_notification_log_sink.dart (70%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => callbacks}/desktop_process_runner.dart (76%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => platform}/desktop_notification_platform.dart (81%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/{ => platform}/desktop_notification_platform_detector.dart (84%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => adapter}/desktop_notification_adapter.dart (95%) rename packages/scheduler_notifications_desktop/lib/src/{desktop_notification_adapter_io => desktop_notification_adapter_stub/backends}/desktop_notification_backend.dart (87%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => backends}/desktop_notification_backend_factory.dart (87%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => backends}/stdout_notification_backend.dart (93%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => callbacks}/default_log_sink.dart (58%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => callbacks}/desktop_notification_log_sink.dart (69%) rename packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/{ => platform}/desktop_notification_platform.dart (80%) rename packages/scheduler_persistence/lib/persistence/{ => failures/core}/repository_failure.dart (94%) rename packages/scheduler_persistence/lib/persistence/{ => failures/identity}/repository_duplicate_id.dart (86%) rename packages/scheduler_persistence/lib/persistence/{ => failures/identity}/repository_not_found.dart (85%) rename packages/scheduler_persistence/lib/persistence/{ => failures/identity}/repository_owner_mismatch.dart (87%) rename packages/scheduler_persistence/lib/persistence/{ => failures/revision}/repository_invalid_revision.dart (88%) rename packages/scheduler_persistence/lib/persistence/{ => failures/revision}/repository_stale_revision.dart (89%) rename packages/scheduler_persistence/lib/persistence/{ => failures/storage}/repository_storage_failure.dart (87%) rename packages/scheduler_persistence/lib/persistence/{ => repositories}/locked_block_repository.dart (98%) rename packages/scheduler_persistence/lib/persistence/{ => repositories}/project_repository.dart (97%) rename packages/scheduler_persistence/lib/persistence/{ => repositories}/schedule_snapshot_repository.dart (96%) rename packages/scheduler_persistence/lib/persistence/{ => repositories}/settings_repository.dart (95%) rename packages/scheduler_persistence/lib/persistence/{ => repositories}/task_repository.dart (98%) rename packages/scheduler_persistence/lib/persistence/{ => results}/either.dart (95%) rename packages/scheduler_persistence/lib/persistence/{ => results}/left.dart (84%) rename packages/scheduler_persistence/lib/persistence/{ => results}/repo_result.dart (77%) rename packages/scheduler_persistence/lib/persistence/{ => results}/right.dart (84%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => records}/identified_record.dart (61%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => records}/record.dart (95%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => records}/snapshot_record.dart (90%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => repositories}/in_memory_locked_block_repository.dart (99%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => repositories}/in_memory_project_repository.dart (98%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => repositories}/in_memory_schedule_snapshot_repository.dart (99%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => repositories}/in_memory_settings_repository.dart (98%) rename packages/scheduler_persistence_memory/lib/persistence_memory/{ => repositories}/in_memory_task_repository.dart (99%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => daos}/locked_block_dao.dart (87%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => daos}/project_dao.dart (83%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => daos}/settings_dao.dart (84%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => daos}/snapshot_dao.dart (84%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => daos}/task_dao.dart (82%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => database}/scheduler_db.dart (94%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/locked_time}/locked_blocks.dart (96%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/locked_time}/locked_overrides.dart (96%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/projects}/projects.dart (96%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/settings}/settings_table.dart (95%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/snapshots}/snapshots.dart (97%) rename packages/scheduler_persistence_sqlite/lib/src/scheduler_db/{ => tables/tasks}/tasks.dart (97%) diff --git a/packages/scheduler_backup/lib/src/encrypted_backup.dart b/packages/scheduler_backup/lib/src/encrypted_backup.dart index 706f997..58d0ef2 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup.dart @@ -7,12 +7,12 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; -part 'encrypted_backup/backup_decryption_exception.dart'; -part 'encrypted_backup/backup_encryption_codec.dart'; -part 'encrypted_backup/backup_exception.dart'; -part 'encrypted_backup/backup_file_names.dart'; -part 'encrypted_backup/backup_paths.dart'; -part 'encrypted_backup/encrypted_backup_operations.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'; diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart similarity index 98% rename from packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart index 32d3c6f..d983714 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/backup_encryption_codec.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; Future> _encryptBytes({ required String passphrase, diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart similarity index 92% rename from packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart index 31fd2c8..0b53184 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/backup_decryption_exception.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_decryption_exception.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; /// Thrown when an encrypted backup cannot be decrypted with the passphrase. final class BackupDecryptionException implements Exception { diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart similarity index 89% rename from packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart index 1a8b466..8a36df4 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/backup_exception.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; /// Thrown when backup input paths or file contents are invalid. final class BackupException implements Exception { diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart similarity index 98% rename from packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart index b5bb58e..80ee099 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/encrypted_backup_operations.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; /// Create an encrypted backup of the scheduler SQLite file. /// diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart similarity index 95% rename from packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart index 4a010ae..a596959 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/backup_file_names.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_file_names.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; Future _nextAvailableBackupFile(Directory directory, String name) async { final preferred = File(_joinPath(directory.path, name)); diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart similarity index 97% rename from packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart rename to packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart index 7890550..8cb5cba 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/backup_paths.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart @@ -1,4 +1,4 @@ -part of '../encrypted_backup.dart'; +part of '../../encrypted_backup.dart'; /// Default scheduler SQLite database path. File defaultSchedulerSqliteFile() { diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index f731a88..cd1762d 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -18,9 +18,9 @@ import 'scheduling_engine.dart'; import 'task_actions.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; -part 'application_commands/application_command_code.dart'; -part 'application_commands/application_child_task_draft.dart'; -part 'application_commands/application_command_read_hint.dart'; -part 'application_commands/application_command_result.dart'; -part 'application_commands/v1_application_command_use_cases.dart'; -part 'application_commands/planning_state.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_commands/application_command_code.dart b/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart similarity index 92% rename from packages/scheduler_core/lib/src/application_commands/application_command_code.dart rename to packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart index 94b498a..84b3eeb 100644 --- a/packages/scheduler_core/lib/src/application_commands/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; /// Stable V1 application command identifiers. enum ApplicationCommandCode { diff --git a/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart similarity index 94% rename from packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart rename to packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart index d469da0..4bafc31 100644 --- a/packages/scheduler_core/lib/src/application_commands/application_child_task_draft.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; /// Draft row for creating one child task from an application command. class ApplicationChildTaskDraft { diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart similarity index 95% rename from packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart rename to packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart index 06102d7..1897022 100644 --- a/packages/scheduler_core/lib/src/application_commands/application_command_read_hint.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; /// Hint for read models that should be refreshed after a command. class ApplicationCommandReadHint { diff --git a/packages/scheduler_core/lib/src/application_commands/application_command_result.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_commands/application_command_result.dart rename to packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart index adb8fca..d6b8722 100644 --- a/packages/scheduler_core/lib/src/application_commands/application_command_result.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; /// Structured result returned by V1 application commands. class ApplicationCommandResult { diff --git a/packages/scheduler_core/lib/src/application_commands/planning_state.dart b/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_commands/planning_state.dart rename to packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart index 51288b6..5902833 100644 --- a/packages/scheduler_core/lib/src/application_commands/planning_state.dart +++ b/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; class _PlanningState { _PlanningState({ diff --git a/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart rename to packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart index 1338fa1..fa8498c 100644 --- a/packages/scheduler_core/lib/src/application_commands/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart @@ -1,4 +1,4 @@ -part of '../application_commands.dart'; +part of '../../application_commands.dart'; /// UI-facing V1 command use cases. class V1ApplicationCommandUseCases { diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application_layer.dart index c62bae6..57e1f3e 100644 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ b/packages/scheduler_core/lib/src/application_layer.dart @@ -15,33 +15,33 @@ import 'repositories.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; -part 'application_layer/owner_time_zone_context.dart'; -part 'application_layer/application_operation_context.dart'; -part 'application_layer/application_failure_code.dart'; -part 'application_layer/application_failure.dart'; -part 'application_layer/application_result.dart'; -part 'application_layer/application_failure_exception.dart'; -part 'application_layer/application_persistence_exception.dart'; -part 'application_layer/application_operation_record.dart'; -part 'application_layer/notice_acknowledgement_record.dart'; -part 'application_layer/owner_settings.dart'; -part 'application_layer/task_activity_repository.dart'; -part 'application_layer/project_statistics_repository.dart'; -part 'application_layer/owner_settings_repository.dart'; -part 'application_layer/notice_acknowledgement_repository.dart'; -part 'application_layer/application_operation_repository.dart'; -part 'application_layer/application_unit_of_work_repositories.dart'; -part 'application_layer/application_unit_of_work.dart'; -part 'application_layer/application_scheduling_loader.dart'; -part 'application_layer/in_memory_application_unit_of_work.dart'; -part 'application_layer/in_memory_application_state.dart'; -part 'application_layer/in_memory_application_repositories.dart'; -part 'application_layer/unit_of_work_task_repository.dart'; -part 'application_layer/unit_of_work_project_repository.dart'; -part 'application_layer/unit_of_work_locked_block_repository.dart'; -part 'application_layer/unit_of_work_scheduling_snapshot_repository.dart'; -part 'application_layer/unit_of_work_task_activity_repository.dart'; -part 'application_layer/unit_of_work_project_statistics_repository.dart'; -part 'application_layer/unit_of_work_owner_settings_repository.dart'; -part 'application_layer/unit_of_work_notice_acknowledgement_repository.dart'; -part 'application_layer/unit_of_work_operation_repository.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_layer/application_operation_context.dart b/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart similarity index 96% rename from packages/scheduler_core/lib/src/application_layer/application_operation_context.dart rename to packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart index aa9d187..858b21d 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_operation_context.dart +++ b/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Per-operation application context. class ApplicationOperationContext { diff --git a/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart similarity index 92% rename from packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart rename to packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart index 7e26a6c..342e739 100644 --- a/packages/scheduler_core/lib/src/application_layer/owner_time_zone_context.dart +++ b/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Owner-local time context supplied to application operations. class OwnerTimeZoneContext { diff --git a/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart similarity index 96% rename from packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart rename to packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart index fc0f3dc..68423bf 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_scheduling_loader.dart +++ b/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Repository loader for common scheduler input assembly. class ApplicationSchedulingLoader { diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart b/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart similarity index 94% rename from packages/scheduler_core/lib/src/application_layer/application_operation_record.dart rename to packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart index f8fe4aa..342e82c 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_operation_record.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Persisted exactly-once operation record. class ApplicationOperationRecord { diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart similarity index 93% rename from packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart rename to packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart index 4e5f1c7..86afa0b 100644 --- a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_record.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Owner-scoped acknowledgement for a one-time scheduling notice. class NoticeAcknowledgementRecord { diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings.dart b/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart similarity index 97% rename from packages/scheduler_core/lib/src/application_layer/owner_settings.dart rename to packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart index dca2743..af4fc7b 100644 --- a/packages/scheduler_core/lib/src/application_layer/owner_settings.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Owner-level settings needed by application use cases. class OwnerSettings { diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart index c7a44c2..bf071ae 100644 --- a/packages/scheduler_core/lib/src/application_layer/in_memory_application_repositories.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../application_layer.dart'; class _InMemoryApplicationRepositories implements ApplicationUnitOfWorkRepositories { diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart index 0c68d39..28649a5 100644 --- a/packages/scheduler_core/lib/src/application_layer/in_memory_application_state.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../application_layer.dart'; class _InMemoryApplicationState { _InMemoryApplicationState({ diff --git a/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart index 7861618..4cf69c1 100644 --- a/packages/scheduler_core/lib/src/application_layer/in_memory_application_unit_of_work.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../application_layer.dart'; /// In-memory unit-of-work implementation for deterministic application tests. class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { diff --git a/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart index 31bb9b0..2e1673b 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_operation_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository contract for exactly-once operation records. abstract interface class ApplicationOperationRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart similarity index 93% rename from packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart index 7711608..a798e93 100644 --- a/packages/scheduler_core/lib/src/application_layer/notice_acknowledgement_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository contract for consumed one-time notices. abstract interface class NoticeAcknowledgementRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart similarity index 89% rename from packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart index 990174e..4d4afcc 100644 --- a/packages/scheduler_core/lib/src/application_layer/owner_settings_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository contract for owner settings. abstract interface class OwnerSettingsRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart similarity index 92% rename from packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart index b2656ea..5b2fb00 100644 --- a/packages/scheduler_core/lib/src/application_layer/project_statistics_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository contract for project statistics aggregates. abstract interface class ProjectStatisticsRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart similarity index 94% rename from packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart index 139927d..c258d3d 100644 --- a/packages/scheduler_core/lib/src/application_layer/task_activity_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository contract for internal task activities. abstract interface class TaskActivityRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart index c8b1a58..bef584d 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Atomic application transaction boundary. abstract interface class ApplicationUnitOfWork { diff --git a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart similarity index 92% rename from packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart index cb77ce9..86e94ed 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_unit_of_work_repositories.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; /// Repository set available inside one application unit of work. abstract interface class ApplicationUnitOfWorkRepositories { diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart index e8cbeee..8fca707 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_notice_acknowledgement_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkNoticeAcknowledgementRepository implements NoticeAcknowledgementRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart index 9c2bda6..04c1d6e 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_operation_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { _UnitOfWorkOperationRepository(this._operationsById); diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart similarity index 97% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart index 2665551..27c0dd3 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_owner_settings_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { _UnitOfWorkOwnerSettingsRepository({ diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart index 1ead197..e379ff0 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkProjectRepository implements ProjectRepository { _UnitOfWorkProjectRepository({ diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart index 89a4382..73a2a68 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_project_statistics_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkProjectStatisticsRepository implements ProjectStatisticsRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart index 854ae7c..1a5909a 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { _UnitOfWorkLockedBlockRepository({ diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart similarity index 94% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart index bb2c427..e7d9214 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkSchedulingSnapshotRepository implements SchedulingSnapshotRepository { diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart index 61412f4..7e59e59 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_activity_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { _UnitOfWorkTaskActivityRepository({ diff --git a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart rename to packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart index 743916f..dafe8d3 100644 --- a/packages/scheduler_core/lib/src/application_layer/unit_of_work_task_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../../../application_layer.dart'; class _UnitOfWorkTaskRepository implements TaskRepository { _UnitOfWorkTaskRepository({ diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart similarity index 92% rename from packages/scheduler_core/lib/src/application_layer/application_failure.dart rename to packages/scheduler_core/lib/src/application_layer/results/application_failure.dart index 1beed99..bd7c3c8 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_failure.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Typed failure returned by application commands and queries. class ApplicationFailure { diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart similarity index 83% rename from packages/scheduler_core/lib/src/application_layer/application_failure_code.dart rename to packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart index db9eee3..777f90e 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_failure_code.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Stable application-layer result categories. enum ApplicationFailureCode { diff --git a/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart similarity index 87% rename from packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart rename to packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart index dee317f..d90b35d 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_failure_exception.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Exception for expected failures raised inside a unit-of-work callback. class ApplicationFailureException implements Exception { diff --git a/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart similarity index 82% rename from packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart rename to packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart index e1f7e5a..4d3d702 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_persistence_exception.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Exception representing persistence/driver failures at repository boundaries. class ApplicationPersistenceException implements Exception { diff --git a/packages/scheduler_core/lib/src/application_layer/application_result.dart b/packages/scheduler_core/lib/src/application_layer/results/application_result.dart similarity index 96% rename from packages/scheduler_core/lib/src/application_layer/application_result.dart rename to packages/scheduler_core/lib/src/application_layer/results/application_result.dart index 881c807..c478c5e 100644 --- a/packages/scheduler_core/lib/src/application_layer/application_result.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_result.dart @@ -1,4 +1,4 @@ -part of '../application_layer.dart'; +part of '../../application_layer.dart'; /// Result wrapper for expected application success/failure paths. class ApplicationResult { diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application_management.dart index 5aecbbb..061e8f9 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application_management.dart @@ -14,19 +14,19 @@ import 'locked_time.dart'; import 'models.dart'; import 'project_statistics.dart'; import 'time_contracts.dart'; -part 'application_management/application_management_command_code.dart'; -part 'application_management/owner_settings_warning_code.dart'; -part 'application_management/get_backlog_request.dart'; -part 'application_management/backlog_item_read_model.dart'; -part 'application_management/backlog_query_result.dart'; -part 'application_management/get_project_defaults_request.dart'; -part 'application_management/project_defaults_query_result.dart'; -part 'application_management/project_profile_draft.dart'; -part 'application_management/project_profile_update.dart'; -part 'application_management/locked_block_draft.dart'; -part 'application_management/locked_block_update.dart'; -part 'application_management/owner_settings_update_result.dart'; -part 'application_management/owner_settings_update.dart'; -part 'application_management/notice_acknowledgement_result.dart'; -part 'application_management/v1_application_management_use_cases.dart'; -part 'application_management/parsed_notice_id.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_management/application_management_command_code.dart b/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart similarity index 88% rename from packages/scheduler_core/lib/src/application_management/application_management_command_code.dart rename to packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart index ffcf978..06d4fda 100644 --- a/packages/scheduler_core/lib/src/application_management/application_management_command_code.dart +++ b/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Stable management command identifiers. enum ApplicationManagementCommandCode { diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart similarity index 78% rename from packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart rename to packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart index fbac4b9..a01e1fc 100644 --- a/packages/scheduler_core/lib/src/application_management/owner_settings_warning_code.dart +++ b/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Typed warning codes for settings changes that reinterpret local dates/times. enum OwnerSettingsWarningCode { diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart b/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_management/locked_block_draft.dart rename to packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart index 33bc3b3..ab018f8 100644 --- a/packages/scheduler_core/lib/src/application_management/locked_block_draft.dart +++ b/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Draft used when creating a locked block. class LockedBlockDraft { diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart b/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart similarity index 93% rename from packages/scheduler_core/lib/src/application_management/project_profile_draft.dart rename to packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart index 99c7f83..af0470a 100644 --- a/packages/scheduler_core/lib/src/application_management/project_profile_draft.dart +++ b/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Draft used when creating a project. class ProjectProfileDraft { diff --git a/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart similarity index 79% rename from packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart rename to packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart index d187142..04cd55d 100644 --- a/packages/scheduler_core/lib/src/application_management/parsed_notice_id.dart +++ b/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; class _ParsedNoticeId { const _ParsedNoticeId({ diff --git a/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart b/packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart similarity index 88% rename from packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart rename to packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart index 6fa8906..d0d5048 100644 --- a/packages/scheduler_core/lib/src/application_management/backlog_item_read_model.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// One backlog row with its configured staleness marker. class BacklogItemReadModel { diff --git a/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart b/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_management/backlog_query_result.dart rename to packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart index 921971e..a388ea1 100644 --- a/packages/scheduler_core/lib/src/application_management/backlog_query_result.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Query result for the backlog surface. class BacklogQueryResult { diff --git a/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart b/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart similarity index 90% rename from packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart rename to packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart index 9960b65..b5e3cd5 100644 --- a/packages/scheduler_core/lib/src/application_management/project_defaults_query_result.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Project defaults query result. class ProjectDefaultsQueryResult { diff --git a/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart b/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart similarity index 90% rename from packages/scheduler_core/lib/src/application_management/get_backlog_request.dart rename to packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart index 3892b02..2d5e280 100644 --- a/packages/scheduler_core/lib/src/application_management/get_backlog_request.dart +++ b/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Backlog query request. class GetBacklogRequest { diff --git a/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart similarity index 89% rename from packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart rename to packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart index d5490aa..eefd6e0 100644 --- a/packages/scheduler_core/lib/src/application_management/get_project_defaults_request.dart +++ b/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Query request for project defaults and learned suggestions. class GetProjectDefaultsRequest { diff --git a/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart similarity index 89% rename from packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart rename to packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart index 2c5b300..671b5e4 100644 --- a/packages/scheduler_core/lib/src/application_management/notice_acknowledgement_result.dart +++ b/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Result for notice acknowledgement commands. class NoticeAcknowledgementResult { diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart rename to packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart index 22fa941..99c8bfb 100644 --- a/packages/scheduler_core/lib/src/application_management/owner_settings_update_result.dart +++ b/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Result for owner settings updates. class OwnerSettingsUpdateResult { diff --git a/packages/scheduler_core/lib/src/application_management/locked_block_update.dart b/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart similarity index 91% rename from packages/scheduler_core/lib/src/application_management/locked_block_update.dart rename to packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart index 46f8f0b..b7204d0 100644 --- a/packages/scheduler_core/lib/src/application_management/locked_block_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Patch used when updating a locked block definition. class LockedBlockUpdate { diff --git a/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart b/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart similarity index 90% rename from packages/scheduler_core/lib/src/application_management/owner_settings_update.dart rename to packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart index 1631108..4395dd6 100644 --- a/packages/scheduler_core/lib/src/application_management/owner_settings_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Patch used when updating owner settings. class OwnerSettingsUpdate { diff --git a/packages/scheduler_core/lib/src/application_management/project_profile_update.dart b/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart similarity index 93% rename from packages/scheduler_core/lib/src/application_management/project_profile_update.dart rename to packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart index bf5c898..4dcf4cd 100644 --- a/packages/scheduler_core/lib/src/application_management/project_profile_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// Patch used when updating configured project defaults. class ProjectProfileUpdate { diff --git a/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart similarity index 99% rename from packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart rename to packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart index 5ad379b..6cfa204 100644 --- a/packages/scheduler_core/lib/src/application_management/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart @@ -1,4 +1,4 @@ -part of '../application_management.dart'; +part of '../../application_management.dart'; /// V1 management facade used by future Flutter state management. class V1ApplicationManagementUseCases { diff --git a/packages/scheduler_core/lib/src/backlog.dart b/packages/scheduler_core/lib/src/backlog.dart index 3bc4a7f..f468f79 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/backlog.dart @@ -9,9 +9,9 @@ library; // scheduler so the engine can focus on moving tasks through time. import 'models.dart'; -part 'backlog/backlog_filter.dart'; -part 'backlog/backlog_sort_key.dart'; -part 'backlog/backlog_staleness_marker.dart'; -part 'backlog/backlog_staleness_settings.dart'; -part 'backlog/backlog_view.dart'; -part 'backlog/indexed_task.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/backlog/backlog_filter.dart b/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart similarity index 96% rename from packages/scheduler_core/lib/src/backlog/backlog_filter.dart rename to packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart index f36ce61..b0e7fab 100644 --- a/packages/scheduler_core/lib/src/backlog/backlog_filter.dart +++ b/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Derived backlog filters for a unified backlog list. /// diff --git a/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart b/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart similarity index 95% rename from packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart rename to packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart index 1269794..abc33db 100644 --- a/packages/scheduler_core/lib/src/backlog/backlog_sort_key.dart +++ b/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Sort options for a unified backlog list. /// diff --git a/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart similarity index 94% rename from packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart rename to packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart index 10f22f2..c2b254c 100644 --- a/packages/scheduler_core/lib/src/backlog/backlog_staleness_marker.dart +++ b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Visual age bucket for backlog display. /// diff --git a/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart similarity index 97% rename from packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart rename to packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart index 2229ee7..4fc3768 100644 --- a/packages/scheduler_core/lib/src/backlog/backlog_staleness_settings.dart +++ b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Configurable thresholds for backlog age markers. /// diff --git a/packages/scheduler_core/lib/src/backlog/backlog_view.dart b/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart similarity index 99% rename from packages/scheduler_core/lib/src/backlog/backlog_view.dart rename to packages/scheduler_core/lib/src/backlog/views/backlog_view.dart index 61b948d..51069d2 100644 --- a/packages/scheduler_core/lib/src/backlog/backlog_view.dart +++ b/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Read-only backlog projection over the unified task list. /// diff --git a/packages/scheduler_core/lib/src/backlog/indexed_task.dart b/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart similarity index 88% rename from packages/scheduler_core/lib/src/backlog/indexed_task.dart rename to packages/scheduler_core/lib/src/backlog/views/indexed_task.dart index ded17d1..0c0d93f 100644 --- a/packages/scheduler_core/lib/src/backlog/indexed_task.dart +++ b/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart @@ -1,4 +1,4 @@ -part of '../backlog.dart'; +part of '../../backlog.dart'; /// Backlog task paired with its source-list position for stable sorting. class _IndexedTask { diff --git a/packages/scheduler_core/lib/src/child_tasks.dart b/packages/scheduler_core/lib/src/child_tasks.dart index 6041b64..fd6017c 100644 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ b/packages/scheduler_core/lib/src/child_tasks.dart @@ -10,12 +10,12 @@ library; import 'models.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; -part 'child_tasks/child_task_entry.dart'; -part 'child_tasks/child_task_break_up_request.dart'; -part 'child_tasks/child_task_break_up_result.dart'; -part 'child_tasks/child_task_break_up_service.dart'; -part 'child_tasks/child_task_view.dart'; -part 'child_tasks/child_task_completion_result.dart'; -part 'child_tasks/child_task_completion_service.dart'; -part 'child_tasks/child_task_summary.dart'; -part 'child_tasks/indexed_child.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/child_tasks/indexed_child.dart b/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart similarity index 97% rename from packages/scheduler_core/lib/src/child_tasks/indexed_child.dart rename to packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart index 790926e..7f27e93 100644 --- a/packages/scheduler_core/lib/src/child_tasks/indexed_child.dart +++ b/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; class _IndexedChild { const _IndexedChild({ diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart similarity index 98% rename from packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart rename to packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart index 0bb6e1d..8cdfa8c 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_entry.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Row-style input for creating one child task. /// diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart similarity index 98% rename from packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart rename to packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart index faf1fc2..2cedcd8 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_summary.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Status counts for a parent's direct children. class ChildTaskSummary { diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart similarity index 98% rename from packages/scheduler_core/lib/src/child_tasks/child_task_view.dart rename to packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart index f25e3cf..7a49e3d 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_view.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Read-only parent/child projection over a task list. /// diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart b/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart similarity index 95% rename from packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart rename to packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart index 37c26d4..7ae03bf 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_request.dart +++ b/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Command input for breaking one parent task into direct children. class ChildTaskBreakUpRequest { diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart b/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart similarity index 97% rename from packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart rename to packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart index 21280ae..710167b 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_result.dart +++ b/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Result from breaking a parent task into direct children. class ChildTaskBreakUpResult { diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart b/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart similarity index 98% rename from packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart rename to packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart index e50bf2d..316d093 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_result.dart +++ b/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Result from child/parent completion propagation. class ChildTaskCompletionResult { diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart similarity index 98% rename from packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart rename to packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart index 2391462..23bf73f 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_break_up_service.dart +++ b/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Creates direct child tasks from ordered child-entry rows. class ChildTaskBreakUpService { diff --git a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart b/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart rename to packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart index 2976786..a16d8a3 100644 --- a/packages/scheduler_core/lib/src/child_tasks/child_task_completion_service.dart +++ b/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart @@ -1,4 +1,4 @@ -part of '../child_tasks.dart'; +part of '../../child_tasks.dart'; /// Applies direct parent/child completion rules. /// diff --git a/packages/scheduler_core/lib/src/document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping.dart index 1638bf4..c8a0749 100644 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping.dart @@ -17,31 +17,31 @@ import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'task_statistics.dart'; import 'time_contracts.dart'; -part 'document_mapping/document_mapping_failure_code.dart'; -part 'document_mapping/document_mapping_exception.dart'; -part 'document_mapping/document_metadata.dart'; -part 'document_mapping/persistence_enum_mapping.dart'; -part 'document_mapping/task_document_mapping.dart'; -part 'document_mapping/task_statistics_document_mapping.dart'; -part 'document_mapping/project_document_mapping.dart'; -part 'document_mapping/project_statistics_document_mapping.dart'; -part 'document_mapping/locked_block_recurrence_document_mapping.dart'; -part 'document_mapping/locked_block_document_mapping.dart'; -part 'document_mapping/locked_block_override_document_mapping.dart'; -part 'document_mapping/task_activity_document_mapping.dart'; -part 'document_mapping/owner_settings_document_mapping.dart'; -part 'document_mapping/backlog_staleness_document_mapping.dart'; -part 'document_mapping/notice_acknowledgement_document_mapping.dart'; -part 'document_mapping/application_operation_document_mapping.dart'; -part 'document_mapping/scheduling_snapshot_document_mapping.dart'; -part 'document_mapping/scheduling_window_document_mapping.dart'; -part 'document_mapping/time_interval_document_mapping.dart'; -part 'document_mapping/scheduling_notice_document_mapping.dart'; -part 'document_mapping/scheduling_change_document_mapping.dart'; -part 'document_mapping/scheduling_overlap_document_mapping.dart'; -part 'document_mapping/task_document_extension.dart'; -part 'document_mapping/task_statistics_document_extension.dart'; -part 'document_mapping/project_statistics_document_extension.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'; extension on Task { void _validateTaskDocumentMetadata(Map document) { diff --git a/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart similarity index 97% rename from packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart index 4e37ca4..eb53e45 100644 --- a/packages/scheduler_core/lib/src/document_mapping/application_operation_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [ApplicationOperationRecord]. abstract final class ApplicationOperationDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart similarity index 96% rename from packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart index 0d39b1a..db55fb0 100644 --- a/packages/scheduler_core/lib/src/document_mapping/backlog_staleness_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [BacklogStalenessSettings]. abstract final class BacklogStalenessDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart similarity index 97% rename from packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart index 22a51d4..3b98ee4 100644 --- a/packages/scheduler_core/lib/src/document_mapping/notice_acknowledgement_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [NoticeAcknowledgementRecord]. abstract final class NoticeAcknowledgementDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart index 854a49a..4df1841 100644 --- a/packages/scheduler_core/lib/src/document_mapping/owner_settings_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [OwnerSettings]. abstract final class OwnerSettingsDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart index 65cdccd..d9bee32 100644 --- a/packages/scheduler_core/lib/src/document_mapping/persistence_enum_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Stable enum encode/decode helpers for V1 document maps. abstract final class PersistenceEnumMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart similarity index 93% rename from packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart rename to packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart index 5440dac..d383dfb 100644 --- a/packages/scheduler_core/lib/src/document_mapping/document_mapping_exception.dart +++ b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Typed mapping failure for malformed V1 documents. class DocumentMappingException implements Exception { diff --git a/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart similarity index 82% rename from packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart rename to packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart index 061275b..2971cef 100644 --- a/packages/scheduler_core/lib/src/document_mapping/document_mapping_failure_code.dart +++ b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Stable document mapping failure categories. enum DocumentMappingFailureCode { diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart index 3dd6323..041562d 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_block_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [LockedBlock]. abstract final class LockedBlockDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart index 7525bd1..2ff50c4 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_block_override_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [LockedBlockOverride]. abstract final class LockedBlockOverrideDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart similarity index 97% rename from packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart index 043e45d..5facbb8 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_block_recurrence_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [LockedBlockRecurrence]. abstract final class LockedBlockRecurrenceDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart b/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart similarity index 90% rename from packages/scheduler_core/lib/src/document_mapping/document_metadata.dart rename to packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart index 9f48044..5b15a3d 100644 --- a/packages/scheduler_core/lib/src/document_mapping/document_metadata.dart +++ b/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Common metadata decoded from a V1 top-level document. class DocumentMetadata { diff --git a/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart index 93d4792..c505f90 100644 --- a/packages/scheduler_core/lib/src/document_mapping/project_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [ProjectProfile]. abstract final class ProjectDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart rename to packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart index 6b86bb5..bd3d4f9 100644 --- a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Convenience extension for converting [ProjectStatistics] into a document. extension ProjectStatisticsDocumentExtension on ProjectStatistics { diff --git a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart index 606fe6d..acea981 100644 --- a/packages/scheduler_core/lib/src/document_mapping/project_statistics_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [ProjectStatistics]. abstract final class ProjectStatisticsDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart similarity index 97% rename from packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart index b6f5714..734831a 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling_change_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [SchedulingChange]. abstract final class SchedulingChangeDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart index 4096b74..9f687db 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling_notice_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [SchedulingNotice]. abstract final class SchedulingNoticeDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart similarity index 96% rename from packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart index e2dc7e6..439a87c 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling_overlap_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [SchedulingOverlap]. abstract final class SchedulingOverlapDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart index 7f47067..e10efb2 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling_snapshot_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [SchedulingStateSnapshot]. abstract final class SchedulingSnapshotDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart similarity index 95% rename from packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart index 06a5c8f..c0b2758 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling_window_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [SchedulingWindow]. abstract final class SchedulingWindowDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart index df9fdc8..6a476ab 100644 --- a/packages/scheduler_core/lib/src/document_mapping/task_activity_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [TaskActivity]. abstract final class TaskActivityDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart similarity index 93% rename from packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart rename to packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart index 1830657..7e3d437 100644 --- a/packages/scheduler_core/lib/src/document_mapping/task_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Convenience extension for converting a [Task] into a V1 document map. extension TaskDocumentExtension on Task { diff --git a/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart index 4b51d0f..3d3a877 100644 --- a/packages/scheduler_core/lib/src/document_mapping/task_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [Task]. abstract final class TaskDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart similarity index 86% rename from packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart rename to packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart index fabd306..12f679d 100644 --- a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Convenience extension for converting [TaskStatistics] into an embedded map. extension TaskStatisticsDocumentExtension on TaskStatistics { diff --git a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart similarity index 98% rename from packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart index b503c61..871794f 100644 --- a/packages/scheduler_core/lib/src/document_mapping/task_statistics_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [TaskStatistics]. abstract final class TaskStatisticsDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart similarity index 95% rename from packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart rename to packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart index 1817f7d..4f3a924 100644 --- a/packages/scheduler_core/lib/src/document_mapping/time_interval_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart @@ -1,4 +1,4 @@ -part of '../document_mapping.dart'; +part of '../../document_mapping.dart'; /// Document mapping for [TimeInterval]. abstract final class TimeIntervalDocumentMapping { diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/document_migration.dart index c5c172e..7cdb9c3 100644 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ b/packages/scheduler_core/lib/src/document_migration.dart @@ -10,16 +10,16 @@ library; import 'document_mapping.dart'; import 'persistence_contract.dart'; import 'time_contracts.dart'; -part 'document_migration/document_migration_entity.dart'; -part 'document_migration/document_migration_status.dart'; -part 'document_migration/document_migration_failure_code.dart'; -part 'document_migration/document_migration_note_code.dart'; -part 'document_migration/document_migration_provenance.dart'; -part 'document_migration/document_migration_note.dart'; -part 'document_migration/document_migration_report.dart'; -part 'document_migration/document_migration_result.dart'; -part 'document_migration/v1_document_migration_service.dart'; -part 'document_migration/v0_migrator.dart'; -part 'document_migration/schema_version_read.dart'; -part 'document_migration/legacy_document_exception.dart'; -part 'document_migration/legacy_metadata_instants.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/document_migration/document_migration_entity.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart similarity index 75% rename from packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart rename to packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart index 9730c47..af5a730 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_entity.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Entity shape being migrated. enum DocumentMigrationEntity { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart similarity index 82% rename from packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart rename to packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart index 688b5b9..57cea1c 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_failure_code.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Typed categories for migration failures. enum DocumentMigrationFailureCode { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart similarity index 80% rename from packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart rename to packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart index 09da94b..0f65bea 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_note_code.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Non-fatal migration notes surfaced to callers during dry-runs. enum DocumentMigrationNoteCode { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart similarity index 73% rename from packages/scheduler_core/lib/src/document_migration/document_migration_status.dart rename to packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart index 4e953e1..172a03f 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_status.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Result state for a migration attempt. enum DocumentMigrationStatus { diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart similarity index 83% rename from packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart rename to packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart index 4370a09..285ce5a 100644 --- a/packages/scheduler_core/lib/src/document_migration/legacy_document_exception.dart +++ b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; class _LegacyDocumentException implements Exception { const _LegacyDocumentException({ diff --git a/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart rename to packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart index 6b1b590..0f8b8d0 100644 --- a/packages/scheduler_core/lib/src/document_migration/legacy_metadata_instants.dart +++ b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; class _LegacyMetadataInstants { const _LegacyMetadataInstants({ diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart similarity index 87% rename from packages/scheduler_core/lib/src/document_migration/document_migration_note.dart rename to packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart index b0b3770..3f12708 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_note.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// A non-fatal detail about a migrated document. class DocumentMigrationNote { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart similarity index 80% rename from packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart rename to packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart index 68be7a7..00b0626 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_provenance.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Provenance labels written by migrations. abstract final class DocumentMigrationProvenance { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart similarity index 95% rename from packages/scheduler_core/lib/src/document_migration/document_migration_report.dart rename to packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart index 27b36e6..51f589e 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_report.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Typed dry-run report for one document migration attempt. class DocumentMigrationReport { diff --git a/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart similarity index 93% rename from packages/scheduler_core/lib/src/document_migration/document_migration_result.dart rename to packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart index 4e5b08b..39370b5 100644 --- a/packages/scheduler_core/lib/src/document_migration/document_migration_result.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Migration output plus report. class DocumentMigrationResult { diff --git a/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart b/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart similarity index 83% rename from packages/scheduler_core/lib/src/document_migration/schema_version_read.dart rename to packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart index 81d7f05..3ff8049 100644 --- a/packages/scheduler_core/lib/src/document_migration/schema_version_read.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; class _SchemaVersionRead { const _SchemaVersionRead.version(this.version) : failure = null; diff --git a/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart b/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart similarity index 75% rename from packages/scheduler_core/lib/src/document_migration/v0_migrator.dart rename to packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart index ac2e172..e53f28f 100644 --- a/packages/scheduler_core/lib/src/document_migration/v0_migrator.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; typedef _V0Migrator = Map Function( Map document, diff --git a/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart rename to packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart index adcbbc9..ee5d07e 100644 --- a/packages/scheduler_core/lib/src/document_migration/v1_document_migration_service.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart @@ -1,4 +1,4 @@ -part of '../document_migration.dart'; +part of '../../document_migration.dart'; /// Pure V0-to-V1 migration service. /// diff --git a/packages/scheduler_core/lib/src/locked_time.dart b/packages/scheduler_core/lib/src/locked_time.dart index b3fc34a..0774dfc 100644 --- a/packages/scheduler_core/lib/src/locked_time.dart +++ b/packages/scheduler_core/lib/src/locked_time.dart @@ -10,11 +10,11 @@ library; import 'models.dart'; import 'time_contracts.dart'; -part 'locked_time/locked_weekday.dart'; -part 'locked_time/clock_time.dart'; -part 'locked_time/locked_block_recurrence.dart'; -part 'locked_time/locked_block.dart'; -part 'locked_time/locked_block_occurrence.dart'; -part 'locked_time/locked_block_override_type.dart'; -part 'locked_time/locked_block_override.dart'; -part 'locked_time/locked_schedule_expansion.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/locked_time/clock_time.dart b/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart similarity index 72% rename from packages/scheduler_core/lib/src/locked_time/clock_time.dart rename to packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart index b3e9d08..c339b79 100644 --- a/packages/scheduler_core/lib/src/locked_time/clock_time.dart +++ b/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Backwards-compatible name for explicit wall-clock time. typedef ClockTime = WallTime; diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block.dart b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart similarity index 99% rename from packages/scheduler_core/lib/src/locked_time/locked_block.dart rename to packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart index ffe0c7c..f661436 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_block.dart +++ b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Scheduling constraint that reserves time without becoming a task card. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart similarity index 97% rename from packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart rename to packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart index 7419e53..2156803 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_block_occurrence.dart +++ b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Concrete locked-time occurrence for one calendar day. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart b/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart similarity index 92% rename from packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart rename to packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart index dfed29e..1fa1fb8 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_block_override_type.dart +++ b/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Type of one-day override applied to locked time. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart b/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart similarity index 94% rename from packages/scheduler_core/lib/src/locked_time/locked_weekday.dart rename to packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart index 04c9565..607b513 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_weekday.dart +++ b/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Weekday value using DateTime's Monday-first convention. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart b/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart similarity index 99% rename from packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart rename to packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart index 4d6ad7e..7abb9be 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_schedule_expansion.dart +++ b/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Expands locked blocks and one-day overrides into concrete intervals. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart b/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart similarity index 99% rename from packages/scheduler_core/lib/src/locked_time/locked_block_override.dart rename to packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart index 8979839..836b490 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_block_override.dart +++ b/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// One-day change to locked time that leaves the base recurrence unchanged. /// diff --git a/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart similarity index 96% rename from packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart rename to packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart index de6b8a2..cc6b489 100644 --- a/packages/scheduler_core/lib/src/locked_time/locked_block_recurrence.dart +++ b/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart @@ -1,4 +1,4 @@ -part of '../locked_time.dart'; +part of '../../locked_time.dart'; /// Recurrence rule for locked time. /// diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/models.dart index 18d4c22..928f59e 100644 --- a/packages/scheduler_core/lib/src/models.dart +++ b/packages/scheduler_core/lib/src/models.dart @@ -15,15 +15,15 @@ library; // 4. `TimeInterval` is the shared time-span helper used by scheduling logic. import 'task_statistics.dart'; -part 'models/domain_validation_code.dart'; -part 'models/domain_validation_exception.dart'; -part 'models/task_type.dart'; -part 'models/task_status.dart'; -part 'models/priority_level.dart'; -part 'models/reward_level.dart'; -part 'models/difficulty_level.dart'; -part 'models/reminder_profile.dart'; -part 'models/backlog_tag.dart'; -part 'models/task.dart'; -part 'models/project_profile.dart'; -part 'models/time_interval.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/models/project_profile.dart b/packages/scheduler_core/lib/src/models/entities/project_profile.dart similarity index 99% rename from packages/scheduler_core/lib/src/models/project_profile.dart rename to packages/scheduler_core/lib/src/models/entities/project_profile.dart index a1480d5..d9edb6b 100644 --- a/packages/scheduler_core/lib/src/models/project_profile.dart +++ b/packages/scheduler_core/lib/src/models/entities/project_profile.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../models.dart'; /// Starter project defaults used when creating or scheduling tasks. /// diff --git a/packages/scheduler_core/lib/src/models/task.dart b/packages/scheduler_core/lib/src/models/entities/task.dart similarity index 99% rename from packages/scheduler_core/lib/src/models/task.dart rename to packages/scheduler_core/lib/src/models/entities/task.dart index ff9c1e2..ac54f3f 100644 --- a/packages/scheduler_core/lib/src/models/task.dart +++ b/packages/scheduler_core/lib/src/models/entities/task.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../models.dart'; /// Starter task model for the scheduling core. /// diff --git a/packages/scheduler_core/lib/src/models/time_interval.dart b/packages/scheduler_core/lib/src/models/entities/time_interval.dart similarity index 99% rename from packages/scheduler_core/lib/src/models/time_interval.dart rename to packages/scheduler_core/lib/src/models/entities/time_interval.dart index 2338b08..b1bf8cb 100644 --- a/packages/scheduler_core/lib/src/models/time_interval.dart +++ b/packages/scheduler_core/lib/src/models/entities/time_interval.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../models.dart'; /// Starter time range value used by scheduling helpers. /// diff --git a/packages/scheduler_core/lib/src/models/difficulty_level.dart b/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart similarity index 95% rename from packages/scheduler_core/lib/src/models/difficulty_level.dart rename to packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart index 2ea21b6..9d7da9d 100644 --- a/packages/scheduler_core/lib/src/models/difficulty_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Expected effort or activation difficulty for a task. /// diff --git a/packages/scheduler_core/lib/src/models/reward_level.dart b/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart similarity index 94% rename from packages/scheduler_core/lib/src/models/reward_level.dart rename to packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart index 489ea4f..196375a 100644 --- a/packages/scheduler_core/lib/src/models/reward_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Expected reward or payoff from completing a task. /// diff --git a/packages/scheduler_core/lib/src/models/backlog_tag.dart b/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart similarity index 91% rename from packages/scheduler_core/lib/src/models/backlog_tag.dart rename to packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart index cf697fd..a4a6472 100644 --- a/packages/scheduler_core/lib/src/models/backlog_tag.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Lightweight backlog-only metadata. /// diff --git a/packages/scheduler_core/lib/src/models/priority_level.dart b/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart similarity index 94% rename from packages/scheduler_core/lib/src/models/priority_level.dart rename to packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart index a29a39e..bdd459d 100644 --- a/packages/scheduler_core/lib/src/models/priority_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// User-facing importance level. /// diff --git a/packages/scheduler_core/lib/src/models/reminder_profile.dart b/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart similarity index 93% rename from packages/scheduler_core/lib/src/models/reminder_profile.dart rename to packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart index edf1deb..8198007 100644 --- a/packages/scheduler_core/lib/src/models/reminder_profile.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Reminder intensity preference. /// diff --git a/packages/scheduler_core/lib/src/models/task_status.dart b/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart similarity index 96% rename from packages/scheduler_core/lib/src/models/task_status.dart rename to packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart index 6c92737..3aa6321 100644 --- a/packages/scheduler_core/lib/src/models/task_status.dart +++ b/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Current lifecycle state of a task. /// diff --git a/packages/scheduler_core/lib/src/models/task_type.dart b/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart similarity index 96% rename from packages/scheduler_core/lib/src/models/task_type.dart rename to packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart index 1d5c86f..d38017a 100644 --- a/packages/scheduler_core/lib/src/models/task_type.dart +++ b/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../../models.dart'; /// Scheduling behavior category. /// diff --git a/packages/scheduler_core/lib/src/models/domain_validation_code.dart b/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart similarity index 96% rename from packages/scheduler_core/lib/src/models/domain_validation_code.dart rename to packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart index 0da2848..ee0b591 100644 --- a/packages/scheduler_core/lib/src/models/domain_validation_code.dart +++ b/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../models.dart'; /// Stable validation failure categories for domain model boundaries. /// diff --git a/packages/scheduler_core/lib/src/models/domain_validation_exception.dart b/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart similarity index 93% rename from packages/scheduler_core/lib/src/models/domain_validation_exception.dart rename to packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart index 9b90db6..1d7f6ef 100644 --- a/packages/scheduler_core/lib/src/models/domain_validation_exception.dart +++ b/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart @@ -1,4 +1,4 @@ -part of '../models.dart'; +part of '../../models.dart'; /// Typed validation error thrown by domain model constructors. class DomainValidationException extends ArgumentError { diff --git a/packages/scheduler_core/lib/src/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence_contract.dart index 6e11363..c8a1e08 100644 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ b/packages/scheduler_core/lib/src/persistence_contract.dart @@ -8,39 +8,39 @@ library; // domain models or import a database client. import 'time_contracts.dart'; -part 'persistence_contract/persistence_collections.dart'; -part 'persistence_contract/persistence_index_direction.dart'; -part 'persistence_contract/persistence_index_field.dart'; -part 'persistence_contract/persistence_index_spec.dart'; -part 'persistence_contract/repository_query_names.dart'; -part 'persistence_contract/persistence_index_catalog.dart'; -part 'persistence_contract/persistence_payload_limits.dart'; -part 'persistence_contract/persistence_guard_result.dart'; -part 'persistence_contract/persistence_payload_guard.dart'; -part 'persistence_contract/persistence_date_time_convention.dart'; -part 'persistence_contract/persistence_civil_date_convention.dart'; -part 'persistence_contract/persistence_wall_time_convention.dart'; -part 'persistence_contract/persistence_enum_name.dart'; -part 'persistence_contract/document_fields.dart'; -part 'persistence_contract/task_document_fields.dart'; -part 'persistence_contract/task_activity_document_fields.dart'; -part 'persistence_contract/task_statistics_document_fields.dart'; -part 'persistence_contract/project_document_fields.dart'; -part 'persistence_contract/project_statistics_document_fields.dart'; -part 'persistence_contract/clock_time_document_fields.dart'; -part 'persistence_contract/locked_block_recurrence_document_fields.dart'; -part 'persistence_contract/locked_block_document_fields.dart'; -part 'persistence_contract/locked_block_override_document_fields.dart'; -part 'persistence_contract/owner_settings_document_fields.dart'; -part 'persistence_contract/backlog_staleness_document_fields.dart'; -part 'persistence_contract/notice_acknowledgement_document_fields.dart'; -part 'persistence_contract/application_operation_document_fields.dart'; -part 'persistence_contract/scheduling_snapshot_document_fields.dart'; -part 'persistence_contract/scheduling_window_document_fields.dart'; -part 'persistence_contract/time_interval_document_fields.dart'; -part 'persistence_contract/scheduling_notice_document_fields.dart'; -part 'persistence_contract/scheduling_change_document_fields.dart'; -part 'persistence_contract/scheduling_overlap_document_fields.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. diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart similarity index 95% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart rename to packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart index 10e1bf2..d6898e3 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_collections.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Stable V1 logical collection names for persistence adapters. abstract final class PersistenceCollections { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart similarity index 93% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart rename to packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart index f110715..08ebf34 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_civil_date_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Civil-date storage convention for future document persistence. abstract final class PersistenceCivilDateConvention { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart similarity index 94% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart rename to packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart index 8de931a..1161b52 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_date_time_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// DateTime storage convention for future document persistence. abstract final class PersistenceDateTimeConvention { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart similarity index 86% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart rename to packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart index a31b3f2..26b6e23 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_enum_name.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Legacy enum-name extension retained for older callers. /// diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart similarity index 93% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart rename to packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart index 4fa094c..9c52cb6 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_wall_time_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Wall-time storage convention for future document persistence. abstract final class PersistenceWallTimeConvention { diff --git a/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart similarity index 93% rename from packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart index eaf96eb..14fa7d3 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/application_operation_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for idempotent operation records. abstract final class ApplicationOperationDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart similarity index 86% rename from packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart index 67a4c71..b70ba41 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/backlog_staleness_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for backlog staleness settings. abstract final class BacklogStalenessDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart similarity index 93% rename from packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart index 4785fd0..a11de62 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/notice_acknowledgement_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for notice acknowledgement documents. abstract final class NoticeAcknowledgementDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart similarity index 94% rename from packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart index 49e8783..c069f19 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/owner_settings_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [OwnerSettings] documents. abstract final class OwnerSettingsDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart similarity index 93% rename from packages/scheduler_core/lib/src/persistence_contract/document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart index 8d3ba5c..071a62b 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Shared V1 document field names. abstract final class DocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart similarity index 95% rename from packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart index 8b3e6b2..7374f63 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/locked_block_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [LockedBlock] documents. abstract final class LockedBlockDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart similarity index 95% rename from packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart index 2cd83f0..fb32362 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/locked_block_override_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [LockedBlockOverride] documents. abstract final class LockedBlockOverrideDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart similarity index 85% rename from packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart index c26ec8b..6be9001 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/locked_block_recurrence_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [LockedBlockRecurrence] embedded documents. abstract final class LockedBlockRecurrenceDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart similarity index 95% rename from packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart index 53d99e1..92d3c34 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/project_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [ProjectProfile] documents. abstract final class ProjectDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart similarity index 96% rename from packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart index ac1dce6..ee80025 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/project_statistics_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [ProjectStatistics] documents. abstract final class ProjectStatisticsDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart similarity index 90% rename from packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart index c3ca256..09e6f11 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/scheduling_change_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [SchedulingChange] embedded documents. abstract final class SchedulingChangeDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart similarity index 92% rename from packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart index c843f55..7e4661b 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/scheduling_notice_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [SchedulingNotice] embedded documents. abstract final class SchedulingNoticeDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart similarity index 88% rename from packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart index 71718df..9263a60 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/scheduling_overlap_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [SchedulingOverlap] embedded documents. abstract final class SchedulingOverlapDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart similarity index 96% rename from packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart index 3e6573a..f7e3b44 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/scheduling_snapshot_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [SchedulingStateSnapshot] documents. abstract final class SchedulingSnapshotDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart similarity index 84% rename from packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart index 8e8db94..54449b4 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/scheduling_window_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [SchedulingWindow] embedded documents. abstract final class SchedulingWindowDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart similarity index 94% rename from packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart index 10ef992..3c17092 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/task_activity_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for internal [TaskActivity] documents. abstract final class TaskActivityDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart similarity index 97% rename from packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart index 921ffc2..1005526 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/task_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [Task] documents. abstract final class TaskDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart similarity index 96% rename from packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart index 9f2734f..be7f4f1 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/task_statistics_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [TaskStatistics] embedded documents. abstract final class TaskStatisticsDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart similarity index 80% rename from packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart index e309815..24cfdd8 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/clock_time_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [ClockTime] embedded documents. abstract final class ClockTimeDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart similarity index 85% rename from packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart rename to packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart index e861bbd..3801ffc 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/time_interval_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../../persistence_contract.dart'; /// Document field names for [TimeInterval] embedded documents. abstract final class TimeIntervalDocumentFields { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart similarity index 99% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart rename to packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart index 2b4b4b6..98cf617 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_catalog.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Required V1 index plan. abstract final class PersistenceIndexCatalog { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart similarity index 74% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart rename to packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart index 422612a..1eec613 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_direction.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Sort direction in an adapter-neutral index specification. enum PersistenceIndexDirection { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart similarity index 85% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart rename to packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart index a56897c..03a2c49 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_field.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// One field in a declarative index key. class PersistenceIndexField { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart similarity index 92% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart rename to packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart index 1076fe2..791fc0f 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_index_spec.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// A stable index plan future adapters can translate to driver-specific calls. class PersistenceIndexSpec { diff --git a/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart similarity index 97% rename from packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart rename to packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart index f4a8995..704b128 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/repository_query_names.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Stable repository query identifiers for index coverage tests. abstract final class RepositoryQueryNames { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart similarity index 94% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart rename to packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart index 983bc01..f5f5522 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_guard_result.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Guard result for bounded document payload checks. class PersistenceGuardResult { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart similarity index 95% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart rename to packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart index f8b5e2d..b001494 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_guard.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Pure guard helpers for bounded embedded document payloads. abstract final class PersistencePayloadGuard { diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart similarity index 92% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart rename to packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart index 0a3fa51..ebde4b1 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_payload_limits.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart @@ -1,4 +1,4 @@ -part of '../persistence_contract.dart'; +part of '../../persistence_contract.dart'; /// Bounded payload and retention policy for non-authoritative documents. abstract final class PersistencePayloadLimits { diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics.dart index 44e9c76..99feacb 100644 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics.dart @@ -9,14 +9,14 @@ library; import 'models.dart'; import 'task_lifecycle.dart'; -part 'project_statistics/project_completion_time_bucket.dart'; -part 'project_statistics/project_suggestion_type.dart'; -part 'project_statistics/project_suggestion_confidence.dart'; -part 'project_statistics/project_statistics.dart'; -part 'project_statistics/project_statistics_application_result.dart'; -part 'project_statistics/project_statistics_aggregation_service.dart'; -part 'project_statistics/project_suggestion_policy.dart'; -part 'project_statistics/project_suggestion.dart'; -part 'project_statistics/project_suggestion_set.dart'; -part 'project_statistics/project_default_resolution.dart'; -part 'project_statistics/project_suggestion_service.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/project_statistics/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart similarity index 80% rename from packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart rename to packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart index ee998c9..507d733 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_completion_time_bucket.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Coarse completion-time buckets used for learned project suggestions. enum ProjectCompletionTimeBucket { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart similarity index 76% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart rename to packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart index b86c0f1..7509041 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_confidence.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// How concentrated the supporting observations are for a suggestion. enum ProjectSuggestionConfidence { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart similarity index 81% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart rename to packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart index 7ecaf20..9742545 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_type.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Suggestion categories derived from project observations. enum ProjectSuggestionType { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart similarity index 94% rename from packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart rename to packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart index 642c57e..8587d5d 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_default_resolution.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Configured project defaults plus optional suggestions kept separate. class ProjectDefaultResolution { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart similarity index 99% rename from packages/scheduler_core/lib/src/project_statistics/project_statistics.dart rename to packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart index 0221848..4203f6b 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Immutable project-level observations used for future filtering and hints. class ProjectStatistics { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart similarity index 92% rename from packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart rename to packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart index 7bb0b3c..d6576e0 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_statistics_application_result.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Result of applying project-statistic effects from activities. class ProjectStatisticsApplicationResult { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart similarity index 98% rename from packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart rename to packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart index 4c58643..b5950ee 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_statistics_aggregation_service.dart +++ b/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Updates project statistics from canonical task activities exactly once. class ProjectStatisticsAggregationService { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart similarity index 94% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart rename to packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart index 1ac0d55..5d6342e 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// One optional learned suggestion with provenance. class ProjectSuggestion { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart similarity index 92% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart rename to packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart index 7078beb..4ab32b1 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_policy.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Minimum sample sizes for deterministic project suggestions. class ProjectSuggestionPolicy { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart rename to packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart index afbe56c..615aae3 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_service.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Derives deterministic project suggestions from aggregate observations. class ProjectSuggestionService { diff --git a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart similarity index 93% rename from packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart rename to packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart index 5b89f92..5fdd1f1 100644 --- a/packages/scheduler_core/lib/src/project_statistics/project_suggestion_set.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart @@ -1,4 +1,4 @@ -part of '../project_statistics.dart'; +part of '../../project_statistics.dart'; /// Optional suggestion set derived from one project's observations. class ProjectSuggestionSet { diff --git a/packages/scheduler_core/lib/src/reminder_policy.dart b/packages/scheduler_core/lib/src/reminder_policy.dart index 8cbfd1b..7997f79 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/reminder_policy.dart @@ -9,9 +9,9 @@ library; import 'models.dart'; import 'occupancy_policy.dart'; -part 'reminder_policy/reminder_directive_action.dart'; -part 'reminder_policy/reminder_directive_reason.dart'; -part 'reminder_policy/effective_reminder_profile.dart'; -part 'reminder_policy/effective_reminder_profile_source.dart'; -part 'reminder_policy/reminder_directive.dart'; -part 'reminder_policy/reminder_policy_service.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/reminder_policy/reminder_directive.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart similarity index 97% rename from packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart rename to packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart index f350145..6ecfda7 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// UI/platform-independent directive for one task reminder check. class ReminderDirective { diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart similarity index 80% rename from packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart rename to packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart index bffc11b..d449a3a 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_action.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// High-level reminder directive for application/platform layers. enum ReminderDirectiveAction { diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart similarity index 90% rename from packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart rename to packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart index b7b77c5..0539c35 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/reminder_directive_reason.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// Stable reason codes for reminder directives. enum ReminderDirectiveReason { diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart similarity index 90% rename from packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart rename to packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart index c0eae9a..8433179 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// Result of resolving the effective reminder profile for one task. class EffectiveReminderProfile { diff --git a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart similarity index 77% rename from packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart rename to packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart index 56ad447..164b2cb 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/effective_reminder_profile_source.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// Source for an effective reminder profile. enum EffectiveReminderProfileSource { diff --git a/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart b/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart rename to packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart index 9409a2d..9fc305a 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/reminder_policy_service.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart @@ -1,4 +1,4 @@ -part of '../reminder_policy.dart'; +part of '../../reminder_policy.dart'; /// Resolves reminder profiles and reminder directives. class ReminderPolicyService { diff --git a/packages/scheduler_core/lib/src/repositories.dart b/packages/scheduler_core/lib/src/repositories.dart index a80222f..a08fa1e 100644 --- a/packages/scheduler_core/lib/src/repositories.dart +++ b/packages/scheduler_core/lib/src/repositories.dart @@ -10,19 +10,19 @@ import 'locked_time.dart'; import 'models.dart'; import 'scheduling_engine.dart'; import 'time_contracts.dart'; -part 'repositories/repository_failure_code.dart'; -part 'repositories/repository_failure.dart'; -part 'repositories/repository_mutation_result.dart'; -part 'repositories/repository_record.dart'; -part 'repositories/repository_page_request.dart'; -part 'repositories/repository_page.dart'; -part 'repositories/backlog_candidate_query.dart'; -part 'repositories/task_repository.dart'; -part 'repositories/project_repository.dart'; -part 'repositories/locked_block_repository.dart'; -part 'repositories/scheduling_state_snapshot.dart'; -part 'repositories/scheduling_snapshot_repository.dart'; -part 'repositories/in_memory_task_repository.dart'; -part 'repositories/in_memory_project_repository.dart'; -part 'repositories/in_memory_locked_block_repository.dart'; -part 'repositories/in_memory_scheduling_snapshot_repository.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/repositories/repository_failure.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart similarity index 91% rename from packages/scheduler_core/lib/src/repositories/repository_failure.dart rename to packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart index 0535908..42331f8 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_failure.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Typed repository failure for optimistic writes and uniqueness checks. class RepositoryFailure { diff --git a/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart similarity index 84% rename from packages/scheduler_core/lib/src/repositories/repository_failure_code.dart rename to packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart index 35a36aa..4aa0f55 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_failure_code.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Stable repository mutation failure categories. enum RepositoryFailureCode { diff --git a/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart similarity index 94% rename from packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart rename to packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart index 661e1b0..ee0b79b 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_mutation_result.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Mutation result that avoids throwing for expected repository conflicts. class RepositoryMutationResult { diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart similarity index 99% rename from packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart rename to packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart index ce52887..c81eb3f 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory_locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// In-memory locked-time repository useful for tests and early wiring. class InMemoryLockedBlockRepository implements LockedBlockRepository { diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart similarity index 99% rename from packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart rename to packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart index 48bad78..85b8021 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory_project_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// In-memory project repository useful for tests and early application wiring. class InMemoryProjectRepository implements ProjectRepository { diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart index 7b145c0..10a0981 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory_scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// In-memory snapshot repository useful for tests and early application wiring. class InMemorySchedulingSnapshotRepository diff --git a/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart similarity index 99% rename from packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart rename to packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart index 5618f83..f67f986 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory_task_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// In-memory task repository useful for tests and early application wiring. class InMemoryTaskRepository implements TaskRepository { diff --git a/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/repositories/locked_block_repository.dart rename to packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart index fcf0a80..16345e7 100644 --- a/packages/scheduler_core/lib/src/repositories/locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Repository contract for locked-block and one-day override documents. abstract interface class LockedBlockRepository { diff --git a/packages/scheduler_core/lib/src/repositories/project_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart similarity index 97% rename from packages/scheduler_core/lib/src/repositories/project_repository.dart rename to packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart index 8f2c9bd..d633730 100644 --- a/packages/scheduler_core/lib/src/repositories/project_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Repository contract for project profile documents. abstract interface class ProjectRepository { diff --git a/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart similarity index 93% rename from packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart index 6ad760e..662f436 100644 --- a/packages/scheduler_core/lib/src/repositories/scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Repository contract for scheduling operation/state snapshot documents. abstract interface class SchedulingSnapshotRepository { diff --git a/packages/scheduler_core/lib/src/repositories/task_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart similarity index 98% rename from packages/scheduler_core/lib/src/repositories/task_repository.dart rename to packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart index b6ce2d9..9f1c316 100644 --- a/packages/scheduler_core/lib/src/repositories/task_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Repository contract for task documents. abstract interface class TaskRepository { diff --git a/packages/scheduler_core/lib/src/repositories/repository_page.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart similarity index 87% rename from packages/scheduler_core/lib/src/repositories/repository_page.dart rename to packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart index 121e373..5a83dc4 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_page.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// One bounded page of repository query results. class RepositoryPage { diff --git a/packages/scheduler_core/lib/src/repositories/repository_page_request.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart similarity index 90% rename from packages/scheduler_core/lib/src/repositories/repository_page_request.dart rename to packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart index 6e2e18c..a2d5b54 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_page_request.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Cursor contract for bounded repository queries. class RepositoryPageRequest { diff --git a/packages/scheduler_core/lib/src/repositories/repository_record.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart similarity index 91% rename from packages/scheduler_core/lib/src/repositories/repository_record.dart rename to packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart index b89b357..8b74e89 100644 --- a/packages/scheduler_core/lib/src/repositories/repository_record.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Repository value plus adapter-neutral metadata. class RepositoryRecord { diff --git a/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart similarity index 91% rename from packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart rename to packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart index 3387e76..133cefd 100644 --- a/packages/scheduler_core/lib/src/repositories/backlog_candidate_query.dart +++ b/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Status/project filters for backlog candidate queries. class BacklogCandidateQuery { diff --git a/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart similarity index 98% rename from packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart rename to packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart index 366e6ce..e026759 100644 --- a/packages/scheduler_core/lib/src/repositories/scheduling_state_snapshot.dart +++ b/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart @@ -1,4 +1,4 @@ -part of '../repositories.dart'; +part of '../../repositories.dart'; /// Snapshot of one scheduling operation or persisted planning state. class SchedulingStateSnapshot { diff --git a/packages/scheduler_core/lib/src/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine.dart index 193af5d..6388276 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine.dart @@ -19,21 +19,21 @@ import 'models.dart'; import 'occupancy_policy.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; -part 'scheduling_engine/scheduling_notice_type.dart'; -part 'scheduling_engine/scheduling_operation_code.dart'; -part 'scheduling_engine/scheduling_outcome_code.dart'; -part 'scheduling_engine/scheduling_issue_code.dart'; -part 'scheduling_engine/scheduling_movement_code.dart'; -part 'scheduling_engine/scheduling_conflict_code.dart'; -part 'scheduling_engine/scheduling_window.dart'; -part 'scheduling_engine/scheduling_input.dart'; -part 'scheduling_engine/scheduling_change.dart'; -part 'scheduling_engine/scheduling_overlap.dart'; -part 'scheduling_engine/scheduling_notice.dart'; -part 'scheduling_engine/scheduling_result.dart'; -part 'scheduling_engine/scheduling_engine.dart'; -part 'scheduling_engine/placement_item.dart'; -part 'scheduling_engine/backlog_insertion_plan.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'; const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); const TaskTransitionService _transitionService = TaskTransitionService(); diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart similarity index 83% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart index d2a529c..b5df4a7 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_conflict_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Stable conflict identifiers for overlap/interrupt notices. enum SchedulingConflictCode { diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart similarity index 87% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart index ac7b318..529657e 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_issue_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Stable issue identifiers for validation, no-slot, and no-op notices. enum SchedulingIssueCode { diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart similarity index 93% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart index 1d303b9..0403491 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice_type.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Category for scheduler notices. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart similarity index 88% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart index d10016d..1d91e4b 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_movement_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Stable movement identifiers for task placement changes. enum SchedulingMovementCode { diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart similarity index 95% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart index 1db3a14..f24a19e 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_operation_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Stable scheduler operation identifiers. enum SchedulingOperationCode { diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart similarity index 92% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart rename to packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart index 2830814..ecd5e38 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_outcome_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Stable high-level outcome categories for scheduling operations. enum SchedulingOutcomeCode { diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart similarity index 99% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart rename to packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart index 9de4ccb..d9646d8 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../scheduling_engine.dart'; /// Starter scheduling engine. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart similarity index 95% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart index 04dc40a..81a576b 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_change.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Exact placement change made by a scheduling operation. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart similarity index 93% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart index 434d634..98ce7e4 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_overlap.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Overlap between a scheduled task and blocked time. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart similarity index 98% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart index f5713f0..03c247b 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_input.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// In-memory input for scheduling operations. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart similarity index 97% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart index 09fa0fa..61b9510 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_window.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Window of time available to a scheduling operation. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart similarity index 96% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart index 5cabf25..a6fcaa3 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_notice.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Starter notice type returned by scheduling operations. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart similarity index 97% rename from packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart rename to packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart index eb3c106..6c56678 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/scheduling_result.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../../scheduling_engine.dart'; /// Starter result wrapper for scheduling operations. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart similarity index 92% rename from packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart rename to packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart index 7f63ed8..7783b18 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/backlog_insertion_plan.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../scheduling_engine.dart'; /// Planned task intervals keyed by task id. /// diff --git a/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart b/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart similarity index 93% rename from packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart rename to packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart index c0e8580..68aa65d 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/placement_item.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart @@ -1,4 +1,4 @@ -part of '../scheduling_engine.dart'; +part of '../../scheduling_engine.dart'; /// One item in a placement queue. /// diff --git a/packages/scheduler_core/lib/src/task_actions.dart b/packages/scheduler_core/lib/src/task_actions.dart index 53676db..4fc18b6 100644 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ b/packages/scheduler_core/lib/src/task_actions.dart @@ -13,14 +13,14 @@ import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; -part 'task_actions/flexible_task_quick_action.dart'; -part 'task_actions/required_task_action.dart'; -part 'task_actions/push_destination.dart'; -part 'task_actions/flexible_task_action_result.dart'; -part 'task_actions/push_destination_result.dart'; -part 'task_actions/required_task_action_result.dart'; -part 'task_actions/surprise_task_log_request.dart'; -part 'task_actions/surprise_task_log_result.dart'; -part 'task_actions/flexible_task_action_service.dart'; -part 'task_actions/required_task_action_service.dart'; -part 'task_actions/surprise_task_log_service.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/task_actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart similarity index 94% rename from packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart rename to packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart index 1fb03b1..209b06c 100644 --- a/packages/scheduler_core/lib/src/task_actions/flexible_task_quick_action.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Quick actions available from a flexible task card. /// diff --git a/packages/scheduler_core/lib/src/task_actions/push_destination.dart b/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart similarity index 94% rename from packages/scheduler_core/lib/src/task_actions/push_destination.dart rename to packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart index 2c6f0c7..a811418 100644 --- a/packages/scheduler_core/lib/src/task_actions/push_destination.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Explicit push destinations shown after choosing the push quick action. /// diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action.dart b/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart similarity index 94% rename from packages/scheduler_core/lib/src/task_actions/required_task_action.dart rename to packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart index a66a2f4..58187cf 100644 --- a/packages/scheduler_core/lib/src/task_actions/required_task_action.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// State transitions available from required visible task cards. /// diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart similarity index 97% rename from packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart rename to packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart index d45b69b..abe1b73 100644 --- a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_request.dart +++ b/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Input for logging work the user already did outside the plan. /// diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart similarity index 97% rename from packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart rename to packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart index 8715749..829617a 100644 --- a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Domain result for a flexible task quick action. /// diff --git a/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart b/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart similarity index 96% rename from packages/scheduler_core/lib/src/task_actions/push_destination_result.dart rename to packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart index d96ad54..8ef80d0 100644 --- a/packages/scheduler_core/lib/src/task_actions/push_destination_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Result from applying a selected push destination. /// diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart similarity index 96% rename from packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart rename to packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart index c8cdc73..d8d627f 100644 --- a/packages/scheduler_core/lib/src/task_actions/required_task_action_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Result from applying a required task state transition. class RequiredTaskActionResult { diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart similarity index 96% rename from packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart rename to packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart index 8a462b4..c5baec8 100644 --- a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Result from logging a surprise task. class SurpriseTaskLogResult { diff --git a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart rename to packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart index 0414933..8d84e50 100644 --- a/packages/scheduler_core/lib/src/task_actions/flexible_task_action_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Applies low-friction quick actions for flexible task cards. /// diff --git a/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart similarity index 98% rename from packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart rename to packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart index 97ee665..f9acabe 100644 --- a/packages/scheduler_core/lib/src/task_actions/required_task_action_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Applies lifecycle actions for required visible task cards. /// diff --git a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart rename to packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart index 1cdac2f..a49b4d9 100644 --- a/packages/scheduler_core/lib/src/task_actions/surprise_task_log_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart @@ -1,4 +1,4 @@ -part of '../task_actions.dart'; +part of '../../task_actions.dart'; /// Logs surprise completed work and repairs affected flexible tasks. /// diff --git a/packages/scheduler_core/lib/src/task_lifecycle.dart b/packages/scheduler_core/lib/src/task_lifecycle.dart index 1043c8b..91977ea 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle.dart @@ -10,11 +10,11 @@ library; import 'models.dart'; import 'task_statistics.dart'; import 'time_contracts.dart'; -part 'task_lifecycle/task_transition_code.dart'; -part 'task_lifecycle/task_activity_code.dart'; -part 'task_lifecycle/task_transition_outcome_code.dart'; -part 'task_lifecycle/task_activity.dart'; -part 'task_lifecycle/task_transition_result.dart'; -part 'task_lifecycle/task_activity_application_result.dart'; -part 'task_lifecycle/task_activity_accounting_service.dart'; -part 'task_lifecycle/task_transition_service.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/task_lifecycle/task_activity_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart similarity index 86% rename from packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart rename to packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart index 484d370..1bb9d66 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Stable internal activity categories derived from transitions. enum TaskActivityCode { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart similarity index 84% rename from packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart rename to packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart index 4927f18..5a98cca 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Canonical task transition commands. enum TaskTransitionCode { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart similarity index 80% rename from packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart rename to packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart index 89e7427..359f87c 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_outcome_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Typed result categories for expected transition states. enum TaskTransitionOutcomeCode { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart similarity index 97% rename from packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart rename to packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart index 66aefb1..0cd1214 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_activity.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Immutable internal activity fact. /// diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart similarity index 92% rename from packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart rename to packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart index 58069e7..5ff72f2 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_application_result.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Result of applying activity-derived task statistics. class TaskActivityApplicationResult { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart similarity index 96% rename from packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart rename to packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart index ee58d84..c204f02 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_result.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Result of applying one canonical transition. class TaskTransitionResult { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart similarity index 97% rename from packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart rename to packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart index d9baa38..ab9970e 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_activity_accounting_service.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Applies task-statistic effects from internal activities exactly once. class TaskActivityAccountingService { diff --git a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart similarity index 99% rename from packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart rename to packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart index a84f941..a6fdef2 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/task_transition_service.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart @@ -1,4 +1,4 @@ -part of '../task_lifecycle.dart'; +part of '../../task_lifecycle.dart'; /// Canonical lifecycle transition service. class TaskTransitionService { diff --git a/packages/scheduler_core/lib/src/time_contracts.dart b/packages/scheduler_core/lib/src/time_contracts.dart index 2f318be..b2f4dcd 100644 --- a/packages/scheduler_core/lib/src/time_contracts.dart +++ b/packages/scheduler_core/lib/src/time_contracts.dart @@ -2,17 +2,17 @@ library; import 'models.dart'; -part 'time_contracts/clock.dart'; -part 'time_contracts/system_clock.dart'; -part 'time_contracts/fixed_clock.dart'; -part 'time_contracts/id_generator.dart'; -part 'time_contracts/sequential_id_generator.dart'; -part 'time_contracts/civil_date.dart'; -part 'time_contracts/wall_time.dart'; -part 'time_contracts/local_time_resolution.dart'; -part 'time_contracts/nonexistent_local_time_policy.dart'; -part 'time_contracts/repeated_local_time_policy.dart'; -part 'time_contracts/time_zone_resolution_options.dart'; -part 'time_contracts/resolved_local_date_time.dart'; -part 'time_contracts/time_zone_resolver.dart'; -part 'time_contracts/fixed_offset_time_zone_resolver.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/time_contracts/civil_date.dart b/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart similarity index 98% rename from packages/scheduler_core/lib/src/time_contracts/civil_date.dart rename to packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart index 43cf73c..a385d78 100644 --- a/packages/scheduler_core/lib/src/time_contracts/civil_date.dart +++ b/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Calendar date without a time or timezone. class CivilDate implements Comparable { diff --git a/packages/scheduler_core/lib/src/time_contracts/wall_time.dart b/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart similarity index 97% rename from packages/scheduler_core/lib/src/time_contracts/wall_time.dart rename to packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart index 4c2fa64..2a32686 100644 --- a/packages/scheduler_core/lib/src/time_contracts/wall_time.dart +++ b/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Wall-clock time without a date or timezone. class WallTime implements Comparable { diff --git a/packages/scheduler_core/lib/src/time_contracts/clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart similarity index 73% rename from packages/scheduler_core/lib/src/time_contracts/clock.dart rename to packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart index 96d7cce..70600d6 100644 --- a/packages/scheduler_core/lib/src/time_contracts/clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Deterministic source of the current instant. abstract interface class Clock { diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart similarity index 83% rename from packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart rename to packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart index c883a53..00dad8c 100644 --- a/packages/scheduler_core/lib/src/time_contracts/fixed_clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Test/application clock with a fixed instant. class FixedClock implements Clock { diff --git a/packages/scheduler_core/lib/src/time_contracts/system_clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart similarity index 86% rename from packages/scheduler_core/lib/src/time_contracts/system_clock.dart rename to packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart index bc40c42..12b4468 100644 --- a/packages/scheduler_core/lib/src/time_contracts/system_clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Production clock boundary. Domain services should receive this through /// constructors rather than reading wall time directly. diff --git a/packages/scheduler_core/lib/src/time_contracts/id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart similarity index 78% rename from packages/scheduler_core/lib/src/time_contracts/id_generator.dart rename to packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart index b7ba5eb..e85fca0 100644 --- a/packages/scheduler_core/lib/src/time_contracts/id_generator.dart +++ b/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Deterministic ID source for application/domain convenience entry points. abstract interface class IdGenerator { diff --git a/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart similarity index 90% rename from packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart rename to packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart index 7ddb40d..6270506 100644 --- a/packages/scheduler_core/lib/src/time_contracts/sequential_id_generator.dart +++ b/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../time_contracts.dart'; /// Simple deterministic ID generator useful for tests and local wiring. class SequentialIdGenerator implements IdGenerator { diff --git a/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart similarity index 84% rename from packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart index c2b39f9..d208544 100644 --- a/packages/scheduler_core/lib/src/time_contracts/nonexistent_local_time_policy.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; enum NonexistentLocalTimePolicy { /// Move through a daylight-saving gap to the first valid local instant after it. diff --git a/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart similarity index 88% rename from packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart index 42b5c30..57aab99 100644 --- a/packages/scheduler_core/lib/src/time_contracts/repeated_local_time_policy.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; enum RepeatedLocalTimePolicy { /// Use the first instant for a repeated local time during a fall-back transition. diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart similarity index 89% rename from packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart index aaf01d9..867f2a0 100644 --- a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolution_options.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; class TimeZoneResolutionOptions { const TimeZoneResolutionOptions({ diff --git a/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart similarity index 69% rename from packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart index 9dbae23..cd43a75 100644 --- a/packages/scheduler_core/lib/src/time_contracts/local_time_resolution.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; enum LocalTimeResolution { exact, diff --git a/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart similarity index 91% rename from packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart index cc6ba80..8eb18e0 100644 --- a/packages/scheduler_core/lib/src/time_contracts/resolved_local_date_time.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; class ResolvedLocalDateTime { const ResolvedLocalDateTime({ diff --git a/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart similarity index 96% rename from packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart index 099a02a..11d519d 100644 --- a/packages/scheduler_core/lib/src/time_contracts/fixed_offset_time_zone_resolver.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; /// Resolver for zones whose offset is already known by the application boundary. class FixedOffsetTimeZoneResolver implements TimeZoneResolver { diff --git a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart similarity index 87% rename from packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart rename to packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart index 702e2a8..efe4bed 100644 --- a/packages/scheduler_core/lib/src/time_contracts/time_zone_resolver.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart @@ -1,4 +1,4 @@ -part of '../time_contracts.dart'; +part of '../../../time_contracts.dart'; /// Boundary for converting local civil date/time values to instants. abstract interface class TimeZoneResolver { diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 99ce16b..9f582b5 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -10,11 +10,11 @@ library; import 'locked_time.dart'; import 'models.dart'; import 'time_contracts.dart'; -part 'timeline_state/timeline_item_category.dart'; -part 'timeline_state/timeline_background_token.dart'; -part 'timeline_state/timeline_reward_icon_token.dart'; -part 'timeline_state/timeline_difficulty_icon_token.dart'; -part 'timeline_state/timeline_quick_action.dart'; -part 'timeline_state/timeline_item.dart'; -part 'timeline_state/compact_timeline_state.dart'; -part 'timeline_state/timeline_item_mapper.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/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart similarity index 99% rename from packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart rename to packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart index ffd9ab6..d2283e7 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_item_mapper.dart +++ b/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Converts domain tasks into Today timeline items. class TimelineItemMapper { diff --git a/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart similarity index 97% rename from packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart rename to packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart index 16afa31..bf3615d 100644 --- a/packages/scheduler_core/lib/src/timeline_state/compact_timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Reduced Today timeline state for manual compact mode. class CompactTimelineState { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart b/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart similarity index 97% rename from packages/scheduler_core/lib/src/timeline_state/timeline_item.dart rename to packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart index db8ce65..528c331 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_item.dart +++ b/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Display-ready timeline item derived from a domain [Task]. class TimelineItem { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart similarity index 82% rename from packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart rename to packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart index 4250b84..f7eb8b5 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_background_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Background style token derived from the scheduling behavior type. enum TimelineBackgroundToken { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart similarity index 81% rename from packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart rename to packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart index 1b12185..5f15c18 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_difficulty_icon_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Difficulty icon token for the second timeline card icon. enum TimelineDifficultyIconToken { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart similarity index 84% rename from packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart rename to packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart index 39e4a49..60b2753 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_item_category.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Broad display category for an item placed on the Today timeline. enum TimelineItemCategory { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart similarity index 83% rename from packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart rename to packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart index f962616..3f900cf 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_quick_action.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Quick actions the timeline can expose without depending on a UI framework. enum TimelineQuickAction { diff --git a/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart similarity index 80% rename from packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart rename to packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart index 307a6cc..c9feeba 100644 --- a/packages/scheduler_core/lib/src/timeline_state/timeline_reward_icon_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart @@ -1,4 +1,4 @@ -part of '../timeline_state.dart'; +part of '../../timeline_state.dart'; /// Reward icon token for the first timeline card icon. enum TimelineRewardIconToken { diff --git a/packages/scheduler_core/lib/src/today_state.dart b/packages/scheduler_core/lib/src/today_state.dart index 6a6580b..a589a61 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state.dart @@ -14,10 +14,10 @@ import 'repositories.dart'; import 'scheduling_engine.dart'; import 'timeline_state.dart'; import 'time_contracts.dart'; -part 'today_state/today_timeline_item_source.dart'; -part 'today_state/get_today_state_request.dart'; -part 'today_state/today_timeline_item.dart'; -part 'today_state/today_compact_state.dart'; -part 'today_state/today_pending_notice.dart'; -part 'today_state/today_state.dart'; -part 'today_state/get_today_state_query.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/today_state/today_compact_state.dart b/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart similarity index 97% rename from packages/scheduler_core/lib/src/today_state/today_compact_state.dart rename to packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart index 43ecdeb..63256f8 100644 --- a/packages/scheduler_core/lib/src/today_state/today_compact_state.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Compact projection derived from the same Today item list. class TodayCompactState { diff --git a/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart b/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart similarity index 97% rename from packages/scheduler_core/lib/src/today_state/today_pending_notice.dart rename to packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart index e685ef4..dd3bbae 100644 --- a/packages/scheduler_core/lib/src/today_state/today_pending_notice.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Pending notice data surfaced through Today state. class TodayPendingNotice { diff --git a/packages/scheduler_core/lib/src/today_state/today_state.dart b/packages/scheduler_core/lib/src/today_state/models/today_state.dart similarity index 98% rename from packages/scheduler_core/lib/src/today_state/today_state.dart rename to packages/scheduler_core/lib/src/today_state/models/today_state.dart index eccd00b..f08dfb2 100644 --- a/packages/scheduler_core/lib/src/today_state/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_state.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Complete V1 Today state for one owner-local date. class TodayState { diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart similarity index 98% rename from packages/scheduler_core/lib/src/today_state/today_timeline_item.dart rename to packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart index adb65cc..07e22df 100644 --- a/packages/scheduler_core/lib/src/today_state/today_timeline_item.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// One item in the Today read model, with task status/source metadata. class TodayTimelineItem { diff --git a/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart similarity index 76% rename from packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart rename to packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart index 4c859a7..905e58d 100644 --- a/packages/scheduler_core/lib/src/today_state/today_timeline_item_source.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Source category for one Today timeline item. enum TodayTimelineItemSource { diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart b/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart similarity index 99% rename from packages/scheduler_core/lib/src/today_state/get_today_state_query.dart rename to packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart index 1483abd..698f9e1 100644 --- a/packages/scheduler_core/lib/src/today_state/get_today_state_query.dart +++ b/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Builds Today state from application repositories. class GetTodayStateQuery { diff --git a/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart b/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart similarity index 96% rename from packages/scheduler_core/lib/src/today_state/get_today_state_request.dart rename to packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart index 21f8b0a..0cc85cb 100644 --- a/packages/scheduler_core/lib/src/today_state/get_today_state_request.dart +++ b/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart @@ -1,4 +1,4 @@ -part of '../today_state.dart'; +part of '../../today_state.dart'; /// Request for the V1 Today read model. class GetTodayStateRequest { diff --git a/packages/scheduler_export/lib/src/export_controller.dart b/packages/scheduler_export/lib/src/export_controller.dart index 119cfc2..f833249 100644 --- a/packages/scheduler_export/lib/src/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller.dart @@ -5,15 +5,15 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; -part 'export_controller/export_context.dart'; -part 'export_controller/export_exception.dart'; -part 'export_controller/export_repository_exception.dart'; -part 'export_controller/export_writer.dart'; -part 'export_controller/export_writer_factory.dart'; -part 'export_controller/export_writer_registry.dart'; -part 'export_controller/export_controller.dart'; -part 'export_controller/export_format_normalizer.dart'; -part 'export_controller/export_json_context_encoder.dart'; -part 'export_controller/json_export_writer.dart'; -part 'export_controller/csv_cell_encoder.dart'; -part 'export_controller/csv_export_writer.dart'; +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/export_context.dart b/packages/scheduler_export/lib/src/export_controller/context/export_context.dart similarity index 92% rename from packages/scheduler_export/lib/src/export_controller/export_context.dart rename to packages/scheduler_export/lib/src/export_controller/context/export_context.dart index ae3453f..1df6118 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_context.dart +++ b/packages/scheduler_export/lib/src/export_controller/context/export_context.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../export_controller.dart'; /// Export metadata shared with every writer. final class ExportContext { diff --git a/packages/scheduler_export/lib/src/export_controller/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart similarity index 97% rename from packages/scheduler_export/lib/src/export_controller/export_controller.dart rename to packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart index eeb5616..3a378f0 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../export_controller.dart'; /// Coordinates repository reads and export writer calls. final class ExportController { diff --git a/packages/scheduler_export/lib/src/export_controller/export_exception.dart b/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart similarity index 89% rename from packages/scheduler_export/lib/src/export_controller/export_exception.dart rename to packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart index 6e27cdf..bcb1799 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_exception.dart +++ b/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../export_controller.dart'; /// Thrown when an export cannot complete. final class ExportException implements Exception { diff --git a/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart b/packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart similarity index 90% rename from packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart rename to packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart index 774a2ab..88a824c 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_repository_exception.dart +++ b/packages/scheduler_export/lib/src/export_controller/errors/export_repository_exception.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../export_controller.dart'; /// Thrown when repository reads fail during export. final class ExportRepositoryException extends ExportException { diff --git a/packages/scheduler_export/lib/src/export_controller/export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart similarity index 87% rename from packages/scheduler_export/lib/src/export_controller/export_writer.dart rename to packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart index 14a332a..c856345 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_writer.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/core/export_writer.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; /// Writer interface for readable exports. abstract interface class ExportWriter { diff --git a/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart similarity index 83% rename from packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart rename to packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart index bc80de2..c887d25 100644 --- a/packages/scheduler_export/lib/src/export_controller/csv_cell_encoder.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_cell_encoder.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; String _csvCell(String value) { final needsQuotes = diff --git a/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart similarity index 97% rename from packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart rename to packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart index 64e1960..55ac0b1 100644 --- a/packages/scheduler_export/lib/src/export_controller/csv_export_writer.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/csv/csv_export_writer.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; /// Minimal CSV export writer stub. final class CsvExportWriter implements ExportWriter { diff --git a/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart b/packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart similarity index 87% rename from packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart rename to packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart index fe42e5e..aaa02e6 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_format_normalizer.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/formats/export_format_normalizer.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; String _normalizeFormat(String format) { return _requiredTrimmed(format, 'format').toLowerCase(); diff --git a/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart b/packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart similarity index 82% rename from packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart rename to packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart index bea5baf..bda8605 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_json_context_encoder.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/json/export_json_context_encoder.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; Map _contextToJson(ExportContext context) { return { diff --git a/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart b/packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart similarity index 96% rename from packages/scheduler_export/lib/src/export_controller/json_export_writer.dart rename to packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart index 26caaa3..09e9220 100644 --- a/packages/scheduler_export/lib/src/export_controller/json_export_writer.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/json/json_export_writer.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; /// Minimal JSON export writer stub. final class JsonExportWriter implements ExportWriter { diff --git a/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart similarity index 74% rename from packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart rename to packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart index 5033de4..032c698 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_writer_factory.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_factory.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +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/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart similarity index 96% rename from packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart rename to packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart index 432c74a..54a19d9 100644 --- a/packages/scheduler_export/lib/src/export_controller/export_writer_registry.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart @@ -1,4 +1,4 @@ -part of '../export_controller.dart'; +part of '../../../export_controller.dart'; /// Registry that resolves export format names to writer factories. final class ExportWriterRegistry { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter.dart index 03efdaa..48ece23 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -2,9 +2,9 @@ library; import 'dart:async'; -part 'notification_adapter/notification_request.dart'; -part 'notification_adapter/notification_request_impl.dart'; -part 'notification_adapter/notification_feedback_type.dart'; -part 'notification_adapter/notification_feedback.dart'; -part 'notification_adapter/notification_adapter.dart'; -part 'notification_adapter/fake_notification_adapter.dart'; +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/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart similarity index 97% rename from packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart index 3c7d78d..b401b59 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/fake_notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; /// Test fake for notification adapter behavior. final class FakeNotificationAdapter implements NotificationAdapter { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart similarity index 90% rename from packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart index e1e9573..da98df1 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; /// Platform-neutral notification adapter boundary. abstract interface class NotificationAdapter { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart similarity index 93% rename from packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart index acb691c..26073cc 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; /// Feedback event emitted by a notification adapter. final class NotificationFeedback { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart similarity index 85% rename from packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart index 188e536..df0a92c 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/notification_feedback_type.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback_type.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; /// User feedback category emitted by a notification adapter. enum NotificationFeedbackType { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart similarity index 96% rename from packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart index 45a3629..ce52fab 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; /// Request to schedule one notification through a platform adapter. sealed class NotificationRequest { diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart similarity index 85% rename from packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart rename to packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart index cc02c40..0ae4131 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/notification_request_impl.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request_impl.dart @@ -1,4 +1,4 @@ -part of '../notification_adapter.dart'; +part of '../../notification_adapter.dart'; final class _NotificationRequest extends NotificationRequest { const _NotificationRequest({ 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 62ddffc..acad696 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 @@ -6,14 +6,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:scheduler_notifications/notifications.dart'; -part 'desktop_notification_adapter_io/desktop_notification_platform.dart'; -part 'desktop_notification_adapter_io/desktop_notification_log_sink.dart'; -part 'desktop_notification_adapter_io/desktop_process_runner.dart'; -part 'desktop_notification_adapter_io/desktop_notification_backend.dart'; -part 'desktop_notification_adapter_io/desktop_notification_adapter.dart'; -part 'desktop_notification_adapter_io/desktop_notification_backend_factory.dart'; -part 'desktop_notification_adapter_io/desktop_notification_platform_detector.dart'; -part 'desktop_notification_adapter_io/linux_notify_send_backend.dart'; -part 'desktop_notification_adapter_io/apple_script_string_encoder.dart'; -part 'desktop_notification_adapter_io/mac_os_notification_backend.dart'; -part 'desktop_notification_adapter_io/stdout_notification_backend.dart'; +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/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart similarity index 95% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart index 4b0ef01..c88d98d 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../desktop_notification_adapter_io.dart'; /// Notification adapter that delegates to a desktop backend. final class DesktopNotificationAdapter implements NotificationAdapter { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart similarity index 87% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart index 29d13b7..de18569 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../../desktop_notification_adapter_io.dart'; /// Platform-neutral backend used by [DesktopNotificationAdapter]. abstract interface class DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart similarity index 94% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart index 1d8c0e8..9a46dcc 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../../desktop_notification_adapter_io.dart'; /// Create the best default desktop notification backend. DesktopNotificationBackend createDefaultDesktopNotificationBackend({ diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart similarity index 93% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart index b589ef7..cb09eed 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../../desktop_notification_adapter_io.dart'; /// Fallback backend that writes notification requests to stdout/logs. final class StdoutNotificationBackend implements DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart similarity index 56% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart index f2f98d1..b9880cb 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/apple_script_string_encoder.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/apple_script_string_encoder.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../../desktop_notification_adapter_io.dart'; String _appleScriptString(String value) { return jsonEncode(value); diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart similarity index 95% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart index 98d1270..b3f6dd4 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/linux_notify_send_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../../desktop_notification_adapter_io.dart'; /// Linux backend using `notify-send`. final class LinuxNotifySendBackend implements DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart similarity index 94% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart index 15bc7ca..2222af9 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/mac_os_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../../desktop_notification_adapter_io.dart'; /// macOS backend using `osascript`. final class MacOsNotificationBackend implements DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart similarity index 70% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart index 2ddde83..104b098 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_log_sink.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_notification_log_sink.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +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/desktop_process_runner.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart similarity index 76% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart index c6cf5e6..159ff19 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_process_runner.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/callbacks/desktop_process_runner.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../desktop_notification_adapter_io.dart'; /// Process runner used by command-backed notification backends. typedef DesktopProcessRunner = Future Function( diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart similarity index 81% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart index 4ff6301..d7b3cd4 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../desktop_notification_adapter_io.dart'; /// Supported desktop platform categories. enum DesktopNotificationPlatform { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart similarity index 84% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart index 62e5db8..ccedcae 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_platform_detector.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/platform/desktop_notification_platform_detector.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../desktop_notification_adapter_io.dart'; DesktopNotificationPlatform _currentPlatform() { if (Platform.isLinux) return DesktopNotificationPlatform.linux; 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 d55f233..e7c1e55 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 @@ -4,10 +4,10 @@ library; import 'dart:async'; import 'package:scheduler_notifications/notifications.dart'; -part 'desktop_notification_adapter_stub/desktop_notification_platform.dart'; -part 'desktop_notification_adapter_stub/desktop_notification_log_sink.dart'; -part 'desktop_notification_adapter_stub/desktop_notification_backend.dart'; -part 'desktop_notification_adapter_stub/desktop_notification_backend_factory.dart'; -part 'desktop_notification_adapter_stub/desktop_notification_adapter.dart'; -part 'desktop_notification_adapter_stub/default_log_sink.dart'; -part 'desktop_notification_adapter_stub/stdout_notification_backend.dart'; +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/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart similarity index 95% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart index 2212ef2..d3ad029 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../desktop_notification_adapter_stub.dart'; /// Notification adapter that delegates to a desktop backend. final class DesktopNotificationAdapter implements NotificationAdapter { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart similarity index 87% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart index d14f54c..7fdd5a8 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/desktop_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_io.dart'; +part of '../../desktop_notification_adapter_stub.dart'; /// Platform-neutral backend used by [DesktopNotificationAdapter]. abstract interface class DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart similarity index 87% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart index a40b4cd..603ffb4 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../desktop_notification_adapter_stub.dart'; /// Create the best default desktop notification backend. DesktopNotificationBackend createDefaultDesktopNotificationBackend({ diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart similarity index 93% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart index 0bd2d9a..38c59e2 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../desktop_notification_adapter_stub.dart'; /// Fallback backend that writes notification requests to stdout/logs. final class StdoutNotificationBackend implements DesktopNotificationBackend { diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart similarity index 58% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart index c976700..8ce5c09 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/default_log_sink.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/default_log_sink.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../desktop_notification_adapter_stub.dart'; void _defaultLogSink(String line) { // ignore: avoid_print diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart similarity index 69% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart index 6fc66d4..c81c5ed 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_log_sink.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/callbacks/desktop_notification_log_sink.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +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/desktop_notification_platform.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart similarity index 80% rename from packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart rename to packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart index e18fe86..438c864 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/desktop_notification_platform.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/platform/desktop_notification_platform.dart @@ -1,4 +1,4 @@ -part of '../desktop_notification_adapter_stub.dart'; +part of '../../desktop_notification_adapter_stub.dart'; /// Supported desktop platform categories. enum DesktopNotificationPlatform { diff --git a/packages/scheduler_persistence/lib/persistence.dart b/packages/scheduler_persistence/lib/persistence.dart index 47b3b22..b3592a7 100644 --- a/packages/scheduler_persistence/lib/persistence.dart +++ b/packages/scheduler_persistence/lib/persistence.dart @@ -7,19 +7,19 @@ library; import 'package:scheduler_core/scheduler_core.dart' as core; -part 'persistence/repository_failure.dart'; -part 'persistence/repository_not_found.dart'; -part 'persistence/repository_duplicate_id.dart'; -part 'persistence/repository_invalid_revision.dart'; -part 'persistence/repository_stale_revision.dart'; -part 'persistence/repository_owner_mismatch.dart'; -part 'persistence/repository_storage_failure.dart'; -part 'persistence/either.dart'; -part 'persistence/left.dart'; -part 'persistence/right.dart'; -part 'persistence/repo_result.dart'; -part 'persistence/task_repository.dart'; -part 'persistence/project_repository.dart'; -part 'persistence/locked_block_repository.dart'; -part 'persistence/settings_repository.dart'; -part 'persistence/schedule_snapshot_repository.dart'; +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/repository_failure.dart b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart similarity index 94% rename from packages/scheduler_persistence/lib/persistence/repository_failure.dart rename to packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart index 77e7e23..d7570f8 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_failure.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// Adapter-neutral repository failure. sealed class RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart similarity index 86% rename from packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart rename to packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart index 2f48537..0b09ab4 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_duplicate_id.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// A record with the requested id already exists. final class RepositoryDuplicateId extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_not_found.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart similarity index 85% rename from packages/scheduler_persistence/lib/persistence/repository_not_found.dart rename to packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart index feab57d..628abf5 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_not_found.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// The requested entity was not found. final class RepositoryNotFound extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart similarity index 87% rename from packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart rename to packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart index a57ee20..177b3ff 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_owner_mismatch.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// The requested record exists under a different owner scope. final class RepositoryOwnerMismatch extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart similarity index 88% rename from packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart rename to packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart index 35f0286..d0e7a62 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_invalid_revision.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// The caller supplied an invalid revision value. final class RepositoryInvalidRevision extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart similarity index 89% rename from packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart rename to packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart index 6a532e2..c4a413d 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_stale_revision.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// The caller attempted to write using a stale revision. final class RepositoryStaleRevision extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart b/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart similarity index 87% rename from packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart rename to packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart index 0cd1ca7..527b35b 100644 --- a/packages/scheduler_persistence/lib/persistence/repository_storage_failure.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../../persistence.dart'; /// The adapter could not complete the operation due to storage failure. final class RepositoryStorageFailure extends RepositoryFailure { diff --git a/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart similarity index 98% rename from packages/scheduler_persistence/lib/persistence/locked_block_repository.dart rename to packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart index 6f958c3..7af0911 100644 --- a/packages/scheduler_persistence/lib/persistence/locked_block_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository contract for locked blocks and date-scoped overrides. abstract interface class LockedBlockRepository { diff --git a/packages/scheduler_persistence/lib/persistence/project_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart similarity index 97% rename from packages/scheduler_persistence/lib/persistence/project_repository.dart rename to packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart index 36c6f09..17b2d0c 100644 --- a/packages/scheduler_persistence/lib/persistence/project_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository contract for project profile records. abstract interface class ProjectRepository { diff --git a/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart similarity index 96% rename from packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart rename to packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart index 429d7b1..a3671ee 100644 --- a/packages/scheduler_persistence/lib/persistence/schedule_snapshot_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository contract for diagnostic schedule snapshots. abstract interface class ScheduleSnapshotRepository { diff --git a/packages/scheduler_persistence/lib/persistence/settings_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart similarity index 95% rename from packages/scheduler_persistence/lib/persistence/settings_repository.dart rename to packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart index a8e480a..e38cbc5 100644 --- a/packages/scheduler_persistence/lib/persistence/settings_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository contract for owner settings. abstract interface class SettingsRepository { diff --git a/packages/scheduler_persistence/lib/persistence/task_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart similarity index 98% rename from packages/scheduler_persistence/lib/persistence/task_repository.dart rename to packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart index dd41bbb..b0899da 100644 --- a/packages/scheduler_persistence/lib/persistence/task_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository contract for task records. abstract interface class TaskRepository { diff --git a/packages/scheduler_persistence/lib/persistence/either.dart b/packages/scheduler_persistence/lib/persistence/results/either.dart similarity index 95% rename from packages/scheduler_persistence/lib/persistence/either.dart rename to packages/scheduler_persistence/lib/persistence/results/either.dart index dca6ede..f0125b4 100644 --- a/packages/scheduler_persistence/lib/persistence/either.dart +++ b/packages/scheduler_persistence/lib/persistence/results/either.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Minimal Either type for expected repository success/failure paths. sealed class Either { diff --git a/packages/scheduler_persistence/lib/persistence/left.dart b/packages/scheduler_persistence/lib/persistence/results/left.dart similarity index 84% rename from packages/scheduler_persistence/lib/persistence/left.dart rename to packages/scheduler_persistence/lib/persistence/results/left.dart index e045e6f..62196b1 100644 --- a/packages/scheduler_persistence/lib/persistence/left.dart +++ b/packages/scheduler_persistence/lib/persistence/results/left.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Failure side of [Either]. final class Left extends Either { diff --git a/packages/scheduler_persistence/lib/persistence/repo_result.dart b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart similarity index 77% rename from packages/scheduler_persistence/lib/persistence/repo_result.dart rename to packages/scheduler_persistence/lib/persistence/results/repo_result.dart index 62d9528..7560b89 100644 --- a/packages/scheduler_persistence/lib/persistence/repo_result.dart +++ b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Repository result wrapper for expected adapter outcomes. typedef RepoResult = Either; diff --git a/packages/scheduler_persistence/lib/persistence/right.dart b/packages/scheduler_persistence/lib/persistence/results/right.dart similarity index 84% rename from packages/scheduler_persistence/lib/persistence/right.dart rename to packages/scheduler_persistence/lib/persistence/results/right.dart index 8557424..caca5da 100644 --- a/packages/scheduler_persistence/lib/persistence/right.dart +++ b/packages/scheduler_persistence/lib/persistence/results/right.dart @@ -1,4 +1,4 @@ -part of '../persistence.dart'; +part of '../../persistence.dart'; /// Success side of [Either]. final class Right extends Either { diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory.dart b/packages/scheduler_persistence_memory/lib/persistence_memory.dart index 7ed1daa..59d9e92 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory.dart @@ -9,14 +9,14 @@ import 'dart:convert'; import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_persistence/persistence.dart'; -part 'persistence_memory/in_memory_task_repository.dart'; -part 'persistence_memory/in_memory_project_repository.dart'; -part 'persistence_memory/in_memory_locked_block_repository.dart'; -part 'persistence_memory/in_memory_settings_repository.dart'; -part 'persistence_memory/in_memory_schedule_snapshot_repository.dart'; -part 'persistence_memory/identified_record.dart'; -part 'persistence_memory/record.dart'; -part 'persistence_memory/snapshot_record.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'; const _snapshotRetention = Duration(days: 30); diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart similarity index 61% rename from packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart index 8894632..c665128 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/identified_record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; abstract interface class _IdentifiedRecord { String get id; diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart similarity index 95% rename from packages/scheduler_persistence_memory/lib/persistence_memory/record.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart index 25b0b53..ee22723 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; final class _Record implements _IdentifiedRecord { _Record({ diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart similarity index 90% rename from packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart index 6e2d5a7..3e3416e 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/snapshot_record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; final class _SnapshotRecord implements _IdentifiedRecord { _SnapshotRecord({ diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart similarity index 99% rename from packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart index 799c647..7a686b7 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_locked_block_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_locked_block_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; /// In-memory locked-block repository with owner scoping and revision checks. final class InMemoryLockedBlockRepository implements LockedBlockRepository { diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart similarity index 98% rename from packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart index c31dfea..58768ba 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_project_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_project_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; /// In-memory project repository with owner scoping and revision checks. final class InMemoryProjectRepository implements ProjectRepository { diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart similarity index 99% rename from packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart index f7ce9b7..9661c11 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_schedule_snapshot_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; /// In-memory diagnostic schedule snapshot repository. final class InMemoryScheduleSnapshotRepository diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart similarity index 98% rename from packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart index 9369259..dc86418 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_settings_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_settings_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; /// In-memory owner settings repository with owner scoping and revision checks. final class InMemorySettingsRepository implements SettingsRepository { diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart similarity index 99% rename from packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart rename to packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart index 29cec50..1fd8976 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/in_memory_task_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_task_repository.dart @@ -1,4 +1,4 @@ -part of '../persistence_memory.dart'; +part of '../../persistence_memory.dart'; /// In-memory task repository with owner scoping and revision checks. final class InMemoryTaskRepository implements TaskRepository { diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart index 5dfb3a8..10cf902 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart @@ -7,15 +7,15 @@ library; import 'package:drift/drift.dart'; part 'scheduler_db.g.dart'; -part 'scheduler_db/tasks.dart'; -part 'scheduler_db/projects.dart'; -part 'scheduler_db/locked_blocks.dart'; -part 'scheduler_db/locked_overrides.dart'; -part 'scheduler_db/settings_table.dart'; -part 'scheduler_db/snapshots.dart'; -part 'scheduler_db/task_dao.dart'; -part 'scheduler_db/project_dao.dart'; -part 'scheduler_db/locked_block_dao.dart'; -part 'scheduler_db/settings_dao.dart'; -part 'scheduler_db/snapshot_dao.dart'; -part 'scheduler_db/scheduler_db.dart'; +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/locked_block_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart similarity index 87% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart index 0fea943..bf231af 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_block_dao.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/locked_block_dao.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Generated locked-time accessors for blocks and date overrides. @DriftAccessor(tables: [LockedBlocks, LockedOverrides]) diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart similarity index 83% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart index 73cd834..2cef75d 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/project_dao.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/project_dao.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Generated project-row accessor. @DriftAccessor(tables: [Projects]) diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart similarity index 84% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart index c072830..9e384f7 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_dao.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/settings_dao.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Generated owner-settings accessor. @DriftAccessor(tables: [SettingsTable]) diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart similarity index 84% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart index 7b67f2f..174f9c0 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshot_dao.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/snapshot_dao.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Generated diagnostic snapshot accessor. @DriftAccessor(tables: [Snapshots]) diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart similarity index 82% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart index 9eca99c..fe6f0e6 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/task_dao.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Generated task-row accessor. @DriftAccessor(tables: [Tasks]) diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart similarity index 94% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart index 8274ed4..b09c9c8 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/scheduler_db.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/database/scheduler_db.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../scheduler_db.dart'; /// Drift database for scheduler persistence. @DriftDatabase( diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart similarity index 96% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart index 1ddea2b..df0350d 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_blocks.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_blocks.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Recurring and one-off locked-time rows. @DataClassName('LockedBlockRow') diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart similarity index 96% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart index 74c20e2..d5381c5 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/locked_overrides.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/locked_time/locked_overrides.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Date-scoped overrides for recurring locked blocks. @DataClassName('LockedOverrideRow') diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart similarity index 96% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart index a14561f..742da1d 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/projects.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/projects/projects.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Project configuration rows. @DataClassName('ProjectRow') diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart similarity index 95% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart index 6a8115d..1e7a0c5 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/settings_table.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/settings/settings_table.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Owner-level settings rows. @DataClassName('SettingsRow') diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart similarity index 97% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart index e4d71ab..892d56c 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/snapshots.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/snapshots/snapshots.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Bounded diagnostic schedule snapshots. @DataClassName('SnapshotRow') diff --git a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart similarity index 97% rename from packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart rename to packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart index 9ec3f11..ea73624 100644 --- a/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tasks.dart +++ b/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart @@ -1,4 +1,4 @@ -part of '../scheduler_db.dart'; +part of '../../../scheduler_db.dart'; /// Authoritative task rows. @DataClassName('TaskRow') From ec19dd498d126bfbc3c90e15fb761fb4625d5515 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 13:36:12 -0700 Subject: [PATCH 15/16] docs: expand dartdocs and add compliance hooks --- .githooks/pre-commit | 15 ++ .github/workflows/ci.yml | 3 + REUSE.toml | 17 ++ analysis_options.yaml | 17 ++ apps/focus_flow_flutter/LICENSE.md | 6 + apps/focus_flow_flutter/README.md | 3 + apps/focus_flow_flutter/analysis_options.yaml | 12 + apps/focus_flow_flutter/lib/app.dart | 3 + .../lib/app/demo/demo_date_formatter.dart | 5 + .../lib/app/demo/demo_seed_tasks.dart | 7 + .../lib/app/demo_scheduler_composition.dart | 5 + .../lib/app/focus_flow_app.dart | 5 + .../lib/app/home/focus_flow_home.dart | 5 + .../lib/app/home/focus_flow_home_state.dart | 18 ++ .../lib/app/home/plan_issue.dart | 11 + .../lib/app/home/today_screen_scaffold.dart | 23 ++ .../app/home/today_screen_scaffold_state.dart | 14 + .../demo_scheduler_composition.dart | 3 + .../command/scheduler_command_callbacks.dart | 3 + .../scheduler_command_controller_impl.dart | 17 ++ .../command/scheduler_command_state.dart | 3 + .../scheduler_command_controller.dart | 3 + .../scheduler_read_controller.dart | 7 + .../controllers/today_screen_controller.dart | 10 + apps/focus_flow_flutter/lib/main.dart | 3 + .../models/today/required_banner_model.dart | 3 + .../lib/models/today/task_visual_kind.dart | 3 + .../today/time/time_string_formatter.dart | 9 + .../lib/models/today/timeline_card_model.dart | 3 + .../timeline_item_presentation_mapper.dart | 9 + .../models/today/timeline_range_model.dart | 3 + .../lib/models/today/today_screen_data.dart | 3 + .../lib/models/today_screen_models.dart | 3 + .../lib/theme/focus_flow_theme.dart | 3 + .../lib/theme/focus_flow_tokens.dart | 3 + .../lib/theme/scheduler_visual_tokens.dart | 3 + .../lib/widgets/app_shell.dart | 5 + .../lib/widgets/backlog_pane.dart | 19 ++ .../lib/widgets/read_state_view.dart | 9 + .../lib/widgets/required_banner.dart | 76 ++++++ .../lib/widgets/schedule_components.dart | 23 ++ .../lib/widgets/sidebar.dart | 42 +++ .../widgets/task_selection/action_button.dart | 14 + .../task_selection/header_metadata.dart | 11 + .../lib/widgets/task_selection/info_tile.dart | 14 + .../widgets/task_selection/modal_accent.dart | 5 + .../task_selection/modal_status_button.dart | 17 ++ .../lib/widgets/task_selection_modal.dart | 5 + .../widgets/timeline/axis/timeline_grid.dart | 5 + .../timeline/axis/timeline_grid_painter.dart | 16 ++ .../lib/widgets/timeline/difficulty_bars.dart | 18 ++ .../lib/widgets/timeline/reward_icon.dart | 17 ++ .../timeline/task_card/card_colors.dart | 17 ++ .../timeline/task_card/card_metrics.dart | 103 ++++++++ .../widgets/timeline/task_card/card_text.dart | 17 ++ .../timeline/task_card/compact_card_text.dart | 22 ++ .../task_card/expanded_card_content.dart | 20 ++ .../timeline/task_card/no_op_icon.dart | 17 ++ .../timeline/task_card/quick_actions.dart | 20 ++ .../timeline/task_card/status_ring.dart | 20 ++ .../task_card/task_timeline_card_state.dart | 9 + .../timeline/task_card/trailing_controls.dart | 20 ++ .../widgets/timeline/task_timeline_card.dart | 5 + .../lib/widgets/timeline/timeline_axis.dart | 5 + .../widgets/timeline/timeline_geometry.dart | 9 + .../lib/widgets/timeline/timeline_view.dart | 5 + .../timeline_card_placement.dart | 43 +++ .../timeline_scroll_behavior.dart | 9 + .../timeline_view/timeline_view_state.dart | 45 ++++ .../lib/widgets/today_pane.dart | 9 + .../lib/widgets/top_bar.dart | 5 + .../widgets/top_bar/top_bar_icon_button.dart | 17 ++ .../lib/widgets/top_bar/top_bar_metrics.dart | 78 ++++++ .../widgets/top_bar/top_bar_mode_toggle.dart | 11 + .../top_bar/top_bar_settings_button.dart | 11 + apps/focus_flow_flutter/pubspec.yaml | 3 + .../test/forbidden_imports_test.dart | 18 ++ apps/focus_flow_flutter/test/widget_test.dart | 19 ++ packages/scheduler_backup/LICENSE.md | 6 + .../scheduler_backup/analysis_options.yaml | 5 +- packages/scheduler_backup/bin/backup.dart | 7 + packages/scheduler_backup/lib/backup.dart | 3 + .../lib/scheduler_backup.dart | 3 + .../lib/src/encrypted_backup.dart | 22 ++ .../codec/backup_encryption_codec.dart | 11 + .../errors/backup_decryption_exception.dart | 5 + .../errors/backup_exception.dart | 5 + .../encrypted_backup_operations.dart | 3 + .../paths/backup_file_names.dart | 11 + .../encrypted_backup/paths/backup_paths.dart | 7 + packages/scheduler_backup/pubspec.yaml | 3 + .../test/encrypted_backup_test.dart | 5 + packages/scheduler_core/LICENSE.md | 6 + packages/scheduler_core/analysis_options.yaml | 15 +- .../scheduler_core/lib/scheduler_core.dart | 3 + .../lib/src/application_commands.dart | 3 + .../codes/application_command_code.dart | 53 ++++ .../application_child_task_draft.dart | 5 + .../application_command_read_hint.dart | 5 + .../messages/application_command_result.dart | 5 + .../planning/planning_state.dart | 51 ++++ .../v1_application_command_use_cases.dart | 40 +++ .../lib/src/application_layer.dart | 3 + .../application_operation_context.dart | 5 + .../context/owner_time_zone_context.dart | 5 + .../application_scheduling_loader.dart | 5 + .../records/application_operation_record.dart | 5 + .../notice_acknowledgement_record.dart | 5 + .../records/owner_settings.dart | 5 + .../in_memory_application_repositories.dart | 25 ++ .../in_memory_application_state.dart | 77 ++++++ .../in_memory_application_unit_of_work.dart | 14 + .../application_operation_repository.dart | 11 + .../notice_acknowledgement_repository.dart | 13 + .../owner_settings_repository.dart | 11 + .../project_statistics_repository.dart | 13 + .../tasks/task_activity_repository.dart | 21 ++ .../application_unit_of_work.dart | 7 + ...application_unit_of_work_repositories.dart | 21 ++ ...ork_notice_acknowledgement_repository.dart | 22 ++ .../unit_of_work_operation_repository.dart | 41 +++ ...nit_of_work_owner_settings_repository.dart | 20 ++ .../unit_of_work_project_repository.dart | 35 +++ ...of_work_project_statistics_repository.dart | 25 ++ .../unit_of_work_locked_block_repository.dart | 62 +++++ ...f_work_scheduling_snapshot_repository.dart | 15 ++ ...unit_of_work_task_activity_repository.dart | 32 +++ .../tasks/unit_of_work_task_repository.dart | 49 ++++ .../results/application_failure.dart | 5 + .../results/application_failure_code.dart | 26 ++ .../application_failure_exception.dart | 5 + .../application_persistence_exception.dart | 5 + .../results/application_result.dart | 5 + .../lib/src/application_management.dart | 3 + .../application_management_command_code.dart | 35 +++ .../codes/owner_settings_warning_code.dart | 8 + .../drafts/locked_block_draft.dart | 28 ++ .../drafts/project_profile_draft.dart | 28 ++ .../parsing/parsed_notice_id.dart | 12 + .../read_models/backlog_item_read_model.dart | 5 + .../read_models/backlog_query_result.dart | 5 + .../project_defaults_query_result.dart | 5 + .../requests/get_backlog_request.dart | 5 + .../get_project_defaults_request.dart | 5 + .../notice_acknowledgement_result.dart | 5 + .../results/owner_settings_update_result.dart | 5 + .../updates/locked_block_update.dart | 25 ++ .../updates/owner_settings_update.dart | 19 ++ .../updates/project_profile_update.dart | 28 ++ .../v1_application_management_use_cases.dart | 37 +++ .../lib/src/application_recovery.dart | 3 + .../app_open_recovery_outcome.dart | 11 + .../app_open_recovery_result.dart | 5 + .../run_app_open_recovery_request.dart | 5 + .../v1_app_open_recovery_use_cases.dart | 23 ++ packages/scheduler_core/lib/src/backlog.dart | 3 + .../src/backlog/queries/backlog_filter.dart | 3 + .../src/backlog/queries/backlog_sort_key.dart | 3 + .../staleness/backlog_staleness_marker.dart | 3 + .../staleness/backlog_staleness_settings.dart | 5 + .../lib/src/backlog/views/backlog_view.dart | 5 + .../lib/src/backlog/views/indexed_task.dart | 10 + .../scheduler_core/lib/src/child_tasks.dart | 3 + .../child_tasks/indexing/indexed_child.dart | 24 ++ .../child_tasks/models/child_task_entry.dart | 5 + .../models/child_task_summary.dart | 5 + .../child_tasks/models/child_task_view.dart | 5 + .../requests/child_task_break_up_request.dart | 5 + .../results/child_task_break_up_result.dart | 5 + .../results/child_task_completion_result.dart | 5 + .../services/child_task_break_up_service.dart | 5 + .../child_task_completion_service.dart | 7 + .../lib/src/document_mapping.dart | 15 ++ ...pplication_operation_document_mapping.dart | 7 + .../backlog_staleness_document_mapping.dart | 7 + ...tice_acknowledgement_document_mapping.dart | 7 + .../owner_settings_document_mapping.dart | 7 + .../enums/persistence_enum_mapping.dart | 67 +++++ .../failures/document_mapping_exception.dart | 7 + .../document_mapping_failure_code.dart | 20 ++ .../locked_block_document_mapping.dart | 7 + ...ocked_block_override_document_mapping.dart | 7 + ...ked_block_recurrence_document_mapping.dart | 7 + .../metadata/document_metadata.dart | 19 ++ .../projects/project_document_mapping.dart | 7 + ...project_statistics_document_extension.dart | 93 +++++++ .../project_statistics_document_mapping.dart | 7 + .../scheduling_change_document_mapping.dart | 7 + .../scheduling_notice_document_mapping.dart | 7 + .../scheduling_overlap_document_mapping.dart | 7 + .../scheduling_snapshot_document_mapping.dart | 7 + .../scheduling_window_document_mapping.dart | 7 + .../tasks/task_activity_document_mapping.dart | 7 + .../tasks/task_document_extension.dart | 5 + .../tasks/task_document_mapping.dart | 9 + .../task_statistics_document_extension.dart | 5 + .../task_statistics_document_mapping.dart | 7 + .../time/time_interval_document_mapping.dart | 7 + .../lib/src/document_migration.dart | 3 + .../codes/document_migration_entity.dart | 14 + .../document_migration_failure_code.dart | 14 + .../codes/document_migration_note_code.dart | 8 + .../codes/document_migration_status.dart | 11 + .../legacy/legacy_document_exception.dart | 12 + .../legacy/legacy_metadata_instants.dart | 86 ++++++ .../reports/document_migration_note.dart | 13 + .../document_migration_provenance.dart | 5 + .../reports/document_migration_report.dart | 36 +++ .../reports/document_migration_result.dart | 9 + .../service/schema_version_read.dart | 15 ++ .../service/v0_migrator.dart | 3 + .../v1_document_migration_service.dart | 32 +++ .../scheduler_core/lib/src/free_slots.dart | 3 + .../lib/src/free_slots/free_slot_service.dart | 13 + .../protected_free_slot_conflict.dart | 5 + .../required_commitment_schedule_result.dart | 5 + .../required_commitment_schedule_status.dart | 3 + .../scheduler_core/lib/src/locked_time.dart | 3 + .../src/locked_time/aliases/clock_time.dart | 3 + .../src/locked_time/blocks/locked_block.dart | 5 + .../blocks/locked_block_occurrence.dart | 5 + .../enums/locked_block_override_type.dart | 3 + .../src/locked_time/enums/locked_weekday.dart | 25 ++ .../expansion/locked_schedule_expansion.dart | 19 ++ .../overrides/locked_block_override.dart | 5 + .../recurrence/locked_block_recurrence.dart | 5 + packages/scheduler_core/lib/src/models.dart | 3 + .../src/models/entities/project_profile.dart | 5 + .../lib/src/models/entities/task.dart | 5 + .../src/models/entities/time_interval.dart | 13 + .../models/enums/effort/difficulty_level.dart | 3 + .../src/models/enums/effort/reward_level.dart | 3 + .../models/enums/planning/backlog_tag.dart | 3 + .../models/enums/planning/priority_level.dart | 3 + .../enums/planning/reminder_profile.dart | 3 + .../src/models/enums/tasks/task_status.dart | 3 + .../lib/src/models/enums/tasks/task_type.dart | 3 + .../validation/domain_validation_code.dart | 68 +++++ .../domain_validation_exception.dart | 5 + .../lib/src/occupancy_policy.dart | 3 + .../occupancy_policy/occupancy_category.dart | 3 + .../src/occupancy_policy/occupancy_entry.dart | 5 + .../occupancy_policy/occupancy_policy.dart | 15 ++ .../occupancy_policy/occupancy_source.dart | 3 + .../lib/src/persistence_contract.dart | 3 + .../collections/persistence_collections.dart | 36 +++ .../persistence_civil_date_convention.dart | 3 + .../persistence_date_time_convention.dart | 3 + .../conventions/persistence_enum_name.dart | 3 + .../persistence_wall_time_convention.dart | 3 + ...application_operation_document_fields.dart | 31 +++ .../backlog_staleness_document_fields.dart | 10 + ...otice_acknowledgement_document_fields.dart | 28 ++ .../owner_settings_document_fields.dart | 37 +++ .../fields/core/document_fields.dart | 5 + .../locked_block_document_fields.dart | 46 ++++ ...locked_block_override_document_fields.dart | 46 ++++ ...cked_block_recurrence_document_fields.dart | 10 + .../projects/project_document_fields.dart | 46 ++++ .../project_statistics_document_fields.dart | 52 ++++ .../scheduling_change_document_fields.dart | 19 ++ .../scheduling_notice_document_fields.dart | 25 ++ .../scheduling_overlap_document_fields.dart | 13 + .../scheduling_snapshot_document_fields.dart | 61 +++++ .../scheduling_window_document_fields.dart | 10 + .../tasks/task_activity_document_fields.dart | 40 +++ .../fields/tasks/task_document_fields.dart | 79 ++++++ .../task_statistics_document_fields.dart | 43 +++ .../time/clock_time_document_fields.dart | 7 + .../time/time_interval_document_fields.dart | 13 + .../indexes/persistence_index_catalog.dart | 7 + .../indexes/persistence_index_direction.dart | 8 + .../indexes/persistence_index_field.dart | 10 + .../indexes/persistence_index_spec.dart | 22 ++ .../indexes/repository_query_names.dart | 74 ++++++ .../payloads/persistence_guard_result.dart | 20 ++ .../payloads/persistence_payload_guard.dart | 7 + .../payloads/persistence_payload_limits.dart | 26 ++ .../persistence_document_field_sets.dart | 5 + .../lib/src/project_statistics.dart | 3 + .../enums/project_completion_time_bucket.dart | 17 ++ .../enums/project_suggestion_confidence.dart | 11 + .../enums/project_suggestion_type.dart | 17 ++ .../models/project_default_resolution.dart | 16 ++ .../models/project_statistics.dart | 5 + ...project_statistics_application_result.dart | 5 + ...roject_statistics_aggregation_service.dart | 5 + .../suggestions/project_suggestion.dart | 5 + .../project_suggestion_policy.dart | 19 ++ .../project_suggestion_service.dart | 49 ++++ .../suggestions/project_suggestion_set.dart | 22 ++ .../scheduler_core/lib/src/quick_capture.dart | 3 + .../quick_capture/quick_capture_request.dart | 5 + .../quick_capture/quick_capture_result.dart | 5 + .../quick_capture/quick_capture_service.dart | 5 + .../quick_capture/quick_capture_status.dart | 3 + .../lib/src/reminder_policy.dart | 3 + .../directives/reminder_directive.dart | 5 + .../directives/reminder_directive_action.dart | 14 + .../directives/reminder_directive_reason.dart | 35 +++ .../profiles/effective_reminder_profile.dart | 5 + .../effective_reminder_profile_source.dart | 11 + .../services/reminder_policy_service.dart | 15 ++ .../scheduler_core/lib/src/repositories.dart | 3 + .../failures/repository_failure.dart | 16 ++ .../failures/repository_failure_code.dart | 20 ++ .../failures/repository_mutation_result.dart | 16 ++ .../in_memory_locked_block_repository.dart | 63 +++++ .../in_memory_project_repository.dart | 36 +++ ...memory_scheduling_snapshot_repository.dart | 29 ++ .../in_memory/in_memory_task_repository.dart | 50 ++++ .../interfaces/locked_block_repository.dart | 13 + .../interfaces/project_repository.dart | 3 + .../scheduling_snapshot_repository.dart | 3 + .../interfaces/task_repository.dart | 3 + .../pagination/repository_page.dart | 12 + .../pagination/repository_page_request.dart | 5 + .../pagination/repository_record.dart | 18 ++ .../queries/backlog_candidate_query.dart | 16 ++ .../queries/scheduling_state_snapshot.dart | 5 + .../lib/src/repository_values.dart | 3 + .../lib/src/repository_values/owner_id.dart | 11 + .../lib/src/repository_values/page.dart | 7 + .../src/repository_values/page_request.dart | 5 + .../lib/src/repository_values/revision.dart | 13 + .../lib/src/scheduling_engine.dart | 11 + .../conflicts/scheduling_conflict_code.dart | 11 + .../codes/notices/scheduling_issue_code.dart | 29 ++ .../codes/notices/scheduling_notice_type.dart | 3 + .../operations/scheduling_movement_code.dart | 23 ++ .../operations/scheduling_operation_code.dart | 3 + .../operations/scheduling_outcome_code.dart | 3 + .../engine/scheduling_engine.dart | 7 + .../models/changes/scheduling_change.dart | 5 + .../models/changes/scheduling_overlap.dart | 5 + .../models/inputs/scheduling_input.dart | 5 + .../models/inputs/scheduling_window.dart | 5 + .../models/notices/scheduling_notice.dart | 5 + .../models/results/scheduling_result.dart | 7 + .../planning/backlog_insertion_plan.dart | 5 + .../planning/placement_item.dart | 5 + .../scheduler_core/lib/src/task_actions.dart | 3 + .../actions/flexible_task_quick_action.dart | 3 + .../actions/push_destination.dart | 3 + .../actions/required_task_action.dart | 3 + .../requests/surprise_task_log_request.dart | 5 + .../results/flexible_task_action_result.dart | 5 + .../results/push_destination_result.dart | 5 + .../results/required_task_action_result.dart | 5 + .../results/surprise_task_log_result.dart | 5 + .../flexible_task_action_service.dart | 5 + .../required_task_action_service.dart | 5 + .../services/surprise_task_log_service.dart | 23 ++ .../lib/src/task_lifecycle.dart | 3 + .../codes/task_activity_code.dart | 29 ++ .../codes/task_transition_code.dart | 29 ++ .../codes/task_transition_outcome_code.dart | 14 + .../task_lifecycle/models/task_activity.dart | 5 + .../task_activity_application_result.dart | 5 + .../models/task_transition_result.dart | 5 + .../task_activity_accounting_service.dart | 5 + .../services/task_transition_service.dart | 59 +++++ .../lib/src/task_statistics.dart | 5 + .../lib/src/time_contracts.dart | 3 + .../src/time_contracts/civil/civil_date.dart | 31 +++ .../src/time_contracts/civil/wall_time.dart | 24 ++ .../lib/src/time_contracts/clocks/clock.dart | 5 + .../time_contracts/clocks/fixed_clock.dart | 9 + .../time_contracts/clocks/system_clock.dart | 7 + .../src/time_contracts/ids/id_generator.dart | 5 + .../ids/sequential_id_generator.dart | 12 + .../nonexistent_local_time_policy.dart | 5 + .../policies/repeated_local_time_policy.dart | 5 + .../time_zone_resolution_options.dart | 12 + .../resolution/local_time_resolution.dart | 16 ++ .../resolution/resolved_local_date_time.dart | 24 ++ .../fixed_offset_time_zone_resolver.dart | 11 + .../zones/resolvers/time_zone_resolver.dart | 5 + .../lib/src/timeline_state.dart | 3 + .../mapping/timeline_item_mapper.dart | 29 ++ .../models/compact_timeline_state.dart | 5 + .../timeline_state/models/timeline_item.dart | 5 + .../tokens/timeline_background_token.dart | 20 ++ .../timeline_difficulty_icon_token.dart | 20 ++ .../tokens/timeline_item_category.dart | 3 + .../tokens/timeline_quick_action.dart | 23 ++ .../tokens/timeline_reward_icon_token.dart | 20 ++ .../scheduler_core/lib/src/today_state.dart | 3 + .../models/today_compact_state.dart | 5 + .../models/today_pending_notice.dart | 5 + .../src/today_state/models/today_state.dart | 5 + .../models/today_timeline_item.dart | 5 + .../models/today_timeline_item_source.dart | 8 + .../queries/get_today_state_query.dart | 37 +++ .../requests/get_today_state_request.dart | 5 + packages/scheduler_core/pubspec.yaml | 3 + .../test/application_commands_test.dart | 15 ++ .../test/application_layer_test.dart | 11 + .../test/application_management_test.dart | 9 + .../test/application_recovery_test.dart | 19 ++ .../scheduler_core/test/child_tasks_test.dart | 13 + .../test/document_mapping_test.dart | 9 + .../test/document_migration_test.dart | 7 + .../test/domain_contracts_test.dart | 7 + .../test/domain_invariants_test.dart | 9 + .../test/edge_case_regression_test.dart | 13 + .../scheduler_core/test/free_slots_test.dart | 13 + .../test/occupancy_policy_test.dart | 38 +++ .../test/persistence_edge_cases_test.dart | 7 + .../test/persistence_index_contract_test.dart | 9 + .../test/project_statistics_test.dart | 7 + .../test/reminder_policy_test.dart | 9 + .../test/repositories_test.dart | 9 + .../test/required_task_actions_test.dart | 7 + .../test/scheduling_engine_test.dart | 5 + .../test/scheduling_invariants_test.dart | 47 ++++ .../test/surprise_task_logging_test.dart | 15 ++ .../test/task_lifecycle_test.dart | 9 + .../test/time_contracts_test.dart | 15 ++ .../test/timeline_state_test.dart | 7 + .../scheduler_core/test/today_state_test.dart | 17 ++ packages/scheduler_export/LICENSE.md | 6 + .../scheduler_export/analysis_options.yaml | 5 +- packages/scheduler_export/lib/export.dart | 3 + .../lib/scheduler_export.dart | 3 + .../lib/src/export_controller.dart | 3 + .../context/export_context.dart | 3 + .../controller/export_controller.dart | 8 + .../errors/export_exception.dart | 5 + .../errors/export_repository_exception.dart | 3 + .../writers/core/export_writer.dart | 3 + .../writers/csv/csv_cell_encoder.dart | 5 + .../writers/csv/csv_export_writer.dart | 19 ++ .../formats/export_format_normalizer.dart | 7 + .../json/export_json_context_encoder.dart | 5 + .../writers/json/json_export_writer.dart | 24 ++ .../registry/export_writer_factory.dart | 3 + .../registry/export_writer_registry.dart | 5 + packages/scheduler_export/pubspec.yaml | 3 + .../test/export_controller_test.dart | 19 ++ packages/scheduler_export_json/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../scheduler_export_json/bin/export.dart | 9 + .../lib/export_json.dart | 3 + .../lib/scheduler_export_json.dart | 3 + .../lib/src/json_csv_writers.dart | 3 + .../json_csv_writers/csv_cell_encoder.dart | 5 + .../json_csv_writers/csv_export_writer.dart | 19 ++ .../export_json_context_encoder.dart | 5 + .../json_csv_writers/json_export_writer.dart | 24 ++ packages/scheduler_export_json/pubspec.yaml | 3 + .../test/export_json_test.dart | 9 + .../scheduler_integration_tests/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../integration/scheduler_flow_test.dart | 7 + .../scheduler_integration_tests/pubspec.yaml | 3 + .../test/scheduler_flow_test.dart | 5 + packages/scheduler_notifications/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../lib/notifications.dart | 3 + .../lib/scheduler_notifications.dart | 3 + .../lib/src/notification_adapter.dart | 3 + .../adapter/fake_notification_adapter.dart | 19 ++ .../adapter/notification_adapter.dart | 3 + .../feedback/notification_feedback.dart | 3 + .../feedback/notification_feedback_type.dart | 3 + .../requests/notification_request.dart | 5 + .../requests/notification_request_impl.dart | 7 + packages/scheduler_notifications/pubspec.yaml | 3 + .../test/fake_adapter_test.dart | 5 + .../test/notification_adapter_contract.dart | 5 + .../LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../lib/desktop_notifications.dart | 3 + .../lib/scheduler_notifications_desktop.dart | 3 + .../lib/src/desktop_notification_adapter.dart | 3 + .../src/desktop_notification_adapter_io.dart | 3 + .../adapter/desktop_notification_adapter.dart | 11 + .../core/desktop_notification_backend.dart | 3 + .../desktop_notification_backend_factory.dart | 3 + .../fallback/stdout_notification_backend.dart | 11 + .../platform/apple_script_string_encoder.dart | 5 + .../platform/linux_notify_send_backend.dart | 16 ++ .../platform/mac_os_notification_backend.dart | 14 + .../desktop_notification_log_sink.dart | 3 + .../callbacks/desktop_process_runner.dart | 3 + .../desktop_notification_platform.dart | 3 + ...esktop_notification_platform_detector.dart | 5 + .../desktop_notification_adapter_stub.dart | 3 + .../adapter/desktop_notification_adapter.dart | 11 + .../desktop_notification_backend.dart | 3 + .../desktop_notification_backend_factory.dart | 3 + .../backends/stdout_notification_backend.dart | 11 + .../callbacks/default_log_sink.dart | 5 + .../desktop_notification_log_sink.dart | 3 + .../desktop_notification_platform.dart | 3 + .../pubspec.yaml | 3 + .../desktop_notification_adapter_test.dart | 35 ++- packages/scheduler_persistence/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../lib/persistence.dart | 3 + .../failures/core/repository_failure.dart | 5 + .../identity/repository_duplicate_id.dart | 3 + .../identity/repository_not_found.dart | 3 + .../identity/repository_owner_mismatch.dart | 3 + .../revision/repository_invalid_revision.dart | 3 + .../revision/repository_stale_revision.dart | 3 + .../storage/repository_storage_failure.dart | 3 + .../repositories/locked_block_repository.dart | 3 + .../repositories/project_repository.dart | 3 + .../schedule_snapshot_repository.dart | 3 + .../repositories/settings_repository.dart | 3 + .../repositories/task_repository.dart | 3 + .../lib/persistence/results/either.dart | 5 + .../lib/persistence/results/left.dart | 3 + .../lib/persistence/results/repo_result.dart | 3 + .../lib/persistence/results/right.dart | 3 + packages/scheduler_persistence/pubspec.yaml | 3 + .../test/repo_conformance.dart | 33 +++ .../test/repo_contract_smoke_test.dart | 5 + .../scheduler_persistence_memory/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../lib/memory.dart | 3 + .../lib/persistence_memory.dart | 7 + .../records/identified_record.dart | 7 + .../persistence_memory/records/record.dart | 26 ++ .../records/snapshot_record.dart | 21 ++ .../in_memory_locked_block_repository.dart | 26 ++ .../in_memory_project_repository.dart | 15 ++ ...n_memory_schedule_snapshot_repository.dart | 39 +++ .../in_memory_settings_repository.dart | 11 + .../in_memory_task_repository.dart | 21 ++ .../scheduler_persistence_memory/pubspec.yaml | 3 + .../memory_repository_conformance_test.dart | 5 + .../scheduler_persistence_sqlite/LICENSE.md | 6 + .../analysis_options.yaml | 5 +- .../lib/sqlite.dart | 3 + .../lib/src/scheduler_db.dart | 3 + .../scheduler_db/daos/locked_block_dao.dart | 5 + .../src/scheduler_db/daos/project_dao.dart | 5 + .../src/scheduler_db/daos/settings_dao.dart | 5 + .../src/scheduler_db/daos/snapshot_dao.dart | 5 + .../lib/src/scheduler_db/daos/task_dao.dart | 5 + .../scheduler_db/database/scheduler_db.dart | 9 + .../tables/locked_time/locked_blocks.dart | 43 +++ .../tables/locked_time/locked_overrides.dart | 43 +++ .../tables/projects/projects.dart | 43 +++ .../tables/settings/settings_table.dart | 33 +++ .../tables/snapshots/snapshots.dart | 58 ++++ .../src/scheduler_db/tables/tasks/tasks.dart | 76 ++++++ .../lib/src/sqlite_repositories.dart | 13 + .../sqlite_locked_block_repository.dart | 33 +++ .../sqlite_project_repository.dart | 19 ++ .../sqlite_schedule_snapshot_repository.dart | 61 +++++ .../sqlite_settings_repository.dart | 15 ++ .../sqlite_task_repository.dart | 27 ++ .../scheduler_persistence_sqlite/pubspec.yaml | 3 + .../test/scheduler_db_test.dart | 5 + .../sqlite_repository_conformance_test.dart | 7 + pubspec.yaml | 5 + scripts/bootstrap_dev.sh | 8 + scripts/build.dart | 21 ++ scripts/dev.dart | 11 + scripts/dev.sh | 3 + scripts/package_release.sh | 3 + scripts/test.dart | 8 + scripts/test.sh | 3 + test/scheduler_core_test.dart | 2 + tool/check_coverage.dart | 7 + tool/check_docs.dart | 249 +++++++++++++++++- tool/check_reuse.dart | 193 ++++++++++++++ 571 files changed, 7637 insertions(+), 30 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 REUSE.toml create mode 100644 analysis_options.yaml create mode 100644 apps/focus_flow_flutter/LICENSE.md create mode 100644 packages/scheduler_backup/LICENSE.md create mode 100644 packages/scheduler_core/LICENSE.md create mode 100644 packages/scheduler_export/LICENSE.md create mode 100644 packages/scheduler_export_json/LICENSE.md create mode 100644 packages/scheduler_integration_tests/LICENSE.md create mode 100644 packages/scheduler_notifications/LICENSE.md create mode 100644 packages/scheduler_notifications_desktop/LICENSE.md create mode 100644 packages/scheduler_persistence/LICENSE.md create mode 100644 packages/scheduler_persistence_memory/LICENSE.md create mode 100644 packages/scheduler_persistence_sqlite/LICENSE.md create mode 100644 tool/check_reuse.dart 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/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 74a6706..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` @@ -22,6 +31,9 @@ linter: # 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 index 58d2025..c78bdc1 100644 --- a/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart +++ b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart @@ -1,3 +1,6 @@ +// 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. @@ -19,6 +22,8 @@ String _formatDateLabel(CivilDate date) { 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 index e5d117e..6aadc5d 100644 --- a/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart +++ b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart @@ -1,5 +1,10 @@ +// 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( @@ -118,6 +123,8 @@ List _todaySeedTasks(CivilDate date, DateTime 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, 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 a4a28d5..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; @@ -12,6 +15,8 @@ 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, 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 0912db6..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Composes the FocusFlow Flutter application around Focus Flow App. library; @@ -30,6 +33,8 @@ class FocusFlowApp extends StatelessWidget { /// 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( 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 index bf4788f..eb92ebc 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart @@ -1,3 +1,6 @@ +// 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. @@ -8,6 +11,8 @@ class FocusFlowHome extends StatefulWidget { /// 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 index 82d70e8..11eaa7a 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -1,9 +1,21 @@ +// 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(); @@ -14,6 +26,8 @@ class _FocusFlowHomeState extends State { 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(); @@ -21,6 +35,8 @@ class _FocusFlowHomeState extends State { 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; @@ -35,6 +51,8 @@ class _FocusFlowHomeState extends State { } } + /// 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( diff --git a/apps/focus_flow_flutter/lib/app/home/plan_issue.dart b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart index 4508e4a..45173ef 100644 --- a/apps/focus_flow_flutter/lib/app/home/plan_issue.dart +++ b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart @@ -1,10 +1,21 @@ +// 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( diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index 96a4d98..8f28211 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -1,6 +1,13 @@ +// 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, @@ -9,12 +16,28 @@ class _TodayScreenScaffold extends StatefulWidget { 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 index 02fa25c..5fd50bf 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -1,9 +1,21 @@ +// 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) { @@ -15,6 +27,8 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { }); } + /// 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( 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 index 2a4d532..275fd9b 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 802398a..1d3d5fa 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -1,3 +1,6 @@ +// 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. @@ -25,7 +28,13 @@ class SchedulerCommandController extends ChangeNotifier { /// 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. @@ -146,6 +155,8 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// 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, @@ -173,15 +184,21 @@ class SchedulerCommandController extends ChangeNotifier { } } + /// 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 index 8f814f0..2c15d88 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart @@ -1,3 +1,6 @@ +// 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. 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 417af9e..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Coordinates Scheduler Command Controller state for the FocusFlow Flutter UI. library; 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 f6e5d38..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Coordinates Scheduler Read Controller state for the FocusFlow Flutter UI. library; @@ -84,6 +87,8 @@ class ApplicationReadController extends UiReadController { /// 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. @@ -116,6 +121,8 @@ class ApplicationReadController extends UiReadController { @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 a7e2953..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; @@ -53,7 +56,12 @@ class TodayScreenController extends ChangeNotifier { /// 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. @@ -105,6 +113,8 @@ class TodayScreenController extends ChangeNotifier { 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) { diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index 1532609..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; 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 index 53ffc08..5e6166a 100644 --- a/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart @@ -1,3 +1,6 @@ +// 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. 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 index 8a8a6f9..bc2d7fe 100644 --- a/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart +++ b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart index 722542e..1ed136d 100644 --- a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -1,5 +1,10 @@ +// 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', @@ -18,6 +23,8 @@ String _dateLabel(CivilDate date) { 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; @@ -25,6 +32,8 @@ int _minutesSinceMidnight(DateTime? value) { 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 ''; diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart index 4aa6d82..bf1baa1 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart index 3697b4a..edb96c4 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -1,5 +1,10 @@ +// 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 && @@ -16,6 +21,8 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) { }; } +/// 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'; @@ -30,6 +37,8 @@ String _typeLabelFor(TaskType taskType, TaskVisualKind visualKind) { }; } +/// 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, 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 index 2b48a20..059d236 100644 --- a/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart +++ b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart index 4e35ca8..4455c8e 100644 --- a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -1,3 +1,6 @@ +// 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. 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 ac68893..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Today Screen Models models for the FocusFlow Flutter UI. library; 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 57ed6de..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; 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 616f985..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Focus Flow Tokens styling for the FocusFlow Flutter UI. library; 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 00a8b97..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Defines Scheduler Visual Tokens styling for the FocusFlow Flutter UI. library; diff --git a/apps/focus_flow_flutter/lib/widgets/app_shell.dart b/apps/focus_flow_flutter/lib/widgets/app_shell.dart index 6c94d90..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; @@ -16,6 +19,8 @@ class AppShell extends StatelessWidget { /// 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 9d56f95..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; @@ -24,6 +27,8 @@ class BacklogPane extends StatelessWidget { /// 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( @@ -52,6 +57,8 @@ class BacklogContent extends StatelessWidget { /// 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( @@ -91,19 +98,29 @@ class QuickCaptureForm extends StatefulWidget { /// 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( @@ -142,6 +159,8 @@ class CommandStatusBanner extends StatelessWidget { /// 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 8ffd8ec..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; @@ -32,6 +35,8 @@ class ReadStateView extends StatelessWidget { /// 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( @@ -82,6 +87,8 @@ class EmptyStatePanel extends StatelessWidget { /// 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( @@ -138,6 +145,8 @@ class FailureStatePanel extends StatelessWidget { /// 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 99cd71d..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; @@ -17,6 +20,8 @@ class RequiredBanner extends StatelessWidget { /// 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; @@ -97,44 +102,115 @@ class RequiredBanner extends StatelessWidget { } } +/// 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( diff --git a/apps/focus_flow_flutter/lib/widgets/schedule_components.dart b/apps/focus_flow_flutter/lib/widgets/schedule_components.dart index 1aae2db..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; @@ -14,6 +17,8 @@ class CompactPanel extends StatelessWidget { /// 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) { @@ -42,6 +47,8 @@ class TimelineRow extends StatelessWidget { /// 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; @@ -104,19 +111,29 @@ class BacklogRow extends StatefulWidget { /// 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; @@ -175,6 +192,8 @@ class StalenessMarker extends StatelessWidget { /// 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( @@ -192,6 +211,8 @@ class NoticeBanner extends StatelessWidget { /// 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( @@ -216,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 fce3bbd..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; @@ -10,6 +13,8 @@ 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( @@ -46,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( @@ -63,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( @@ -77,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( @@ -111,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, @@ -119,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 index 7e75afd..b2562a3 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -1,11 +1,25 @@ +// 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( 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 index 2a5c5c2..ec6a2c3 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart @@ -1,10 +1,21 @@ +// 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( diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart index 9314e7a..84ac17d 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -1,11 +1,25 @@ +// 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( 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 index 29e7c5e..8fe6fb0 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart @@ -1,5 +1,10 @@ +// 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, diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart index 78b6a03..aed36b8 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -1,16 +1,33 @@ +// 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( 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 b26aee9..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; @@ -33,6 +36,8 @@ class TaskSelectionModal extends StatelessWidget { /// 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); 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 index 748c9b3..e83fdcb 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../timeline_axis.dart'; /// Background grid aligned to timeline tick marks. @@ -11,6 +14,8 @@ class TimelineGrid extends StatelessWidget { /// 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( 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 index f9b31e1..ee3833d 100644 --- 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 @@ -1,11 +1,25 @@ +// 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() @@ -23,6 +37,8 @@ class _TimelineGridPainter 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 _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 aa9926b..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; @@ -49,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( @@ -61,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; @@ -98,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 2586f53..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; @@ -20,6 +23,8 @@ class RewardIcon extends StatelessWidget { /// 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( @@ -29,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() @@ -59,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) @@ -73,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 index 98447b1..5fe1fd7 100644 --- 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 @@ -1,16 +1,33 @@ +// 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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart index 2fa9a45..1e3a927 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -1,14 +1,37 @@ +// 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, @@ -29,42 +52,122 @@ class _CardMetrics { 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( 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 index 8a579c2..7c147ec 100644 --- 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 @@ -1,16 +1,33 @@ +// 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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart index dbc906b..e9a7f58 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,11 +15,24 @@ class _CompactCardText extends StatelessWidget { 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); @@ -83,6 +103,8 @@ class _CompactCardText extends StatelessWidget { ); } + /// 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) { diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart index 21740aa..4dede80 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,11 +15,24 @@ class _ExpandedCardContent extends StatelessWidget { 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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart index 983c6ea..0ad674e 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -1,16 +1,33 @@ +// 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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart index b42338d..1f09d0f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,11 +15,24 @@ class _QuickActions extends StatelessWidget { 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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart index 6d31f11..97f0497 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,11 +15,24 @@ class _StatusRing extends StatelessWidget { 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); diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart index 4660beb..dac36c3 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -1,8 +1,17 @@ +// 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; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart index a6aff39..79c59d7 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,11 +15,24 @@ class _TrailingControls extends StatelessWidget { 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( 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 96b48bf..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline. library; @@ -40,6 +43,8 @@ class TaskTimelineCard extends StatefulWidget { /// 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 8d795f9..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; @@ -20,6 +23,8 @@ class TimelineAxis extends StatelessWidget { /// 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(); 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 1556809..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline. library; @@ -60,6 +63,8 @@ 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) || @@ -70,6 +75,8 @@ class TimelineGeometry { 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( @@ -80,6 +87,8 @@ class TimelineGeometry { ); } + /// 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) % 24; final minute = minutes % 60; 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 e6a0a7a..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,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Renders Timeline View pieces for the FocusFlow Flutter timeline. library; @@ -49,6 +52,8 @@ class TimelineView extends StatefulWidget { /// 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 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 index aecc8cd..cc1b702 100644 --- 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 @@ -1,6 +1,13 @@ +// 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, @@ -9,14 +16,32 @@ class _TimelineCardPlacement { 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; @@ -30,6 +55,8 @@ class _TimelineCardPlacement { ); } + /// 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, @@ -74,6 +101,8 @@ class _TimelineCardPlacement { 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, @@ -113,6 +142,8 @@ class _TimelineCardPlacement { ]; } + /// 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, @@ -120,21 +151,29 @@ class _TimelineCardPlacement { 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) { @@ -143,12 +182,16 @@ class _TimelineCardPlacement { 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; 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 index 46472e1..581ee38 100644 --- 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 @@ -1,8 +1,17 @@ +// 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 index e2eca0c..c07a94b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -1,22 +1,53 @@ +// 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); @@ -34,12 +65,16 @@ class _TimelineViewState extends State { } } + /// 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( @@ -111,6 +146,8 @@ class _TimelineViewState extends State { ); } + /// 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, @@ -125,6 +162,8 @@ class _TimelineViewState extends State { _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( @@ -133,6 +172,8 @@ class _TimelineViewState extends State { ); } + /// 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 = [ @@ -141,6 +182,8 @@ class _TimelineViewState extends State { ]; } + /// 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, @@ -168,6 +211,8 @@ class _TimelineViewState extends State { }); } + /// 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) { diff --git a/apps/focus_flow_flutter/lib/widgets/today_pane.dart b/apps/focus_flow_flutter/lib/widgets/today_pane.dart index de0ec10..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; @@ -24,6 +27,8 @@ class TodayPane extends StatelessWidget { /// 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( @@ -48,6 +53,8 @@ class TodayContent extends StatelessWidget { /// 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( @@ -75,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 98c5059..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; @@ -18,6 +21,8 @@ class TopBar extends StatelessWidget { /// 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 LayoutBuilder( 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 index f0bf92c..2a6d6cd 100644 --- 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 @@ -1,16 +1,33 @@ +// 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( 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 index 37afaf9..d42e0a9 100644 --- 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 @@ -1,38 +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 index a5def8d..304e679 100644 --- 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 @@ -1,10 +1,21 @@ +// 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( 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 index 2a3166a..c356dcb 100644 --- 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 @@ -1,10 +1,21 @@ +// 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( 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 2aaeaa6..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; @@ -33,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 []; @@ -43,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( @@ -69,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 00c6893..fb84af7 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Tests Widget behavior in the FocusFlow Flutter app. library; @@ -625,6 +628,8 @@ void main() { }); } +/// 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, @@ -637,10 +642,14 @@ Future _pumpPlanApp( 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( @@ -652,15 +661,21 @@ 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, @@ -688,6 +703,8 @@ Future _pumpTimelineCard( 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, @@ -708,6 +725,8 @@ Future _pumpConstrainedWidget( 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, 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 58d0ef2..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; @@ -23,11 +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; diff --git a/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart index d983714..00b43ff 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/codec/backup_encryption_codec.dart @@ -1,5 +1,10 @@ +// 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, @@ -26,6 +31,8 @@ Future> _encryptBytes({ 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, @@ -56,6 +63,8 @@ Future> _decryptBytes({ } } +/// 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, @@ -71,6 +80,8 @@ Future _deriveKey({ ); } +/// 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( 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 index 0b53184..6820bb1 100644 --- 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 @@ -1,3 +1,6 @@ +// 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. @@ -10,6 +13,8 @@ final class BackupDecryptionException implements Exception { /// 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 index 8a36df4..7133193 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/errors/backup_exception.dart @@ -1,3 +1,6 @@ +// 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. @@ -8,6 +11,8 @@ final class BackupException implements Exception { /// 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 index 80ee099..9174859 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/operations/encrypted_backup_operations.dart @@ -1,3 +1,6 @@ +// 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. 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 index a596959..2c91a8e 100644 --- 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 @@ -1,5 +1,10 @@ +// 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()) { @@ -19,12 +24,18 @@ Future _nextAvailableBackupFile(Directory directory, String name) async { } } +/// 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 index 8cb5cba..2bd5e85 100644 --- a/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart +++ b/packages/scheduler_backup/lib/src/encrypted_backup/paths/backup_paths.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../encrypted_backup.dart'; /// Default scheduler SQLite database path. @@ -18,6 +21,8 @@ Directory defaultSchedulerBackupDirectory() { )); } +/// 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'] ?? @@ -28,6 +33,8 @@ Directory _homeDirectory() { 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) 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..a8eb15a 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; diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index cd1762d..657ff56 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Application Commands behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart index 84b3eeb..4cc4509 100644 --- a/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart @@ -1,22 +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_commands/messages/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart index 4bafc31..49a9086 100644 --- a/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart index 1897022..9f339cd 100644 --- a/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart @@ -1,7 +1,12 @@ +// 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 [], diff --git a/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart b/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart index d6b8722..e957201 100644 --- a/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart +++ b/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart b/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart index 5902833..9e1be77 100644 --- a/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart +++ b/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart @@ -1,6 +1,13 @@ +// 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, @@ -8,10 +15,20 @@ class _PlanningState { }) : 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, @@ -20,6 +37,8 @@ class _PlanningState { ); } + /// 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; @@ -31,6 +50,8 @@ class _PlanningState { ); } + /// 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), @@ -40,6 +61,8 @@ class _PlanningState { } } +/// 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, @@ -54,6 +77,8 @@ ApplicationResult _failure( ); } +/// 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, ) { @@ -75,6 +100,8 @@ ApplicationResult _failureForQuickCapture( ); } +/// 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, @@ -102,6 +129,8 @@ ApplicationFailure? _failureForSchedulingResult( }; } +/// 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)) { @@ -114,6 +143,8 @@ ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) { ); } +/// 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, @@ -159,6 +190,8 @@ List _activitiesForSchedulingChanges({ 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; @@ -170,6 +203,8 @@ TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { 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, @@ -186,10 +221,14 @@ List _changedTasks(List beforeTasks, List afterTasks) { 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 || @@ -209,6 +248,8 @@ bool _taskChanged(Task before, Task after) { 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; @@ -217,6 +258,8 @@ bool _sameNullableInstant(DateTime? first, DateTime? second) { 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) { @@ -227,22 +270,30 @@ Task? _taskById(List tasks, String taskId) { 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) { diff --git a/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart index fa8498c..245753c 100644 --- a/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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, @@ -26,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. @@ -840,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, @@ -901,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, @@ -946,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, @@ -997,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, @@ -1012,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, @@ -1052,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, diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application_layer.dart index 57e1f3e..a19fb48 100644 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ b/packages/scheduler_core/lib/src/application_layer.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Application Layer behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart b/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart index 858b21d..23ffa0e 100644 --- a/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart +++ b/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart index 342e739..59ad45e 100644 --- a/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart +++ b/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart index 68423bf..8b29ed1 100644 --- a/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart +++ b/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart @@ -1,7 +1,12 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart b/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart index 342e82c..4f256c5 100644 --- a/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart index 86afa0b..637268d 100644 --- a/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart b/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart index af4fc7b..9c9d24f 100644 --- a/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart +++ b/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart index bf071ae..a572a2a 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart @@ -1,7 +1,14 @@ +// 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, @@ -43,30 +50,48 @@ class _InMemoryApplicationRepositories ), 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_layer/repositories/in_memory/in_memory_application_state.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart index 28649a5..effc9ca 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart @@ -1,6 +1,13 @@ +// 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, @@ -27,31 +34,101 @@ class _InMemoryApplicationState { 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), diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart index 4cf69c1..d3049dc 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart @@ -1,7 +1,12 @@ +// 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 [], @@ -107,7 +112,12 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { }, ); + /// 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. @@ -175,6 +185,8 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { _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, @@ -244,6 +256,8 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { } } + /// 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( diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart index 2e1673b..096348b 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart @@ -1,16 +1,27 @@ +// 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_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart index a798e93..5785bc6 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart @@ -1,21 +1,34 @@ +// 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_layer/repositories/interfaces/application/owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart index 4d4afcc..770066d 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart @@ -1,13 +1,24 @@ +// 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_layer/repositories/interfaces/projects/project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart index 5b2fb00..3d1719e 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart @@ -1,17 +1,30 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart index c258d3d..2e3ffe0 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart @@ -1,26 +1,47 @@ +// 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_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart index bef584d..206fefe 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart @@ -1,7 +1,12 @@ +// 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, @@ -10,6 +15,8 @@ abstract interface class ApplicationUnitOfWork { ) 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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart index 86e94ed..a8dbbfc 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart @@ -1,22 +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_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart index 8fca707..d40eb21 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart @@ -1,16 +1,30 @@ +// 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, @@ -20,6 +34,8 @@ class _UnitOfWorkNoticeAcknowledgementRepository _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, @@ -29,6 +45,8 @@ class _UnitOfWorkNoticeAcknowledgementRepository ); } + /// 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, @@ -41,6 +59,8 @@ class _UnitOfWorkNoticeAcknowledgementRepository 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( @@ -51,6 +71,8 @@ class _UnitOfWorkNoticeAcknowledgementRepository _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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart index 04c1d6e..a49d974 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart @@ -1,15 +1,28 @@ +// 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, @@ -22,11 +35,15 @@ class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { 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, @@ -44,6 +61,8 @@ class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { } } +/// 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, @@ -51,6 +70,8 @@ String _noticeAcknowledgementKey({ 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); @@ -63,6 +84,8 @@ RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { ); } +/// 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; @@ -72,8 +95,12 @@ bool _taskOverlapsWindow(Task task, SchedulingWindow window) { 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; @@ -86,6 +113,8 @@ int _compareScheduledTasks(Task left, Task right) { 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) { @@ -94,6 +123,8 @@ int _compareBacklogCandidates(Task left, Task right) { 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) { @@ -102,6 +133,8 @@ int _compareProjects(ProjectProfile left, ProjectProfile right) { 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) { @@ -110,6 +143,8 @@ int _compareLockedBlocks(LockedBlock left, LockedBlock right) { 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, @@ -125,6 +160,8 @@ int _compareLockedOverrides( 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) { @@ -133,6 +170,8 @@ int _compareActivities(TaskActivity left, TaskActivity right) { 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, @@ -145,6 +184,8 @@ int _compareNoticeAcknowledgements( 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) { diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart index 27c0dd3..cba7866 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart @@ -1,20 +1,36 @@ +// 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, @@ -30,6 +46,8 @@ class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { ); } + /// 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; @@ -37,6 +55,8 @@ class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { (_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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart index e379ff0..c3b4908 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart @@ -1,6 +1,13 @@ +// 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, @@ -9,13 +16,25 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { _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]; @@ -30,11 +49,15 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { ); } + /// 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, @@ -52,6 +75,8 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { 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; @@ -59,6 +84,8 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { _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, @@ -78,6 +105,8 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { 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, @@ -111,6 +140,8 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { 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, @@ -134,7 +165,11 @@ class _UnitOfWorkProjectRepository implements ProjectRepository { ); } + /// 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_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart index 73a2a68..5bd8e63 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart @@ -1,7 +1,14 @@ +// 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, @@ -10,15 +17,27 @@ class _UnitOfWorkProjectStatisticsRepository _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, @@ -34,11 +53,15 @@ class _UnitOfWorkProjectStatisticsRepository ); } + /// 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; @@ -47,6 +70,8 @@ class _UnitOfWorkProjectStatisticsRepository (_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, diff --git a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart index 1a5909a..31dc001 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart @@ -1,6 +1,13 @@ +// 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, @@ -15,16 +22,37 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { _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]; @@ -39,11 +67,15 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -61,11 +93,15 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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, @@ -81,11 +117,15 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -103,6 +143,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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; @@ -110,6 +152,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { _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; @@ -117,6 +161,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { _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, @@ -136,6 +182,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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, @@ -169,6 +217,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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, @@ -192,6 +242,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -211,6 +263,8 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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, @@ -244,11 +298,19 @@ class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { 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_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart index e7d9214..b7f2fa0 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart @@ -1,16 +1,29 @@ +// 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, @@ -22,6 +35,8 @@ class _UnitOfWorkSchedulingSnapshotRepository ); } + /// 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_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart index 7e59e59..510c361 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart @@ -1,18 +1,34 @@ +// 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( @@ -22,6 +38,8 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { ); } + /// 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( @@ -29,6 +47,8 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { ); } + /// 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( @@ -38,11 +58,15 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { ); } + /// 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, @@ -55,12 +79,16 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { 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) { @@ -69,6 +97,8 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { } } + /// 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, @@ -87,5 +117,7 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { 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_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart index dafe8d3..181abe5 100644 --- a/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart +++ b/packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart @@ -1,6 +1,13 @@ +// 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, @@ -9,13 +16,25 @@ class _UnitOfWorkTaskRepository implements TaskRepository { _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]; @@ -29,11 +48,15 @@ class _UnitOfWorkTaskRepository implements TaskRepository { ); } + /// 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( @@ -41,6 +64,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { ); } + /// 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, @@ -53,6 +78,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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, @@ -69,6 +96,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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, @@ -86,6 +115,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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, @@ -103,6 +134,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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( @@ -112,6 +145,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { ); } + /// 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, @@ -129,6 +164,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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, @@ -143,6 +180,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { ); } + /// 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; @@ -150,6 +189,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { _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) { @@ -157,6 +198,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { } } + /// 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, @@ -176,6 +219,8 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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, @@ -206,7 +251,11 @@ class _UnitOfWorkTaskRepository implements TaskRepository { 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_layer/results/application_failure.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart index bd7c3c8..5d09c67 100644 --- a/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart index 777f90e..e427cca 100644 --- a/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart @@ -1,13 +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_layer/results/application_failure_exception.dart b/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart index d90b35d..2f30565 100644 --- a/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart @@ -1,7 +1,12 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart index 4d3d702..a033e8d 100644 --- a/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart @@ -1,6 +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_layer/results/application_result.dart b/packages/scheduler_core/lib/src/application_layer/results/application_result.dart index c478c5e..a433378 100644 --- a/packages/scheduler_core/lib/src/application_layer/results/application_result.dart +++ b/packages/scheduler_core/lib/src/application_layer/results/application_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application_management.dart index 061e8f9..c2ba8e9 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application_management.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Application Management behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart b/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart index 06d4fda..7aaea08 100644 --- a/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart +++ b/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart @@ -1,16 +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_management/codes/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart index a01e1fc..5483a3a 100644 --- a/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart +++ b/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart @@ -1,7 +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_management/drafts/locked_block_draft.dart b/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart index ab018f8..6771e3d 100644 --- a/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart +++ b/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart @@ -1,7 +1,12 @@ +// 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, @@ -13,12 +18,35 @@ class LockedBlockDraft { 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_management/drafts/project_profile_draft.dart b/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart index af0470a..da7b1a4 100644 --- a/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart +++ b/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart @@ -1,7 +1,12 @@ +// 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, @@ -13,12 +18,35 @@ class ProjectProfileDraft { 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_management/parsing/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart index 04cd55d..fa1b6ec 100644 --- a/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart +++ b/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart @@ -1,11 +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_management/read_models/backlog_item_read_model.dart b/packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart index d0d5048..800f1e6 100644 --- a/packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart b/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart index a388ea1..0d12f17 100644 --- a/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart b/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart index b5e3cd5..bd16284 100644 --- a/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart +++ b/packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart b/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart index 2d5e280..33e0b40 100644 --- a/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart +++ b/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart index eefd6e0..b355c66 100644 --- a/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart +++ b/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart index 671b5e4..fab8293 100644 --- a/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart +++ b/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart index 99c8bfb..97cce91 100644 --- a/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart +++ b/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart @@ -1,7 +1,12 @@ +// 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 = diff --git a/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart b/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart index b7204d0..adbb0bf 100644 --- a/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart @@ -1,7 +1,12 @@ +// 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, @@ -12,11 +17,31 @@ class LockedBlockUpdate { 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_management/updates/owner_settings_update.dart b/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart index 4395dd6..e7f3913 100644 --- a/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart @@ -1,7 +1,12 @@ +// 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, @@ -10,9 +15,23 @@ class OwnerSettingsUpdate { 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_management/updates/project_profile_update.dart b/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart index 4dcf4cd..2b07d10 100644 --- a/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart +++ b/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart @@ -1,7 +1,12 @@ +// 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, @@ -13,12 +18,35 @@ class ProjectProfileUpdate { 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/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart index 6cfa204..20cbe71 100644 --- a/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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(), @@ -99,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, @@ -132,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, @@ -158,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, @@ -174,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, @@ -209,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, @@ -235,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, @@ -254,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, @@ -284,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, @@ -307,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, @@ -340,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, @@ -386,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, @@ -441,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, @@ -458,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, @@ -476,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, @@ -488,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, @@ -502,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) { diff --git a/packages/scheduler_core/lib/src/application_recovery.dart b/packages/scheduler_core/lib/src/application_recovery.dart index 2e7a53a..2be5dc1 100644 --- a/packages/scheduler_core/lib/src/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application_recovery.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Application Recovery behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart index 23db761..c41b829 100644 --- a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart @@ -1,8 +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_recovery/app_open_recovery_result.dart b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart index b0c30e4..f7f545d 100644 --- a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart +++ b/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart index b79ad6d..41a8746 100644 --- a/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart +++ b/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart index f214d23..b84239a 100644 --- a/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart +++ b/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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, @@ -152,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( @@ -167,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, @@ -183,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, @@ -195,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, @@ -214,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, @@ -248,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, @@ -295,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, @@ -311,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 || @@ -319,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/backlog.dart b/packages/scheduler_core/lib/src/backlog.dart index f468f79..2cbbd31 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/backlog.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Backlog behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart b/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart index b0e7fab..d49f579 100644 --- a/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart +++ b/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../backlog.dart'; /// Derived backlog filters for a unified backlog list. diff --git a/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart b/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart index abc33db..7fbdd39 100644 --- a/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart +++ b/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../backlog.dart'; /// Sort options for a unified backlog list. diff --git a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart index c2b254c..e0a837d 100644 --- a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart +++ b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../backlog.dart'; /// Visual age bucket for backlog display. diff --git a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart index 4fc3768..f100a3a 100644 --- a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart +++ b/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../backlog.dart'; /// Configurable thresholds for backlog age markers. @@ -7,6 +10,8 @@ part of '../../backlog.dart'; /// 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), diff --git a/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart index 51069d2..e19cecf 100644 --- a/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart +++ b/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../backlog.dart'; /// Read-only backlog projection over the unified task list. @@ -7,6 +10,8 @@ part of '../../backlog.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart b/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart index 0c0d93f..c538c6f 100644 --- a/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart +++ b/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart @@ -1,12 +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/child_tasks.dart b/packages/scheduler_core/lib/src/child_tasks.dart index fd6017c..3fb2cc5 100644 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ b/packages/scheduler_core/lib/src/child_tasks.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Child Tasks behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart b/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart index 7f27e93..62b03ca 100644 --- a/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart +++ b/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart @@ -1,15 +1,29 @@ +// 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, @@ -21,6 +35,8 @@ int _priorityRank(PriorityLevel? priority) { }; } +/// 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) { @@ -31,16 +47,22 @@ Task? _taskById(List tasks, String taskId) { 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, @@ -49,6 +71,8 @@ String _activityId( 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) { diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart index 8cdfa8c..0820410 100644 --- a/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../child_tasks.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart index 2cedcd8..94df154 100644 --- a/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart b/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart index 7a49e3d..6502920 100644 --- a/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart +++ b/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../child_tasks.dart'; /// 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); diff --git a/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart b/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart index 7ae03bf..3ed8538 100644 --- a/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart +++ b/packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart b/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart index 710167b..3176a06 100644 --- a/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart +++ b/packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart b/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart index 316d093..2fde7ea 100644 --- a/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart +++ b/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart b/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart index 23bf73f..23fa5ce 100644 --- a/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart +++ b/packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart @@ -1,7 +1,12 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart b/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart index a16d8a3..9b8509a 100644 --- a/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart +++ b/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../child_tasks.dart'; /// Applies direct parent/child completion rules. @@ -6,6 +9,8 @@ part of '../../child_tasks.dart'; /// 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(), @@ -241,6 +246,8 @@ class ChildTaskCompletionService { ); } + /// 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, diff --git a/packages/scheduler_core/lib/src/document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping.dart index c8a0749..7da131a 100644 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Document Mapping behavior for the Scheduler Core package. library; @@ -43,7 +46,11 @@ 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( @@ -53,7 +60,11 @@ extension on Task { } } +/// 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, ) { @@ -71,7 +82,11 @@ extension on ProjectStatistics { } } +/// 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, ) { diff --git a/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart index eb53e45..20bd1ae 100644 --- a/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -21,6 +26,8 @@ abstract final class ApplicationOperationDocumentMapping { }; } + /// 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, ) { diff --git a/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart index db55fb0..46bd537 100644 --- a/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart @@ -1,7 +1,12 @@ +// 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: @@ -10,6 +15,8 @@ abstract final class BacklogStalenessDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart index 3b98ee4..f409bb3 100644 --- a/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -20,6 +25,8 @@ abstract final class NoticeAcknowledgementDocumentMapping { }; } + /// 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, ) { diff --git a/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart index 4df1841..efcfa93 100644 --- a/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -30,6 +35,8 @@ abstract final class OwnerSettingsDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart index d9bee32..12628cc 100644 --- a/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart @@ -1,11 +1,18 @@ +// 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); @@ -36,6 +43,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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', @@ -45,10 +54,14 @@ abstract final class PersistenceEnumMapping { 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', @@ -59,10 +72,14 @@ abstract final class PersistenceEnumMapping { 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', @@ -71,12 +88,16 @@ abstract final class PersistenceEnumMapping { 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', @@ -86,10 +107,14 @@ abstract final class PersistenceEnumMapping { 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', @@ -99,10 +124,14 @@ abstract final class PersistenceEnumMapping { 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', @@ -110,20 +139,28 @@ abstract final class PersistenceEnumMapping { 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', @@ -134,10 +171,14 @@ abstract final class PersistenceEnumMapping { 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, ) => @@ -147,6 +188,8 @@ abstract final class PersistenceEnumMapping { 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, @@ -155,6 +198,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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', @@ -168,10 +213,14 @@ abstract final class PersistenceEnumMapping { 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', @@ -181,6 +230,8 @@ abstract final class PersistenceEnumMapping { 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, @@ -189,6 +240,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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', @@ -204,6 +257,8 @@ abstract final class PersistenceEnumMapping { 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 @@ -214,6 +269,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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', @@ -231,6 +288,8 @@ abstract final class PersistenceEnumMapping { '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, ) { @@ -243,6 +302,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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 => @@ -253,6 +314,8 @@ abstract final class PersistenceEnumMapping { '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, ) { @@ -265,6 +328,8 @@ abstract final class PersistenceEnumMapping { ); } + /// 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, ) => @@ -276,6 +341,8 @@ abstract final class PersistenceEnumMapping { 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, ) { diff --git a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart index d383dfb..de1b8b2 100644 --- a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart +++ b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart @@ -1,7 +1,12 @@ +// 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, @@ -17,6 +22,8 @@ class DocumentMappingException implements Exception { /// 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: ' diff --git a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart index 2971cef..ce0b0b2 100644 --- a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart +++ b/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart @@ -1,11 +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/document_mapping/locked_time/locked_block_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart index 041562d..e201c33 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -32,6 +37,8 @@ abstract final class LockedBlockDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart index 2ff50c4..0d8cb15 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -31,6 +36,8 @@ abstract final class LockedBlockOverrideDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart index 5facbb8..bba354b 100644 --- a/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart @@ -1,7 +1,12 @@ +// 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', @@ -11,6 +16,8 @@ abstract final class LockedBlockRecurrenceDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart b/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart index 5b15a3d..69177f1 100644 --- a/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart +++ b/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart @@ -1,7 +1,12 @@ +// 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, @@ -10,9 +15,23 @@ class DocumentMetadata { 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/document_mapping/projects/project_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart index c505f90..982e54b 100644 --- a/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -35,6 +40,8 @@ abstract final class ProjectDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart index bd3d4f9..64794ec 100644 --- a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart @@ -1,7 +1,12 @@ +// 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, @@ -20,6 +25,8 @@ extension ProjectStatisticsDocumentExtension on ProjectStatistics { } } +/// 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, @@ -29,6 +36,8 @@ const _taskTypeByCode = { '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, @@ -39,6 +48,8 @@ const _taskStatusByCode = { '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, @@ -47,6 +58,8 @@ const _priorityByCode = { '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, @@ -56,6 +69,8 @@ const _rewardByCode = { '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, @@ -65,6 +80,8 @@ const _difficultyByCode = { '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, @@ -72,10 +89,14 @@ const _reminderProfileByCode = { '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, @@ -86,12 +107,16 @@ const _lockedWeekdayByCode = { '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, @@ -104,6 +129,8 @@ const _taskActivityCodeByCode = { '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, @@ -112,6 +139,8 @@ const _schedulingNoticeTypeByCode = { '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, @@ -125,6 +154,8 @@ const _schedulingIssueCodeByCode = { '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': @@ -141,6 +172,8 @@ const _schedulingMovementCodeByCode = { 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, @@ -150,6 +183,8 @@ const _schedulingConflictCodeByCode = { 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, @@ -159,6 +194,8 @@ const _projectCompletionTimeBucketByCode = '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) { @@ -171,6 +208,8 @@ T _decodeCode(Map valuesByCode, String code, String fieldName) { 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, @@ -196,6 +235,8 @@ Map _commonFields({ }; } +/// 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) { @@ -222,6 +263,8 @@ DocumentMetadata _readCommon(Map document) { ); } +/// 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(); @@ -246,24 +289,32 @@ T _mapInvalidValues(T Function() read) { } } +/// 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( @@ -281,6 +332,8 @@ String _requiredString(Map document, String 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, @@ -301,6 +354,8 @@ String? _requiredNullableString( ); } +/// 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( @@ -318,6 +373,8 @@ int _requiredInt(Map document, String 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, @@ -338,6 +395,8 @@ int? _requiredNullableInt( ); } +/// 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( @@ -355,12 +414,16 @@ bool _requiredBool(Map document, String 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, @@ -371,12 +434,16 @@ DateTime? _requiredNullableDateTime( : 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, @@ -387,12 +454,16 @@ CivilDate? _requiredNullableCivilDate( : 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, @@ -403,6 +474,8 @@ WallTime? _requiredNullableWallTime( : 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, @@ -426,6 +499,8 @@ Map _requiredMap( ); } +/// 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, @@ -452,6 +527,8 @@ Map? _requiredNullableMap( ); } +/// 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( @@ -469,6 +546,8 @@ List _requiredList(Map document, String 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, @@ -488,12 +567,16 @@ List _mappedList( }).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, @@ -503,6 +586,8 @@ Map _enumCountMapToDocument( }; } +/// 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, @@ -514,6 +599,8 @@ Map _intKeyCountMapFromDocument( }; } +/// 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, @@ -526,6 +613,8 @@ Map _enumCountMapFromDocument( }; } +/// 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; @@ -536,12 +625,16 @@ int _countValue(Object? value, String 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 || diff --git a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart index acea981..9acc263 100644 --- a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -50,6 +55,8 @@ abstract final class ProjectStatisticsDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart index 734831a..3c26120 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -15,6 +20,8 @@ abstract final class SchedulingChangeDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart index 9f687db..bee49d8 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -26,6 +31,8 @@ abstract final class SchedulingNoticeDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart index 439a87c..b5e6971 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -12,6 +17,8 @@ abstract final class SchedulingOverlapDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart index e10efb2..4533181 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -58,6 +63,8 @@ abstract final class SchedulingSnapshotDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart index c0b2758..eadc7c9 100644 --- a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart @@ -1,7 +1,12 @@ +// 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: @@ -11,6 +16,8 @@ abstract final class SchedulingWindowDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart index 6a476ab..dc2a837 100644 --- a/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -30,6 +35,8 @@ abstract final class TaskActivityDocumentMapping { }; } + /// 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); diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart index 7e3d437..d289a3f 100644 --- a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart index 3d3a877..3f43df4 100644 --- a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart @@ -1,7 +1,12 @@ +// 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, @@ -55,6 +60,8 @@ abstract final class TaskDocumentMapping { }; } + /// 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); @@ -119,6 +126,8 @@ abstract final class TaskDocumentMapping { }); } + /// 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, ) { diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart index 12f679d..4914af7 100644 --- a/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart @@ -1,7 +1,12 @@ +// 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/document_mapping/tasks/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart index 871794f..f0f1dec 100644 --- a/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart @@ -1,7 +1,12 @@ +// 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: @@ -29,6 +34,8 @@ abstract final class TaskStatisticsDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart index 4f3a924..7f78c2c 100644 --- a/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart +++ b/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart @@ -1,7 +1,12 @@ +// 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: @@ -12,6 +17,8 @@ abstract final class TimeIntervalDocumentMapping { }; } + /// 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( diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/document_migration.dart index 7cdb9c3..c031d9b 100644 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ b/packages/scheduler_core/lib/src/document_migration.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Document Migration behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart index af5a730..e355e1a 100644 --- a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart @@ -1,9 +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/document_migration/codes/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart index 57cea1c..6b7bd20 100644 --- a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart @@ -1,9 +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/document_migration/codes/document_migration_note_code.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart index 0f65bea..56a38a7 100644 --- a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart @@ -1,7 +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/document_migration/codes/document_migration_status.dart b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart index 172a03f..5b420ac 100644 --- a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart +++ b/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart @@ -1,8 +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/document_migration/legacy/legacy_document_exception.dart b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart index 285ce5a..cb5e20b 100644 --- a/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart +++ b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart @@ -1,11 +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/document_migration/legacy/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart index 0f8b8d0..b163ff3 100644 --- a/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart +++ b/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart @@ -1,15 +1,29 @@ +// 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', @@ -20,6 +34,8 @@ const _taskTypeCodes = { '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', @@ -31,6 +47,8 @@ const _taskStatusCodes = { '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', @@ -41,6 +59,8 @@ const _priorityCodes = { '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', @@ -53,6 +73,8 @@ const _rewardCodes = { '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', @@ -65,6 +87,8 @@ const _difficultyCodes = { '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', @@ -72,10 +96,14 @@ const _reminderProfileCodes = { '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', @@ -86,14 +114,20 @@ const _lockedWeekdayCodes = { '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); @@ -105,11 +139,15 @@ _SchemaVersionRead _readSchemaVersion(Map document) { 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, @@ -132,6 +170,8 @@ DocumentMigrationResult _failed({ ); } +/// 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, @@ -154,6 +194,8 @@ DocumentMigrationResult _failedFromMapping({ ); } +/// 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( @@ -171,6 +213,8 @@ String _requiredString(Map document, String 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, @@ -191,6 +235,8 @@ String? _requiredNullableString( ); } +/// 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( @@ -208,6 +254,8 @@ int? _requiredNullableInt(Map document, String 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 _LegacyDocumentException( @@ -225,6 +273,8 @@ bool _requiredBool(Map document, String fieldName) { ); } +/// 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, @@ -245,6 +295,8 @@ Map _requiredMap( ); } +/// 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, @@ -261,6 +313,8 @@ String _requiredLegacyCode( 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, @@ -280,6 +334,8 @@ String? _nullableLegacyCode( 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, @@ -316,6 +372,8 @@ List _requiredLegacyCodeList( }).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( @@ -334,6 +392,8 @@ String _requiredInstant(Map document, String fieldName) { 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( @@ -344,6 +404,8 @@ String? _nullableInstant(Map document, String fieldName) { 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, @@ -354,6 +416,8 @@ String? _optionalNullableInstant( 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; @@ -391,6 +455,8 @@ String? _instantToStored(Object? value, String fieldName) { ); } +/// 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( @@ -408,6 +474,8 @@ String _requiredCivilDate(Map document, String fieldName) { 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, @@ -421,6 +489,8 @@ String? _requiredNullableCivilDate( 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; @@ -449,6 +519,8 @@ String? _civilDateToStored(Object? value, String fieldName) { ); } +/// 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( @@ -466,6 +538,8 @@ String _requiredWallTime(Map document, String fieldName) { 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, @@ -479,6 +553,8 @@ String? _requiredNullableWallTime( 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; @@ -520,6 +596,8 @@ String? _wallTimeToStored(Object? value, String fieldName) { ); } +/// 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, @@ -570,10 +648,14 @@ Map? _nullableRecurrence( }; } +/// 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) @@ -581,6 +663,8 @@ Map _deepCopyMap(Map map, String 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; @@ -591,6 +675,8 @@ String _stringKey(Object? key, String fieldName) { ); } +/// 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); diff --git a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart index 3f12708..ce0258f 100644 --- a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart @@ -1,14 +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/document_migration/reports/document_migration_provenance.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart index 00b0626..831885d 100644 --- a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart @@ -1,6 +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/document_migration/reports/document_migration_report.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart index 51f589e..7c2e0d3 100644 --- a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart @@ -1,7 +1,12 @@ +// 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, @@ -15,16 +20,47 @@ class DocumentMigrationReport { 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/document_migration/reports/document_migration_result.dart b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart index 39370b5..c5f56c8 100644 --- a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart +++ b/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart @@ -1,7 +1,12 @@ +// 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, @@ -10,8 +15,12 @@ class DocumentMigrationResult { /// 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. diff --git a/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart b/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart index 3ff8049..3466740 100644 --- a/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart @@ -1,9 +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/document_migration/service/v0_migrator.dart b/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart index e53f28f..91558cc 100644 --- a/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../document_migration.dart'; typedef _V0Migrator = Map Function( diff --git a/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart index ee5d07e..9c71f0a 100644 --- a/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart +++ b/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../document_migration.dart'; /// Pure V0-to-V1 migration service. @@ -7,14 +10,23 @@ part of '../../document_migration.dart'; /// 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, @@ -24,6 +36,8 @@ class V1DocumentMigrationService { ); } + /// 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, @@ -33,6 +47,8 @@ class V1DocumentMigrationService { ); } + /// 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, @@ -42,6 +58,8 @@ class V1DocumentMigrationService { ); } + /// 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, ) { @@ -53,6 +71,8 @@ class V1DocumentMigrationService { ); } + /// 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, @@ -158,6 +178,8 @@ class V1DocumentMigrationService { ); } + /// 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, @@ -247,6 +269,8 @@ class V1DocumentMigrationService { }; } + /// 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, @@ -291,6 +315,8 @@ class V1DocumentMigrationService { }; } + /// 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, @@ -325,6 +351,8 @@ class V1DocumentMigrationService { }; } + /// 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, @@ -372,6 +400,8 @@ class V1DocumentMigrationService { }; } + /// 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, @@ -387,6 +417,8 @@ class V1DocumentMigrationService { }; } + /// 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, diff --git a/packages/scheduler_core/lib/src/free_slots.dart b/packages/scheduler_core/lib/src/free_slots.dart index 1325118..d315483 100644 --- a/packages/scheduler_core/lib/src/free_slots.dart +++ b/packages/scheduler_core/lib/src/free_slots.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Free Slots behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart index 8f8566a..6e4b32f 100644 --- a/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart +++ b/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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. @@ -186,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, @@ -204,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, @@ -237,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) { @@ -247,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/free_slots/protected_free_slot_conflict.dart b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart index 26062ab..467cef2 100644 --- a/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart +++ b/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart index ab0c89e..8b7c61b 100644 --- a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart index 7c64690..1d6d84b 100644 --- a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart +++ b/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/locked_time.dart b/packages/scheduler_core/lib/src/locked_time.dart index 0774dfc..9003496 100644 --- a/packages/scheduler_core/lib/src/locked_time.dart +++ b/packages/scheduler_core/lib/src/locked_time.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Locked Time behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart b/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart index c339b79..ab73afb 100644 --- a/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart +++ b/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart index f661436..bb5d63b 100644 --- a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart +++ b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart @@ -1,3 +1,6 @@ +// 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. @@ -12,6 +15,8 @@ part of '../../locked_time.dart'; /// - 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, diff --git a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart index 2156803..8fe48b0 100644 --- a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart +++ b/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../locked_time.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart b/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart index 1fa1fb8..441ea8a 100644 --- a/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart +++ b/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart b/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart index 607b513..3c9752b 100644 --- a/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart +++ b/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,14 +9,36 @@ part of '../../locked_time.dart'; /// 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. diff --git a/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart b/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart index 7abb9be..8d2d3bb 100644 --- a/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart +++ b/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../locked_time.dart'; /// Expands locked blocks and one-day overrides into concrete intervals. @@ -6,6 +9,8 @@ part of '../../locked_time.dart'; /// 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, @@ -377,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, @@ -406,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, @@ -425,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, @@ -445,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; @@ -452,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, @@ -504,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/locked_time/overrides/locked_block_override.dart b/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart index 836b490..af622cc 100644 --- a/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart +++ b/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart @@ -1,3 +1,6 @@ +// 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. @@ -11,6 +14,8 @@ part of '../../locked_time.dart'; /// - [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, diff --git a/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart index cc6b489..68a3bf8 100644 --- a/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart +++ b/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../locked_time.dart'; /// Recurrence rule for locked time. @@ -7,6 +10,8 @@ part of '../../locked_time.dart'; /// 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) { diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/models.dart index 928f59e..21ed47c 100644 --- a/packages/scheduler_core/lib/src/models.dart +++ b/packages/scheduler_core/lib/src/models.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Models behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/models/entities/project_profile.dart b/packages/scheduler_core/lib/src/models/entities/project_profile.dart index d9edb6b..66db66c 100644 --- a/packages/scheduler_core/lib/src/models/entities/project_profile.dart +++ b/packages/scheduler_core/lib/src/models/entities/project_profile.dart @@ -1,3 +1,6 @@ +// 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. @@ -10,6 +13,8 @@ part of '../../models.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/models/entities/task.dart b/packages/scheduler_core/lib/src/models/entities/task.dart index ac54f3f..46b9b8d 100644 --- a/packages/scheduler_core/lib/src/models/entities/task.dart +++ b/packages/scheduler_core/lib/src/models/entities/task.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../models.dart'; /// Starter task model for the scheduling core. @@ -22,6 +25,8 @@ part of '../../models.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/models/entities/time_interval.dart b/packages/scheduler_core/lib/src/models/entities/time_interval.dart index b1bf8cb..8e5e493 100644 --- a/packages/scheduler_core/lib/src/models/entities/time_interval.dart +++ b/packages/scheduler_core/lib/src/models/entities/time_interval.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../models.dart'; /// Starter time range value used by scheduling helpers. @@ -7,6 +10,8 @@ part of '../../models.dart'; /// 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, @@ -45,6 +50,8 @@ class TimeInterval { } } +/// 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, @@ -63,6 +70,8 @@ String _requiredTrimmed( 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, @@ -84,6 +93,8 @@ String? _nullableTrimmed( 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; @@ -98,6 +109,8 @@ void _validatePositiveDuration(int? durationMinutes, String name) { } } +/// 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, diff --git a/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart b/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart index 9d7da9d..7dc5035 100644 --- a/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Expected effort or activation difficulty for a task. diff --git a/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart b/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart index 196375a..8856091 100644 --- a/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Expected reward or payoff from completing a task. diff --git a/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart b/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart index a4a6472..f6e4242 100644 --- a/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Lightweight backlog-only metadata. diff --git a/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart b/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart index bdd459d..bb03c2c 100644 --- a/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// User-facing importance level. diff --git a/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart b/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart index 8198007..b53a079 100644 --- a/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart +++ b/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Reminder intensity preference. diff --git a/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart b/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart index 3aa6321..72b3b50 100644 --- a/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart +++ b/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Current lifecycle state of a task. diff --git a/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart b/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart index d38017a..ed2663e 100644 --- a/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart +++ b/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../models.dart'; /// Scheduling behavior category. diff --git a/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart b/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart index ee0b591..b912cb2 100644 --- a/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart +++ b/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../models.dart'; /// Stable validation failure categories for domain model boundaries. @@ -6,26 +9,91 @@ part of '../../models.dart'; /// 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/models/validation/domain_validation_exception.dart b/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart index 1d7f6ef..f710461 100644 --- a/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart +++ b/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy.dart index 523ee56..4779f97 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Occupancy Policy behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart index ec69b2f..ec871dc 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart index 5d67009..9bded27 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart index d94798e..1b5975e 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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. @@ -135,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, @@ -148,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, @@ -220,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; @@ -236,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/occupancy_policy/occupancy_source.dart b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart index cf86143..b31ba0c 100644 --- a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart +++ b/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../occupancy_policy.dart'; /// Where an occupancy entry came from. diff --git a/packages/scheduler_core/lib/src/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence_contract.dart index c8a1e08..2971e52 100644 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ b/packages/scheduler_core/lib/src/persistence_contract.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Persistence Contract behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart index d6898e3..79f24ec 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart @@ -1,18 +1,52 @@ +// 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, @@ -22,6 +56,8 @@ abstract final class PersistenceCollections { 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart index 08ebf34..e238af5 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart index 1161b52..4362ae8 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence_contract.dart'; /// DateTime storage convention for future document persistence. diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart index 26b6e23..21f44d3 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart index 9c52cb6..8b4034e 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart index 14fa7d3..48310e9 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart @@ -1,17 +1,48 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart index b70ba41..b72f3f4 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart @@ -1,10 +1,20 @@ +// 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_contract/fields/application/notice_acknowledgement_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart index a11de62..004c68d 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart @@ -1,16 +1,44 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart index c069f19..4ff8bce 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart @@ -1,19 +1,56 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart index 071a62b..1de4c5f 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../persistence_contract.dart'; /// Shared V1 document field names. @@ -20,6 +23,8 @@ abstract final class DocumentFields { /// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart index 7374f63..155e259 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart @@ -1,22 +1,68 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart index fb32362..5a6f86c 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart @@ -1,22 +1,68 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart index 6be9001..61f1ada 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart @@ -1,10 +1,20 @@ +// 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_contract/fields/projects/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart index 92d3c34..62bb015 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart @@ -1,22 +1,68 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart index ee80025..66d55b4 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart @@ -1,24 +1,76 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart index 09e6f11..8340294 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart @@ -1,13 +1,32 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart index 7e4661b..2186fc5 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart @@ -1,15 +1,40 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart index 9263a60..e681ff7 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart @@ -1,11 +1,24 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart index f7e3b44..4fd0a13 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart @@ -1,27 +1,88 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart index 54449b4..533495b 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart @@ -1,10 +1,20 @@ +// 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_contract/fields/tasks/task_activity_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart index 3c17092..f4381a6 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart @@ -1,20 +1,60 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart index 1005526..dc76f03 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart @@ -1,33 +1,112 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart index be7f4f1..649d8fa 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart @@ -1,23 +1,66 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart index 24cfdd8..bfa044a 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart @@ -1,9 +1,16 @@ +// 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_contract/fields/time/time_interval_document_fields.dart b/packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart index 3801ffc..5e87870 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart @@ -1,11 +1,24 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart index 98cf617..d961cb1 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart @@ -1,7 +1,12 @@ +// 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', @@ -292,6 +297,8 @@ abstract final class PersistenceIndexCatalog { ), ]; + /// 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_contract/indexes/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart index 1eec613..43edde9 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart @@ -1,7 +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_contract/indexes/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart index 03a2c49..6e69031 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart @@ -1,12 +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_contract/indexes/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart index 791fc0f..525b807 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart @@ -1,7 +1,12 @@ +// 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, @@ -11,10 +16,27 @@ class PersistenceIndexSpec { 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_contract/indexes/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart index 704b128..305b0ab 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart @@ -1,37 +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_contract/payloads/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart index f5f5522..b534fea 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart @@ -1,7 +1,12 @@ +// 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, @@ -9,10 +14,14 @@ class PersistenceGuardResult { 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, @@ -26,8 +35,19 @@ class PersistenceGuardResult { ); } + /// 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_contract/payloads/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart index b001494..fadd3a4 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart @@ -1,7 +1,12 @@ +// 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, @@ -17,6 +22,8 @@ abstract final class PersistencePayloadGuard { 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, diff --git a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart index ebde4b1..c737e2c 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart @@ -1,13 +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_contract/persistence_document_field_sets.dart b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart index 37c66ce..5431251 100644 --- a/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart +++ b/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics.dart index 99feacb..a7a0d9e 100644 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Project Statistics behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart index 507d733..614b812 100644 --- a/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart @@ -1,10 +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/project_statistics/enums/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart index 7509041..38a036b 100644 --- a/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart @@ -1,8 +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/project_statistics/enums/project_suggestion_type.dart b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart index 9742545..5a1f59d 100644 --- a/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart +++ b/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart @@ -1,10 +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/project_statistics/models/project_default_resolution.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart index 8587d5d..3147ebd 100644 --- a/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart @@ -1,7 +1,12 @@ +// 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, @@ -13,9 +18,20 @@ class ProjectDefaultResolution { /// 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/project_statistics/models/project_statistics.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart index 4203f6b..9e955fa 100644 --- a/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart index d6576e0..88d7573 100644 --- a/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart +++ b/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart @@ -1,7 +1,12 @@ +// 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 [], diff --git a/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart index b5950ee..5b2920f 100644 --- a/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart +++ b/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart @@ -1,7 +1,12 @@ +// 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]. diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart index 5d6342e..73c2b69 100644 --- a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart index 4ab32b1..4286b30 100644 --- a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart @@ -1,7 +1,12 @@ +// 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, @@ -10,9 +15,23 @@ class ProjectSuggestionPolicy { 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/project_statistics/suggestions/project_suggestion_service.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart index 615aae3..7561933 100644 --- a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart @@ -1,11 +1,18 @@ +// 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]. @@ -47,6 +54,8 @@ class ProjectSuggestionService { ); } + /// 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) { @@ -71,6 +80,8 @@ class ProjectSuggestionService { ); } + /// 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, @@ -98,6 +109,8 @@ class ProjectSuggestionService { } } +/// 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, @@ -105,6 +118,8 @@ Map _incrementCount(Map counts, K key) { }; } +/// 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) { @@ -119,6 +134,8 @@ Map _positiveIntKeyCounts(Map counts, String name) { 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) { @@ -130,12 +147,16 @@ Map _enumCounts(Map counts, String name) { 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) { @@ -144,6 +165,8 @@ int _countTotal(Map counts) { 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; @@ -161,6 +184,8 @@ int? _weightedLowerMedian(Map counts) { 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; @@ -181,6 +206,8 @@ T? _uniqueMode(Map counts) { 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; @@ -194,6 +221,8 @@ ProjectSuggestionConfidence _confidence(int support, int sampleSize) { 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) @@ -201,6 +230,8 @@ Map _withoutNotSetReward(Map counts) { }; } +/// 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, ) { @@ -210,6 +241,8 @@ Map _withoutNotSetDifficulty( }; } +/// 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; @@ -220,10 +253,14 @@ DateTime? _dateTimeMetadata(Object? value) { 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) { @@ -246,6 +283,8 @@ int? _durationMinutes(TaskActivity activity, Task? task) { 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; @@ -253,6 +292,8 @@ int _pushesBeforeCompletion(Task? task) { 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; @@ -263,6 +304,8 @@ RewardLevel? _rewardLevel(Object? 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; @@ -273,6 +316,8 @@ DifficultyLevel? _difficultyLevel(Object? 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; @@ -283,6 +328,8 @@ ReminderProfile? _reminderProfile(Object? 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) { @@ -292,6 +339,8 @@ T? _enumByName(Iterable values, String name) { 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) { diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart index 5fdd1f1..a4b299b 100644 --- a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart +++ b/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart @@ -1,7 +1,12 @@ +// 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, @@ -11,10 +16,27 @@ class ProjectSuggestionSet { 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/quick_capture.dart b/packages/scheduler_core/lib/src/quick_capture.dart index 34a3078..f271926 100644 --- a/packages/scheduler_core/lib/src/quick_capture.dart +++ b/packages/scheduler_core/lib/src/quick_capture.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Quick Capture behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart index f854260..d6af0c0 100644 --- a/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../quick_capture.dart'; /// Input for low-friction task capture. @@ -7,6 +10,8 @@ part of '../quick_capture.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart index 65ea65c..c380746 100644 --- a/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../quick_capture.dart'; /// Result of a quick-capture request. @@ -7,6 +10,8 @@ part of '../quick_capture.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart index 92f1129..6e205fc 100644 --- a/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../quick_capture.dart'; /// Coordinates quick capture defaults and optional scheduling. @@ -7,6 +10,8 @@ part of '../quick_capture.dart'; /// [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/quick_capture/quick_capture_status.dart b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart index 423d0c7..908ce20 100644 --- a/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart +++ b/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../quick_capture.dart'; /// Outcome of a quick-capture request. diff --git a/packages/scheduler_core/lib/src/reminder_policy.dart b/packages/scheduler_core/lib/src/reminder_policy.dart index 7997f79..88b0aa4 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/reminder_policy.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Reminder Policy behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart index 6ecfda7..801e3c2 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart index d449a3a..4a93115 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart @@ -1,9 +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/reminder_policy/directives/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart index 0539c35..2c7cd82 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart @@ -1,16 +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/reminder_policy/profiles/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart index 8433179..a509204 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart index 164b2cb..d9754c6 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart @@ -1,8 +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/services/reminder_policy_service.dart b/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart index 9fc305a..f60bf11 100644 --- a/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart +++ b/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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(), @@ -60,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, @@ -170,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, @@ -203,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, @@ -226,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/repositories.dart b/packages/scheduler_core/lib/src/repositories.dart index a08fa1e..7cebdba 100644 --- a/packages/scheduler_core/lib/src/repositories.dart +++ b/packages/scheduler_core/lib/src/repositories.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Repositories behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart index 42331f8..e3e783b 100644 --- a/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart @@ -1,7 +1,12 @@ +// 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, @@ -9,8 +14,19 @@ class RepositoryFailure { 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/repositories/failures/repository_failure_code.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart index 4aa0f55..f9d1c88 100644 --- a/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart @@ -1,11 +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/repositories/failures/repository_mutation_result.dart b/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart index ee0b79b..8bb383c 100644 --- a/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart +++ b/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart @@ -1,22 +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/repositories/in_memory/in_memory_locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart index c81eb3f..4c8ced5 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart @@ -1,7 +1,12 @@ +// 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 [], @@ -25,19 +30,43 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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]; @@ -52,11 +81,15 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -74,11 +107,15 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -94,11 +131,15 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -116,6 +157,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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; @@ -123,6 +166,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { _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; @@ -130,6 +175,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { _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, @@ -149,6 +196,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -199,6 +248,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -222,6 +273,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -241,6 +294,8 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -291,12 +346,20 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { 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/repositories/in_memory/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart index 85b8021..0c5fc4b 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart @@ -1,7 +1,12 @@ +// 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']) @@ -15,16 +20,31 @@ class InMemoryProjectRepository implements ProjectRepository { 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]; @@ -39,11 +59,15 @@ class InMemoryProjectRepository implements ProjectRepository { ); } + /// 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, @@ -61,6 +85,8 @@ class InMemoryProjectRepository implements ProjectRepository { 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; @@ -68,6 +94,8 @@ class InMemoryProjectRepository implements ProjectRepository { _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, @@ -87,6 +115,8 @@ class InMemoryProjectRepository implements ProjectRepository { 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, @@ -137,6 +167,8 @@ class InMemoryProjectRepository implements ProjectRepository { 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, @@ -160,7 +192,11 @@ class InMemoryProjectRepository implements ProjectRepository { ); } + /// 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/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart index 10a0981..ce264ff 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart @@ -1,21 +1,32 @@ +// 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, @@ -27,12 +38,16 @@ class InMemorySchedulingSnapshotRepository ); } + /// 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); @@ -45,6 +60,8 @@ RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { ); } +/// 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; @@ -54,8 +71,12 @@ bool _taskOverlapsWindow(Task task, SchedulingWindow window) { 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; @@ -68,6 +89,8 @@ int _compareScheduledTasks(Task left, Task right) { 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) { @@ -76,6 +99,8 @@ int _compareBacklogCandidates(Task left, Task right) { 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) { @@ -84,6 +109,8 @@ int _compareProjects(ProjectProfile left, ProjectProfile right) { 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) { @@ -92,6 +119,8 @@ int _compareLockedBlocks(LockedBlock left, LockedBlock right) { 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, diff --git a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart index f67f986..952608a 100644 --- a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart @@ -1,7 +1,12 @@ +// 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', @@ -15,16 +20,31 @@ class InMemoryTaskRepository implements TaskRepository { 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]; @@ -38,11 +58,15 @@ class InMemoryTaskRepository implements TaskRepository { ); } + /// 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( @@ -50,6 +74,8 @@ class InMemoryTaskRepository implements TaskRepository { ); } + /// 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, @@ -62,6 +88,8 @@ class InMemoryTaskRepository implements TaskRepository { 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, @@ -78,6 +106,8 @@ class InMemoryTaskRepository implements TaskRepository { 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, @@ -95,6 +125,8 @@ class InMemoryTaskRepository implements TaskRepository { 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, @@ -112,6 +144,8 @@ class InMemoryTaskRepository implements TaskRepository { 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( @@ -121,6 +155,8 @@ class InMemoryTaskRepository implements TaskRepository { ); } + /// 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, @@ -141,6 +177,8 @@ class InMemoryTaskRepository implements TaskRepository { 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, @@ -155,6 +193,8 @@ class InMemoryTaskRepository implements TaskRepository { ); } + /// 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; @@ -162,6 +202,8 @@ class InMemoryTaskRepository implements TaskRepository { _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) { @@ -169,6 +211,8 @@ class InMemoryTaskRepository implements TaskRepository { } } + /// 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, @@ -188,6 +232,8 @@ class InMemoryTaskRepository implements TaskRepository { 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, @@ -238,7 +284,11 @@ class InMemoryTaskRepository implements TaskRepository { 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/repositories/interfaces/locked_block_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart index 16345e7..0178653 100644 --- a/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart @@ -1,3 +1,6 @@ +// 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. @@ -42,17 +45,23 @@ abstract interface class LockedBlockRepository { /// 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, @@ -60,11 +69,15 @@ abstract interface class LockedBlockRepository { 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, diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart index d633730..e4d5963 100644 --- a/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../repositories.dart'; /// Repository contract for project profile documents. diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart index 662f436..49fdaff 100644 --- a/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../repositories.dart'; /// Repository contract for scheduling operation/state snapshot documents. diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart b/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart index 9f1c316..d479d6a 100644 --- a/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart +++ b/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../repositories.dart'; /// Repository contract for task documents. diff --git a/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart index 5a83dc4..7072fff 100644 --- a/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart @@ -1,14 +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/repositories/pagination/repository_page_request.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart index a2d5b54..b38d50a 100644 --- a/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart b/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart index 8b74e89..5bb81f4 100644 --- a/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart +++ b/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart @@ -1,7 +1,12 @@ +// 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, @@ -9,10 +14,23 @@ class RepositoryRecord { 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/repositories/queries/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart index 133cefd..f8d03f6 100644 --- a/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart +++ b/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart @@ -1,7 +1,12 @@ +// 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, @@ -9,8 +14,19 @@ class BacklogCandidateQuery { 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/repositories/queries/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart index e026759..4f323d9 100644 --- a/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart +++ b/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/repository_values.dart b/packages/scheduler_core/lib/src/repository_values.dart index 95a523a..23802a2 100644 --- a/packages/scheduler_core/lib/src/repository_values.dart +++ b/packages/scheduler_core/lib/src/repository_values.dart @@ -1,3 +1,6 @@ +// 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 diff --git a/packages/scheduler_core/lib/src/repository_values/owner_id.dart b/packages/scheduler_core/lib/src/repository_values/owner_id.dart index dddd783..01163c0 100644 --- a/packages/scheduler_core/lib/src/repository_values/owner_id.dart +++ b/packages/scheduler_core/lib/src/repository_values/owner_id.dart @@ -1,20 +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/repository_values/page.dart b/packages/scheduler_core/lib/src/repository_values/page.dart index fa35079..cfc5da6 100644 --- a/packages/scheduler_core/lib/src/repository_values/page.dart +++ b/packages/scheduler_core/lib/src/repository_values/page.dart @@ -1,7 +1,12 @@ +// 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, @@ -17,6 +22,8 @@ class Page { 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) { diff --git a/packages/scheduler_core/lib/src/repository_values/page_request.dart b/packages/scheduler_core/lib/src/repository_values/page_request.dart index d249de2..5dc598a 100644 --- a/packages/scheduler_core/lib/src/repository_values/page_request.dart +++ b/packages/scheduler_core/lib/src/repository_values/page_request.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/repository_values/revision.dart b/packages/scheduler_core/lib/src/repository_values/revision.dart index aebda11..8fe5227 100644 --- a/packages/scheduler_core/lib/src/repository_values/revision.dart +++ b/packages/scheduler_core/lib/src/repository_values/revision.dart @@ -1,7 +1,12 @@ +// 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. @@ -13,17 +18,25 @@ class Revision implements Comparable { /// 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/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine.dart index 6388276..63646f2 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Scheduling Engine behavior for the Scheduler Core package. library; @@ -35,7 +38,15 @@ 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_engine/codes/conflicts/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart index b5df4a7..f9d41ae 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart @@ -1,8 +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_engine/codes/notices/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart index 529657e..021c4d6 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart @@ -1,14 +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_engine/codes/notices/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart index 0403491..14041bd 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../scheduling_engine.dart'; /// Category for scheduler notices. diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart index 1d91e4b..852bc61 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart @@ -1,12 +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_engine/codes/operations/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart index f24a19e..fa3b4e8 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../scheduling_engine.dart'; /// Stable scheduler operation identifiers. diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart index ecd5e38..e12a4b1 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart index d9646d8..abb5d5b 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../scheduling_engine.dart'; /// Starter scheduling engine. @@ -19,6 +22,8 @@ part of '../../scheduling_engine.dart'; /// - `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(), }); @@ -1227,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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart index 81a576b..0b5e536 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart index 98ce7e4..2d843ff 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart index 03c247b..98173a0 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../scheduling_engine.dart'; /// In-memory input for scheduling operations. @@ -7,6 +10,8 @@ part of '../../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart index 61b9510..76c09dd 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart @@ -1,3 +1,6 @@ +// 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. @@ -7,6 +10,8 @@ part of '../../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart index a6fcaa3..6f2fa1a 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../scheduling_engine.dart'; /// Starter notice type returned by scheduling operations. @@ -6,6 +9,8 @@ part of '../../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart index 6c56678..b133259 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../scheduling_engine.dart'; /// Starter result wrapper for scheduling operations. @@ -6,6 +9,8 @@ part of '../../../scheduling_engine.dart'; /// 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, @@ -41,6 +46,8 @@ class SchedulingResult { 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 = [ diff --git a/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart index 7783b18..f5c1fa7 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../scheduling_engine.dart'; /// Planned task intervals keyed by task id. @@ -6,6 +9,8 @@ part of '../../scheduling_engine.dart'; /// 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. diff --git a/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart b/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart index 68aa65d..702ac2c 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart +++ b/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../scheduling_engine.dart'; /// One item in a placement queue. @@ -6,6 +9,8 @@ part of '../../scheduling_engine.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/task_actions.dart b/packages/scheduler_core/lib/src/task_actions.dart index 4fc18b6..580ff8a 100644 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ b/packages/scheduler_core/lib/src/task_actions.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Task Actions behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart index 209b06c..fe5ef3b 100644 --- a/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart b/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart index a811418..1131bac 100644 --- a/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart b/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart index 58187cf..7c565b8 100644 --- a/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart +++ b/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart index abe1b73..dcdabfc 100644 --- a/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart +++ b/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../task_actions.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart index 829617a..0bcc94f 100644 --- a/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../task_actions.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart b/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart index 8ef80d0..557cda9 100644 --- a/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../task_actions.dart'; /// Result from applying a selected push destination. @@ -6,6 +9,8 @@ part of '../../task_actions.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart b/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart index d8d627f..c24e7e4 100644 --- a/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart index c5baec8..1b73a28 100644 --- a/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart +++ b/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart index 8d84e50..12870b9 100644 --- a/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart @@ -1,3 +1,6 @@ +// 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. @@ -7,6 +10,8 @@ part of '../../task_actions.dart'; /// 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(), diff --git a/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart b/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart index f9acabe..35874f2 100644 --- a/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ part of '../../task_actions.dart'; /// 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(), diff --git a/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart index a49b4d9..7af04ec 100644 --- a/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart +++ b/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart @@ -1,3 +1,6 @@ +// 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. @@ -7,6 +10,8 @@ part of '../../task_actions.dart'; /// 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(), @@ -214,6 +219,8 @@ class SurpriseTaskLogService { ); } + /// 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, @@ -250,6 +257,8 @@ class SurpriseTaskLogService { } } +/// 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, @@ -308,6 +317,8 @@ List _activitiesForPushDestination({ 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, @@ -321,6 +332,8 @@ TaskActivityCode _activityCodeForPushChange({ : 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, ) { @@ -334,6 +347,8 @@ SchedulingOperationCode _operationCodeForPushDestination( }; } +/// 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, @@ -362,6 +377,8 @@ TaskActivity _surpriseCompletedActivity({ ); } +/// 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( @@ -372,6 +389,8 @@ List _lockedIntervalsForAccounting(SchedulingInput input) { .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, @@ -386,11 +405,15 @@ bool _hasDuplicateActivity({ 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, diff --git a/packages/scheduler_core/lib/src/task_lifecycle.dart b/packages/scheduler_core/lib/src/task_lifecycle.dart index 91977ea..7356540 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Task Lifecycle behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart index 1bb9d66..0af7725 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart @@ -1,14 +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/task_lifecycle/codes/task_transition_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart index 5a98cca..2a37721 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart @@ -1,14 +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/task_lifecycle/codes/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart index 359f87c..4d3298b 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart @@ -1,9 +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/task_lifecycle/models/task_activity.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart index 0cd1214..4937f75 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../task_lifecycle.dart'; /// Immutable internal activity fact. @@ -6,6 +9,8 @@ part of '../../task_lifecycle.dart'; /// 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, diff --git a/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart index 5ff72f2..6c46629 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart @@ -1,7 +1,12 @@ +// 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 [], diff --git a/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart b/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart index c204f02..4d0b409 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart index ab9970e..68cbe47 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart @@ -1,7 +1,12 @@ +// 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 diff --git a/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart b/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart index a6fdef2..3425815 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart +++ b/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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(), @@ -153,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, @@ -216,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, @@ -283,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, @@ -327,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, @@ -372,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, @@ -411,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, @@ -454,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, @@ -495,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, @@ -540,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, @@ -564,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, @@ -592,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, @@ -644,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, @@ -665,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, @@ -690,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, @@ -698,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, @@ -706,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, @@ -730,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, @@ -744,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; @@ -753,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) || @@ -762,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; @@ -769,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 || @@ -782,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/task_statistics.dart b/packages/scheduler_core/lib/src/task_statistics.dart index c48b4bd..3240a07 100644 --- a/packages/scheduler_core/lib/src/task_statistics.dart +++ b/packages/scheduler_core/lib/src/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/time_contracts.dart b/packages/scheduler_core/lib/src/time_contracts.dart index b2f4dcd..7e50e7c 100644 --- a/packages/scheduler_core/lib/src/time_contracts.dart +++ b/packages/scheduler_core/lib/src/time_contracts.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Time Contracts behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart b/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart index a385d78..8142bfb 100644 --- a/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart +++ b/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart @@ -1,7 +1,12 @@ +// 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 || @@ -16,10 +21,14 @@ class CivilDate implements Comparable { } } + /// 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( @@ -37,23 +46,39 @@ class CivilDate implements Comparable { ); } + /// 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); @@ -67,6 +92,8 @@ class CivilDate implements Comparable { 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 && @@ -75,9 +102,13 @@ class CivilDate implements Comparable { 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/time_contracts/civil/wall_time.dart b/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart index 2a32686..37656b5 100644 --- a/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart +++ b/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart @@ -1,7 +1,12 @@ +// 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, @@ -16,6 +21,8 @@ class WallTime implements Comparable { } } + /// 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( @@ -29,29 +36,46 @@ class WallTime implements Comparable { 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/time_contracts/clocks/clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart index 70600d6..3a6cc52 100644 --- a/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart @@ -1,6 +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/time_contracts/clocks/fixed_clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart index 00dad8c..0a0d9b6 100644 --- a/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart @@ -1,11 +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/time_contracts/clocks/system_clock.dart b/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart index 12b4468..7fd3314 100644 --- a/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart +++ b/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart @@ -1,10 +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/time_contracts/ids/id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart index e85fca0..f6ba0ee 100644 --- a/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart +++ b/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart @@ -1,6 +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/time_contracts/ids/sequential_id_generator.dart b/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart index 6270506..6b886fa 100644 --- a/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart +++ b/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart @@ -1,15 +1,27 @@ +// 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'; diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart index d208544..638c336 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart @@ -1,5 +1,10 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart index 57aab99..df15935 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart @@ -1,5 +1,10 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart b/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart index 867f2a0..94244a2 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart @@ -1,11 +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/time_contracts/zones/resolution/local_time_resolution.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart index cd43a75..0d49cfc 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart @@ -1,8 +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/time_contracts/zones/resolution/resolved_local_date_time.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart index 8eb18e0..d1d3bd7 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart @@ -1,6 +1,13 @@ +// 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, @@ -10,10 +17,27 @@ class ResolvedLocalDateTime { 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/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart index 11d519d..04f77eb 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart @@ -1,13 +1,24 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart index efe4bed..b948e6a 100644 --- a/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart +++ b/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 9f582b5..d29d8aa 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Timeline State behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart index d2283e7..4d4a927 100644 --- a/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart +++ b/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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 = @@ -131,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 && @@ -141,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; @@ -150,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; @@ -170,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, @@ -188,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'; @@ -195,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; @@ -216,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: @@ -240,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: @@ -257,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: @@ -274,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/timeline_state/models/compact_timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart index bf3615d..d9a6b50 100644 --- a/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart b/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart index 528c331..79913f9 100644 --- a/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart +++ b/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart index f7eb8b5..f6454ea 100644 --- a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart @@ -1,11 +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/timeline_state/tokens/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart index 5f15c18..9a0c533 100644 --- a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart @@ -1,11 +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/timeline_state/tokens/timeline_item_category.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart index 60b2753..e921eb3 100644 --- a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart index 3f900cf..d1e0787 100644 --- a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart @@ -1,12 +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/timeline_state/tokens/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart index c9feeba..f85bf65 100644 --- a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart +++ b/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart @@ -1,11 +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/today_state.dart b/packages/scheduler_core/lib/src/today_state.dart index a589a61..c250030 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Today State behavior for the Scheduler Core package. library; diff --git a/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart b/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart index 63256f8..018d9fa 100644 --- a/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart b/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart index dd3bbae..3bea855 100644 --- a/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/today_state/models/today_state.dart b/packages/scheduler_core/lib/src/today_state/models/today_state.dart index f08dfb2..8b3670b 100644 --- a/packages/scheduler_core/lib/src/today_state/models/today_state.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_state.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart index 07e22df..40f00b6 100644 --- a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart @@ -1,7 +1,12 @@ +// 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, diff --git a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart index 905e58d..be845fc 100644 --- a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart +++ b/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart @@ -1,7 +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/queries/get_today_state_query.dart b/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart index 698f9e1..256b296 100644 --- a/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart +++ b/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart @@ -1,7 +1,12 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + 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, @@ -161,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, @@ -176,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 || @@ -189,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, @@ -212,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, @@ -226,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, { @@ -242,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; @@ -260,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, @@ -276,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 {}, @@ -318,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, @@ -325,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) { @@ -348,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; @@ -362,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/today_state/requests/get_today_state_request.dart b/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart index 0cc85cb..27afd0e 100644 --- a/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart +++ b/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart @@ -1,7 +1,12 @@ +// 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, 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 bf93be9..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); @@ -522,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, @@ -529,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 [], }) { @@ -545,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, @@ -554,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, @@ -588,6 +601,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/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 f833249..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; 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 index 1df6118..d94b036 100644 --- a/packages/scheduler_export/lib/src/export_controller/context/export_context.dart +++ b/packages/scheduler_export/lib/src/export_controller/context/export_context.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../export_controller.dart'; /// Export metadata shared with every writer. diff --git a/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart index 3a378f0..de85645 100644 --- a/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart +++ b/packages/scheduler_export/lib/src/export_controller/controller/export_controller.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../export_controller.dart'; /// Coordinates repository reads and export writer calls. @@ -9,7 +12,12 @@ final class ExportController { }) : _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]. 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 index bcb1799..05bf419 100644 --- a/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart +++ b/packages/scheduler_export/lib/src/export_controller/errors/export_exception.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../export_controller.dart'; /// Thrown when an export cannot complete. @@ -8,6 +11,8 @@ final class ExportException implements Exception { /// 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 index 88a824c..0fcfaa0 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../export_controller.dart'; /// Thrown when repository reads fail during export. 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 index c856345..224f620 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../export_controller.dart'; /// Writer interface for readable exports. 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 index c887d25..181dc3b 100644 --- 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 @@ -1,5 +1,10 @@ +// 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'); 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 index 55ac0b1..a1470ff 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../export_controller.dart'; /// Minimal CSV export writer stub. @@ -5,10 +8,20 @@ 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(); @@ -19,6 +32,8 @@ final class CsvExportWriter implements ExportWriter { ); } + /// 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) { @@ -45,6 +60,8 @@ final class CsvExportWriter implements ExportWriter { ].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) { @@ -54,6 +71,8 @@ final class CsvExportWriter implements ExportWriter { _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 index aaa02e6..61d503a 100644 --- 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 @@ -1,9 +1,16 @@ +// 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) { 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 index bda8605..50e27ab 100644 --- 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 @@ -1,5 +1,10 @@ +// 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, 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 index 09e9220..a0835b2 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../export_controller.dart'; /// Minimal JSON export writer stub. @@ -5,11 +8,24 @@ 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(); @@ -19,6 +35,8 @@ final class JsonExportWriter implements ExportWriter { ); } + /// 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(); @@ -32,6 +50,8 @@ final class JsonExportWriter implements ExportWriter { _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(); @@ -40,6 +60,8 @@ final class JsonExportWriter implements ExportWriter { _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) { @@ -48,6 +70,8 @@ final class JsonExportWriter implements ExportWriter { 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 index 032c698..653dfae 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../export_controller.dart'; /// Factory signature for sink-backed export writers. diff --git a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart index 54a19d9..b70406b 100644 --- a/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart +++ b/packages/scheduler_export/lib/src/export_controller/writers/registry/export_writer_registry.dart @@ -1,3 +1,6 @@ +// 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. @@ -12,6 +15,8 @@ final class ExportWriterRegistry { ), ); + /// 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. 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 3b9c427..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; 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 index b439dd6..e2d8c23 100644 --- 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 @@ -1,5 +1,10 @@ +// 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'); 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 index 4b25b1d..b5d60df 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../json_csv_writers.dart'; /// CSV export writer for scheduler tasks. @@ -5,10 +8,20 @@ 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(); @@ -19,6 +32,8 @@ final class CsvExportWriter implements ExportWriter { ); } + /// 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) { @@ -45,6 +60,8 @@ final class CsvExportWriter implements ExportWriter { ].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) { @@ -54,6 +71,8 @@ final class CsvExportWriter implements ExportWriter { _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 index 98393a4..3bb9291 100644 --- 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 @@ -1,5 +1,10 @@ +// 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, 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 index df3a45d..f04a7db 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../json_csv_writers.dart'; /// JSON export writer for scheduler tasks. @@ -5,11 +8,24 @@ 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(); @@ -19,6 +35,8 @@ final class JsonExportWriter implements ExportWriter { ); } + /// 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(); @@ -33,6 +51,8 @@ final class JsonExportWriter implements ExportWriter { _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(); @@ -41,6 +61,8 @@ final class JsonExportWriter implements ExportWriter { _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) { @@ -49,6 +71,8 @@ final class JsonExportWriter implements ExportWriter { 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 48ece23..8105622 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + /// Implements Notification Adapter behavior for the Scheduler Notifications package. library; diff --git a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart index b401b59..6eca676 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/fake_notification_adapter.dart @@ -1,11 +1,22 @@ +// 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. @@ -15,14 +26,20 @@ final class FakeNotificationAdapter implements NotificationAdapter { /// 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'); @@ -41,6 +58,8 @@ final class FakeNotificationAdapter implements NotificationAdapter { } } +/// 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_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart index da98df1..37cc756 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/adapter/notification_adapter.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../notification_adapter.dart'; /// Platform-neutral notification adapter boundary. 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 index 26073cc..5ccfdd5 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/feedback/notification_feedback.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../notification_adapter.dart'; /// Feedback event emitted by a notification adapter. 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 index df0a92c..752c012 100644 --- 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 @@ -1,3 +1,6 @@ +// 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. 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 index ce52fab..eb2ca29 100644 --- a/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart +++ b/packages/scheduler_notifications/lib/src/notification_adapter/requests/notification_request.dart @@ -1,3 +1,6 @@ +// 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. @@ -19,6 +22,8 @@ sealed class 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 this.id, required this.title, 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 index 0ae4131..0a7ea6a 100644 --- 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 @@ -1,6 +1,13 @@ +// 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, 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 acad696..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; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart index c88d98d..50b2c4f 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/adapter/desktop_notification_adapter.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ final class DesktopNotificationAdapter implements NotificationAdapter { 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. @@ -23,14 +28,20 @@ final class DesktopNotificationAdapter implements NotificationAdapter { ); } + /// 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 index de18569..5d63ef6 100644 --- 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 @@ -1,3 +1,6 @@ +// 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]. diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart index 9a46dcc..a07ff17 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/core/desktop_notification_backend_factory.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart index cb09eed..b8e3d14 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/fallback/stdout_notification_backend.dart @@ -1,3 +1,6 @@ +// 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. @@ -8,14 +11,20 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { 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( @@ -24,6 +33,8 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { ); } + /// 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 index b9880cb..6a677d0 100644 --- 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 @@ -1,5 +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 index b3f6dd4..e336a69 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/linux_notify_send_backend.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../desktop_notification_adapter_io.dart'; /// Linux backend using `notify-send`. @@ -9,12 +12,21 @@ final class LinuxNotifySendBackend implements DesktopNotificationBackend { }) : _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( @@ -27,11 +39,15 @@ final class LinuxNotifySendBackend implements DesktopNotificationBackend { ); } + /// 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, diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart index 2222af9..70bbcf3 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_io/backends/platform/mac_os_notification_backend.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../desktop_notification_adapter_io.dart'; /// macOS backend using `osascript`. @@ -9,12 +12,21 @@ final class MacOsNotificationBackend implements DesktopNotificationBackend { }) : _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)} ' @@ -29,6 +41,8 @@ final class MacOsNotificationBackend implements DesktopNotificationBackend { } } + /// 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 index 104b098..86df51c 100644 --- 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 @@ -1,3 +1,6 @@ +// 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. 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 index 159ff19..0b379d6 100644 --- 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 @@ -1,3 +1,6 @@ +// 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. 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 index d7b3cd4..be1cb92 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../desktop_notification_adapter_io.dart'; /// Supported desktop platform categories. 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 index ccedcae..ac2d5c0 100644 --- 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 @@ -1,5 +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 `_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; 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 e7c1e55..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,3 +1,6 @@ +// 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; diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart index d3ad029..3b876dd 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/adapter/desktop_notification_adapter.dart @@ -1,3 +1,6 @@ +// 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. @@ -6,6 +9,8 @@ final class DesktopNotificationAdapter implements NotificationAdapter { 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. @@ -23,14 +28,20 @@ final class DesktopNotificationAdapter implements NotificationAdapter { ); } + /// 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 index 7fdd5a8..497f848 100644 --- 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 @@ -1,3 +1,6 @@ +// 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]. diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart index 603ffb4..39626a7 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/desktop_notification_backend_factory.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart index 38c59e2..797cc2d 100644 --- a/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart +++ b/packages/scheduler_notifications_desktop/lib/src/desktop_notification_adapter_stub/backends/stdout_notification_backend.dart @@ -1,3 +1,6 @@ +// 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. @@ -8,14 +11,20 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { 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( @@ -24,6 +33,8 @@ final class StdoutNotificationBackend implements DesktopNotificationBackend { ); } + /// 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 index 8ce5c09..a00c134 100644 --- 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 @@ -1,5 +1,10 @@ +// 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 index c81c5ed..b4f4dd3 100644 --- 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 @@ -1,3 +1,6 @@ +// 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. 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 index 438c864..a15b633 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../desktop_notification_adapter_stub.dart'; /// Supported desktop platform categories. 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 b3592a7..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 diff --git a/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart index d7570f8..374db6d 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/core/repository_failure.dart @@ -1,7 +1,12 @@ +// 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, 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 index 0b09ab4..7fc5705 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_duplicate_id.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../persistence.dart'; /// A record with the requested id already exists. 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 index 628abf5..7ccf6b2 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_not_found.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../persistence.dart'; /// The requested entity was not found. 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 index 177b3ff..af3fefb 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/identity/repository_owner_mismatch.dart @@ -1,3 +1,6 @@ +// 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. 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 index d0e7a62..8a30884 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_invalid_revision.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../../persistence.dart'; /// The caller supplied an invalid revision value. 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 index c4a413d..1de5aab 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/revision/repository_stale_revision.dart @@ -1,3 +1,6 @@ +// 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. 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 index 527b35b..887535e 100644 --- a/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart +++ b/packages/scheduler_persistence/lib/persistence/failures/storage/repository_storage_failure.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart index 7af0911..ddb631c 100644 --- a/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/locked_block_repository.dart @@ -1,3 +1,6 @@ +// 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. diff --git a/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart index 17b2d0c..344408e 100644 --- a/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/project_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Repository contract for project profile records. diff --git a/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart index a3671ee..30fc0ab 100644 --- a/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/schedule_snapshot_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Repository contract for diagnostic schedule snapshots. diff --git a/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart index e38cbc5..1d540ec 100644 --- a/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/settings_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Repository contract for owner settings. diff --git a/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart index b0899da..49d8022 100644 --- a/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart +++ b/packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Repository contract for task records. diff --git a/packages/scheduler_persistence/lib/persistence/results/either.dart b/packages/scheduler_persistence/lib/persistence/results/either.dart index f0125b4..b8c1097 100644 --- a/packages/scheduler_persistence/lib/persistence/results/either.dart +++ b/packages/scheduler_persistence/lib/persistence/results/either.dart @@ -1,7 +1,12 @@ +// 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. diff --git a/packages/scheduler_persistence/lib/persistence/results/left.dart b/packages/scheduler_persistence/lib/persistence/results/left.dart index 62196b1..288866c 100644 --- a/packages/scheduler_persistence/lib/persistence/results/left.dart +++ b/packages/scheduler_persistence/lib/persistence/results/left.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Failure side of [Either]. diff --git a/packages/scheduler_persistence/lib/persistence/results/repo_result.dart b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart index 7560b89..e91dde6 100644 --- a/packages/scheduler_persistence/lib/persistence/results/repo_result.dart +++ b/packages/scheduler_persistence/lib/persistence/results/repo_result.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Repository result wrapper for expected adapter outcomes. diff --git a/packages/scheduler_persistence/lib/persistence/results/right.dart b/packages/scheduler_persistence/lib/persistence/results/right.dart index caca5da..2932ea4 100644 --- a/packages/scheduler_persistence/lib/persistence/results/right.dart +++ b/packages/scheduler_persistence/lib/persistence/results/right.dart @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../persistence.dart'; /// Success side of [Either]. 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 59d9e92..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 @@ -18,6 +21,10 @@ 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); 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 index c665128..9dcc908 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/identified_record.dart @@ -1,5 +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 index ee22723..f4ff449 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/record.dart @@ -1,6 +1,13 @@ +// 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, @@ -11,14 +18,33 @@ final class _Record implements _IdentifiedRecord { }) : 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, 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 index 3e3416e..ee07118 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/records/snapshot_record.dart @@ -1,6 +1,13 @@ +// 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, @@ -9,10 +16,24 @@ final class _SnapshotRecord implements _IdentifiedRecord { 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 index 7a686b7..497c4c0 100644 --- 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 @@ -1,12 +1,22 @@ +// 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, @@ -20,6 +30,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { _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, @@ -41,6 +53,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { )); } + /// 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, @@ -55,6 +69,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, @@ -74,6 +90,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { )); } + /// 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, @@ -91,6 +109,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -116,6 +136,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -143,6 +165,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, @@ -164,6 +188,8 @@ final class InMemoryLockedBlockRepository implements LockedBlockRepository { 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, 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 index 58768ba..bf146db 100644 --- 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 @@ -1,10 +1,17 @@ +// 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, @@ -17,6 +24,8 @@ final class InMemoryProjectRepository implements ProjectRepository { 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, @@ -34,6 +43,8 @@ final class InMemoryProjectRepository implements ProjectRepository { )); } + /// 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, @@ -59,6 +70,8 @@ final class InMemoryProjectRepository implements ProjectRepository { 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, @@ -90,6 +103,8 @@ final class InMemoryProjectRepository implements ProjectRepository { 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, diff --git a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart index 9661c11..ca2c902 100644 --- a/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart +++ b/packages/scheduler_persistence_memory/lib/persistence_memory/repositories/in_memory_schedule_snapshot_repository.dart @@ -1,10 +1,17 @@ +// 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, @@ -17,6 +24,8 @@ final class InMemoryScheduleSnapshotRepository 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, @@ -34,6 +43,8 @@ final class InMemoryScheduleSnapshotRepository )); } + /// 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, @@ -52,6 +63,8 @@ final class InMemoryScheduleSnapshotRepository 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, @@ -72,6 +85,8 @@ final class InMemoryScheduleSnapshotRepository } } +/// 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, @@ -105,6 +120,8 @@ Either> _checkWrite({ 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, @@ -119,6 +136,8 @@ core.Page _pageRecords( 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; @@ -130,10 +149,14 @@ int _cursorOffset(String? cursor) { 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, @@ -148,6 +171,8 @@ core.Task _cloneTask( )); } +/// 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, @@ -158,6 +183,8 @@ core.ProjectProfile _cloneProject(_Record record) { ); } +/// 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, @@ -176,6 +203,8 @@ core.ProjectProfile _cloneProjectValue( )); } +/// 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, @@ -190,6 +219,8 @@ core.LockedBlock _cloneLockedBlock( )); } +/// 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, @@ -204,6 +235,8 @@ core.LockedBlockOverride _cloneLockedBlockOverride( )); } +/// 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, @@ -213,6 +246,8 @@ core.OwnerSettings _cloneSettings(_Record record) { ); } +/// 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, @@ -229,6 +264,8 @@ core.OwnerSettings _cloneSettingsValue( )); } +/// 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, @@ -244,6 +281,8 @@ core.SchedulingStateSnapshot _cloneSnapshot( )); } +/// 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 index dc86418..fadfca1 100644 --- 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 @@ -1,10 +1,17 @@ +// 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, @@ -16,6 +23,8 @@ final class InMemorySettingsRepository implements SettingsRepository { 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, @@ -43,6 +52,8 @@ final class InMemorySettingsRepository implements SettingsRepository { 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, 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 index 1fd8976..6f5bd66 100644 --- 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 @@ -1,10 +1,17 @@ +// 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, @@ -17,6 +24,8 @@ final class InMemoryTaskRepository implements TaskRepository { 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, @@ -29,6 +38,8 @@ final class InMemoryTaskRepository implements TaskRepository { )); } + /// 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, @@ -46,6 +57,8 @@ final class InMemoryTaskRepository implements TaskRepository { )); } + /// 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, @@ -62,6 +75,8 @@ final class InMemoryTaskRepository implements TaskRepository { )); } + /// 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, @@ -77,6 +92,8 @@ final class InMemoryTaskRepository implements TaskRepository { )); } + /// 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, @@ -94,6 +111,8 @@ final class InMemoryTaskRepository implements TaskRepository { 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, @@ -119,6 +138,8 @@ final class InMemoryTaskRepository implements TaskRepository { 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, 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 10cf902..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 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 index bf231af..54d9f48 100644 --- 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 @@ -1,8 +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 index 2cef75d..1b1e08a 100644 --- 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 @@ -1,7 +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 index 9e384f7..55f6841 100644 --- 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 @@ -1,8 +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 index 174f9c0..8bb67cb 100644 --- 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 @@ -1,8 +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 index fe6f0e6..98f4939 100644 --- 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 @@ -1,7 +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 index b09c9c8..d2115be 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../../scheduler_db.dart'; /// Drift database for scheduler persistence. @@ -19,11 +22,17 @@ part of '../../scheduler_db.dart'; ], ) 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 { 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 index df0350d..403da32 100644 --- 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 @@ -1,23 +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 index d5381c5..5d955ba 100644 --- 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 @@ -1,22 +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 index 742da1d..59a675f 100644 --- 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 @@ -1,25 +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 index 1e7a0c5..b436228 100644 --- 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 @@ -1,22 +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 index 892d56c..c86386f 100644 --- 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 @@ -1,29 +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 index ea73624..0fbf2c3 100644 --- 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 @@ -1,42 +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 4b5f71e..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; @@ -14,14 +17,24 @@ 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); +/// 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 index 2161405..dc127f5 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../sqlite_repositories.dart'; /// Drift-backed locked-block and override repository. @@ -8,6 +11,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { /// 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, @@ -20,6 +25,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -44,6 +51,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { )); } + /// 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, @@ -56,6 +65,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -79,6 +90,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { )); } + /// 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, @@ -95,6 +108,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -128,6 +143,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -161,6 +178,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -177,6 +196,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -213,17 +234,23 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -250,6 +277,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -276,6 +305,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { 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, @@ -289,6 +320,8 @@ final class SqliteLockedBlockRepository implements LockedBlockRepository { ); } + /// 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, 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 index c2011f7..7fea479 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../sqlite_repositories.dart'; /// Drift-backed project repository. @@ -8,6 +11,8 @@ final class SqliteProjectRepository implements ProjectRepository { /// 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, @@ -20,6 +25,8 @@ final class SqliteProjectRepository implements ProjectRepository { 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, @@ -44,6 +51,8 @@ final class SqliteProjectRepository implements ProjectRepository { )); } + /// 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, @@ -62,6 +71,8 @@ final class SqliteProjectRepository implements ProjectRepository { 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, @@ -98,6 +109,8 @@ final class SqliteProjectRepository implements ProjectRepository { 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, @@ -136,11 +149,15 @@ final class SqliteProjectRepository implements ProjectRepository { 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, @@ -169,6 +186,8 @@ final class SqliteProjectRepository implements ProjectRepository { 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, 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 index edd07f1..16c1c26 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../sqlite_repositories.dart'; /// Drift-backed schedule snapshot repository. @@ -9,6 +12,8 @@ final class SqliteScheduleSnapshotRepository /// 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, @@ -21,6 +26,8 @@ final class SqliteScheduleSnapshotRepository 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, @@ -44,6 +51,8 @@ final class SqliteScheduleSnapshotRepository )); } + /// 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, @@ -60,6 +69,8 @@ final class SqliteScheduleSnapshotRepository 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, @@ -75,12 +86,16 @@ final class SqliteScheduleSnapshotRepository 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, @@ -152,6 +167,8 @@ TasksCompanion _taskCompanion( ); } +/// 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( @@ -184,6 +201,8 @@ core.Task _taskFromRow(TaskRow row) { }); } +/// 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, @@ -223,6 +242,8 @@ ProjectsCompanion _projectCompanion( ); } +/// 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( @@ -242,6 +263,8 @@ core.ProjectProfile _projectFromRow(ProjectRow row) { }); } +/// 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, @@ -278,6 +301,8 @@ LockedBlocksCompanion _lockedBlockCompanion( ); } +/// 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( @@ -295,6 +320,8 @@ core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) { }); } +/// 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, @@ -337,6 +364,8 @@ LockedOverridesCompanion _lockedOverrideCompanion( ); } +/// 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( @@ -352,6 +381,8 @@ core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) { }); } +/// 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, @@ -379,6 +410,8 @@ SettingsTableCompanion _settingsCompanion( ); } +/// 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, @@ -394,6 +427,8 @@ core.OwnerSettings _settingsFromRow(SettingsRow row) { }); } +/// 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, @@ -441,6 +476,8 @@ SnapshotsCompanion _snapshotCompanion( ); } +/// 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( @@ -467,6 +504,8 @@ core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) { }); } +/// 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, @@ -486,22 +525,32 @@ Map _commonDocumentFields( }; } +/// 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 @@ -509,30 +558,42 @@ DateTime? _nullableDateTime(Map doc, String fieldName) { : 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); 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 index be78987..1de3ae3 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../sqlite_repositories.dart'; /// Drift-backed owner settings repository. @@ -8,6 +11,8 @@ final class SqliteSettingsRepository implements SettingsRepository { /// 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, @@ -17,6 +22,8 @@ final class SqliteSettingsRepository implements SettingsRepository { 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, @@ -37,6 +44,8 @@ final class SqliteSettingsRepository implements SettingsRepository { 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, @@ -70,12 +79,16 @@ final class SqliteSettingsRepository implements SettingsRepository { 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, @@ -98,6 +111,8 @@ final class SqliteSettingsRepository implements SettingsRepository { 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, 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 index 40759f7..d23fcb9 100644 --- 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 @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + part of '../sqlite_repositories.dart'; /// Drift-backed task repository. @@ -8,6 +11,8 @@ final class SqliteTaskRepository implements TaskRepository { /// 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, @@ -20,6 +25,8 @@ final class SqliteTaskRepository implements TaskRepository { 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, @@ -31,6 +38,8 @@ final class SqliteTaskRepository implements TaskRepository { )); } + /// 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, @@ -45,6 +54,8 @@ final class SqliteTaskRepository implements TaskRepository { )); } + /// 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, @@ -59,6 +70,8 @@ final class SqliteTaskRepository implements TaskRepository { )); } + /// 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, @@ -75,6 +88,8 @@ final class SqliteTaskRepository implements TaskRepository { )); } + /// 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, @@ -91,6 +106,8 @@ final class SqliteTaskRepository implements TaskRepository { 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, @@ -125,6 +142,8 @@ final class SqliteTaskRepository implements TaskRepository { 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, @@ -150,6 +169,8 @@ final class SqliteTaskRepository implements TaskRepository { 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, @@ -167,11 +188,15 @@ final class SqliteTaskRepository implements TaskRepository { ); } + /// 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, @@ -200,6 +225,8 @@ final class SqliteTaskRepository implements TaskRepository { 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, 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..20f86ab --- /dev/null +++ b/tool/check_reuse.dart @@ -0,0 +1,193 @@ +// 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 == 'pubspec.yaml' || + path == 'analysis_options.yaml' || + path == '.github/workflows/ci.yml'; +} From 0c97567283920af8a5ca409b07d0af1544ba79bb Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 1 Jul 2026 13:54:28 -0700 Subject: [PATCH 16/16] refactor: group scheduler core src modules --- AGENTS.md | 14 ++++++ Focus Flow Contributors.md | 9 ++++ .../scheduler_core/lib/scheduler_core.dart | 50 +++++++++---------- .../application_commands.dart | 20 ++++---- .../codes/application_command_code.dart | 0 .../application_child_task_draft.dart | 0 .../application_command_read_hint.dart | 0 .../messages/application_command_result.dart | 0 .../planning/planning_state.dart | 0 .../v1_application_command_use_cases.dart | 0 .../{ => application}/application_layer.dart | 16 +++--- .../application_operation_context.dart | 0 .../context/owner_time_zone_context.dart | 0 .../application_scheduling_loader.dart | 0 .../records/application_operation_record.dart | 0 .../notice_acknowledgement_record.dart | 0 .../records/owner_settings.dart | 0 .../in_memory_application_repositories.dart | 0 .../in_memory_application_state.dart | 0 .../in_memory_application_unit_of_work.dart | 0 .../application_operation_repository.dart | 0 .../notice_acknowledgement_repository.dart | 0 .../owner_settings_repository.dart | 0 .../project_statistics_repository.dart | 0 .../tasks/task_activity_repository.dart | 0 .../application_unit_of_work.dart | 0 ...application_unit_of_work_repositories.dart | 0 ...ork_notice_acknowledgement_repository.dart | 0 .../unit_of_work_operation_repository.dart | 0 ...nit_of_work_owner_settings_repository.dart | 0 .../unit_of_work_project_repository.dart | 0 ...of_work_project_statistics_repository.dart | 0 .../unit_of_work_locked_block_repository.dart | 0 ...f_work_scheduling_snapshot_repository.dart | 0 ...unit_of_work_task_activity_repository.dart | 0 .../tasks/unit_of_work_task_repository.dart | 0 .../results/application_failure.dart | 0 .../results/application_failure_code.dart | 0 .../application_failure_exception.dart | 0 .../application_persistence_exception.dart | 0 .../results/application_result.dart | 0 .../application_management.dart | 10 ++-- .../application_management_command_code.dart | 0 .../codes/owner_settings_warning_code.dart | 0 .../drafts/locked_block_draft.dart | 0 .../drafts/project_profile_draft.dart | 0 .../parsing/parsed_notice_id.dart | 0 .../read_models/backlog_item_read_model.dart | 0 .../read_models/backlog_query_result.dart | 0 .../project_defaults_query_result.dart | 0 .../requests/get_backlog_request.dart | 0 .../get_project_defaults_request.dart | 0 .../notice_acknowledgement_result.dart | 0 .../results/owner_settings_update_result.dart | 0 .../updates/locked_block_update.dart | 0 .../updates/owner_settings_update.dart | 0 .../updates/project_profile_update.dart | 0 .../v1_application_management_use_cases.dart | 0 .../application_recovery.dart | 12 ++--- .../app_open_recovery_outcome.dart | 0 .../app_open_recovery_result.dart | 0 .../run_app_open_recovery_request.dart | 0 .../v1_app_open_recovery_use_cases.dart | 0 .../lib/src/{ => domain}/locked_time.dart | 0 .../locked_time/aliases/clock_time.dart | 0 .../locked_time/blocks/locked_block.dart | 0 .../blocks/locked_block_occurrence.dart | 0 .../enums/locked_block_override_type.dart | 0 .../locked_time/enums/locked_weekday.dart | 0 .../expansion/locked_schedule_expansion.dart | 0 .../overrides/locked_block_override.dart | 0 .../recurrence/locked_block_recurrence.dart | 0 .../lib/src/{ => domain}/models.dart | 0 .../models/entities/project_profile.dart | 0 .../{ => domain}/models/entities/task.dart | 0 .../models/entities/time_interval.dart | 0 .../models/enums/effort/difficulty_level.dart | 0 .../models/enums/effort/reward_level.dart | 0 .../models/enums/planning/backlog_tag.dart | 0 .../models/enums/planning/priority_level.dart | 0 .../enums/planning/reminder_profile.dart | 0 .../models/enums/tasks/task_status.dart | 0 .../models/enums/tasks/task_type.dart | 0 .../validation/domain_validation_code.dart | 0 .../domain_validation_exception.dart | 0 .../src/{ => domain}/occupancy_policy.dart | 0 .../occupancy_policy/occupancy_category.dart | 0 .../occupancy_policy/occupancy_entry.dart | 0 .../occupancy_policy/occupancy_policy.dart | 0 .../occupancy_policy/occupancy_source.dart | 0 .../src/{ => domain}/project_statistics.dart | 2 +- .../enums/project_completion_time_bucket.dart | 0 .../enums/project_suggestion_confidence.dart | 0 .../enums/project_suggestion_type.dart | 0 .../models/project_default_resolution.dart | 0 .../models/project_statistics.dart | 0 ...project_statistics_application_result.dart | 0 ...roject_statistics_aggregation_service.dart | 0 .../suggestions/project_suggestion.dart | 0 .../project_suggestion_policy.dart | 0 .../project_suggestion_service.dart | 0 .../suggestions/project_suggestion_set.dart | 0 .../lib/src/{ => domain}/task_statistics.dart | 0 .../lib/src/{ => domain}/time_contracts.dart | 0 .../time_contracts/civil/civil_date.dart | 0 .../time_contracts/civil/wall_time.dart | 0 .../time_contracts/clocks/clock.dart | 0 .../time_contracts/clocks/fixed_clock.dart | 0 .../time_contracts/clocks/system_clock.dart | 0 .../time_contracts/ids/id_generator.dart | 0 .../ids/sequential_id_generator.dart | 0 .../nonexistent_local_time_policy.dart | 0 .../policies/repeated_local_time_policy.dart | 0 .../time_zone_resolution_options.dart | 0 .../resolution/local_time_resolution.dart | 0 .../resolution/resolved_local_date_time.dart | 0 .../fixed_offset_time_zone_resolver.dart | 0 .../zones/resolvers/time_zone_resolver.dart | 0 .../{ => persistence}/document_mapping.dart | 18 +++---- ...pplication_operation_document_mapping.dart | 0 .../backlog_staleness_document_mapping.dart | 0 ...tice_acknowledgement_document_mapping.dart | 0 .../owner_settings_document_mapping.dart | 0 .../enums/persistence_enum_mapping.dart | 0 .../failures/document_mapping_exception.dart | 0 .../document_mapping_failure_code.dart | 0 .../locked_block_document_mapping.dart | 0 ...ocked_block_override_document_mapping.dart | 0 ...ked_block_recurrence_document_mapping.dart | 0 .../metadata/document_metadata.dart | 0 .../projects/project_document_mapping.dart | 0 ...project_statistics_document_extension.dart | 0 .../project_statistics_document_mapping.dart | 0 .../scheduling_change_document_mapping.dart | 0 .../scheduling_notice_document_mapping.dart | 0 .../scheduling_overlap_document_mapping.dart | 0 .../scheduling_snapshot_document_mapping.dart | 0 .../scheduling_window_document_mapping.dart | 0 .../tasks/task_activity_document_mapping.dart | 0 .../tasks/task_document_extension.dart | 0 .../tasks/task_document_mapping.dart | 0 .../task_statistics_document_extension.dart | 0 .../task_statistics_document_mapping.dart | 0 .../time/time_interval_document_mapping.dart | 0 .../{ => persistence}/document_migration.dart | 2 +- .../codes/document_migration_entity.dart | 0 .../document_migration_failure_code.dart | 0 .../codes/document_migration_note_code.dart | 0 .../codes/document_migration_status.dart | 0 .../legacy/legacy_document_exception.dart | 0 .../legacy/legacy_metadata_instants.dart | 0 .../reports/document_migration_note.dart | 0 .../document_migration_provenance.dart | 0 .../reports/document_migration_report.dart | 0 .../reports/document_migration_result.dart | 0 .../service/schema_version_read.dart | 0 .../service/v0_migrator.dart | 0 .../v1_document_migration_service.dart | 0 .../persistence_contract.dart | 2 +- .../collections/persistence_collections.dart | 0 .../persistence_civil_date_convention.dart | 0 .../persistence_date_time_convention.dart | 0 .../conventions/persistence_enum_name.dart | 0 .../persistence_wall_time_convention.dart | 0 ...application_operation_document_fields.dart | 0 .../backlog_staleness_document_fields.dart | 0 ...otice_acknowledgement_document_fields.dart | 0 .../owner_settings_document_fields.dart | 0 .../fields/core/document_fields.dart | 0 .../locked_block_document_fields.dart | 0 ...locked_block_override_document_fields.dart | 0 ...cked_block_recurrence_document_fields.dart | 0 .../projects/project_document_fields.dart | 0 .../project_statistics_document_fields.dart | 0 .../scheduling_change_document_fields.dart | 0 .../scheduling_notice_document_fields.dart | 0 .../scheduling_overlap_document_fields.dart | 0 .../scheduling_snapshot_document_fields.dart | 0 .../scheduling_window_document_fields.dart | 0 .../tasks/task_activity_document_fields.dart | 0 .../fields/tasks/task_document_fields.dart | 0 .../task_statistics_document_fields.dart | 0 .../time/clock_time_document_fields.dart | 0 .../time/time_interval_document_fields.dart | 0 .../indexes/persistence_index_catalog.dart | 0 .../indexes/persistence_index_direction.dart | 0 .../indexes/persistence_index_field.dart | 0 .../indexes/persistence_index_spec.dart | 0 .../indexes/repository_query_names.dart | 0 .../payloads/persistence_guard_result.dart | 0 .../payloads/persistence_payload_guard.dart | 0 .../payloads/persistence_payload_limits.dart | 0 .../persistence_document_field_sets.dart | 0 .../src/{ => persistence}/repositories.dart | 8 +-- .../failures/repository_failure.dart | 0 .../failures/repository_failure_code.dart | 0 .../failures/repository_mutation_result.dart | 0 .../in_memory_locked_block_repository.dart | 0 .../in_memory_project_repository.dart | 0 ...memory_scheduling_snapshot_repository.dart | 0 .../in_memory/in_memory_task_repository.dart | 0 .../interfaces/locked_block_repository.dart | 0 .../interfaces/project_repository.dart | 0 .../scheduling_snapshot_repository.dart | 0 .../interfaces/task_repository.dart | 0 .../pagination/repository_page.dart | 0 .../pagination/repository_page_request.dart | 0 .../pagination/repository_record.dart | 0 .../queries/backlog_candidate_query.dart | 0 .../queries/scheduling_state_snapshot.dart | 0 .../{ => persistence}/repository_values.dart | 0 .../repository_values/owner_id.dart | 0 .../repository_values/page.dart | 0 .../repository_values/page_request.dart | 0 .../repository_values/revision.dart | 0 .../lib/src/{ => scheduling}/backlog.dart | 2 +- .../backlog/queries/backlog_filter.dart | 0 .../backlog/queries/backlog_sort_key.dart | 0 .../staleness/backlog_staleness_marker.dart | 0 .../staleness/backlog_staleness_settings.dart | 0 .../backlog/views/backlog_view.dart | 0 .../backlog/views/indexed_task.dart | 0 .../lib/src/{ => scheduling}/child_tasks.dart | 4 +- .../child_tasks/indexing/indexed_child.dart | 0 .../child_tasks/models/child_task_entry.dart | 0 .../models/child_task_summary.dart | 0 .../child_tasks/models/child_task_view.dart | 0 .../requests/child_task_break_up_request.dart | 0 .../results/child_task_break_up_result.dart | 0 .../results/child_task_completion_result.dart | 0 .../services/child_task_break_up_service.dart | 0 .../child_task_completion_service.dart | 0 .../lib/src/{ => scheduling}/free_slots.dart | 4 +- .../free_slots/free_slot_service.dart | 0 .../protected_free_slot_conflict.dart | 0 .../required_commitment_schedule_result.dart | 0 .../required_commitment_schedule_status.dart | 0 .../src/{ => scheduling}/quick_capture.dart | 4 +- .../quick_capture/quick_capture_request.dart | 0 .../quick_capture/quick_capture_result.dart | 0 .../quick_capture/quick_capture_service.dart | 0 .../quick_capture/quick_capture_status.dart | 0 .../src/{ => scheduling}/reminder_policy.dart | 4 +- .../directives/reminder_directive.dart | 0 .../directives/reminder_directive_action.dart | 0 .../directives/reminder_directive_reason.dart | 0 .../profiles/effective_reminder_profile.dart | 0 .../effective_reminder_profile_source.dart | 0 .../services/reminder_policy_service.dart | 0 .../{ => scheduling}/scheduling_engine.dart | 6 +-- .../conflicts/scheduling_conflict_code.dart | 0 .../codes/notices/scheduling_issue_code.dart | 0 .../codes/notices/scheduling_notice_type.dart | 0 .../operations/scheduling_movement_code.dart | 0 .../operations/scheduling_operation_code.dart | 0 .../operations/scheduling_outcome_code.dart | 0 .../engine/scheduling_engine.dart | 0 .../models/changes/scheduling_change.dart | 0 .../models/changes/scheduling_overlap.dart | 0 .../models/inputs/scheduling_input.dart | 0 .../models/inputs/scheduling_window.dart | 0 .../models/notices/scheduling_notice.dart | 0 .../models/results/scheduling_result.dart | 0 .../planning/backlog_insertion_plan.dart | 0 .../planning/placement_item.dart | 0 .../src/{ => scheduling}/task_actions.dart | 6 +-- .../actions/flexible_task_quick_action.dart | 0 .../actions/push_destination.dart | 0 .../actions/required_task_action.dart | 0 .../requests/surprise_task_log_request.dart | 0 .../results/flexible_task_action_result.dart | 0 .../results/push_destination_result.dart | 0 .../results/required_task_action_result.dart | 0 .../results/surprise_task_log_result.dart | 0 .../flexible_task_action_service.dart | 0 .../required_task_action_service.dart | 0 .../services/surprise_task_log_service.dart | 0 .../src/{ => scheduling}/task_lifecycle.dart | 6 +-- .../codes/task_activity_code.dart | 0 .../codes/task_transition_code.dart | 0 .../codes/task_transition_outcome_code.dart | 0 .../task_lifecycle/models/task_activity.dart | 0 .../task_activity_application_result.dart | 0 .../models/task_transition_result.dart | 0 .../task_activity_accounting_service.dart | 0 .../services/task_transition_service.dart | 0 .../src/{ => scheduling}/timeline_state.dart | 6 +-- .../mapping/timeline_item_mapper.dart | 0 .../models/compact_timeline_state.dart | 0 .../timeline_state/models/timeline_item.dart | 0 .../tokens/timeline_background_token.dart | 0 .../timeline_difficulty_icon_token.dart | 0 .../tokens/timeline_item_category.dart | 0 .../tokens/timeline_quick_action.dart | 0 .../tokens/timeline_reward_icon_token.dart | 0 .../lib/src/{ => scheduling}/today_state.dart | 10 ++-- .../models/today_compact_state.dart | 0 .../models/today_pending_notice.dart | 0 .../today_state/models/today_state.dart | 0 .../models/today_timeline_item.dart | 0 .../models/today_timeline_item_source.dart | 0 .../queries/get_today_state_query.dart | 0 .../requests/get_today_state_request.dart | 0 tool/check_reuse.dart | 2 + 304 files changed, 121 insertions(+), 96 deletions(-) create mode 100644 Focus Flow Contributors.md rename packages/scheduler_core/lib/src/{ => application}/application_commands.dart (69%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/codes/application_command_code.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/messages/application_child_task_draft.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/messages/application_command_read_hint.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/messages/application_command_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/planning/planning_state.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_commands/use_cases/v1_application_command_use_cases.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer.dart (90%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/context/application_operation_context.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/context/owner_time_zone_context.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/loading/application_scheduling_loader.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/records/application_operation_record.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/records/notice_acknowledgement_record.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/records/owner_settings.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/in_memory/in_memory_application_repositories.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/in_memory/in_memory_application_state.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/application/application_operation_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/application/owner_settings_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/projects/project_statistics_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/tasks/task_activity_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/results/application_failure.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/results/application_failure_code.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/results/application_failure_exception.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/results/application_persistence_exception.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_layer/results/application_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management.dart (89%) rename packages/scheduler_core/lib/src/{ => application}/application_management/codes/application_management_command_code.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/codes/owner_settings_warning_code.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/drafts/locked_block_draft.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/drafts/project_profile_draft.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/parsing/parsed_notice_id.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/read_models/backlog_item_read_model.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/read_models/backlog_query_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/read_models/project_defaults_query_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/requests/get_backlog_request.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/requests/get_project_defaults_request.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/results/notice_acknowledgement_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/results/owner_settings_update_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/updates/locked_block_update.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/updates/owner_settings_update.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/updates/project_profile_update.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_management/use_cases/v1_application_management_use_cases.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_recovery.dart (75%) rename packages/scheduler_core/lib/src/{ => application}/application_recovery/app_open_recovery_outcome.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_recovery/app_open_recovery_result.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_recovery/run_app_open_recovery_request.dart (100%) rename packages/scheduler_core/lib/src/{ => application}/application_recovery/v1_app_open_recovery_use_cases.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/aliases/clock_time.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/blocks/locked_block.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/blocks/locked_block_occurrence.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/enums/locked_block_override_type.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/enums/locked_weekday.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/expansion/locked_schedule_expansion.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/overrides/locked_block_override.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/locked_time/recurrence/locked_block_recurrence.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/entities/project_profile.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/entities/task.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/entities/time_interval.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/effort/difficulty_level.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/effort/reward_level.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/planning/backlog_tag.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/planning/priority_level.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/planning/reminder_profile.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/tasks/task_status.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/enums/tasks/task_type.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/validation/domain_validation_code.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/models/validation/domain_validation_exception.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/occupancy_policy.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/occupancy_policy/occupancy_category.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/occupancy_policy/occupancy_entry.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/occupancy_policy/occupancy_policy.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/occupancy_policy/occupancy_source.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics.dart (96%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/enums/project_completion_time_bucket.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/enums/project_suggestion_confidence.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/enums/project_suggestion_type.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/models/project_default_resolution.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/models/project_statistics.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/models/project_statistics_application_result.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/services/project_statistics_aggregation_service.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/suggestions/project_suggestion.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/suggestions/project_suggestion_policy.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/suggestions/project_suggestion_service.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/project_statistics/suggestions/project_suggestion_set.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/task_statistics.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/civil/civil_date.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/civil/wall_time.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/clocks/clock.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/clocks/fixed_clock.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/clocks/system_clock.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/ids/id_generator.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/ids/sequential_id_generator.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/policies/nonexistent_local_time_policy.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/policies/repeated_local_time_policy.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/policies/time_zone_resolution_options.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/resolution/local_time_resolution.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/resolution/resolved_local_date_time.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart (100%) rename packages/scheduler_core/lib/src/{ => domain}/time_contracts/zones/resolvers/time_zone_resolver.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping.dart (92%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/application/application_operation_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/application/backlog_staleness_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/application/notice_acknowledgement_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/application/owner_settings_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/enums/persistence_enum_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/failures/document_mapping_exception.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/failures/document_mapping_failure_code.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/locked_time/locked_block_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/locked_time/locked_block_override_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/metadata/document_metadata.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/projects/project_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/projects/project_statistics_document_extension.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/projects/project_statistics_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/scheduling/scheduling_change_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/scheduling/scheduling_notice_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/scheduling/scheduling_overlap_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/scheduling/scheduling_window_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/tasks/task_activity_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/tasks/task_document_extension.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/tasks/task_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/tasks/task_statistics_document_extension.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/tasks/task_statistics_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_mapping/time/time_interval_document_mapping.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration.dart (97%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/codes/document_migration_entity.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/codes/document_migration_failure_code.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/codes/document_migration_note_code.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/codes/document_migration_status.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/legacy/legacy_document_exception.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/legacy/legacy_metadata_instants.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/reports/document_migration_note.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/reports/document_migration_provenance.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/reports/document_migration_report.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/reports/document_migration_result.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/service/schema_version_read.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/service/v0_migrator.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/document_migration/service/v1_document_migration_service.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract.dart (98%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/collections/persistence_collections.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/conventions/persistence_civil_date_convention.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/conventions/persistence_date_time_convention.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/conventions/persistence_enum_name.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/conventions/persistence_wall_time_convention.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/application/application_operation_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/application/backlog_staleness_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/application/owner_settings_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/core/document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/locked_time/locked_block_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/projects/project_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/projects/project_statistics_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/tasks/task_activity_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/tasks/task_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/tasks/task_statistics_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/time/clock_time_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/fields/time/time_interval_document_fields.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/indexes/persistence_index_catalog.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/indexes/persistence_index_direction.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/indexes/persistence_index_field.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/indexes/persistence_index_spec.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/indexes/repository_query_names.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/payloads/persistence_guard_result.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/payloads/persistence_payload_guard.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/payloads/persistence_payload_limits.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/persistence_contract/persistence_document_field_sets.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories.dart (89%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/failures/repository_failure.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/failures/repository_failure_code.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/failures/repository_mutation_result.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/in_memory/in_memory_locked_block_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/in_memory/in_memory_project_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/in_memory/in_memory_task_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/interfaces/locked_block_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/interfaces/project_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/interfaces/scheduling_snapshot_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/interfaces/task_repository.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/pagination/repository_page.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/pagination/repository_page_request.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/pagination/repository_record.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/queries/backlog_candidate_query.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repositories/queries/scheduling_state_snapshot.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repository_values.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repository_values/owner_id.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repository_values/page.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repository_values/page_request.dart (100%) rename packages/scheduler_core/lib/src/{ => persistence}/repository_values/revision.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog.dart (96%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/queries/backlog_filter.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/queries/backlog_sort_key.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/staleness/backlog_staleness_marker.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/staleness/backlog_staleness_settings.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/views/backlog_view.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/backlog/views/indexed_task.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks.dart (92%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/indexing/indexed_child.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/models/child_task_entry.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/models/child_task_summary.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/models/child_task_view.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/requests/child_task_break_up_request.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/results/child_task_break_up_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/results/child_task_completion_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/services/child_task_break_up_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/child_tasks/services/child_task_completion_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/free_slots.dart (89%) rename packages/scheduler_core/lib/src/{ => scheduling}/free_slots/free_slot_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/free_slots/protected_free_slot_conflict.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/free_slots/required_commitment_schedule_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/free_slots/required_commitment_schedule_status.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/quick_capture.dart (90%) rename packages/scheduler_core/lib/src/{ => scheduling}/quick_capture/quick_capture_request.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/quick_capture/quick_capture_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/quick_capture/quick_capture_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/quick_capture/quick_capture_status.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy.dart (91%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/directives/reminder_directive.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/directives/reminder_directive_action.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/directives/reminder_directive_reason.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/profiles/effective_reminder_profile.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/profiles/effective_reminder_profile_source.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/reminder_policy/services/reminder_policy_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine.dart (96%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/notices/scheduling_issue_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/notices/scheduling_notice_type.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/operations/scheduling_movement_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/operations/scheduling_operation_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/codes/operations/scheduling_outcome_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/engine/scheduling_engine.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/changes/scheduling_change.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/changes/scheduling_overlap.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/inputs/scheduling_input.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/inputs/scheduling_window.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/notices/scheduling_notice.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/models/results/scheduling_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/planning/backlog_insertion_plan.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/scheduling_engine/planning/placement_item.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions.dart (91%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/actions/flexible_task_quick_action.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/actions/push_destination.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/actions/required_task_action.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/requests/surprise_task_log_request.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/results/flexible_task_action_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/results/push_destination_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/results/required_task_action_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/results/surprise_task_log_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/services/flexible_task_action_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/services/required_task_action_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_actions/services/surprise_task_log_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle.dart (89%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/codes/task_activity_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/codes/task_transition_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/codes/task_transition_outcome_code.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/models/task_activity.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/models/task_activity_application_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/models/task_transition_result.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/services/task_activity_accounting_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/task_lifecycle/services/task_transition_service.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state.dart (89%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/mapping/timeline_item_mapper.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/models/compact_timeline_state.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/models/timeline_item.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/tokens/timeline_background_token.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/tokens/timeline_difficulty_icon_token.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/tokens/timeline_item_category.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/tokens/timeline_quick_action.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/timeline_state/tokens/timeline_reward_icon_token.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state.dart (80%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/models/today_compact_state.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/models/today_pending_notice.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/models/today_state.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/models/today_timeline_item.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/models/today_timeline_item_source.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/queries/get_today_state_query.dart (100%) rename packages/scheduler_core/lib/src/{ => scheduling}/today_state/requests/get_today_state_request.dart (100%) 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/packages/scheduler_core/lib/scheduler_core.dart b/packages/scheduler_core/lib/scheduler_core.dart index a8eb15a..8736dd5 100644 --- a/packages/scheduler_core/lib/scheduler_core.dart +++ b/packages/scheduler_core/lib/scheduler_core.dart @@ -22,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_commands.dart b/packages/scheduler_core/lib/src/application/application_commands.dart similarity index 69% rename from packages/scheduler_core/lib/src/application_commands.dart rename to packages/scheduler_core/lib/src/application/application_commands.dart index 657ff56..4c0d3b7 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application/application_commands.dart @@ -11,16 +11,16 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/codes/application_command_code.dart rename to packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart diff --git a/packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_child_task_draft.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/messages/application_child_task_draft.dart rename to packages/scheduler_core/lib/src/application/application_commands/messages/application_child_task_draft.dart diff --git a/packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_read_hint.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/messages/application_command_read_hint.dart rename to packages/scheduler_core/lib/src/application/application_commands/messages/application_command_read_hint.dart diff --git a/packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart b/packages/scheduler_core/lib/src/application/application_commands/messages/application_command_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/messages/application_command_result.dart rename to packages/scheduler_core/lib/src/application/application_commands/messages/application_command_result.dart diff --git a/packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/planning/planning_state.dart rename to packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart diff --git a/packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_commands/use_cases/v1_application_command_use_cases.dart rename to packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart diff --git a/packages/scheduler_core/lib/src/application_layer.dart b/packages/scheduler_core/lib/src/application/application_layer.dart similarity index 90% rename from packages/scheduler_core/lib/src/application_layer.dart rename to packages/scheduler_core/lib/src/application/application_layer.dart index a19fb48..5d67f36 100644 --- a/packages/scheduler_core/lib/src/application_layer.dart +++ b/packages/scheduler_core/lib/src/application/application_layer.dart @@ -10,14 +10,14 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart b/packages/scheduler_core/lib/src/application/application_layer/context/application_operation_context.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/context/application_operation_context.dart rename to packages/scheduler_core/lib/src/application/application_layer/context/application_operation_context.dart diff --git a/packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart b/packages/scheduler_core/lib/src/application/application_layer/context/owner_time_zone_context.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/context/owner_time_zone_context.dart rename to packages/scheduler_core/lib/src/application/application_layer/context/owner_time_zone_context.dart diff --git a/packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart b/packages/scheduler_core/lib/src/application/application_layer/loading/application_scheduling_loader.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/loading/application_scheduling_loader.dart rename to packages/scheduler_core/lib/src/application/application_layer/loading/application_scheduling_loader.dart diff --git a/packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart b/packages/scheduler_core/lib/src/application/application_layer/records/application_operation_record.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/records/application_operation_record.dart rename to packages/scheduler_core/lib/src/application/application_layer/records/application_operation_record.dart diff --git a/packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart b/packages/scheduler_core/lib/src/application/application_layer/records/notice_acknowledgement_record.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/records/notice_acknowledgement_record.dart rename to packages/scheduler_core/lib/src/application/application_layer/records/notice_acknowledgement_record.dart diff --git a/packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart b/packages/scheduler_core/lib/src/application/application_layer/records/owner_settings.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/records/owner_settings.dart rename to packages/scheduler_core/lib/src/application/application_layer/records/owner_settings.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_repositories.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_repositories.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_state.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_state.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/in_memory/in_memory_application_unit_of_work.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/application_operation_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/application_operation_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/notice_acknowledgement_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/application/owner_settings_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/application/owner_settings_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/projects/project_statistics_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/projects/project_statistics_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/tasks/task_activity_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/tasks/task_activity_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/interfaces/unit_of_work/application_unit_of_work_repositories.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_notice_acknowledgement_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_operation_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/application/unit_of_work_owner_settings_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/projects/unit_of_work_project_statistics_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_locked_block_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/scheduling/unit_of_work_scheduling_snapshot_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_activity_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart rename to packages/scheduler_core/lib/src/application/application_layer/repositories/unit_of_work/tasks/unit_of_work_task_repository.dart diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_failure.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/results/application_failure.dart rename to packages/scheduler_core/lib/src/application/application_layer/results/application_failure.dart diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/results/application_failure_code.dart rename to packages/scheduler_core/lib/src/application/application_layer/results/application_failure_code.dart diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_failure_exception.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/results/application_failure_exception.dart rename to packages/scheduler_core/lib/src/application/application_layer/results/application_failure_exception.dart diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_persistence_exception.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/results/application_persistence_exception.dart rename to packages/scheduler_core/lib/src/application/application_layer/results/application_persistence_exception.dart diff --git a/packages/scheduler_core/lib/src/application_layer/results/application_result.dart b/packages/scheduler_core/lib/src/application/application_layer/results/application_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_layer/results/application_result.dart rename to packages/scheduler_core/lib/src/application/application_layer/results/application_result.dart diff --git a/packages/scheduler_core/lib/src/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart similarity index 89% rename from packages/scheduler_core/lib/src/application_management.dart rename to packages/scheduler_core/lib/src/application/application_management.dart index c2ba8e9..5016520 100644 --- a/packages/scheduler_core/lib/src/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -12,11 +12,11 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart b/packages/scheduler_core/lib/src/application/application_management/codes/application_management_command_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/codes/application_management_command_code.dart rename to packages/scheduler_core/lib/src/application/application_management/codes/application_management_command_code.dart diff --git a/packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart b/packages/scheduler_core/lib/src/application/application_management/codes/owner_settings_warning_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/codes/owner_settings_warning_code.dart rename to packages/scheduler_core/lib/src/application/application_management/codes/owner_settings_warning_code.dart diff --git a/packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart b/packages/scheduler_core/lib/src/application/application_management/drafts/locked_block_draft.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/drafts/locked_block_draft.dart rename to packages/scheduler_core/lib/src/application/application_management/drafts/locked_block_draft.dart diff --git a/packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart b/packages/scheduler_core/lib/src/application/application_management/drafts/project_profile_draft.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/drafts/project_profile_draft.dart rename to packages/scheduler_core/lib/src/application/application_management/drafts/project_profile_draft.dart diff --git a/packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart b/packages/scheduler_core/lib/src/application/application_management/parsing/parsed_notice_id.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/parsing/parsed_notice_id.dart rename to packages/scheduler_core/lib/src/application/application_management/parsing/parsed_notice_id.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_management/read_models/backlog_item_read_model.dart rename to packages/scheduler_core/lib/src/application/application_management/read_models/backlog_item_read_model.dart diff --git a/packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_query_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/read_models/backlog_query_result.dart rename to packages/scheduler_core/lib/src/application/application_management/read_models/backlog_query_result.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/application_management/read_models/project_defaults_query_result.dart rename to packages/scheduler_core/lib/src/application/application_management/read_models/project_defaults_query_result.dart diff --git a/packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/requests/get_backlog_request.dart rename to packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_request.dart diff --git a/packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_project_defaults_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/requests/get_project_defaults_request.dart rename to packages/scheduler_core/lib/src/application/application_management/requests/get_project_defaults_request.dart diff --git a/packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart b/packages/scheduler_core/lib/src/application/application_management/results/notice_acknowledgement_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/results/notice_acknowledgement_result.dart rename to packages/scheduler_core/lib/src/application/application_management/results/notice_acknowledgement_result.dart diff --git a/packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart b/packages/scheduler_core/lib/src/application/application_management/results/owner_settings_update_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/results/owner_settings_update_result.dart rename to packages/scheduler_core/lib/src/application/application_management/results/owner_settings_update_result.dart diff --git a/packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/locked_block_update.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/updates/locked_block_update.dart rename to packages/scheduler_core/lib/src/application/application_management/updates/locked_block_update.dart diff --git a/packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/owner_settings_update.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/updates/owner_settings_update.dart rename to packages/scheduler_core/lib/src/application/application_management/updates/owner_settings_update.dart diff --git a/packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart b/packages/scheduler_core/lib/src/application/application_management/updates/project_profile_update.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/updates/project_profile_update.dart rename to packages/scheduler_core/lib/src/application/application_management/updates/project_profile_update.dart diff --git a/packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_management/use_cases/v1_application_management_use_cases.dart rename to packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart diff --git a/packages/scheduler_core/lib/src/application_recovery.dart b/packages/scheduler_core/lib/src/application/application_recovery.dart similarity index 75% rename from packages/scheduler_core/lib/src/application_recovery.dart rename to packages/scheduler_core/lib/src/application/application_recovery.dart index 2be5dc1..021891c 100644 --- a/packages/scheduler_core/lib/src/application_recovery.dart +++ b/packages/scheduler_core/lib/src/application/application_recovery.dart @@ -11,12 +11,12 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_outcome.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_recovery/app_open_recovery_outcome.dart rename to packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_outcome.dart diff --git a/packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart b/packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_recovery/app_open_recovery_result.dart rename to packages/scheduler_core/lib/src/application/application_recovery/app_open_recovery_result.dart diff --git a/packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart b/packages/scheduler_core/lib/src/application/application_recovery/run_app_open_recovery_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_recovery/run_app_open_recovery_request.dart rename to packages/scheduler_core/lib/src/application/application_recovery/run_app_open_recovery_request.dart diff --git a/packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart b/packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart similarity index 100% rename from packages/scheduler_core/lib/src/application_recovery/v1_app_open_recovery_use_cases.dart rename to packages/scheduler_core/lib/src/application/application_recovery/v1_app_open_recovery_use_cases.dart diff --git a/packages/scheduler_core/lib/src/locked_time.dart b/packages/scheduler_core/lib/src/domain/locked_time.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time.dart rename to packages/scheduler_core/lib/src/domain/locked_time.dart diff --git a/packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart b/packages/scheduler_core/lib/src/domain/locked_time/aliases/clock_time.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/aliases/clock_time.dart rename to packages/scheduler_core/lib/src/domain/locked_time/aliases/clock_time.dart diff --git a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/blocks/locked_block.dart rename to packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block.dart diff --git a/packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart b/packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block_occurrence.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/blocks/locked_block_occurrence.dart rename to packages/scheduler_core/lib/src/domain/locked_time/blocks/locked_block_occurrence.dart diff --git a/packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_block_override_type.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/enums/locked_block_override_type.dart rename to packages/scheduler_core/lib/src/domain/locked_time/enums/locked_block_override_type.dart diff --git a/packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart b/packages/scheduler_core/lib/src/domain/locked_time/enums/locked_weekday.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/enums/locked_weekday.dart rename to packages/scheduler_core/lib/src/domain/locked_time/enums/locked_weekday.dart diff --git a/packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart b/packages/scheduler_core/lib/src/domain/locked_time/expansion/locked_schedule_expansion.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/expansion/locked_schedule_expansion.dart rename to packages/scheduler_core/lib/src/domain/locked_time/expansion/locked_schedule_expansion.dart diff --git a/packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart b/packages/scheduler_core/lib/src/domain/locked_time/overrides/locked_block_override.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/overrides/locked_block_override.dart rename to packages/scheduler_core/lib/src/domain/locked_time/overrides/locked_block_override.dart diff --git a/packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart b/packages/scheduler_core/lib/src/domain/locked_time/recurrence/locked_block_recurrence.dart similarity index 100% rename from packages/scheduler_core/lib/src/locked_time/recurrence/locked_block_recurrence.dart rename to packages/scheduler_core/lib/src/domain/locked_time/recurrence/locked_block_recurrence.dart diff --git a/packages/scheduler_core/lib/src/models.dart b/packages/scheduler_core/lib/src/domain/models.dart similarity index 100% rename from packages/scheduler_core/lib/src/models.dart rename to packages/scheduler_core/lib/src/domain/models.dart diff --git a/packages/scheduler_core/lib/src/models/entities/project_profile.dart b/packages/scheduler_core/lib/src/domain/models/entities/project_profile.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/entities/project_profile.dart rename to packages/scheduler_core/lib/src/domain/models/entities/project_profile.dart diff --git a/packages/scheduler_core/lib/src/models/entities/task.dart b/packages/scheduler_core/lib/src/domain/models/entities/task.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/entities/task.dart rename to packages/scheduler_core/lib/src/domain/models/entities/task.dart diff --git a/packages/scheduler_core/lib/src/models/entities/time_interval.dart b/packages/scheduler_core/lib/src/domain/models/entities/time_interval.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/entities/time_interval.dart rename to packages/scheduler_core/lib/src/domain/models/entities/time_interval.dart diff --git a/packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/effort/difficulty_level.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/effort/difficulty_level.dart rename to packages/scheduler_core/lib/src/domain/models/enums/effort/difficulty_level.dart diff --git a/packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/effort/reward_level.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/effort/reward_level.dart rename to packages/scheduler_core/lib/src/domain/models/enums/effort/reward_level.dart diff --git a/packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/backlog_tag.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/planning/backlog_tag.dart rename to packages/scheduler_core/lib/src/domain/models/enums/planning/backlog_tag.dart diff --git a/packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/priority_level.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/planning/priority_level.dart rename to packages/scheduler_core/lib/src/domain/models/enums/planning/priority_level.dart diff --git a/packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart b/packages/scheduler_core/lib/src/domain/models/enums/planning/reminder_profile.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/planning/reminder_profile.dart rename to packages/scheduler_core/lib/src/domain/models/enums/planning/reminder_profile.dart diff --git a/packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_status.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/tasks/task_status.dart rename to packages/scheduler_core/lib/src/domain/models/enums/tasks/task_status.dart diff --git a/packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart b/packages/scheduler_core/lib/src/domain/models/enums/tasks/task_type.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/enums/tasks/task_type.dart rename to packages/scheduler_core/lib/src/domain/models/enums/tasks/task_type.dart diff --git a/packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/validation/domain_validation_code.dart rename to packages/scheduler_core/lib/src/domain/models/validation/domain_validation_code.dart diff --git a/packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart b/packages/scheduler_core/lib/src/domain/models/validation/domain_validation_exception.dart similarity index 100% rename from packages/scheduler_core/lib/src/models/validation/domain_validation_exception.dart rename to packages/scheduler_core/lib/src/domain/models/validation/domain_validation_exception.dart diff --git a/packages/scheduler_core/lib/src/occupancy_policy.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy.dart similarity index 100% rename from packages/scheduler_core/lib/src/occupancy_policy.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy.dart diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_category.dart similarity index 100% rename from packages/scheduler_core/lib/src/occupancy_policy/occupancy_category.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_category.dart diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_entry.dart similarity index 100% rename from packages/scheduler_core/lib/src/occupancy_policy/occupancy_entry.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_entry.dart diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_policy.dart similarity index 100% rename from packages/scheduler_core/lib/src/occupancy_policy/occupancy_policy.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_policy.dart diff --git a/packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart b/packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_source.dart similarity index 100% rename from packages/scheduler_core/lib/src/occupancy_policy/occupancy_source.dart rename to packages/scheduler_core/lib/src/domain/occupancy_policy/occupancy_source.dart diff --git a/packages/scheduler_core/lib/src/project_statistics.dart b/packages/scheduler_core/lib/src/domain/project_statistics.dart similarity index 96% rename from packages/scheduler_core/lib/src/project_statistics.dart rename to packages/scheduler_core/lib/src/domain/project_statistics.dart index a7a0d9e..e4a58fd 100644 --- a/packages/scheduler_core/lib/src/project_statistics.dart +++ b/packages/scheduler_core/lib/src/domain/project_statistics.dart @@ -11,7 +11,7 @@ library; // project defaults or implement reports UI. import 'models.dart'; -import 'task_lifecycle.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'; diff --git a/packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_completion_time_bucket.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/enums/project_completion_time_bucket.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/enums/project_completion_time_bucket.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_confidence.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_confidence.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_confidence.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart b/packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_type.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/enums/project_suggestion_type.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/enums/project_suggestion_type.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_default_resolution.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/models/project_default_resolution.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/models/project_default_resolution.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/models/project_statistics.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart b/packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics_application_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/models/project_statistics_application_result.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/models/project_statistics_application_result.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart b/packages/scheduler_core/lib/src/domain/project_statistics/services/project_statistics_aggregation_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/services/project_statistics_aggregation_service.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/services/project_statistics_aggregation_service.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_policy.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_policy.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_policy.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_service.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_service.dart diff --git a/packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart b/packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_set.dart similarity index 100% rename from packages/scheduler_core/lib/src/project_statistics/suggestions/project_suggestion_set.dart rename to packages/scheduler_core/lib/src/domain/project_statistics/suggestions/project_suggestion_set.dart diff --git a/packages/scheduler_core/lib/src/task_statistics.dart b/packages/scheduler_core/lib/src/domain/task_statistics.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_statistics.dart rename to packages/scheduler_core/lib/src/domain/task_statistics.dart diff --git a/packages/scheduler_core/lib/src/time_contracts.dart b/packages/scheduler_core/lib/src/domain/time_contracts.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts.dart rename to packages/scheduler_core/lib/src/domain/time_contracts.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart b/packages/scheduler_core/lib/src/domain/time_contracts/civil/civil_date.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/civil/civil_date.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/civil/civil_date.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart b/packages/scheduler_core/lib/src/domain/time_contracts/civil/wall_time.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/civil/wall_time.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/civil/wall_time.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/clock.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/clocks/clock.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/clocks/clock.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/fixed_clock.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/clocks/fixed_clock.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/clocks/fixed_clock.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart b/packages/scheduler_core/lib/src/domain/time_contracts/clocks/system_clock.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/clocks/system_clock.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/clocks/system_clock.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart b/packages/scheduler_core/lib/src/domain/time_contracts/ids/id_generator.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/ids/id_generator.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/ids/id_generator.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart b/packages/scheduler_core/lib/src/domain/time_contracts/ids/sequential_id_generator.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/ids/sequential_id_generator.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/ids/sequential_id_generator.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/policies/nonexistent_local_time_policy.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/nonexistent_local_time_policy.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/policies/repeated_local_time_policy.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/repeated_local_time_policy.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/policies/time_zone_resolution_options.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/policies/time_zone_resolution_options.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/local_time_resolution.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/resolution/local_time_resolution.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/local_time_resolution.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/resolution/resolved_local_date_time.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/resolution/resolved_local_date_time.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/fixed_offset_time_zone_resolver.dart diff --git a/packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart b/packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/time_zone_resolver.dart similarity index 100% rename from packages/scheduler_core/lib/src/time_contracts/zones/resolvers/time_zone_resolver.dart rename to packages/scheduler_core/lib/src/domain/time_contracts/zones/resolvers/time_zone_resolver.dart diff --git a/packages/scheduler_core/lib/src/document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping.dart similarity index 92% rename from packages/scheduler_core/lib/src/document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping.dart index 7da131a..2462b22 100644 --- a/packages/scheduler_core/lib/src/document_mapping.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping.dart @@ -9,17 +9,17 @@ library; // 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 '../application/application_layer.dart'; +import '../scheduling/backlog.dart'; +import '../domain/locked_time.dart'; +import '../domain/models.dart'; import 'persistence_contract.dart'; -import 'project_statistics.dart'; +import '../domain/project_statistics.dart'; import 'repositories.dart'; -import 'scheduling_engine.dart'; -import 'task_lifecycle.dart'; -import 'task_statistics.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/application_operation_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/application/application_operation_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/application/application_operation_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/backlog_staleness_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/application/backlog_staleness_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/application/backlog_staleness_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/notice_acknowledgement_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/application/notice_acknowledgement_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/application/notice_acknowledgement_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/application/owner_settings_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/application/owner_settings_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/application/owner_settings_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/enums/persistence_enum_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_exception.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_exception.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_exception.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_failure_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/failures/document_mapping_failure_code.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/failures/document_mapping_failure_code.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_override_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_override_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/locked_time/locked_block_recurrence_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/metadata/document_metadata.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/metadata/document_metadata.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/metadata/document_metadata.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/projects/project_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_extension.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/projects/project_statistics_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_change_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_change_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_change_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_notice_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_notice_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_notice_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_overlap_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_overlap_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_overlap_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_snapshot_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_window_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/scheduling/scheduling_window_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/scheduling/scheduling_window_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_activity_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/tasks/task_activity_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_activity_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_extension.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/tasks/task_document_extension.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_extension.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/tasks/task_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_extension.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_extension.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_extension.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/tasks/task_statistics_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_statistics_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/time/time_interval_document_mapping.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_mapping/time/time_interval_document_mapping.dart rename to packages/scheduler_core/lib/src/persistence/document_mapping/time/time_interval_document_mapping.dart diff --git a/packages/scheduler_core/lib/src/document_migration.dart b/packages/scheduler_core/lib/src/persistence/document_migration.dart similarity index 97% rename from packages/scheduler_core/lib/src/document_migration.dart rename to packages/scheduler_core/lib/src/persistence/document_migration.dart index c031d9b..b57cefe 100644 --- a/packages/scheduler_core/lib/src/document_migration.dart +++ b/packages/scheduler_core/lib/src/persistence/document_migration.dart @@ -12,7 +12,7 @@ library; import 'document_mapping.dart'; import 'persistence_contract.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_entity.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/codes/document_migration_entity.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_entity.dart diff --git a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_failure_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/codes/document_migration_failure_code.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_failure_code.dart diff --git a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_note_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/codes/document_migration_note_code.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_note_code.dart diff --git a/packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart b/packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_status.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/codes/document_migration_status.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/codes/document_migration_status.dart diff --git a/packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_document_exception.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/legacy/legacy_document_exception.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_document_exception.dart diff --git a/packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart b/packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_metadata_instants.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/legacy/legacy_metadata_instants.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/legacy/legacy_metadata_instants.dart diff --git a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_note.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/reports/document_migration_note.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_note.dart diff --git a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_provenance.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/reports/document_migration_provenance.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_provenance.dart diff --git a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_report.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/reports/document_migration_report.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_report.dart diff --git a/packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart b/packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/reports/document_migration_result.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/reports/document_migration_result.dart diff --git a/packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/schema_version_read.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/service/schema_version_read.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/service/schema_version_read.dart diff --git a/packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/v0_migrator.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/service/v0_migrator.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/service/v0_migrator.dart diff --git a/packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart b/packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/document_migration/service/v1_document_migration_service.dart rename to packages/scheduler_core/lib/src/persistence/document_migration/service/v1_document_migration_service.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract.dart similarity index 98% rename from packages/scheduler_core/lib/src/persistence_contract.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract.dart index 2971e52..11fb4de 100644 --- a/packages/scheduler_core/lib/src/persistence_contract.dart +++ b/packages/scheduler_core/lib/src/persistence/persistence_contract.dart @@ -10,7 +10,7 @@ library; // codecs and future adapters should use. It deliberately does not serialize // domain models or import a database client. -import 'time_contracts.dart'; +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'; diff --git a/packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/collections/persistence_collections.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/collections/persistence_collections.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/collections/persistence_collections.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_civil_date_convention.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_civil_date_convention.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_civil_date_convention.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_date_time_convention.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_date_time_convention.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_date_time_convention.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_enum_name.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_enum_name.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_enum_name.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_wall_time_convention.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/conventions/persistence_wall_time_convention.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/conventions/persistence_wall_time_convention.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/application/application_operation_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/application_operation_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/application/backlog_staleness_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/backlog_staleness_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/notice_acknowledgement_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/application/owner_settings_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/application/owner_settings_document_fields.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/core/document_fields.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/core/document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/core/document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_override_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/locked_time/locked_block_recurrence_document_fields.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_document_fields.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/projects/project_statistics_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/projects/project_statistics_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_change_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_notice_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_overlap_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_snapshot_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/scheduling/scheduling_window_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_activity_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_activity_document_fields.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_document_fields.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/tasks/task_statistics_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/task_statistics_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/time/clock_time_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/clock_time_document_fields.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/fields/time/time_interval_document_fields.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/fields/time/time_interval_document_fields.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_catalog.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_catalog.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_catalog.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_direction.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_direction.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_direction.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_field.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_field.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_field.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_spec.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/indexes/persistence_index_spec.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/persistence_index_spec.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/repository_query_names.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/indexes/repository_query_names.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/indexes/repository_query_names.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_guard_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_guard_result.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_guard_result.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_guard.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_guard.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_guard.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_limits.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/payloads/persistence_payload_limits.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/payloads/persistence_payload_limits.dart diff --git a/packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart b/packages/scheduler_core/lib/src/persistence/persistence_contract/persistence_document_field_sets.dart similarity index 100% rename from packages/scheduler_core/lib/src/persistence_contract/persistence_document_field_sets.dart rename to packages/scheduler_core/lib/src/persistence/persistence_contract/persistence_document_field_sets.dart diff --git a/packages/scheduler_core/lib/src/repositories.dart b/packages/scheduler_core/lib/src/persistence/repositories.dart similarity index 89% rename from packages/scheduler_core/lib/src/repositories.dart rename to packages/scheduler_core/lib/src/persistence/repositories.dart index 7cebdba..56d42b8 100644 --- a/packages/scheduler_core/lib/src/repositories.dart +++ b/packages/scheduler_core/lib/src/persistence/repositories.dart @@ -9,10 +9,10 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/failures/repository_failure.dart rename to packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure.dart diff --git a/packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/failures/repository_failure_code.dart rename to packages/scheduler_core/lib/src/persistence/repositories/failures/repository_failure_code.dart diff --git a/packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart b/packages/scheduler_core/lib/src/persistence/repositories/failures/repository_mutation_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/failures/repository_mutation_result.dart rename to packages/scheduler_core/lib/src/persistence/repositories/failures/repository_mutation_result.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/repositories/in_memory/in_memory_locked_block_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_locked_block_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_project_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/in_memory/in_memory_project_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_project_repository.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_scheduling_snapshot_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/in_memory/in_memory_task_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/in_memory/in_memory_task_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/locked_block_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/interfaces/locked_block_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/interfaces/locked_block_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/project_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/interfaces/project_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/interfaces/project_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/scheduling_snapshot_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/interfaces/scheduling_snapshot_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/interfaces/scheduling_snapshot_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart b/packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/interfaces/task_repository.dart rename to packages/scheduler_core/lib/src/persistence/repositories/interfaces/task_repository.dart diff --git a/packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/pagination/repository_page.dart rename to packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page.dart diff --git a/packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/pagination/repository_page_request.dart rename to packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_page_request.dart diff --git a/packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart b/packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_record.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/pagination/repository_record.dart rename to packages/scheduler_core/lib/src/persistence/repositories/pagination/repository_record.dart diff --git a/packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart b/packages/scheduler_core/lib/src/persistence/repositories/queries/backlog_candidate_query.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/queries/backlog_candidate_query.dart rename to packages/scheduler_core/lib/src/persistence/repositories/queries/backlog_candidate_query.dart diff --git a/packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart b/packages/scheduler_core/lib/src/persistence/repositories/queries/scheduling_state_snapshot.dart similarity index 100% rename from packages/scheduler_core/lib/src/repositories/queries/scheduling_state_snapshot.dart rename to packages/scheduler_core/lib/src/persistence/repositories/queries/scheduling_state_snapshot.dart diff --git a/packages/scheduler_core/lib/src/repository_values.dart b/packages/scheduler_core/lib/src/persistence/repository_values.dart similarity index 100% rename from packages/scheduler_core/lib/src/repository_values.dart rename to packages/scheduler_core/lib/src/persistence/repository_values.dart diff --git a/packages/scheduler_core/lib/src/repository_values/owner_id.dart b/packages/scheduler_core/lib/src/persistence/repository_values/owner_id.dart similarity index 100% rename from packages/scheduler_core/lib/src/repository_values/owner_id.dart rename to packages/scheduler_core/lib/src/persistence/repository_values/owner_id.dart diff --git a/packages/scheduler_core/lib/src/repository_values/page.dart b/packages/scheduler_core/lib/src/persistence/repository_values/page.dart similarity index 100% rename from packages/scheduler_core/lib/src/repository_values/page.dart rename to packages/scheduler_core/lib/src/persistence/repository_values/page.dart diff --git a/packages/scheduler_core/lib/src/repository_values/page_request.dart b/packages/scheduler_core/lib/src/persistence/repository_values/page_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/repository_values/page_request.dart rename to packages/scheduler_core/lib/src/persistence/repository_values/page_request.dart diff --git a/packages/scheduler_core/lib/src/repository_values/revision.dart b/packages/scheduler_core/lib/src/persistence/repository_values/revision.dart similarity index 100% rename from packages/scheduler_core/lib/src/repository_values/revision.dart rename to packages/scheduler_core/lib/src/persistence/repository_values/revision.dart diff --git a/packages/scheduler_core/lib/src/backlog.dart b/packages/scheduler_core/lib/src/scheduling/backlog.dart similarity index 96% rename from packages/scheduler_core/lib/src/backlog.dart rename to packages/scheduler_core/lib/src/scheduling/backlog.dart index 2cbbd31..738cf2a 100644 --- a/packages/scheduler_core/lib/src/backlog.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog.dart @@ -11,7 +11,7 @@ library; // display-oriented backlog filtering, sorting, and age markers out of the main // scheduler so the engine can focus on moving tasks through time. -import 'models.dart'; +import '../domain/models.dart'; part 'backlog/queries/backlog_filter.dart'; part 'backlog/queries/backlog_sort_key.dart'; part 'backlog/staleness/backlog_staleness_marker.dart'; diff --git a/packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_filter.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/queries/backlog_filter.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_filter.dart diff --git a/packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/queries/backlog_sort_key.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart diff --git a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_marker.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_marker.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_marker.dart diff --git a/packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/staleness/backlog_staleness_settings.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart diff --git a/packages/scheduler_core/lib/src/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/views/backlog_view.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart diff --git a/packages/scheduler_core/lib/src/backlog/views/indexed_task.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/indexed_task.dart similarity index 100% rename from packages/scheduler_core/lib/src/backlog/views/indexed_task.dart rename to packages/scheduler_core/lib/src/scheduling/backlog/views/indexed_task.dart diff --git a/packages/scheduler_core/lib/src/child_tasks.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart similarity index 92% rename from packages/scheduler_core/lib/src/child_tasks.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks.dart index 3fb2cc5..f0abef4 100644 --- a/packages/scheduler_core/lib/src/child_tasks.dart +++ b/packages/scheduler_core/lib/src/scheduling/child_tasks.dart @@ -10,9 +10,9 @@ library; // keeps that relationship queryable without adding dependency scheduling, DAG // traversal, or UI-specific state. -import 'models.dart'; +import '../domain/models.dart'; import 'task_lifecycle.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/indexing/indexed_child.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/indexing/indexed_child.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/indexing/indexed_child.dart diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_entry.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/models/child_task_entry.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_entry.dart diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_summary.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/models/child_task_summary.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_summary.dart diff --git a/packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_view.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/models/child_task_view.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/models/child_task_view.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/requests/child_task_break_up_request.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/requests/child_task_break_up_request.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/results/child_task_break_up_result.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_break_up_result.dart diff --git a/packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_completion_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/results/child_task_completion_result.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/results/child_task_completion_result.dart diff --git a/packages/scheduler_core/lib/src/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 similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/services/child_task_break_up_service.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_break_up_service.dart diff --git a/packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart b/packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/child_tasks/services/child_task_completion_service.dart rename to packages/scheduler_core/lib/src/scheduling/child_tasks/services/child_task_completion_service.dart diff --git a/packages/scheduler_core/lib/src/free_slots.dart b/packages/scheduler_core/lib/src/scheduling/free_slots.dart similarity index 89% rename from packages/scheduler_core/lib/src/free_slots.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots.dart index d315483..7278c70 100644 --- a/packages/scheduler_core/lib/src/free_slots.dart +++ b/packages/scheduler_core/lib/src/scheduling/free_slots.dart @@ -10,8 +10,8 @@ library; // 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 '../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'; diff --git a/packages/scheduler_core/lib/src/free_slots/free_slot_service.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/free_slots/free_slot_service.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots/free_slot_service.dart diff --git a/packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/protected_free_slot_conflict.dart similarity index 100% rename from packages/scheduler_core/lib/src/free_slots/protected_free_slot_conflict.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots/protected_free_slot_conflict.dart diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_result.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_result.dart diff --git a/packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart b/packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_status.dart similarity index 100% rename from packages/scheduler_core/lib/src/free_slots/required_commitment_schedule_status.dart rename to packages/scheduler_core/lib/src/scheduling/free_slots/required_commitment_schedule_status.dart diff --git a/packages/scheduler_core/lib/src/quick_capture.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart similarity index 90% rename from packages/scheduler_core/lib/src/quick_capture.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture.dart index f271926..8c04100 100644 --- a/packages/scheduler_core/lib/src/quick_capture.dart +++ b/packages/scheduler_core/lib/src/scheduling/quick_capture.dart @@ -10,9 +10,9 @@ library; // 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 '../domain/models.dart'; import 'scheduling_engine.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/quick_capture/quick_capture_request.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_request.dart diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/quick_capture/quick_capture_result.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_result.dart diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/quick_capture/quick_capture_service.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_service.dart diff --git a/packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart b/packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_status.dart similarity index 100% rename from packages/scheduler_core/lib/src/quick_capture/quick_capture_status.dart rename to packages/scheduler_core/lib/src/scheduling/quick_capture/quick_capture_status.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart similarity index 91% rename from packages/scheduler_core/lib/src/reminder_policy.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy.dart index 88b0aa4..584b54c 100644 --- a/packages/scheduler_core/lib/src/reminder_policy.dart +++ b/packages/scheduler_core/lib/src/scheduling/reminder_policy.dart @@ -10,8 +10,8 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_action.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_action.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_action.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_reason.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/directives/reminder_directive_reason.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/directives/reminder_directive_reason.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile_source.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/profiles/effective_reminder_profile_source.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/profiles/effective_reminder_profile_source.dart diff --git a/packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart b/packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/reminder_policy/services/reminder_policy_service.dart rename to packages/scheduler_core/lib/src/scheduling/reminder_policy/services/reminder_policy_service.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart similarity index 96% rename from packages/scheduler_core/lib/src/scheduling_engine.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart index 63646f2..3309442 100644 --- a/packages/scheduler_core/lib/src/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine.dart @@ -18,10 +18,10 @@ library; // 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 '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; import 'task_lifecycle.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/conflicts/scheduling_conflict_code.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_issue_code.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_notice_type.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/notices/scheduling_notice_type.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_notice_type.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_movement_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_movement_code.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_movement_code.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_operation_code.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_outcome_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/codes/operations/scheduling_outcome_code.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_outcome_code.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/engine/scheduling_engine.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_change.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_change.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_change.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_overlap.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/changes/scheduling_overlap.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/changes/scheduling_overlap.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_input.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_input.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_input.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_window.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/inputs/scheduling_window.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/inputs/scheduling_window.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/notices/scheduling_notice.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/notices/scheduling_notice.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/notices/scheduling_notice.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/results/scheduling_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/models/results/scheduling_result.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/models/results/scheduling_result.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/backlog_insertion_plan.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/planning/backlog_insertion_plan.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/backlog_insertion_plan.dart diff --git a/packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/placement_item.dart similarity index 100% rename from packages/scheduler_core/lib/src/scheduling_engine/planning/placement_item.dart rename to packages/scheduler_core/lib/src/scheduling/scheduling_engine/planning/placement_item.dart diff --git a/packages/scheduler_core/lib/src/task_actions.dart b/packages/scheduler_core/lib/src/scheduling/task_actions.dart similarity index 91% rename from packages/scheduler_core/lib/src/task_actions.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions.dart index 580ff8a..d06a096 100644 --- a/packages/scheduler_core/lib/src/task_actions.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions.dart @@ -11,11 +11,11 @@ library; // 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 '../domain/models.dart'; +import '../domain/occupancy_policy.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; -import 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/flexible_task_quick_action.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/actions/flexible_task_quick_action.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/actions/flexible_task_quick_action.dart diff --git a/packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/push_destination.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/actions/push_destination.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/actions/push_destination.dart diff --git a/packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/actions/required_task_action.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/actions/required_task_action.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/actions/required_task_action.dart diff --git a/packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/requests/surprise_task_log_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/requests/surprise_task_log_request.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/requests/surprise_task_log_request.dart diff --git a/packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/flexible_task_action_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/results/flexible_task_action_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/results/flexible_task_action_result.dart diff --git a/packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/push_destination_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/results/push_destination_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/results/push_destination_result.dart diff --git a/packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/required_task_action_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/results/required_task_action_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/results/required_task_action_result.dart diff --git a/packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/results/surprise_task_log_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/results/surprise_task_log_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/results/surprise_task_log_result.dart diff --git a/packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/services/flexible_task_action_service.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart diff --git a/packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/services/required_task_action_service.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/services/required_task_action_service.dart diff --git a/packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_actions/services/surprise_task_log_service.dart rename to packages/scheduler_core/lib/src/scheduling/task_actions/services/surprise_task_log_service.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart similarity index 89% rename from packages/scheduler_core/lib/src/task_lifecycle.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart index 7356540..f4ba3bd 100644 --- a/packages/scheduler_core/lib/src/task_lifecycle.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle.dart @@ -10,9 +10,9 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_activity_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/codes/task_activity_code.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_activity_code.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_code.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_code.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_outcome_code.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/codes/task_transition_outcome_code.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/codes/task_transition_outcome_code.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/models/task_activity.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity_application_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/models/task_activity_application_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_activity_application_result.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_transition_result.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/models/task_transition_result.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/models/task_transition_result.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_activity_accounting_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/services/task_activity_accounting_service.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_activity_accounting_service.dart diff --git a/packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart similarity index 100% rename from packages/scheduler_core/lib/src/task_lifecycle/services/task_transition_service.dart rename to packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state.dart similarity index 89% rename from packages/scheduler_core/lib/src/timeline_state.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state.dart index d29d8aa..8c1b5fd 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/scheduling/timeline_state.dart @@ -10,9 +10,9 @@ library; // 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'; +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'; diff --git a/packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/mapping/timeline_item_mapper.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/mapping/timeline_item_mapper.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/compact_timeline_state.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/models/compact_timeline_state.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/models/compact_timeline_state.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/models/timeline_item.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/models/timeline_item.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_background_token.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/tokens/timeline_background_token.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_background_token.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_difficulty_icon_token.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/tokens/timeline_difficulty_icon_token.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_difficulty_icon_token.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_item_category.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/tokens/timeline_item_category.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_item_category.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_quick_action.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/tokens/timeline_quick_action.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_quick_action.dart diff --git a/packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart b/packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_reward_icon_token.dart similarity index 100% rename from packages/scheduler_core/lib/src/timeline_state/tokens/timeline_reward_icon_token.dart rename to packages/scheduler_core/lib/src/scheduling/timeline_state/tokens/timeline_reward_icon_token.dart diff --git a/packages/scheduler_core/lib/src/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state.dart similarity index 80% rename from packages/scheduler_core/lib/src/today_state.dart rename to packages/scheduler_core/lib/src/scheduling/today_state.dart index c250030..8b42495 100644 --- a/packages/scheduler_core/lib/src/today_state.dart +++ b/packages/scheduler_core/lib/src/scheduling/today_state.dart @@ -10,13 +10,13 @@ library; // 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 '../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 'time_contracts.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'; diff --git a/packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_compact_state.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/models/today_compact_state.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/models/today_compact_state.dart diff --git a/packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_pending_notice.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/models/today_pending_notice.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/models/today_pending_notice.dart diff --git a/packages/scheduler_core/lib/src/today_state/models/today_state.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_state.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/models/today_state.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/models/today_state.dart diff --git a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/models/today_timeline_item.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item.dart diff --git a/packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart b/packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item_source.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/models/today_timeline_item_source.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/models/today_timeline_item_source.dart diff --git a/packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart b/packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/queries/get_today_state_query.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/queries/get_today_state_query.dart diff --git a/packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart b/packages/scheduler_core/lib/src/scheduling/today_state/requests/get_today_state_request.dart similarity index 100% rename from packages/scheduler_core/lib/src/today_state/requests/get_today_state_request.dart rename to packages/scheduler_core/lib/src/scheduling/today_state/requests/get_today_state_request.dart diff --git a/tool/check_reuse.dart b/tool/check_reuse.dart index 20f86ab..84e3c23 100644 --- a/tool/check_reuse.dart +++ b/tool/check_reuse.dart @@ -187,6 +187,8 @@ bool _isComplianceScope(String path) { 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';