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
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ _Avoid_: Fallback, degraded, time sensitive
The user-facing status for schedule delivery through an Alarm.
_Avoid_: Notification, native alarm

**Preparation Run**:
One user attempt to prepare for a scheduled commitment.
_Avoid_: Timer session, countdown session

**Preparation Action Event**:
A user-performed action that changes a Preparation Run.
_Avoid_: Timer tick, automatic step transition, elapsed-time snapshot

**Schedule**:
A user commitment with a planned time and preparation context in OnTime.
_Avoid_: Appointment, event, task
Expand Down Expand Up @@ -209,6 +217,13 @@ _Avoid_: Loaded range, stream range, cached range
- iOS with an **iOS AlarmKit Alarm** should show **Alarm Status**.
- iOS without an available **iOS AlarmKit Alarm** but with notification permission should show **Notification Status**.
- **No Scheduled Notification** should be the user-facing empty state across platforms, even when future delivery may use an **Alarm**.
- A **Preparation Run** records **Preparation Action Events**, not timer ticks or automatic step transitions.
- A **Preparation Action Event** may represent starting preparation, skipping a preparation step, or finishing preparation.
- A **Preparation Run** begins when the user performs the starting **Preparation Action Event**.
- A skipped preparation step ends at the skip **Preparation Action Event** time; its unused planned duration is not treated as elapsed preparation time.
- 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.
- Automatic step transitions are derived states, not **Preparation Action Events**.
- A **Preparation Run** must not outlive the scheduled commitment it belongs to.
- Active first-release **Analytics Purposes** are product improvement, debugging and operations, and experimentation.
- A first-release **Experiment** must not be used for marketing targeting, sensitive segmentation, or personalized treatment.
- Marketing and personalization are **Deferred Analytics Purposes**.
Expand Down Expand Up @@ -260,5 +275,6 @@ _Avoid_: Loaded range, stream range, cached range
- "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification.
- "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**.
- "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms.
- "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.
- "Preparation chain" describes storage reconstruction, not product language; resolved: the domain concept is an ordered **Preparation** made of **Preparation Steps**.
- "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.
9 changes: 9 additions & 0 deletions docs/adr/0010-derive-preparation-runs-from-action-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Derive Preparation Runs from Action Events

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.

## Consequences

- Timer ticks may refresh UI state, but they must not be persisted as preparation history.
- Automatic step transitions are derived during restore, resume, and tick refresh; they are not stored as events.
- Stored Preparation Runs are valid only while the schedule fingerprint still matches and must be cleared when the schedule is finished or deleted.
76 changes: 62 additions & 14 deletions lib/data/data_sources/preparation_with_time_local_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import 'dart:convert';

import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';
import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart';
import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart';

abstract interface class PreparationWithTimeLocalDataSource {
Future<void> savePreparation(
String scheduleId, TimedPreparationSnapshotEntity snapshot);
String scheduleId,
TimedPreparationSnapshotEntity snapshot,
);
Future<TimedPreparationSnapshotEntity?> loadPreparation(String scheduleId);
Future<void> clearPreparation(String scheduleId);
}
Expand All @@ -20,31 +23,46 @@ class PreparationWithTimeLocalDataSourceImpl

@override
Future<void> savePreparation(
String scheduleId, TimedPreparationSnapshotEntity snapshot) async {
String scheduleId,
TimedPreparationSnapshotEntity snapshot,
) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_prefsKeyPrefix$scheduleId';

final jsonMap = {
'savedAt': snapshot.savedAt.millisecondsSinceEpoch,
'startedAt': snapshot.startedAt?.millisecondsSinceEpoch,
'scheduleFingerprint': snapshot.scheduleFingerprint,
'actionEvents': snapshot.actionEvents
.map(
(event) => {
'type': event.type.name,
'occurredAt': event.occurredAt.millisecondsSinceEpoch,
'stepId': event.stepId,
},
)
.toList(),
'steps': snapshot.preparation.preparationStepList
.map((s) => {
'id': s.id,
'name': s.preparationName,
'time': s.preparationTime.inMilliseconds,
'nextId': s.nextPreparationId,
'elapsed': s.elapsedTime.inMilliseconds,
'isDone': s.isDone,
})
.toList()
.map(
(s) => {
'id': s.id,
'name': s.preparationName,
'time': s.preparationTime.inMilliseconds,
'nextId': s.nextPreparationId,
'elapsed': s.elapsedTime.inMilliseconds,
'isDone': s.isDone,
},
)
.toList(),
};

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

@override
Future<TimedPreparationSnapshotEntity?> loadPreparation(
String scheduleId) async {
String scheduleId,
) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_prefsKeyPrefix$scheduleId';
final jsonString = prefs.getString(key);
Expand All @@ -67,15 +85,22 @@ class PreparationWithTimeLocalDataSourceImpl
}).toList();

final savedAtMillis = (map['savedAt'] as num?)?.toInt();
final startedAtMillis = (map['startedAt'] as num?)?.toInt();
final scheduleFingerprint = map['scheduleFingerprint'] as String? ?? '';
final actionEvents = _actionEventsFromJson(map['actionEvents']);

return TimedPreparationSnapshotEntity(
preparation:
PreparationWithTimeEntity(preparationStepList: stepEntities),
preparation: PreparationWithTimeEntity(
preparationStepList: stepEntities,
),
savedAt: savedAtMillis == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(savedAtMillis),
startedAt: startedAtMillis == null
? null
: DateTime.fromMillisecondsSinceEpoch(startedAtMillis),
scheduleFingerprint: scheduleFingerprint,
actionEvents: actionEvents,
);
} catch (_) {
return null;
Expand All @@ -89,3 +114,26 @@ class PreparationWithTimeLocalDataSourceImpl
await prefs.remove(key);
}
}

List<PreparationActionEventEntity> _actionEventsFromJson(Object? raw) {
if (raw is! List<dynamic>) return const [];
final events = <PreparationActionEventEntity>[];
for (final item in raw) {
if (item is! Map<String, dynamic>) continue;
final typeName = item['type'] as String?;
final occurredAtMillis = (item['occurredAt'] as num?)?.toInt();
if (typeName == null || occurredAtMillis == null) continue;
final type = PreparationActionEventType.values
.where((value) => value.name == typeName)
.firstOrNull;
if (type == null) continue;
events.add(
PreparationActionEventEntity(
type: type,
occurredAt: DateTime.fromMillisecondsSinceEpoch(occurredAtMillis),
stepId: item['stepId'] as String?,
),
);
}
return events;
}
43 changes: 43 additions & 0 deletions lib/domain/entities/preparation_action_event_entity.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';

enum PreparationActionEventType { start, skipStep, finish }

class PreparationActionEventEntity extends Equatable {
const PreparationActionEventEntity({
required this.type,
required this.occurredAt,
this.stepId,
});

factory PreparationActionEventEntity.start({required DateTime occurredAt}) {
return PreparationActionEventEntity(
type: PreparationActionEventType.start,
occurredAt: occurredAt,
);
}

factory PreparationActionEventEntity.skipStep({
required String stepId,
required DateTime occurredAt,
}) {
return PreparationActionEventEntity(
type: PreparationActionEventType.skipStep,
occurredAt: occurredAt,
stepId: stepId,
);
}

factory PreparationActionEventEntity.finish({required DateTime occurredAt}) {
return PreparationActionEventEntity(
type: PreparationActionEventType.finish,
occurredAt: occurredAt,
);
}

final PreparationActionEventType type;
final DateTime occurredAt;
final String? stepId;

@override
List<Object?> get props => [type, occurredAt, stepId];
}
17 changes: 16 additions & 1 deletion lib/domain/entities/timed_preparation_snapshot_entity.dart
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
import 'package:equatable/equatable.dart';
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart';

class TimedPreparationSnapshotEntity extends Equatable {
const TimedPreparationSnapshotEntity({
required this.preparation,
required this.savedAt,
required this.scheduleFingerprint,
this.startedAt,
this.actionEvents = const [],
});

final PreparationWithTimeEntity preparation;
final DateTime savedAt;
final String scheduleFingerprint;
final DateTime? startedAt;
final List<PreparationActionEventEntity> actionEvents;

TimedPreparationSnapshotEntity copyWith({
PreparationWithTimeEntity? preparation,
DateTime? savedAt,
String? scheduleFingerprint,
DateTime? startedAt,
List<PreparationActionEventEntity>? actionEvents,
}) {
return TimedPreparationSnapshotEntity(
preparation: preparation ?? this.preparation,
savedAt: savedAt ?? this.savedAt,
scheduleFingerprint: scheduleFingerprint ?? this.scheduleFingerprint,
startedAt: startedAt ?? this.startedAt,
actionEvents: actionEvents ?? this.actionEvents,
);
}

@override
List<Object?> get props => [preparation, savedAt, scheduleFingerprint];
List<Object?> get props => [
preparation,
savedAt,
scheduleFingerprint,
startedAt,
actionEvents,
];
}
5 changes: 5 additions & 0 deletions lib/domain/use-cases/save_timed_preparation_use_case.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:injectable/injectable.dart';
import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart';
import 'package:on_time_front/domain/entities/preparation_with_time_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';
Expand All @@ -14,11 +15,15 @@ class SaveTimedPreparationUseCase {
ScheduleWithPreparationEntity schedule,
PreparationWithTimeEntity preparation, {
DateTime? savedAt,
DateTime? startedAt,
List<PreparationActionEventEntity> actionEvents = const [],
}) {
final snapshot = TimedPreparationSnapshotEntity(
preparation: preparation,
savedAt: savedAt ?? DateTime.now(),
scheduleFingerprint: schedule.cacheFingerprint,
startedAt: startedAt,
actionEvents: actionEvents,
);
return _timedPreparationRepository.saveTimedPreparationSnapshot(
schedule.id,
Expand Down
Loading
Loading