From edfb0cbba5972e0071d2b5e67a9c9382ea7d8858 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 10:08:48 -0700 Subject: [PATCH] feat(reminders): add protected rest reminder policy --- ..._Lifecycle_Statistics_Project_Reminders.md | 11 +- ...1_Lifecycle_Metadata_Reminder_Semantics.md | 3 + .../V1_BACKEND_COMPLETION_GAP_MATRIX.md | 6 +- .../V1_PUBLIC_API_BASELINE.md | 15 + .../V1_REQUIREMENTS_TRACEABILITY_MATRIX.md | 11 +- lib/scheduler_core.dart | 1 + lib/src/reminder_policy.dart | 334 ++++++++++++++++++ test/reminder_policy_test.dart | 320 +++++++++++++++++ 8 files changed, 695 insertions(+), 6 deletions(-) rename Codex Documentation/{Current Software Plan => Archived plans}/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md (97%) create mode 100644 lib/src/reminder_policy.dart create mode 100644 test/reminder_policy_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md b/Codex Documentation/Archived plans/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md similarity index 97% rename from Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md rename to Codex Documentation/Archived plans/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md index 3b736a9..bf86983 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md @@ -1,6 +1,6 @@ # V1 Block 13 — Lifecycle, Statistics, Project Defaults, and Reminder Policy -Status: In progress +Status: Complete on 2026-06-25. Purpose: Centralize task transitions, update internal statistics exactly once, add project-level learned suggestions, and expose reminder-policy decisions @@ -222,6 +222,8 @@ resolution. Recommended Codex level: high +Status: Complete on 2026-06-25. + Tasks: - Add an optional task-level reminder-profile override. @@ -258,6 +260,13 @@ Acceptance criteria: - Silent reminders produce no delivery directive. - The core remains Flutter/platform independent. +Verification on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 243 tests +- `git diff --check`: passed + Commit suggestion: ```text diff --git a/Codex Documentation/Current Software Plan/V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md b/Codex Documentation/Current Software Plan/V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md index ec88e8e..44ccda6 100644 --- a/Codex Documentation/Current Software Plan/V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md +++ b/Codex Documentation/Current Software Plan/V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md @@ -113,6 +113,9 @@ Effective reminder profile resolution is: Free-slot suppression is not a reminder profile. It is a scheduling/rest policy directive that can suppress normal flexible reminders during protected rest. +Block 13 implements these pure-core decisions through `ReminderPolicyService` +and typed `ReminderDirective` values without adding platform notification +delivery. ## Consequences diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index f085318..02bd009 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -34,13 +34,13 @@ passed. The current suite has 143 passing tests. | Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 | | Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage | Application-facing orchestration and final backend acceptance scenarios | 14, 17 | | Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 | -| Free slots | Task type and timeline token exist | Creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests | 12, 13, 14 | +| Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, and overlap tests | Application use cases that persist resulting changes atomically | 12, 13, 14 | | Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 | | Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 | | Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, push-before-completion values, project aggregates, and learned suggestion inputs | Atomic persistence | 14 | | Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 | -| Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, and explicit configured-vs-learned resolution | Effective reminder-profile resolver and protected-rest reminder policy | 13 | -| Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; platform delivery remains outside the pure core | 13 | +| Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution, and effective reminder-profile resolver | Application-layer persistence/query orchestration | 13, 14 | +| Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | Platform delivery remains outside the pure core | 13 | | Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 | | Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 | | Application layer | Domain services can be called directly | Coherent use cases that load state, invoke rules, persist all changes atomically, and return UI-ready results | 14 | diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 70a38db..575a523 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -23,6 +23,7 @@ following source files: - `src/persistence_contract.dart` - `src/project_statistics.dart` - `src/quick_capture.dart` +- `src/reminder_policy.dart` - `src/repositories.dart` - `src/scheduling_engine.dart` - `src/task_actions.dart` @@ -45,6 +46,7 @@ changes from accidental API drift. | `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | | `src/project_statistics.dart` | `ProjectCompletionTimeBucket`, `ProjectSuggestionType`, `ProjectSuggestionConfidence` | | `src/quick_capture.dart` | `QuickCaptureStatus` | +| `src/reminder_policy.dart` | `ReminderDirectiveAction`, `ReminderDirectiveReason`, `EffectiveReminderProfileSource` | | `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | | `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` | @@ -65,6 +67,7 @@ changes from accidental API drift. | `src/persistence_contract.dart` | `PersistenceEnumName` | | `src/project_statistics.dart` | `ProjectStatistics`, `ProjectStatisticsApplicationResult`, `ProjectStatisticsAggregationService`, `ProjectSuggestionPolicy`, `ProjectSuggestion`, `ProjectSuggestionSet`, `ProjectDefaultResolution`, `ProjectSuggestionService` | | `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` | +| `src/reminder_policy.dart` | `EffectiveReminderProfile`, `ReminderDirective`, `ReminderPolicyService` | | `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` | | `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` | @@ -241,6 +244,18 @@ Chunk 13.4 intentionally changed the public API: - Expanded completion activity metadata with duration, reward, difficulty, and reminder-profile observations for project aggregation. +Chunk 13.5 intentionally changed the public API: + +- Added `src/reminder_policy.dart` and exported it from `scheduler_core.dart`. +- Added reminder directive action/reason enums and effective-profile source + enum. +- Added `EffectiveReminderProfile`, `ReminderDirective`, and + `ReminderPolicyService`. +- Added deterministic effective-profile resolution using task override, project + configured default, then fallback. +- Added typed reminder directives for normal delivery, suppression, deferral, + and required-task acknowledgement, including protected Free Slot suppression. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index e1beb15..ae8bee5 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -56,6 +56,13 @@ Latest Block 13.4 verification result on 2026-06-25: - `dart test`: passed, 233 tests - `git diff --check`: passed +Latest Block 13.5 verification result on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 243 tests +- `git diff --check`: passed + Status values: - `complete`: Current backend APIs and tests cover the requirement. @@ -93,8 +100,8 @@ Status values: | MVP-SUP-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 records both missed and moved-to-backlog activities. | | MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. | -| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers scheduling protection, helpers, explicit required conflicts, and typed outcomes; reminder directives remain in Block 13. | -| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart` | incomplete | Task override storage and learned suggestions exist; effective reminder resolver and reminder directives remain in Chunk 13.5. | +| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. | +| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | | MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; atomic persistence remains in Block 14. | diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 0921978..a08aacf 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -26,6 +26,7 @@ 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/scheduling_engine.dart'; export 'src/task_actions.dart'; diff --git a/lib/src/reminder_policy.dart b/lib/src/reminder_policy.dart new file mode 100644 index 0000000..4323fcd --- /dev/null +++ b/lib/src/reminder_policy.dart @@ -0,0 +1,334 @@ +// UI/platform-independent reminder policy. +// +// This file decides whether the core would deliver, suppress, defer, or require +// acknowledgement for a reminder at a specific instant. It does not schedule OS +// notifications, run background timers, or move tasks. + +import 'models.dart'; +import 'occupancy_policy.dart'; + +/// High-level reminder directive for application/platform layers. +enum ReminderDirectiveAction { + deliver, + suppress, + defer, + requireAcknowledgement, +} + +/// Stable reason codes for reminder directives. +enum ReminderDirectiveReason { + deliverNormal, + requireRequiredAcknowledgement, + deferUntilTaskWindow, + deferRequiredDuringProtectedRest, + suppressSilentProfile, + suppressProtectedRest, + suppressNoScheduledWindow, + suppressUnsupportedTaskType, + suppressInactiveStatus, + suppressHiddenLockedTime, + suppressFreeSlotRecord, +} + +/// Result of resolving the effective reminder profile for one task. +class EffectiveReminderProfile { + const EffectiveReminderProfile({ + required this.profile, + required this.source, + }); + + /// Resolved reminder profile. + final ReminderProfile profile; + + /// Where [profile] came from. + final EffectiveReminderProfileSource source; +} + +/// Source for an effective reminder profile. +enum EffectiveReminderProfileSource { + taskOverride, + projectDefault, + fallback, +} + +/// UI/platform-independent directive for one task reminder check. +class ReminderDirective { + const ReminderDirective({ + required this.taskId, + required this.action, + required this.reason, + required this.effectiveProfile, + required this.effectiveProfileSource, + required this.evaluatedAt, + this.scheduledStart, + this.scheduledEnd, + this.protectedFreeSlotTaskId, + this.nextEligibleAt, + }); + + /// Task evaluated for reminder behavior. + final String taskId; + + /// What an application layer may do. + final ReminderDirectiveAction action; + + /// Stable reason code for tests, telemetry, and later UI copy. + final ReminderDirectiveReason reason; + + /// Effective reminder profile used for the decision. + final ReminderProfile effectiveProfile; + + /// Source of [effectiveProfile]. + final EffectiveReminderProfileSource effectiveProfileSource; + + /// Instant used to evaluate the directive. + final DateTime evaluatedAt; + + /// Task scheduled start, when present. + final DateTime? scheduledStart; + + /// Task scheduled end, when present. + final DateTime? scheduledEnd; + + /// Active protected Free Slot that affected the decision, when any. + final String? protectedFreeSlotTaskId; + + /// Next known time the application may re-check, when deterministic. + final DateTime? nextEligibleAt; +} + +/// 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/test/reminder_policy_test.dart b/test/reminder_policy_test.dart new file mode 100644 index 0000000..3bd3053 --- /dev/null +++ b/test/reminder_policy_test.dart @@ -0,0 +1,320 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Effective reminder profile resolution', () { + const service = ReminderPolicyService(); + final now = DateTime.utc(2026, 6, 25, 9); + + test('uses task override before project default and fallback', () { + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'green', + defaultReminderProfile: ReminderProfile.gentle, + ); + final task = scheduledTask( + id: 'task', + now: now, + reminderOverride: ReminderProfile.strict, + ); + + final effective = service.resolveEffectiveProfile( + task: task, + project: project, + ); + + expect(effective.profile, ReminderProfile.strict); + expect(effective.source, EffectiveReminderProfileSource.taskOverride); + }); + + test('uses project default before application fallback', () { + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'green', + defaultReminderProfile: ReminderProfile.persistent, + ); + final task = scheduledTask(id: 'task', now: now); + + final effective = service.resolveEffectiveProfile( + task: task, + project: project, + ); + + expect(effective.profile, ReminderProfile.persistent); + expect(effective.source, EffectiveReminderProfileSource.projectDefault); + }); + + test('uses fallback when no task or project value exists', () { + final task = scheduledTask(id: 'task', now: now); + + final effective = service.resolveEffectiveProfile(task: task); + + expect(effective.profile, ReminderProfile.gentle); + expect(effective.source, EffectiveReminderProfileSource.fallback); + }); + + test('does not silently use learned project suggestions', () { + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'green', + defaultReminderProfile: ReminderProfile.strict, + ); + final statistics = ProjectStatistics( + projectId: 'home', + completedTaskCount: 3, + reminderProfileCounts: const {ReminderProfile.gentle: 3}, + ); + final suggestionResolution = const ProjectSuggestionService() + .resolveDefaults(project: project, statistics: statistics); + final task = scheduledTask(id: 'task', now: now); + + final effective = service.resolveEffectiveProfile( + task: task, + project: suggestionResolution.project, + ); + + expect(suggestionResolution.suggestions.reminderProfile!.value, + ReminderProfile.gentle); + expect(effective.profile, ReminderProfile.strict); + expect(effective.source, EffectiveReminderProfileSource.projectDefault); + }); + }); + + group('Reminder directives', () { + const service = ReminderPolicyService(); + final now = DateTime.utc(2026, 6, 25, 9); + + test('all non-silent reminder profiles produce normal flexible delivery', + () { + for (final profile in [ + ReminderProfile.gentle, + ReminderProfile.persistent, + ReminderProfile.strict, + ]) { + final directive = service.directiveForTask( + task: scheduledTask( + id: 'task-${profile.name}', + now: now, + reminderOverride: profile, + ), + now: now, + ); + + expect(directive.action, ReminderDirectiveAction.deliver); + expect(directive.reason, ReminderDirectiveReason.deliverNormal); + expect(directive.effectiveProfile, profile); + } + }); + + test('silent profile suppresses normal reminders', () { + final directive = service.directiveForTask( + task: scheduledTask( + id: 'task', + now: now, + reminderOverride: ReminderProfile.silent, + ), + now: now, + ); + + expect(directive.action, ReminderDirectiveAction.suppress); + expect(directive.reason, ReminderDirectiveReason.suppressSilentProfile); + }); + + test( + 'defers before the scheduled window and delivers at the start boundary', + () { + final task = scheduledTask( + id: 'task', + now: now, + start: DateTime.utc(2026, 6, 25, 10), + ); + + final before = service.directiveForTask( + task: task, + now: DateTime.utc(2026, 6, 25, 9, 59), + ); + final atStart = service.directiveForTask( + task: task, + now: DateTime.utc(2026, 6, 25, 10), + ); + + expect(before.action, ReminderDirectiveAction.defer); + expect(before.reason, ReminderDirectiveReason.deferUntilTaskWindow); + expect(before.nextEligibleAt, DateTime.utc(2026, 6, 25, 10)); + expect(atStart.action, ReminderDirectiveAction.deliver); + }); + + test('suppresses flexible reminders during active protected Free Slots', + () { + final freeSlot = freeSlotTask( + id: 'rest', + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + createdAt: now, + ); + final flexible = scheduledTask(id: 'flexible', now: now); + + final atStart = service.directiveForTask( + task: flexible, + now: DateTime.utc(2026, 6, 25, 9), + contextTasks: [freeSlot, flexible], + ); + final inside = service.directiveForTask( + task: flexible, + now: DateTime.utc(2026, 6, 25, 9, 29), + contextTasks: [freeSlot, flexible], + ); + final atEnd = service.directiveForTask( + task: flexible, + now: DateTime.utc(2026, 6, 25, 9, 30), + contextTasks: [freeSlot, flexible], + ); + + expect(atStart.action, ReminderDirectiveAction.suppress); + expect(atStart.reason, ReminderDirectiveReason.suppressProtectedRest); + expect(atStart.protectedFreeSlotTaskId, 'rest'); + expect(atStart.nextEligibleAt, DateTime.utc(2026, 6, 25, 9, 30)); + expect(inside.action, ReminderDirectiveAction.suppress); + expect(atEnd.action, ReminderDirectiveAction.deliver); + }); + + test('required reminders may pass protected rest by typed policy', () { + final freeSlot = freeSlotTask( + id: 'rest', + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 10), + createdAt: now, + ); + final critical = scheduledTask( + id: 'critical', + now: now, + type: TaskType.critical, + reminderOverride: ReminderProfile.strict, + ); + final inflexiblePersistent = scheduledTask( + id: 'inflexible-persistent', + now: now, + type: TaskType.inflexible, + reminderOverride: ReminderProfile.persistent, + ); + final inflexibleGentle = scheduledTask( + id: 'inflexible-gentle', + now: now, + type: TaskType.inflexible, + reminderOverride: ReminderProfile.gentle, + ); + + final criticalDirective = service.directiveForTask( + task: critical, + now: now, + contextTasks: [freeSlot, critical], + ); + final persistentDirective = service.directiveForTask( + task: inflexiblePersistent, + now: now, + contextTasks: [freeSlot, inflexiblePersistent], + ); + final gentleDirective = service.directiveForTask( + task: inflexibleGentle, + now: now, + contextTasks: [freeSlot, inflexibleGentle], + ); + + expect( + criticalDirective.action, + ReminderDirectiveAction.requireAcknowledgement, + ); + expect( + criticalDirective.reason, + ReminderDirectiveReason.requireRequiredAcknowledgement, + ); + expect(persistentDirective.action, ReminderDirectiveAction.deliver); + expect(gentleDirective.action, ReminderDirectiveAction.defer); + expect( + gentleDirective.reason, + ReminderDirectiveReason.deferRequiredDuringProtectedRest, + ); + expect(gentleDirective.nextEligibleAt, DateTime.utc(2026, 6, 25, 10)); + }); + + test('suppresses locked free-slot completed and unscheduled records', () { + final locked = scheduledTask( + id: 'locked', + now: now, + type: TaskType.locked, + ); + final freeSlot = freeSlotTask( + id: 'rest', + start: now, + end: now.add(const Duration(minutes: 30)), + createdAt: now, + ); + final completed = scheduledTask( + id: 'completed', + now: now, + status: TaskStatus.completed, + ); + final unscheduled = scheduledTask(id: 'unscheduled', now: now) + .copyWith(clearSchedule: true); + + expect( + service.directiveForTask(task: locked, now: now).reason, + ReminderDirectiveReason.suppressHiddenLockedTime, + ); + expect( + service.directiveForTask(task: freeSlot, now: now).reason, + ReminderDirectiveReason.suppressFreeSlotRecord, + ); + expect( + service.directiveForTask(task: completed, now: now).reason, + ReminderDirectiveReason.suppressInactiveStatus, + ); + expect( + service.directiveForTask(task: unscheduled, now: now).reason, + ReminderDirectiveReason.suppressNoScheduledWindow, + ); + }); + }); +} + +Task scheduledTask({ + required String id, + required DateTime now, + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.planned, + ReminderProfile? reminderOverride, + DateTime? start, +}) { + final scheduledStart = start ?? now; + return Task( + id: id, + title: 'Task', + projectId: 'home', + type: type, + status: status, + durationMinutes: 30, + scheduledStart: scheduledStart, + scheduledEnd: scheduledStart.add(const Duration(minutes: 30)), + reminderOverride: reminderOverride, + createdAt: now, + updatedAt: now, + ); +} + +Task freeSlotTask({ + required String id, + required DateTime start, + required DateTime end, + required DateTime createdAt, +}) { + return const FreeSlotService().create( + id: id, + title: 'Rest', + start: start, + end: end, + createdAt: createdAt, + ); +}