Skip to content

Commit 99c6e5e

Browse files
authored
Merge pull request #576 from DevKor-github/feature/issue-535-deepen-use-cases
Draft: Deepen schedule preparation session use case
2 parents a9139b2 + 27152de commit 99c6e5e

7 files changed

Lines changed: 1534 additions & 357 deletions

File tree

CONTEXT.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ _Avoid_: Event, appointment record
9393
An ordered set of steps and durations used to get ready for a Schedule.
9494
_Avoid_: Routine, prep checklist
9595

96+
**Preparation Step**:
97+
One named action in a Preparation with its own expected duration.
98+
_Avoid_: Task, todo, subroutine
99+
100+
**Preparation Start Moment**:
101+
The intended time for the user to begin Preparation so the Schedule can still be met on time.
102+
_Avoid_: Alarm time, notification time, wake-up time
103+
104+
**Schedule Preparation Session**:
105+
The active period when a user is working through Preparation for one Schedule.
106+
_Avoid_: Schedule run, preparation runtime, alarm session
107+
108+
**Early Start Session**:
109+
A Schedule Preparation Session that the user starts before the Preparation Start Moment.
110+
_Avoid_: Manual start, premature alarm, early alarm
111+
96112
**Preparation Duration**:
97113
The sum of a Schedule's Preparation step durations, excluding move time and Schedule Spare Time.
98114
_Avoid_: Total duration, travel time, buffer time
@@ -216,6 +232,12 @@ _Avoid_: Loaded range, stream range, cached range
216232
- A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing.
217233
- **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs.
218234
- 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.
235+
- A **Schedule** has one active **Preparation** selection.
236+
- A **Preparation** contains one or more ordered **Preparation Steps**.
237+
- A **Preparation Start Moment** is derived from the **Schedule**, **Preparation**, movement time, and spare time.
238+
- A **Schedule Preparation Session** belongs to exactly one **Schedule**.
239+
- An **Early Start Session** is a **Schedule Preparation Session** started before the **Preparation Start Moment**.
240+
- A **Schedule Notification** may prompt the user to begin a **Schedule Preparation Session** at the **Preparation Start Moment**.
219241
- The profile setting for upcoming schedule preparation delivery should be called **Schedule Notification Setting**.
220242
- On iOS, user-facing copy may say **Alarm** only when OnTime can deliver an **iOS AlarmKit Alarm**.
221243
- iOS permission prompts should use alarm language only when requesting an **iOS AlarmKit Alarm**; otherwise they should use notification language.
@@ -259,6 +281,9 @@ _Avoid_: Loaded range, stream range, cached range
259281

260282
> **Dev:** "Should the analytics event include the schedule note so we can understand why users are late?"
261283
> **Domain expert:** "No. A **Product Usage Event** can say a schedule was finished late, but it must not include the user's raw note."
284+
>
285+
> **Dev:** "If the user taps Start before the scheduled notification, is that a separate schedule run?"
286+
> **Domain expert:** "No - it is an **Early Start Session**, which is still the **Schedule Preparation Session** for that **Schedule**."
262287
263288
> **Dev:** "Can a **Schedule Notification** replace the **Schedule** if the user taps it?"
264289
> **Domain expert:** "No. The **Schedule Notification** only prompts preparation for the **Schedule**; the **Schedule** remains the planned commitment."
@@ -275,6 +300,7 @@ _Avoid_: Loaded range, stream range, cached range
275300
- "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**.
276301
- "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only.
277302
- "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only.
303+
- "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**.
278304
- "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**.
279305
- "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.
280306
- "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.
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import 'dart:async';
2+
3+
import 'package:injectable/injectable.dart';
4+
import 'package:on_time_front/domain/entities/early_start_session_entity.dart';
5+
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
6+
import 'package:on_time_front/domain/entities/preparation_entity.dart';
7+
import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart';
8+
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
9+
import 'package:on_time_front/domain/entities/schedule_entity.dart';
10+
import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart';
11+
import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart';
12+
import 'package:on_time_front/domain/repositories/early_start_session_repository.dart';
13+
import 'package:on_time_front/domain/repositories/preparation_repository.dart';
14+
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
15+
import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart';
16+
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
17+
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
18+
19+
enum SchedulePreparationPromptStatus { ready, rejected, unavailable }
20+
21+
typedef RestoredSessionCallback =
22+
void Function({
23+
required DateTime? startedAt,
24+
required List<PreparationActionEventEntity> actionEvents,
25+
});
26+
27+
class SchedulePreparationPromptResult {
28+
const SchedulePreparationPromptResult._({
29+
required this.status,
30+
this.schedule,
31+
});
32+
33+
const SchedulePreparationPromptResult.ready(
34+
ScheduleWithPreparationEntity schedule,
35+
) : this._(status: SchedulePreparationPromptStatus.ready, schedule: schedule);
36+
37+
const SchedulePreparationPromptResult.rejected()
38+
: this._(status: SchedulePreparationPromptStatus.rejected);
39+
40+
const SchedulePreparationPromptResult.unavailable()
41+
: this._(status: SchedulePreparationPromptStatus.unavailable);
42+
43+
final SchedulePreparationPromptStatus status;
44+
final ScheduleWithPreparationEntity? schedule;
45+
}
46+
47+
@Singleton()
48+
class SchedulePreparationSessionUseCase {
49+
SchedulePreparationSessionUseCase(
50+
this._scheduleRepository,
51+
this._preparationRepository,
52+
this._timedPreparationRepository,
53+
this._earlyStartSessionRepository,
54+
this._cancelScheduleAlarmUseCase,
55+
this._reconcileAlarmsUseCase,
56+
);
57+
58+
final ScheduleRepository _scheduleRepository;
59+
final PreparationRepository _preparationRepository;
60+
final TimedPreparationRepository _timedPreparationRepository;
61+
final EarlyStartSessionRepository _earlyStartSessionRepository;
62+
final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase;
63+
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
64+
final Set<String> _startedScheduleIds = {};
65+
66+
Future<void> startEarlySession(
67+
ScheduleWithPreparationEntity schedule, {
68+
required DateTime startedAt,
69+
}) async {
70+
await _earlyStartSessionRepository.markStarted(
71+
scheduleId: schedule.id,
72+
startedAt: startedAt,
73+
);
74+
await startSchedulePreparation(schedule.id);
75+
await _cancelScheduleAlarmUseCase(schedule.id);
76+
await saveTimedPreparationSnapshot(
77+
schedule,
78+
savedAt: startedAt,
79+
startedAt: startedAt,
80+
actionEvents: const [],
81+
);
82+
}
83+
84+
Future<void> startSchedulePreparation(String scheduleId) async {
85+
if (!_startedScheduleIds.add(scheduleId)) return;
86+
await _scheduleRepository.startSchedule(scheduleId);
87+
}
88+
89+
Future<bool> hasEarlyStartSession(String scheduleId) async {
90+
return await _earlyStartSessionRepository.getSession(scheduleId) != null;
91+
}
92+
93+
Future<EarlyStartSessionEntity?> getEarlyStartSession(String scheduleId) {
94+
return _earlyStartSessionRepository.getSession(scheduleId);
95+
}
96+
97+
Future<void> saveTimedPreparationSnapshot(
98+
ScheduleWithPreparationEntity schedule, {
99+
DateTime? savedAt,
100+
DateTime? startedAt,
101+
List<PreparationActionEventEntity> actionEvents = const [],
102+
}) {
103+
final snapshot = TimedPreparationSnapshotEntity(
104+
preparation: schedule.preparation,
105+
savedAt: savedAt ?? DateTime.now(),
106+
scheduleFingerprint: schedule.cacheFingerprint,
107+
startedAt: startedAt,
108+
actionEvents: actionEvents,
109+
);
110+
return _timedPreparationRepository.saveTimedPreparationSnapshot(
111+
schedule.id,
112+
snapshot,
113+
);
114+
}
115+
116+
Future<ScheduleWithPreparationEntity> restoreTimedPreparationIfValid(
117+
ScheduleWithPreparationEntity schedule, {
118+
required DateTime now,
119+
RestoredSessionCallback? onRestoredSession,
120+
}) async {
121+
final snapshot = await _timedPreparationRepository
122+
.getTimedPreparationSnapshot(schedule.id);
123+
if (snapshot == null) return schedule;
124+
if (snapshot.scheduleFingerprint != schedule.cacheFingerprint &&
125+
!_canRestoreAcrossFingerprintMismatch(snapshot, schedule)) {
126+
await clearPersistedState(schedule.id);
127+
return schedule;
128+
}
129+
130+
final startedAt = snapshot.startedAt;
131+
onRestoredSession?.call(
132+
startedAt: startedAt,
133+
actionEvents: snapshot.actionEvents,
134+
);
135+
final restoredPreparation = startedAt == null
136+
? _restoreElapsedSnapshot(snapshot, now)
137+
: _derivePreparationRun(
138+
schedule.preparation,
139+
startedAt: startedAt,
140+
actionEvents: snapshot.actionEvents,
141+
now: now,
142+
);
143+
144+
return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity(
145+
schedule,
146+
restoredPreparation,
147+
);
148+
}
149+
150+
Future<void> clearPersistedState(String scheduleId) async {
151+
await _timedPreparationRepository.clearTimedPreparation(scheduleId);
152+
await _earlyStartSessionRepository.clear(scheduleId);
153+
_startedScheduleIds.remove(scheduleId);
154+
}
155+
156+
Future<void> finishSchedulePreparation(
157+
String scheduleId, {
158+
required int latenessTime,
159+
}) async {
160+
await startSchedulePreparation(scheduleId);
161+
await _scheduleRepository.finishSchedule(scheduleId, latenessTime);
162+
await _cancelScheduleAlarmUseCase(scheduleId);
163+
await clearPersistedState(scheduleId);
164+
unawaited(_reconcileAlarmsUseCase());
165+
}
166+
167+
Future<SchedulePreparationPromptResult> resolvePromptedSchedule({
168+
required String scheduleId,
169+
required bool startPreparation,
170+
String? scheduleFingerprint,
171+
}) async {
172+
try {
173+
final schedule = await _scheduleRepository.getScheduleById(scheduleId);
174+
if (_isEnded(schedule.doneStatus)) {
175+
await _cancelScheduleAlarmUseCase(scheduleId);
176+
return const SchedulePreparationPromptResult.rejected();
177+
}
178+
179+
final preparationFuture = _preparationRepository.preparationStream
180+
.map((preparations) => preparations[scheduleId])
181+
.where((preparation) => preparation != null)
182+
.cast<PreparationEntity>()
183+
.first;
184+
await _preparationRepository.getPreparationByScheduleId(scheduleId);
185+
final preparation = await preparationFuture;
186+
final scheduleWithPreparation =
187+
ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity(
188+
schedule,
189+
PreparationWithTimeEntity.fromPreparation(preparation),
190+
);
191+
192+
if (scheduleFingerprint != null &&
193+
scheduleFingerprint != scheduleWithPreparation.cacheFingerprint &&
194+
!startPreparation) {
195+
await _cancelScheduleAlarmUseCase(scheduleId);
196+
return const SchedulePreparationPromptResult.rejected();
197+
}
198+
199+
return SchedulePreparationPromptResult.ready(scheduleWithPreparation);
200+
} catch (_) {
201+
if (startPreparation) {
202+
return const SchedulePreparationPromptResult.unavailable();
203+
}
204+
await _cancelScheduleAlarmUseCase(scheduleId);
205+
return const SchedulePreparationPromptResult.rejected();
206+
}
207+
}
208+
209+
bool _isEnded(ScheduleDoneStatus doneStatus) {
210+
return doneStatus == ScheduleDoneStatus.normalEnd ||
211+
doneStatus == ScheduleDoneStatus.lateEnd ||
212+
doneStatus == ScheduleDoneStatus.abnormalEnd;
213+
}
214+
215+
PreparationWithTimeEntity _restoreElapsedSnapshot(
216+
TimedPreparationSnapshotEntity snapshot,
217+
DateTime now,
218+
) {
219+
final elapsedSinceSave = now.difference(snapshot.savedAt);
220+
return elapsedSinceSave.isNegative
221+
? snapshot.preparation
222+
: snapshot.preparation.timeElapsed(elapsedSinceSave);
223+
}
224+
225+
PreparationWithTimeEntity _derivePreparationRun(
226+
PreparationWithTimeEntity source, {
227+
required DateTime startedAt,
228+
required List<PreparationActionEventEntity> actionEvents,
229+
required DateTime now,
230+
}) {
231+
var preparation = _resetPreparationProgress(source);
232+
var cursor = startedAt;
233+
final orderedEvents =
234+
actionEvents.where((event) => !event.occurredAt.isAfter(now)).toList()
235+
..sort((a, b) => a.occurredAt.compareTo(b.occurredAt));
236+
237+
for (final event in orderedEvents) {
238+
if (event.occurredAt.isAfter(cursor)) {
239+
preparation = preparation.timeElapsed(
240+
event.occurredAt.difference(cursor),
241+
);
242+
cursor = event.occurredAt;
243+
}
244+
245+
switch (event.type) {
246+
case PreparationActionEventType.start:
247+
cursor = event.occurredAt;
248+
case PreparationActionEventType.skipStep:
249+
preparation = _skipCurrentStepForEvent(preparation, event);
250+
cursor = event.occurredAt;
251+
case PreparationActionEventType.finish:
252+
return preparation.timeElapsed(now.difference(cursor));
253+
}
254+
}
255+
256+
if (now.isAfter(cursor)) {
257+
preparation = preparation.timeElapsed(now.difference(cursor));
258+
}
259+
return preparation;
260+
}
261+
262+
PreparationWithTimeEntity _resetPreparationProgress(
263+
PreparationWithTimeEntity source,
264+
) {
265+
return PreparationWithTimeEntity(
266+
preparationStepList: [
267+
for (final step in source.preparationStepList)
268+
PreparationStepWithTimeEntity(
269+
id: step.id,
270+
preparationName: step.preparationName,
271+
preparationTime: step.preparationTime,
272+
nextPreparationId: step.nextPreparationId,
273+
),
274+
],
275+
);
276+
}
277+
278+
PreparationWithTimeEntity _skipCurrentStepForEvent(
279+
PreparationWithTimeEntity preparation,
280+
PreparationActionEventEntity event,
281+
) {
282+
final current = preparation.currentStep;
283+
if (current == null) {
284+
return preparation;
285+
}
286+
final eventStepId = event.stepId;
287+
final eventStepStillExists =
288+
eventStepId != null &&
289+
preparation.preparationStepList.any((step) => step.id == eventStepId);
290+
if (eventStepStillExists && current.id != eventStepId) {
291+
return preparation;
292+
}
293+
return preparation.copyWith(
294+
preparationStepList: [
295+
for (final step in preparation.preparationStepList)
296+
step.id == current.id ? step.copyWith(isDone: true) : step,
297+
],
298+
);
299+
}
300+
301+
bool _canRestoreAcrossFingerprintMismatch(
302+
TimedPreparationSnapshotEntity snapshot,
303+
ScheduleWithPreparationEntity schedule,
304+
) {
305+
return _scheduleTimingFingerprintPrefix(snapshot.scheduleFingerprint) ==
306+
_scheduleTimingFingerprintPrefix(schedule.cacheFingerprint) &&
307+
_hasSamePreparationShape(snapshot.preparation, schedule.preparation);
308+
}
309+
310+
String _scheduleTimingFingerprintPrefix(String fingerprint) {
311+
final parts = fingerprint.split('|');
312+
if (parts.length < 4) {
313+
return fingerprint;
314+
}
315+
return '${parts[0]}|${parts[1]}|${parts[2]}|';
316+
}
317+
318+
bool _hasSamePreparationShape(
319+
PreparationWithTimeEntity left,
320+
PreparationWithTimeEntity right,
321+
) {
322+
final leftSteps = left.preparationStepList;
323+
final rightSteps = right.preparationStepList;
324+
if (leftSteps.length != rightSteps.length) {
325+
return false;
326+
}
327+
for (var index = 0; index < leftSteps.length; index++) {
328+
final leftStep = leftSteps[index];
329+
final rightStep = rightSteps[index];
330+
if (leftStep.preparationName != rightStep.preparationName ||
331+
leftStep.preparationTime != rightStep.preparationTime) {
332+
return false;
333+
}
334+
}
335+
return true;
336+
}
337+
}

0 commit comments

Comments
 (0)