diff --git a/DOCS-morning-session.md b/DOCS-morning-session.md
index 578330b..7da13d2 100644
--- a/DOCS-morning-session.md
+++ b/DOCS-morning-session.md
@@ -25,6 +25,10 @@ weigh in -> bathroom tag (≤ 2 min later) -> shower -> bathroom tag again
| `dressed` | wardrobe tag | previous + 10 min | |
| `atDesk` | desk tag | previous + 10 min | |
+**A later tag catches up a forgotten one** (`LegPass.caughtUp`, 2026-11-01:
+shower tag forgotten, re-rung while dressing). The weigh-in never catches
+up, and the shower's 8-minute floor still applies.
+
Legs latch like the ring's stages. A leg whose tag is not registered is
**skipped at start** (`LegPass.skipped`), so the feature is usable before it
is configured: with no session tags at all it is a weigh-in and nothing
@@ -58,6 +62,8 @@ the first leg by itself — there is nothing to walk to.
- **Silencing a re-ring is the full dismiss sequence**: tag 1, the walk,
tag 2, three questions. The ring screen's `_finishDismiss` sees a live
session for today and resumes it with `reRings + 1`.
+- **A silent buzz 3 min before each deadline** (`SessionWarn.kt`, request
+ code 1004), armed and cleared only with the re-ring.
- **One re-ring per leg.** After a deadline re-ring is dismissed the leg is
in `deadlineRung`; 5 min after its deadline (`bypassGrace`) it passes as
`LegPass.bypassed` — the dead-tag hatch, paid for with one full ring.
diff --git a/phone_app/android/app/src/main/AndroidManifest.xml b/phone_app/android/app/src/main/AndroidManifest.xml
index 571da35..879baf9 100644
--- a/phone_app/android/app/src/main/AndroidManifest.xml
+++ b/phone_app/android/app/src/main/AndroidManifest.xml
@@ -94,6 +94,10 @@
+
+
diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt
index 0881bcd..40339ca 100644
--- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt
+++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt
@@ -119,6 +119,7 @@ object AlarmScheduler {
AlarmManager.AlarmClockInfo(atMillis, showIntent(context)),
reRingPendingIntent(context),
)
+ SessionWarn.arm(context, atMillis)
}
/**
@@ -129,6 +130,7 @@ object AlarmScheduler {
fun clearReRing(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.cancel(reRingPendingIntent(context))
+ SessionWarn.clear(context)
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_RERING_AT).apply()
}
diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt
new file mode 100644
index 0000000..8976f45
--- /dev/null
+++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionWarn.kt
@@ -0,0 +1,85 @@
+package com.kuhy.wake_alarm_sync
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.media.AudioAttributes
+import android.os.Build
+import android.os.VibrationAttributes
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.util.Log
+
+/**
+ * A silent buzz [LEAD_MS] before each morning-session deadline.
+ *
+ * Armed and cleared only alongside the re-ring in [AlarmScheduler], so it
+ * cannot outlive or drift from the deadline it warns about.
+ */
+object SessionWarn {
+ private const val TAG = "WakeAlarmSession"
+
+ /** Distinct from 1001 (daily) and 1003 (re-ring): PendingIntents key on it. */
+ private const val REQUEST_CODE = 1004
+
+ const val LEAD_MS = 3 * 60_000L
+
+ private val PATTERN = longArrayOf(0, 400, 200, 400, 200, 400)
+
+ /** The buzz instant, or null once it has passed (BOOT_COMPLETED replays). */
+ fun warnAt(deadlineMs: Long, nowMs: Long): Long? =
+ (deadlineMs - LEAD_MS).takeIf { it > nowMs }
+
+ fun arm(context: Context, deadlineMs: Long) {
+ val at = warnAt(deadlineMs, System.currentTimeMillis())
+ val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ if (at == null) {
+ manager.cancel(pendingIntent(context))
+ return
+ }
+ manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pendingIntent(context))
+ }
+
+ fun clear(context: Context) {
+ val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ manager.cancel(pendingIntent(context))
+ }
+
+ internal fun buzz(context: Context) {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)
+ ?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ } ?: return
+ val effect = VibrationEffect.createWaveform(PATTERN, -1)
+ // Alarm usage, so Do Not Disturb and a backgrounded app still buzz.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ vibrator.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ALARM))
+ } else {
+ val audio = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build()
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(effect, audio)
+ }
+ Log.i(TAG, "deadline warning buzzed")
+ }
+
+ private fun pendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ REQUEST_CODE,
+ Intent(context, SessionWarnReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+/** Buzzes only while today's session is live; a stale alarm stays silent. */
+class SessionWarnReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (!SessionNotice.isLiveToday(context) || AlarmService.running) return
+ SessionWarn.buzz(context)
+ }
+}
diff --git a/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt
new file mode 100644
index 0000000..036ee33
--- /dev/null
+++ b/phone_app/android/app/src/test/kotlin/com/kuhy/wake_alarm_sync/SessionWarnTest.kt
@@ -0,0 +1,24 @@
+package com.kuhy.wake_alarm_sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class SessionWarnTest {
+ private val deadline = 1_000_000_000L
+
+ @Test
+ fun warnsThreeMinutesBeforeTheDeadline() {
+ assertEquals(deadline - 180_000L, SessionWarn.warnAt(deadline, deadline - 600_000L))
+ }
+
+ @Test
+ fun aWarnInstantAlreadyPastIsDropped() {
+ assertNull(SessionWarn.warnAt(deadline, deadline - 60_000L))
+ }
+
+ @Test
+ fun aDeadlineAlreadyPastIsDropped() {
+ assertNull(SessionWarn.warnAt(deadline, deadline + 1L))
+ }
+}
diff --git a/phone_app/lib/services/morning_session.dart b/phone_app/lib/services/morning_session.dart
index 0a7bcd9..1661b91 100644
--- a/phone_app/lib/services/morning_session.dart
+++ b/phone_app/lib/services/morning_session.dart
@@ -43,6 +43,9 @@ enum LegPass {
/// Never satisfied; passed by the clock after a missed deadline.
bypassed,
+
+ /// Forgotten, then covered by a later room's tag once its floor had passed.
+ caughtUp,
}
/// Where the session stands.
diff --git a/phone_app/lib/services/session_rules.dart b/phone_app/lib/services/session_rules.dart
index b04f36c..2a9ff8b 100644
--- a/phone_app/lib/services/session_rules.dart
+++ b/phone_app/lib/services/session_rules.dart
@@ -84,7 +84,17 @@ extension SessionRules on MorningSession {
String onTag(SessionTagSlot slot, DateTime now) {
final leg = currentLeg;
if (leg == null) return 'Session finished';
- if (slot != leg.slot) return 'Not the ${leg.slot.name} tag';
+ if (slot != leg.slot) {
+ final ahead = _catchUpTo(slot, now);
+ if (ahead == null) return 'Not the ${leg.slot.name} tag';
+ for (var i = leg.index; i < ahead.index; i++) {
+ final skipped = SessionLeg.values[i];
+ if (!passedAt.containsKey(skipped)) {
+ _pass(skipped, now, LegPass.caughtUp);
+ }
+ }
+ return onTag(slot, now);
+ }
switch (leg) {
case SessionLeg.bathroomIn:
final typedAt = pendingWeightAt;
@@ -175,6 +185,20 @@ extension SessionRules on MorningSession {
return deadline(leg);
}
+ /// The later leg [slot] covers; the weigh-in and shower floor never skip.
+ SessionLeg? _catchUpTo(SessionTagSlot slot, DateTime now) {
+ final leg = currentLeg!;
+ for (var i = leg.index; i < SessionLeg.values.length; i++) {
+ final next = SessionLeg.values[i];
+ if (i > leg.index && next.slot == slot) return next;
+ if (passedAt.containsKey(next)) continue;
+ if (next == SessionLeg.bathroomIn) return null;
+ final floor = earliest(next);
+ if (floor != null && now.isBefore(floor)) return null;
+ }
+ return null;
+ }
+
void _pass(SessionLeg leg, DateTime now, LegPass how) {
passedAt[leg] = now;
passedHow[leg] = how;
diff --git a/phone_app/test/services/session_catch_up_test.dart b/phone_app/test/services/session_catch_up_test.dart
new file mode 100644
index 0000000..575efb7
--- /dev/null
+++ b/phone_app/test/services/session_catch_up_test.dart
@@ -0,0 +1,69 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:wake_alarm_sync/services/morning_session.dart';
+import 'package:wake_alarm_sync/services/session_json.dart';
+import 'package:wake_alarm_sync/services/session_rules.dart';
+import 'package:wake_alarm_sync/services/session_tag_settings.dart';
+
+final DateTime t0 = DateTime(2026, 9, 17, 8, 5);
+const SessionTagSettings allTags = SessionTagSettings(
+ tagIds: ['bath', 'ward', 'desk'],
+);
+
+MorningSession fresh({SessionTagSettings tags = allTags, bool nfc = true}) =>
+ SessionRules.start(now: t0, tags: tags, nfcAvailable: nfc);
+
+/// Weighs in and taps the bathroom tag, leaving the shower leg live.
+MorningSession inBathroom() => fresh()
+ ..enterCheckIn(72, 3, t0)
+ ..onTag(SessionTagSlot.bathroom, t0.add(const Duration(seconds: 30)));
+
+void main() {
+ group('catch-up', () {
+ test('a later tag catches up a forgotten shower tag', () {
+ final s = inBathroom();
+ final late = s
+ .earliest(SessionLeg.showerDone)!
+ .add(const Duration(minutes: 4));
+ expect(
+ s.onTag(SessionTagSlot.wardrobe, late),
+ 'Done: ${SessionLeg.dressed.label}',
+ );
+ expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
+ expect(s.passedHow[SessionLeg.dressed], LegPass.tag);
+ expect(s.currentLeg, SessionLeg.atDesk);
+ expect(s.nextDeadline(), late.add(MorningSession.legWindow));
+ expect(s.reRings, 0);
+ });
+
+ test('the desk tag catches up every leg before it', () {
+ final s = inBathroom();
+ final late = s.earliest(SessionLeg.showerDone)!;
+ expect(s.onTag(SessionTagSlot.desk, late), contains('complete'));
+ expect(s.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
+ expect(s.passedHow[SessionLeg.dressed], LegPass.caughtUp);
+ expect(s.outcome, SessionOutcome.completed);
+ });
+
+ test('catch-up still waits out the shower floor', () {
+ final s = inBathroom();
+ final early = s
+ .earliest(SessionLeg.showerDone)!
+ .subtract(const Duration(seconds: 1));
+ expect(s.onTag(SessionTagSlot.wardrobe, early), 'Not the bathroom tag');
+ expect(s.currentLeg, SessionLeg.showerDone);
+ });
+
+ test('the weigh-in is never caught up', () {
+ final s = fresh()..enterCheckIn(72, 3, t0);
+ expect(s.onTag(SessionTagSlot.desk, t0), 'Not the bathroom tag');
+ expect(s.currentLeg, SessionLeg.bathroomIn);
+ });
+
+ test('a caught-up leg survives the store', () {
+ final s = inBathroom()
+ ..onTag(SessionTagSlot.wardrobe, t0.add(const Duration(minutes: 9)));
+ final back = sessionFromJson(s.toJson())!;
+ expect(back.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
+ });
+ });
+}
diff --git a/phone_app/test/session_screen_catch_up_test.dart b/phone_app/test/session_screen_catch_up_test.dart
new file mode 100644
index 0000000..50b71b1
--- /dev/null
+++ b/phone_app/test/session_screen_catch_up_test.dart
@@ -0,0 +1,94 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:wake_alarm_sync/screens/session_screen.dart';
+import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
+import 'package:wake_alarm_sync/services/morning_session.dart';
+import 'package:wake_alarm_sync/services/session_store.dart';
+import 'package:wake_alarm_sync/services/session_tag_settings.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ late List calls;
+ late DateTime now;
+ var ringing = false;
+
+ void mockChannel() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(
+ const MethodChannel(AlarmScheduler.channelName),
+ (call) async {
+ calls.add(call);
+ if (call.method == 'hasNfc') return true;
+ if (call.method == 'isRinging') return ringing;
+ return null;
+ },
+ );
+ }
+
+ /// Delivers a tag from the platform side, as `NfcReader` does.
+ Future tap(String tagId) async {
+ const codec = StandardMethodCodec();
+ await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .handlePlatformMessage(
+ AlarmScheduler.channelName,
+ codec.encodeMethodCall(MethodCall('onNfcTag', tagId)),
+ (_) {},
+ );
+ }
+
+ List methods() => calls.map((c) => c.method).toList();
+
+ setUp(() async {
+ calls = [];
+ now = DateTime(2026, 9, 17, 8, 4);
+ ringing = false;
+ SharedPreferences.setMockInitialValues({});
+ mockChannel();
+ await SessionTagSettings.replaceAll(['bath', 'ward', 'desk'], '');
+ });
+
+ Future pumpSession(WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ routes: {
+ '/': (_) =>
+ SessionScreen(clock: () => now, publish: () async => 'pushed'),
+ '/ring': (_) => const Scaffold(body: Text('RING')),
+ },
+ ),
+ );
+ await tester.pump();
+ await tester.pump();
+ }
+
+ /// Taps energy 3 (required since the check-in) and submits [kg].
+ Future logWeight(WidgetTester tester, String kg) async {
+ await tester.tap(find.widgetWithText(ChoiceChip, '3'));
+ await tester.pump();
+ await tester.enterText(find.byType(TextField), kg);
+ await tester.tap(find.text('Log weight'));
+ await tester.pump();
+ }
+
+ testWidgets('a forgotten shower tag is caught up by the wardrobe', (
+ tester,
+ ) async {
+ await pumpSession(tester);
+ await logWeight(tester, '72');
+ await tap('bath');
+ await tester.pump();
+ now = now.add(const Duration(minutes: 9));
+ await tap('ward');
+ await tester.pump();
+ final saved = (await SessionStore().loadCurrent())!;
+ expect(saved.passedHow[SessionLeg.showerDone], LegPass.caughtUp);
+ expect(saved.currentLeg, SessionLeg.atDesk);
+ final arm = calls.lastWhere((c) => c.method == 'armReRing');
+ final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
+ expect(arm.arguments, {'atMillis': deadline});
+ expect(methods(), isNot(contains('ringNow')));
+ });
+}