feat(reminders): add protected rest reminder policy

This commit is contained in:
Ashley Venn 2026-06-25 10:08:48 -07:00
parent 374907a281
commit edfb0cbba5
8 changed files with 695 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
}

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