Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ _Avoid_: Event, appointment record
An ordered set of steps and durations used to get ready for a Schedule.
_Avoid_: Routine, prep checklist

**Preparation Step**:
One named action in a Preparation with its own expected duration.
_Avoid_: Task, todo, subroutine

**Preparation Start Moment**:
The intended time for the user to begin Preparation so the Schedule can still be met on time.
_Avoid_: Alarm time, notification time, wake-up time

**Schedule Preparation Session**:
The active period when a user is working through Preparation for one Schedule.
_Avoid_: Schedule run, preparation runtime, alarm session

**Early Start Session**:
A Schedule Preparation Session that the user starts before the Preparation Start Moment.
_Avoid_: Manual start, premature alarm, early alarm

**Preparation Duration**:
The sum of a Schedule's Preparation step durations, excluding move time and Schedule Spare Time.
_Avoid_: Total duration, travel time, buffer time
Expand Down Expand Up @@ -216,6 +232,12 @@ _Avoid_: Loaded range, stream range, cached range
- A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing.
- **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs.
- User-facing copy should call a scheduled notification a **Schedule Notification**, not an **Alarm**, unless it opens an OnTime screen without the user first tapping a notification.
- A **Schedule** has one active **Preparation** selection.
- A **Preparation** contains one or more ordered **Preparation Steps**.
- A **Preparation Start Moment** is derived from the **Schedule**, **Preparation**, movement time, and spare time.
- A **Schedule Preparation Session** belongs to exactly one **Schedule**.
- An **Early Start Session** is a **Schedule Preparation Session** started before the **Preparation Start Moment**.
- A **Schedule Notification** may prompt the user to begin a **Schedule Preparation Session** at the **Preparation Start Moment**.
- The profile setting for upcoming schedule preparation delivery should be called **Schedule Notification Setting**.
- On iOS, user-facing copy may say **Alarm** only when OnTime can deliver an **iOS AlarmKit Alarm**.
- iOS permission prompts should use alarm language only when requesting an **iOS AlarmKit Alarm**; otherwise they should use notification language.
Expand Down Expand Up @@ -259,6 +281,9 @@ _Avoid_: Loaded range, stream range, cached range

> **Dev:** "Should the analytics event include the schedule note so we can understand why users are late?"
> **Domain expert:** "No. A **Product Usage Event** can say a schedule was finished late, but it must not include the user's raw note."
>
> **Dev:** "If the user taps Start before the scheduled notification, is that a separate schedule run?"
> **Domain expert:** "No - it is an **Early Start Session**, which is still the **Schedule Preparation Session** for that **Schedule**."

> **Dev:** "Can a **Schedule Notification** replace the **Schedule** if the user taps it?"
> **Domain expert:** "No. The **Schedule Notification** only prompts preparation for the **Schedule**; the **Schedule** remains the planned commitment."
Expand All @@ -275,6 +300,7 @@ _Avoid_: Loaded range, stream range, cached range
- "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**.
- "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only.
- "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only.
- "Schedule preparation session" was implicit in code but not in the glossary; resolved: the canonical term is **Schedule Preparation Session**, with **Early Start Session** for sessions started before the **Preparation Start Moment**.
- "Login completed" was ambiguous for Apple and Google sign-in; resolved: external account prompt completion is **Provider Authentication Completed**, while usable OnTime sign-in is **OnTime Session Established**.
- "Preparation" was ambiguous between the user's fallback steps and a schedule-specific edited set; resolved: use **Default Preparation** for the fallback and **Custom Preparation** for the schedule-specific version.
- "Alarm permission" was ambiguous between **Exact Timing Permission** and notification permission; resolved: notification permission may enable a **Fallback Notification**, but does not mean **Exact Timing Permission** is granted.
Expand Down
337 changes: 337 additions & 0 deletions lib/domain/use-cases/schedule_preparation_session_use_case.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
import 'dart:async';

import 'package:injectable/injectable.dart';
import 'package:on_time_front/domain/entities/early_start_session_entity.dart';
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
import 'package:on_time_front/domain/entities/preparation_entity.dart';
import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart';
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
import 'package:on_time_front/domain/entities/schedule_entity.dart';
import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart';
import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart';
import 'package:on_time_front/domain/repositories/early_start_session_repository.dart';
import 'package:on_time_front/domain/repositories/preparation_repository.dart';
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart';
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';

enum SchedulePreparationPromptStatus { ready, rejected, unavailable }

typedef RestoredSessionCallback =
void Function({
required DateTime? startedAt,
required List<PreparationActionEventEntity> actionEvents,
});

class SchedulePreparationPromptResult {
const SchedulePreparationPromptResult._({
required this.status,
this.schedule,
});

const SchedulePreparationPromptResult.ready(
ScheduleWithPreparationEntity schedule,
) : this._(status: SchedulePreparationPromptStatus.ready, schedule: schedule);

const SchedulePreparationPromptResult.rejected()
: this._(status: SchedulePreparationPromptStatus.rejected);

const SchedulePreparationPromptResult.unavailable()
: this._(status: SchedulePreparationPromptStatus.unavailable);

final SchedulePreparationPromptStatus status;
final ScheduleWithPreparationEntity? schedule;
}

@Singleton()
class SchedulePreparationSessionUseCase {
SchedulePreparationSessionUseCase(
this._scheduleRepository,
this._preparationRepository,
this._timedPreparationRepository,
this._earlyStartSessionRepository,
this._cancelScheduleAlarmUseCase,
this._reconcileAlarmsUseCase,
);

final ScheduleRepository _scheduleRepository;
final PreparationRepository _preparationRepository;
final TimedPreparationRepository _timedPreparationRepository;
final EarlyStartSessionRepository _earlyStartSessionRepository;
final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase;
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
final Set<String> _startedScheduleIds = {};

Future<void> startEarlySession(
ScheduleWithPreparationEntity schedule, {
required DateTime startedAt,
}) async {
await _earlyStartSessionRepository.markStarted(
scheduleId: schedule.id,
startedAt: startedAt,
);
await startSchedulePreparation(schedule.id);
await _cancelScheduleAlarmUseCase(schedule.id);
await saveTimedPreparationSnapshot(
schedule,
savedAt: startedAt,
startedAt: startedAt,
actionEvents: const [],
);
}

Future<void> startSchedulePreparation(String scheduleId) async {
if (!_startedScheduleIds.add(scheduleId)) return;
await _scheduleRepository.startSchedule(scheduleId);
}

Future<bool> hasEarlyStartSession(String scheduleId) async {
return await _earlyStartSessionRepository.getSession(scheduleId) != null;
}

Future<EarlyStartSessionEntity?> getEarlyStartSession(String scheduleId) {
return _earlyStartSessionRepository.getSession(scheduleId);
}

Future<void> saveTimedPreparationSnapshot(
ScheduleWithPreparationEntity schedule, {
DateTime? savedAt,
DateTime? startedAt,
List<PreparationActionEventEntity> actionEvents = const [],
}) {
final snapshot = TimedPreparationSnapshotEntity(
preparation: schedule.preparation,
savedAt: savedAt ?? DateTime.now(),
scheduleFingerprint: schedule.cacheFingerprint,
startedAt: startedAt,
actionEvents: actionEvents,
);
return _timedPreparationRepository.saveTimedPreparationSnapshot(
schedule.id,
snapshot,
);
}

Future<ScheduleWithPreparationEntity> restoreTimedPreparationIfValid(
ScheduleWithPreparationEntity schedule, {
required DateTime now,
RestoredSessionCallback? onRestoredSession,
}) async {
final snapshot = await _timedPreparationRepository
.getTimedPreparationSnapshot(schedule.id);
if (snapshot == null) return schedule;
if (snapshot.scheduleFingerprint != schedule.cacheFingerprint &&
!_canRestoreAcrossFingerprintMismatch(snapshot, schedule)) {
await clearPersistedState(schedule.id);
return schedule;
}

final startedAt = snapshot.startedAt;
onRestoredSession?.call(
startedAt: startedAt,
actionEvents: snapshot.actionEvents,
);
final restoredPreparation = startedAt == null
? _restoreElapsedSnapshot(snapshot, now)
: _derivePreparationRun(
schedule.preparation,
startedAt: startedAt,
actionEvents: snapshot.actionEvents,
now: now,
);

return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity(
schedule,
restoredPreparation,
);
}

Future<void> clearPersistedState(String scheduleId) async {
await _timedPreparationRepository.clearTimedPreparation(scheduleId);
await _earlyStartSessionRepository.clear(scheduleId);
_startedScheduleIds.remove(scheduleId);
}

Future<void> finishSchedulePreparation(
String scheduleId, {
required int latenessTime,
}) async {
await startSchedulePreparation(scheduleId);
await _scheduleRepository.finishSchedule(scheduleId, latenessTime);
await _cancelScheduleAlarmUseCase(scheduleId);
await clearPersistedState(scheduleId);
unawaited(_reconcileAlarmsUseCase());
}

Future<SchedulePreparationPromptResult> resolvePromptedSchedule({
required String scheduleId,
required bool startPreparation,
String? scheduleFingerprint,
}) async {
try {
final schedule = await _scheduleRepository.getScheduleById(scheduleId);
if (_isEnded(schedule.doneStatus)) {
await _cancelScheduleAlarmUseCase(scheduleId);
return const SchedulePreparationPromptResult.rejected();
}

final preparationFuture = _preparationRepository.preparationStream
.map((preparations) => preparations[scheduleId])
.where((preparation) => preparation != null)
.cast<PreparationEntity>()
.first;
await _preparationRepository.getPreparationByScheduleId(scheduleId);
final preparation = await preparationFuture;
final scheduleWithPreparation =
ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity(
schedule,
PreparationWithTimeEntity.fromPreparation(preparation),
);

if (scheduleFingerprint != null &&
scheduleFingerprint != scheduleWithPreparation.cacheFingerprint &&
!startPreparation) {
await _cancelScheduleAlarmUseCase(scheduleId);
return const SchedulePreparationPromptResult.rejected();
}

return SchedulePreparationPromptResult.ready(scheduleWithPreparation);
} catch (_) {
if (startPreparation) {
return const SchedulePreparationPromptResult.unavailable();
}
await _cancelScheduleAlarmUseCase(scheduleId);
return const SchedulePreparationPromptResult.rejected();
}
}

bool _isEnded(ScheduleDoneStatus doneStatus) {
return doneStatus == ScheduleDoneStatus.normalEnd ||
doneStatus == ScheduleDoneStatus.lateEnd ||
doneStatus == ScheduleDoneStatus.abnormalEnd;
}

PreparationWithTimeEntity _restoreElapsedSnapshot(
TimedPreparationSnapshotEntity snapshot,
DateTime now,
) {
final elapsedSinceSave = now.difference(snapshot.savedAt);
return elapsedSinceSave.isNegative
? snapshot.preparation
: snapshot.preparation.timeElapsed(elapsedSinceSave);
}

PreparationWithTimeEntity _derivePreparationRun(
PreparationWithTimeEntity source, {
required DateTime startedAt,
required List<PreparationActionEventEntity> actionEvents,
required DateTime now,
}) {
var preparation = _resetPreparationProgress(source);
var cursor = startedAt;
final orderedEvents =
actionEvents.where((event) => !event.occurredAt.isAfter(now)).toList()
..sort((a, b) => a.occurredAt.compareTo(b.occurredAt));

for (final event in orderedEvents) {
if (event.occurredAt.isAfter(cursor)) {
preparation = preparation.timeElapsed(
event.occurredAt.difference(cursor),
);
cursor = event.occurredAt;
}

switch (event.type) {
case PreparationActionEventType.start:
cursor = event.occurredAt;
case PreparationActionEventType.skipStep:
preparation = _skipCurrentStepForEvent(preparation, event);
cursor = event.occurredAt;
case PreparationActionEventType.finish:
return preparation.timeElapsed(now.difference(cursor));
}
}

if (now.isAfter(cursor)) {
preparation = preparation.timeElapsed(now.difference(cursor));
}
return preparation;
}

PreparationWithTimeEntity _resetPreparationProgress(
PreparationWithTimeEntity source,
) {
return PreparationWithTimeEntity(
preparationStepList: [
for (final step in source.preparationStepList)
PreparationStepWithTimeEntity(
id: step.id,
preparationName: step.preparationName,
preparationTime: step.preparationTime,
nextPreparationId: step.nextPreparationId,
),
],
);
}

PreparationWithTimeEntity _skipCurrentStepForEvent(
PreparationWithTimeEntity preparation,
PreparationActionEventEntity event,
) {
final current = preparation.currentStep;
if (current == null) {
return preparation;
}
final eventStepId = event.stepId;
final eventStepStillExists =
eventStepId != null &&
preparation.preparationStepList.any((step) => step.id == eventStepId);
if (eventStepStillExists && current.id != eventStepId) {
return preparation;
}
return preparation.copyWith(
preparationStepList: [
for (final step in preparation.preparationStepList)
step.id == current.id ? step.copyWith(isDone: true) : step,
],
);
}

bool _canRestoreAcrossFingerprintMismatch(
TimedPreparationSnapshotEntity snapshot,
ScheduleWithPreparationEntity schedule,
) {
return _scheduleTimingFingerprintPrefix(snapshot.scheduleFingerprint) ==
_scheduleTimingFingerprintPrefix(schedule.cacheFingerprint) &&
_hasSamePreparationShape(snapshot.preparation, schedule.preparation);
}

String _scheduleTimingFingerprintPrefix(String fingerprint) {
final parts = fingerprint.split('|');
if (parts.length < 4) {
return fingerprint;
}
return '${parts[0]}|${parts[1]}|${parts[2]}|';
}

bool _hasSamePreparationShape(
PreparationWithTimeEntity left,
PreparationWithTimeEntity right,
) {
final leftSteps = left.preparationStepList;
final rightSteps = right.preparationStepList;
if (leftSteps.length != rightSteps.length) {
return false;
}
for (var index = 0; index < leftSteps.length; index++) {
final leftStep = leftSteps[index];
final rightStep = rightSteps[index];
if (leftStep.preparationName != rightStep.preparationName ||
leftStep.preparationTime != rightStep.preparationTime) {
return false;
}
}
return true;
}
}
Loading
Loading