Skip to content

Commit d8de44d

Browse files
authored
Merge pull request #577 from DevKor-github/feature/preparation
[codex] Restore preparation timers from action events
2 parents 4a5b0f1 + 4ed4e11 commit d8de44d

14 files changed

Lines changed: 702 additions & 37 deletions

CONTEXT.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ _Avoid_: Fallback, degraded, time sensitive
153153
The user-facing status for schedule delivery through an Alarm.
154154
_Avoid_: Notification, native alarm
155155

156+
**Preparation Run**:
157+
One user attempt to prepare for a scheduled commitment.
158+
_Avoid_: Timer session, countdown session
159+
160+
**Preparation Action Event**:
161+
A user-performed action that changes a Preparation Run.
162+
_Avoid_: Timer tick, automatic step transition, elapsed-time snapshot
163+
156164
**Schedule**:
157165
A user commitment with a planned time and preparation context in OnTime.
158166
_Avoid_: Appointment, event, task
@@ -209,6 +217,13 @@ _Avoid_: Loaded range, stream range, cached range
209217
- iOS with an **iOS AlarmKit Alarm** should show **Alarm Status**.
210218
- iOS without an available **iOS AlarmKit Alarm** but with notification permission should show **Notification Status**.
211219
- **No Scheduled Notification** should be the user-facing empty state across platforms, even when future delivery may use an **Alarm**.
220+
- A **Preparation Run** records **Preparation Action Events**, not timer ticks or automatic step transitions.
221+
- A **Preparation Action Event** may represent starting preparation, skipping a preparation step, or finishing preparation.
222+
- A **Preparation Run** begins when the user performs the starting **Preparation Action Event**.
223+
- A skipped preparation step ends at the skip **Preparation Action Event** time; its unused planned duration is not treated as elapsed preparation time.
224+
- The current step and completion state of a **Preparation Run** are derived from its starting action, user-performed **Preparation Action Events**, and the current time.
225+
- Automatic step transitions are derived states, not **Preparation Action Events**.
226+
- A **Preparation Run** must not outlive the scheduled commitment it belongs to.
212227
- Active first-release **Analytics Purposes** are product improvement, debugging and operations, and experimentation.
213228
- A first-release **Experiment** must not be used for marketing targeting, sensitive segmentation, or personalized treatment.
214229
- Marketing and personalization are **Deferred Analytics Purposes**.
@@ -260,5 +275,6 @@ _Avoid_: Loaded range, stream range, cached range
260275
- "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification.
261276
- "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**.
262277
- "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms.
278+
- "Step action events" was ambiguous as either user actions or automatic timer movement; resolved: the canonical term is **Preparation Action Event**, and automatic step transitions are derived rather than recorded as events.
263279
- "Preparation chain" describes storage reconstruction, not product language; resolved: the domain concept is an ordered **Preparation** made of **Preparation Steps**.
264280
- "Total duration" was ambiguous between **Preparation Duration** and the broader preparation-start timing calculation; resolved: **Preparation Duration** is steps only, while move time and **Schedule Spare Time** are separate inputs.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Derive Preparation Runs from Action Events
2+
3+
OnTime will model local preparation progress as a Preparation Run that starts at the user's start action and is adjusted by user-performed Preparation Action Events such as skipping a step or finishing preparation. The app will derive the current step, elapsed time, and completion state from the run start time, action events, current time, and the current schedule fingerprint instead of treating timer ticks or elapsed-time snapshots as the source of truth. This prevents sleep, background suspension, and delayed timers from making preparation progress drift while keeping the first implementation local to the app rather than expanding the backend API.
4+
5+
## Consequences
6+
7+
- Timer ticks may refresh UI state, but they must not be persisted as preparation history.
8+
- Automatic step transitions are derived during restore, resume, and tick refresh; they are not stored as events.
9+
- Stored Preparation Runs are valid only while the schedule fingerprint still matches and must be cleared when the schedule is finished or deleted.

lib/data/data_sources/preparation_with_time_local_data_source.dart

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import 'dart:convert';
22

33
import 'package:injectable/injectable.dart';
44
import 'package:shared_preferences/shared_preferences.dart';
5+
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
56
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
67
import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart';
78
import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart';
89

910
abstract interface class PreparationWithTimeLocalDataSource {
1011
Future<void> savePreparation(
11-
String scheduleId, TimedPreparationSnapshotEntity snapshot);
12+
String scheduleId,
13+
TimedPreparationSnapshotEntity snapshot,
14+
);
1215
Future<TimedPreparationSnapshotEntity?> loadPreparation(String scheduleId);
1316
Future<void> clearPreparation(String scheduleId);
1417
}
@@ -20,31 +23,46 @@ class PreparationWithTimeLocalDataSourceImpl
2023

2124
@override
2225
Future<void> savePreparation(
23-
String scheduleId, TimedPreparationSnapshotEntity snapshot) async {
26+
String scheduleId,
27+
TimedPreparationSnapshotEntity snapshot,
28+
) async {
2429
final prefs = await SharedPreferences.getInstance();
2530
final key = '$_prefsKeyPrefix$scheduleId';
2631

2732
final jsonMap = {
2833
'savedAt': snapshot.savedAt.millisecondsSinceEpoch,
34+
'startedAt': snapshot.startedAt?.millisecondsSinceEpoch,
2935
'scheduleFingerprint': snapshot.scheduleFingerprint,
36+
'actionEvents': snapshot.actionEvents
37+
.map(
38+
(event) => {
39+
'type': event.type.name,
40+
'occurredAt': event.occurredAt.millisecondsSinceEpoch,
41+
'stepId': event.stepId,
42+
},
43+
)
44+
.toList(),
3045
'steps': snapshot.preparation.preparationStepList
31-
.map((s) => {
32-
'id': s.id,
33-
'name': s.preparationName,
34-
'time': s.preparationTime.inMilliseconds,
35-
'nextId': s.nextPreparationId,
36-
'elapsed': s.elapsedTime.inMilliseconds,
37-
'isDone': s.isDone,
38-
})
39-
.toList()
46+
.map(
47+
(s) => {
48+
'id': s.id,
49+
'name': s.preparationName,
50+
'time': s.preparationTime.inMilliseconds,
51+
'nextId': s.nextPreparationId,
52+
'elapsed': s.elapsedTime.inMilliseconds,
53+
'isDone': s.isDone,
54+
},
55+
)
56+
.toList(),
4057
};
4158

4259
await prefs.setString(key, jsonEncode(jsonMap));
4360
}
4461

4562
@override
4663
Future<TimedPreparationSnapshotEntity?> loadPreparation(
47-
String scheduleId) async {
64+
String scheduleId,
65+
) async {
4866
final prefs = await SharedPreferences.getInstance();
4967
final key = '$_prefsKeyPrefix$scheduleId';
5068
final jsonString = prefs.getString(key);
@@ -67,15 +85,22 @@ class PreparationWithTimeLocalDataSourceImpl
6785
}).toList();
6886

6987
final savedAtMillis = (map['savedAt'] as num?)?.toInt();
88+
final startedAtMillis = (map['startedAt'] as num?)?.toInt();
7089
final scheduleFingerprint = map['scheduleFingerprint'] as String? ?? '';
90+
final actionEvents = _actionEventsFromJson(map['actionEvents']);
7191

7292
return TimedPreparationSnapshotEntity(
73-
preparation:
74-
PreparationWithTimeEntity(preparationStepList: stepEntities),
93+
preparation: PreparationWithTimeEntity(
94+
preparationStepList: stepEntities,
95+
),
7596
savedAt: savedAtMillis == null
7697
? DateTime.now()
7798
: DateTime.fromMillisecondsSinceEpoch(savedAtMillis),
99+
startedAt: startedAtMillis == null
100+
? null
101+
: DateTime.fromMillisecondsSinceEpoch(startedAtMillis),
78102
scheduleFingerprint: scheduleFingerprint,
103+
actionEvents: actionEvents,
79104
);
80105
} catch (_) {
81106
return null;
@@ -89,3 +114,26 @@ class PreparationWithTimeLocalDataSourceImpl
89114
await prefs.remove(key);
90115
}
91116
}
117+
118+
List<PreparationActionEventEntity> _actionEventsFromJson(Object? raw) {
119+
if (raw is! List<dynamic>) return const [];
120+
final events = <PreparationActionEventEntity>[];
121+
for (final item in raw) {
122+
if (item is! Map<String, dynamic>) continue;
123+
final typeName = item['type'] as String?;
124+
final occurredAtMillis = (item['occurredAt'] as num?)?.toInt();
125+
if (typeName == null || occurredAtMillis == null) continue;
126+
final type = PreparationActionEventType.values
127+
.where((value) => value.name == typeName)
128+
.firstOrNull;
129+
if (type == null) continue;
130+
events.add(
131+
PreparationActionEventEntity(
132+
type: type,
133+
occurredAt: DateTime.fromMillisecondsSinceEpoch(occurredAtMillis),
134+
stepId: item['stepId'] as String?,
135+
),
136+
);
137+
}
138+
return events;
139+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import 'package:equatable/equatable.dart';
2+
3+
enum PreparationActionEventType { start, skipStep, finish }
4+
5+
class PreparationActionEventEntity extends Equatable {
6+
const PreparationActionEventEntity({
7+
required this.type,
8+
required this.occurredAt,
9+
this.stepId,
10+
});
11+
12+
factory PreparationActionEventEntity.start({required DateTime occurredAt}) {
13+
return PreparationActionEventEntity(
14+
type: PreparationActionEventType.start,
15+
occurredAt: occurredAt,
16+
);
17+
}
18+
19+
factory PreparationActionEventEntity.skipStep({
20+
required String stepId,
21+
required DateTime occurredAt,
22+
}) {
23+
return PreparationActionEventEntity(
24+
type: PreparationActionEventType.skipStep,
25+
occurredAt: occurredAt,
26+
stepId: stepId,
27+
);
28+
}
29+
30+
factory PreparationActionEventEntity.finish({required DateTime occurredAt}) {
31+
return PreparationActionEventEntity(
32+
type: PreparationActionEventType.finish,
33+
occurredAt: occurredAt,
34+
);
35+
}
36+
37+
final PreparationActionEventType type;
38+
final DateTime occurredAt;
39+
final String? stepId;
40+
41+
@override
42+
List<Object?> get props => [type, occurredAt, stepId];
43+
}
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
11
import 'package:equatable/equatable.dart';
2+
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
23
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
34

45
class TimedPreparationSnapshotEntity extends Equatable {
56
const TimedPreparationSnapshotEntity({
67
required this.preparation,
78
required this.savedAt,
89
required this.scheduleFingerprint,
10+
this.startedAt,
11+
this.actionEvents = const [],
912
});
1013

1114
final PreparationWithTimeEntity preparation;
1215
final DateTime savedAt;
1316
final String scheduleFingerprint;
17+
final DateTime? startedAt;
18+
final List<PreparationActionEventEntity> actionEvents;
1419

1520
TimedPreparationSnapshotEntity copyWith({
1621
PreparationWithTimeEntity? preparation,
1722
DateTime? savedAt,
1823
String? scheduleFingerprint,
24+
DateTime? startedAt,
25+
List<PreparationActionEventEntity>? actionEvents,
1926
}) {
2027
return TimedPreparationSnapshotEntity(
2128
preparation: preparation ?? this.preparation,
2229
savedAt: savedAt ?? this.savedAt,
2330
scheduleFingerprint: scheduleFingerprint ?? this.scheduleFingerprint,
31+
startedAt: startedAt ?? this.startedAt,
32+
actionEvents: actionEvents ?? this.actionEvents,
2433
);
2534
}
2635

2736
@override
28-
List<Object?> get props => [preparation, savedAt, scheduleFingerprint];
37+
List<Object?> get props => [
38+
preparation,
39+
savedAt,
40+
scheduleFingerprint,
41+
startedAt,
42+
actionEvents,
43+
];
2944
}

lib/domain/use-cases/save_timed_preparation_use_case.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:injectable/injectable.dart';
2+
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
23
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
34
import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart';
45
import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart';
@@ -14,11 +15,15 @@ class SaveTimedPreparationUseCase {
1415
ScheduleWithPreparationEntity schedule,
1516
PreparationWithTimeEntity preparation, {
1617
DateTime? savedAt,
18+
DateTime? startedAt,
19+
List<PreparationActionEventEntity> actionEvents = const [],
1720
}) {
1821
final snapshot = TimedPreparationSnapshotEntity(
1922
preparation: preparation,
2023
savedAt: savedAt ?? DateTime.now(),
2124
scheduleFingerprint: schedule.cacheFingerprint,
25+
startedAt: startedAt,
26+
actionEvents: actionEvents,
2227
);
2328
return _timedPreparationRepository.saveTimedPreparationSnapshot(
2429
schedule.id,

0 commit comments

Comments
 (0)