Skip to content

Commit c08de30

Browse files
authored
Merge pull request #558 from DevKor-github/feature/issue-531-preparation-prefetch
fix: batch selected-day preparation prefetch
2 parents c14f09f + 4efb102 commit c08de30

3 files changed

Lines changed: 146 additions & 17 deletions

File tree

CONTEXT.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ _Avoid_: Provider login completed, credential received
5353
An allowlisted non-content value attached to a Product Usage Event.
5454
_Avoid_: Event payload, arbitrary metadata, raw detail
5555

56+
**Schedule**:
57+
A planned commitment with an appointment time and place that OnTime helps the user prepare for.
58+
_Avoid_: Calendar event, meeting
59+
60+
**Preparation**:
61+
The set of user-planned steps completed before leaving for a Schedule.
62+
_Avoid_: Prep routine, checklist
63+
64+
**Preparation Duration**:
65+
The sum of a Schedule's Preparation step durations, excluding move time and Schedule Spare Time.
66+
_Avoid_: Total duration, travel time, buffer time
67+
68+
**Schedule Spare Time**:
69+
A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration.
70+
_Avoid_: Preparation time, move time
71+
5672
**Schedule Notification**:
5773
A user-facing notification that starts preparation for a scheduled commitment at the intended moment.
5874
_Avoid_: Schedule alarm, alarm, push
@@ -117,6 +133,8 @@ _Avoid_: Notification, native alarm
117133
- An **Analytics Event Parameter** must not contain user-authored text, direct identifiers, tokens, raw exception strings, request bodies, or response bodies.
118134
- A **Product Usage Event** uses a stable snake_case name and includes a schema version.
119135
- A changed **Product Usage Event** meaning requires a new event name or schema version.
136+
- A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing.
137+
- **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs.
120138
- 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.
121139
- The profile setting for upcoming schedule preparation delivery should be called **Schedule Notification Setting**.
122140
- On iOS, user-facing copy may say **Alarm** only when OnTime can deliver an **iOS AlarmKit Alarm**.
@@ -180,3 +198,4 @@ _Avoid_: Notification, native alarm
180198
- "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification.
181199
- "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**.
182200
- "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms.
201+
- "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.

lib/presentation/calendar/bloc/monthly_schedules_bloc.dart

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ part 'monthly_schedules_state.dart';
2020
@Injectable()
2121
class MonthlySchedulesBloc
2222
extends Bloc<MonthlySchedulesEvent, MonthlySchedulesState> {
23+
static const int _maxPreparationPrefetchConcurrency = 4;
24+
2325
MonthlySchedulesBloc(
2426
this._loadSchedulesForMonthUseCase,
2527
this._getSchedulesByDateUseCase,
@@ -219,30 +221,77 @@ class MonthlySchedulesBloc
219221
}
220222

221223
final fetchedDurations = <String, Duration>{};
222-
var hasUpdates = false;
224+
await _forEachMissingPreparationId(missingIds, (scheduleId) async {
225+
final duration = await _loadPreparationDuration(scheduleId);
226+
if (duration != null) {
227+
fetchedDurations[scheduleId] = duration;
228+
}
229+
});
223230

224-
for (final scheduleId in missingIds) {
225-
try {
226-
await _loadPreparationByScheduleIdUseCase(scheduleId);
227-
if (state.preparationDurationByScheduleId.containsKey(scheduleId)) {
228-
// Stream update already has fresher data.
231+
if (fetchedDurations.isNotEmpty && !emit.isDone) {
232+
final updatedMap = Map<String, Duration>.from(
233+
state.preparationDurationByScheduleId,
234+
);
235+
var hasUpdates = false;
236+
for (final entry in fetchedDurations.entries) {
237+
if (updatedMap.containsKey(entry.key)) {
229238
continue;
230239
}
231-
final preparation = await _getPreparationByScheduleIdUseCase(
232-
scheduleId,
233-
);
234-
fetchedDurations[scheduleId] = preparation.totalDuration;
240+
updatedMap[entry.key] = entry.value;
235241
hasUpdates = true;
236-
} catch (_) {
237-
// Keep fallback UI when loading fails.
242+
}
243+
if (hasUpdates) {
244+
emit(state.copyWith(preparationDurationByScheduleId: () => updatedMap));
238245
}
239246
}
247+
}
240248

241-
if (hasUpdates) {
242-
final updatedMap = Map<String, Duration>.from(
243-
state.preparationDurationByScheduleId,
244-
)..addAll(fetchedDurations);
245-
emit(state.copyWith(preparationDurationByScheduleId: () => updatedMap));
249+
Future<void> _forEachMissingPreparationId(
250+
Set<String> scheduleIds,
251+
Future<void> Function(String scheduleId) load,
252+
) async {
253+
final ids = scheduleIds.toList(growable: false);
254+
var nextIndex = 0;
255+
final workerCount = ids.length < _maxPreparationPrefetchConcurrency
256+
? ids.length
257+
: _maxPreparationPrefetchConcurrency;
258+
259+
String? takeNextId() {
260+
if (nextIndex >= ids.length) {
261+
return null;
262+
}
263+
return ids[nextIndex++];
264+
}
265+
266+
await Future.wait(
267+
List<Future<void>>.generate(workerCount, (_) async {
268+
while (true) {
269+
final scheduleId = takeNextId();
270+
if (scheduleId == null) {
271+
return;
272+
}
273+
await load(scheduleId);
274+
}
275+
}),
276+
);
277+
}
278+
279+
Future<Duration?> _loadPreparationDuration(String scheduleId) async {
280+
if (state.preparationDurationByScheduleId.containsKey(scheduleId)) {
281+
return null;
282+
}
283+
284+
try {
285+
await _loadPreparationByScheduleIdUseCase(scheduleId);
286+
if (state.preparationDurationByScheduleId.containsKey(scheduleId)) {
287+
// Stream update already has fresher data.
288+
return null;
289+
}
290+
final preparation = await _getPreparationByScheduleIdUseCase(scheduleId);
291+
return preparation.totalDuration;
292+
} catch (_) {
293+
// Keep fallback UI when loading fails.
294+
return null;
246295
}
247296
}
248297

test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,67 @@ void main() {
187187
},
188188
);
189189

190+
test(
191+
'preparation prefetch starts multiple uncached schedule loads without serial waiting',
192+
() async {
193+
final loadStarted = <String, Completer<void>>{
194+
scheduleA.id: Completer<void>(),
195+
scheduleB.id: Completer<void>(),
196+
};
197+
final loadAllowedToFinish = <String, Completer<void>>{
198+
scheduleA.id: Completer<void>(),
199+
scheduleB.id: Completer<void>(),
200+
};
201+
addTearDown(() {
202+
for (final completer in loadAllowedToFinish.values) {
203+
if (!completer.isCompleted) {
204+
completer.complete();
205+
}
206+
}
207+
});
208+
209+
loadPreparationByScheduleIdUseCase =
210+
StubLoadPreparationByScheduleIdUseCase((scheduleId) async {
211+
loadStarted[scheduleId]?.complete();
212+
await loadAllowedToFinish[scheduleId]?.future;
213+
});
214+
215+
final bloc = buildBloc();
216+
addTearDown(bloc.close);
217+
218+
bloc.add(
219+
MonthlySchedulesPreparationsPrefetchRequested(
220+
scheduleIds: [scheduleA.id, scheduleB.id],
221+
),
222+
);
223+
224+
await loadStarted[scheduleA.id]!.future.timeout(
225+
const Duration(milliseconds: 50),
226+
);
227+
await loadStarted[scheduleB.id]!.future.timeout(
228+
const Duration(milliseconds: 50),
229+
);
230+
231+
loadAllowedToFinish[scheduleA.id]!.complete();
232+
loadAllowedToFinish[scheduleB.id]!.complete();
233+
234+
final prefetchedState = await bloc.stream.firstWhere(
235+
(state) =>
236+
state.preparationDurationByScheduleId.containsKey(scheduleA.id) &&
237+
state.preparationDurationByScheduleId.containsKey(scheduleB.id),
238+
);
239+
240+
expect(
241+
prefetchedState.preparationDurationByScheduleId[scheduleA.id],
242+
const Duration(minutes: 20),
243+
);
244+
expect(
245+
prefetchedState.preparationDurationByScheduleId[scheduleB.id],
246+
const Duration(minutes: 15),
247+
);
248+
},
249+
);
250+
190251
test('cached schedule preparations are not fetched again', () async {
191252
getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase(
192253
(_, __) => Stream<List<ScheduleEntity>>.fromIterable([

0 commit comments

Comments
 (0)