forked from eva/focus-flow
feat(reminders): add protected rest reminder policy
This commit is contained in:
parent
374907a281
commit
edfb0cbba5
8 changed files with 695 additions and 6 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 13 — Lifecycle, Statistics, Project Defaults, and Reminder Policy
|
# 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,
|
Purpose: Centralize task transitions, update internal statistics exactly once,
|
||||||
add project-level learned suggestions, and expose reminder-policy decisions
|
add project-level learned suggestions, and expose reminder-policy decisions
|
||||||
|
|
@ -222,6 +222,8 @@ resolution.
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete on 2026-06-25.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Add an optional task-level reminder-profile override.
|
- Add an optional task-level reminder-profile override.
|
||||||
|
|
@ -258,6 +260,13 @@ Acceptance criteria:
|
||||||
- Silent reminders produce no delivery directive.
|
- Silent reminders produce no delivery directive.
|
||||||
- The core remains Flutter/platform independent.
|
- 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:
|
Commit suggestion:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -113,6 +113,9 @@ Effective reminder profile resolution is:
|
||||||
|
|
||||||
Free-slot suppression is not a reminder profile. It is a scheduling/rest policy
|
Free-slot suppression is not a reminder profile. It is a scheduling/rest policy
|
||||||
directive that can suppress normal flexible reminders during protected rest.
|
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
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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, free-slot suppression directive; platform delivery remains outside the pure core | 13 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ following source files:
|
||||||
- `src/persistence_contract.dart`
|
- `src/persistence_contract.dart`
|
||||||
- `src/project_statistics.dart`
|
- `src/project_statistics.dart`
|
||||||
- `src/quick_capture.dart`
|
- `src/quick_capture.dart`
|
||||||
|
- `src/reminder_policy.dart`
|
||||||
- `src/repositories.dart`
|
- `src/repositories.dart`
|
||||||
- `src/scheduling_engine.dart`
|
- `src/scheduling_engine.dart`
|
||||||
- `src/task_actions.dart`
|
- `src/task_actions.dart`
|
||||||
|
|
@ -45,6 +46,7 @@ changes from accidental API drift.
|
||||||
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
||||||
| `src/project_statistics.dart` | `ProjectCompletionTimeBucket`, `ProjectSuggestionType`, `ProjectSuggestionConfidence` |
|
| `src/project_statistics.dart` | `ProjectCompletionTimeBucket`, `ProjectSuggestionType`, `ProjectSuggestionConfidence` |
|
||||||
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
||||||
|
| `src/reminder_policy.dart` | `ReminderDirectiveAction`, `ReminderDirectiveReason`, `EffectiveReminderProfileSource` |
|
||||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
|
| `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
|
||||||
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
||||||
| `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` |
|
| `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` |
|
||||||
|
|
@ -65,6 +67,7 @@ changes from accidental API drift.
|
||||||
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
||||||
| `src/project_statistics.dart` | `ProjectStatistics`, `ProjectStatisticsApplicationResult`, `ProjectStatisticsAggregationService`, `ProjectSuggestionPolicy`, `ProjectSuggestion`, `ProjectSuggestionSet`, `ProjectDefaultResolution`, `ProjectSuggestionService` |
|
| `src/project_statistics.dart` | `ProjectStatistics`, `ProjectStatisticsApplicationResult`, `ProjectStatisticsAggregationService`, `ProjectSuggestionPolicy`, `ProjectSuggestion`, `ProjectSuggestionSet`, `ProjectDefaultResolution`, `ProjectSuggestionService` |
|
||||||
| `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` |
|
| `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` |
|
||||||
|
| `src/reminder_policy.dart` | `EffectiveReminderProfile`, `ReminderDirective`, `ReminderPolicyService` |
|
||||||
| `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` |
|
| `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` |
|
||||||
| `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` |
|
| `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` |
|
||||||
| `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` |
|
| `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
|
- Expanded completion activity metadata with duration, reward, difficulty, and
|
||||||
reminder-profile observations for project aggregation.
|
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
|
## Repository contracts
|
||||||
|
|
||||||
The current repository surface is pure Dart and in-memory only:
|
The current repository surface is pure Dart and in-memory only:
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ Latest Block 13.4 verification result on 2026-06-25:
|
||||||
- `dart test`: passed, 233 tests
|
- `dart test`: passed, 233 tests
|
||||||
- `git diff --check`: passed
|
- `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:
|
Status values:
|
||||||
|
|
||||||
- `complete`: Current backend APIs and tests cover the requirement.
|
- `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-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-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-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-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`, `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-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-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-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. |
|
| 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. |
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export 'src/occupancy_policy.dart';
|
||||||
export 'src/persistence_contract.dart';
|
export 'src/persistence_contract.dart';
|
||||||
export 'src/project_statistics.dart';
|
export 'src/project_statistics.dart';
|
||||||
export 'src/quick_capture.dart';
|
export 'src/quick_capture.dart';
|
||||||
|
export 'src/reminder_policy.dart';
|
||||||
export 'src/repositories.dart';
|
export 'src/repositories.dart';
|
||||||
export 'src/scheduling_engine.dart';
|
export 'src/scheduling_engine.dart';
|
||||||
export 'src/task_actions.dart';
|
export 'src/task_actions.dart';
|
||||||
|
|
|
||||||
334
lib/src/reminder_policy.dart
Normal file
334
lib/src/reminder_policy.dart
Normal file
|
|
@ -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<Task> contextTasks = const <Task>[],
|
||||||
|
}) {
|
||||||
|
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<Task> 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;
|
||||||
|
}
|
||||||
320
test/reminder_policy_test.dart
Normal file
320
test/reminder_policy_test.dart
Normal file
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue