Skip to content

feat(reminder): add alarm ring UI toolkit and build scaffolding - #264

Merged
Wintercom merged 3 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-native-leaf
Aug 18, 2026
Merged

feat(reminder): add alarm ring UI toolkit and build scaffolding#264
Wintercom merged 3 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-native-leaf

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

关联 Issue

Part of #263

改动

  • AlarmContract(共享常量)、DayRulerView、AlarmRingUi(手写响铃界面:颜色/字体/进场动画/胶囊按钮/自刷新时钟)——这三个文件互相之间、以及跟其它原生代码之间都没有引用关系,可以单独编译
  • app.config.js(替换静态 app.json,插件能读环境变量)、withTimeflowAlarm 配置插件、新依赖
  • 不包含百度定位模块——见 feat(reminder): 接入本地提醒引擎(实现 #161 时间/地点提醒送达) #263 决定

验证

  • npx tsc --noEmitnpx eslint . 全绿

本轮不含(见 #263 Out of Scope)

  • 百度定位模块
  • 闹钟调度/RN 桥接/响铃 Activity 与 Service(见后续 PR,这几个互相耦合拆不开)

Part of 1024XEngineer#263.

The zero-dependency slice of the timeflow-alarm native module:
AlarmContract (shared constants), DayRulerView and AlarmRingUi (the
hand-drawn ring-screen UI -- colors, typefaces, entrance animation,
pill buttons, self-ticking clock). None of these three import anything
else in the module, so this compiles standalone; the scheduling/RN
bridge/ring Activity+Service classes that DO reference each other are
a separate PR on top of this one.

Also the app-level scaffolding: app.config.js (replaces static
app.json so plugins can read env vars), the withTimeflowAlarm config
plugin, and the new package dependency.

Not included: Baidu location module -- dropped entirely per 1024XEngineer#263's
decision to use system geofencing instead.
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timeflow Ready Ready Preview Aug 17, 2026 7:00am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本次检查覆盖了 Expo 动态配置与权限插件、本地 Android 模块脚手架、手写响铃 UI,以及依赖和 CI 调整。前端 lint、格式、类型检查、47 个 Vitest 与 282 个 Jest 用例均通过;在临时目录执行 expo prebuild --platform android --no-install 也成功。当前需要修正的是百度定位模块删除不完整,项目配置仍向后续维护者和工具表达一个已经废弃的集成。Android Gradle 编译未完成,因为 Gradle wrapper 下载遇到本机证书信任错误。

Comment thread frontend/react-native.config.js Outdated
react-native.config.js still registered the deleted
modules/timeflow-baidu-location for autolinking, package-lock.json
still had an extraneous entry for it, and CI still exported a
TIMEFLOW_BAIDU_LOCATION_API_KEY placeholder for a plugin that's no
longer registered in app.config.js. None of these broke anything
(Expo silently skips the missing directory) but all three kept
describing an integration 1024XEngineer#263 said to drop entirely.

Verified: tsc/eslint clean, `expo prebuild --platform android
--no-install` still succeeds.
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

三处残留都删了:

  1. react-native.config.js 里的 timeflow-baidu-location autolinking 注册
  2. package-lock.json 里的 extraneous 包记录
  3. CI 里的 TIMEFLOW_BAIDU_LOCATION_API_KEY 占位变量

tsc/eslint 全绿,expo prebuild --platform android --no-install 重新验证过。

Code review (PR 1024XEngineer#264): formatDate() constructed a new SimpleDateFormat
on every call, including every 20-second RingRoot tick for as long as
the ring screen is visible -- unnecessary allocation on a repeating
timer. Cached as a static final field; its locale is hardcoded to
Locale.CHINA so there's no correctness risk. formatClock() is left as
a fresh SimpleDateFormat per call since it uses Locale.getDefault(),
which can change at runtime -- caching it would risk showing a stale
locale, not worth it for this level of savings.
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 17, 2026
Closes 1024XEngineer#272

voice.command.result gains an occurrence_overrides field alongside
schedule/schedules. Backend: CommandOutcome/CommandResult carry it
through from ScheduleMutationResult.occurrence_overrides (already
populated by _delete_recurring_range for scope=this_occurrence, but
previously dropped before reaching the wire); schedule_tools.py's
_mutation_result serializes each ScheduleOccurrenceOverrideSnapshot
the same way _snapshot_for_client already does for schedules.

Frontend: AppliedCommand carries the new field through from the WS
message; LocalScheduleWriter.applyCommandResult now handles schedule
and occurrence_overrides independently (a this_occurrence delete
produces only an override, no new schedule snapshot) and writes each
override via the already-existing, already-tested
ScheduleLocalRepository.upsertOccurrenceOverride.

Independent of the reminder-integration branch stack (1024XEngineer#264-1024XEngineer#271) --
the occurrence-override table and upsertOccurrenceOverride already
exist on main; this only needed LocalScheduleWriter's plain-main
shape, not the reminder stack's SqliteLocalScheduleReader wiring.
@Wintercom
Wintercom merged commit 36d1353 into 1024XEngineer:main Aug 18, 2026
8 checks passed
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 18, 2026
Two changes on top of the rebase onto upstream/main's native alarm module
(1024XEngineer#264/1024XEngineer#265):

- AlarmSchedulerPort.consumeNativeDispositions (single-phase, clears the
  native buffer on read) is now peekNativeDispositions + ackNativeDispositions
  (two-phase), matching the split 1024XEngineer#265 already made on the native side.
  hydrateNativeDispositions() now acks only after the whole batch persists
  successfully, so a mid-batch failure leaves the un-acked rows in the
  native buffer for retry instead of losing them.

- This PR's own interface changes (DeviceCapabilityPort.onAppActive,
  LocationMonitorPort.rebuild's new LocationRebuildTarget param, dropping
  MockTimeListener from shared/time) were never propagated to their
  implementers, so `npm run check` failed standalone -- expected for a
  mid-stack PR per its own description, but not acceptable for merging.
  Added the minimal implementations needed to close the gap: onAppActive
  on Native/MockDeviceCapability, MockLocationMonitor.rebuild's signature,
  and a new infrastructure/time/MockTimeListener placeholder. 1024XEngineer#269 replaces
  all of these with real adapters; this just keeps 1024XEngineer#266 green on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wintercom pushed a commit that referenced this pull request Aug 18, 2026
* feat(schedule): sync occurrence overrides to the client

Closes #272

voice.command.result gains an occurrence_overrides field alongside
schedule/schedules. Backend: CommandOutcome/CommandResult carry it
through from ScheduleMutationResult.occurrence_overrides (already
populated by _delete_recurring_range for scope=this_occurrence, but
previously dropped before reaching the wire); schedule_tools.py's
_mutation_result serializes each ScheduleOccurrenceOverrideSnapshot
the same way _snapshot_for_client already does for schedules.

Frontend: AppliedCommand carries the new field through from the WS
message; LocalScheduleWriter.applyCommandResult now handles schedule
and occurrence_overrides independently (a this_occurrence delete
produces only an override, no new schedule snapshot) and writes each
override via the already-existing, already-tested
ScheduleLocalRepository.upsertOccurrenceOverride.

Independent of the reminder-integration branch stack (#264-#271) --
the occurrence-override table and upsertOccurrenceOverride already
exist on main; this only needed LocalScheduleWriter's plain-main
shape, not the reminder stack's SqliteLocalScheduleReader wiring.

* test(schedule): cover the this_occurrence override serialization path

Prettier fix for LocalScheduleWriter.ts (npm run check caught it).

Adds a realtime-toolbox test exercising a delete with scope=this_occurrence
that produces an occurrence_override -- the only path that actually calls
_override_for_client. Closes the 2-line patch-coverage gap Codecov flagged
on the previous commit.

* test(schedule): cover LocalScheduleWriter's missing-field rejection

Closes the 1-line patch-coverage gap Codecov flagged: requireString's
throw path had no test on this branch (the file existed on main with
no dedicated test at all before this PR added one).

* fix(schedule): surface every schedule a mutation produced, not just the first

_delete_recurring_range's this_occurrence path, when it hits an
existing replace override, soft-deletes the replacement schedule and
returns it alongside the untouched parent in
ScheduleMutationResult.schedules. _mutation_result only ever forwarded
schedules[0] to the client, so that soft-delete never reached
voice.command.result and the client kept showing the stale
replacement as active.

CommandOutcome already had a schedules (plural) field - only
list_schedules populated it. _mutation_result now fills it with every
schedule the mutation touched; schedule (singular) stays as
schedules[0] for backward compat with clients not yet updated.

* fix(schedule): check write results and transact LocalScheduleWriter

applyCommandResult ignored the boolean applyCloudSchedule()/
upsertOccurrenceOverride() return, so a failed write (missing parent
schedule, account mismatch) still let the caller send
message.ack status=applied - the server believed the command was
persisted when it wasn't.

Now every write's return value is checked and a false throws, and all
writes for one command result run inside a single transaction via the
repository's new withTransaction(). A command can produce multiple
schedule and override writes that together represent one voice
command landing; without the transaction a partial failure would
leave state the server never actually had, and since the caller
already skips the ack on any throw, that state would never get
retried either.

Also switches to consuming the schedules (plural) field the backend
now populates for mutations, explicitly excluding list_schedules by
operation instead of relying on the coincidence that query results
never set schedule/occurrence_overrides.
Wintercom pushed a commit that referenced this pull request Aug 19, 2026
* feat(reminder): add reminder engine core (domain + application)

LocalReminderApplication and its ports (AlarmSchedulerPort,
LocationMonitorPort, DeviceCapabilityPort, NotificationChannels,
ReminderDeliveryPort, ReminderApplicationPort), plus the domain layer
(reminder.ts, strengthDelivery.ts) that drives arm/fire/confirm state
transitions and recurring-schedule advancement.

Pure logic layer: no device-specific adapters yet, nothing wired into
the app. shared/time gains format.ts (used by the delivery strength
calc); MockClock/MockTimeListener are removed since nothing in this
stack still needs a fake clock once the real engine lands.

* fix(reminder): close native/JS double-fire race in acknowledgeNativeFire

Code review (PR #266): acknowledgeNativeFire() only checked the
persisted disposition_state (confirmed/pending) before proceeding,
not the in-memory activeDeliveries/deliverLocks sets. Those sets are
exactly what canDeliver() checks on the JS-driven handleTime path to
skip a schedule the native alarm already owns -- but the guard only
worked in that one direction. If handleTime was already mid-flight for
a schedule (added to activeDeliveries, not yet persisted any
disposition change) at the moment the native alarm for the same
schedule fired, acknowledgeNativeFire would fall through and the two
channels could both run -- the exact double-fire the surrounding
comment says this mechanism eliminates. Added the activeDeliveries
check as an additional early-return, matching canDeliver()'s guard.

* fix(reminder): peek/ack native dispositions, close CI gap after rebase

Two changes on top of the rebase onto upstream/main's native alarm module
(#264/#265):

- AlarmSchedulerPort.consumeNativeDispositions (single-phase, clears the
  native buffer on read) is now peekNativeDispositions + ackNativeDispositions
  (two-phase), matching the split #265 already made on the native side.
  hydrateNativeDispositions() now acks only after the whole batch persists
  successfully, so a mid-batch failure leaves the un-acked rows in the
  native buffer for retry instead of losing them.

- This PR's own interface changes (DeviceCapabilityPort.onAppActive,
  LocationMonitorPort.rebuild's new LocationRebuildTarget param, dropping
  MockTimeListener from shared/time) were never propagated to their
  implementers, so `npm run check` failed standalone -- expected for a
  mid-stack PR per its own description, but not acceptable for merging.
  Added the minimal implementations needed to close the gap: onAppActive
  on Native/MockDeviceCapability, MockLocationMonitor.rebuild's signature,
  and a new infrastructure/time/MockTimeListener placeholder. #269 replaces
  all of these with real adapters; this just keeps #266 green on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(reminder): break cold-start deadlock in hydrateNativeDispositions, add adapter tests

hydrateNativeDispositions() runs inside startInternal(), which is itself the
task currently occupying opChain. It was calling the public confirm()/snooze()
wrappers, which re-enqueue onto that same opChain -- but that chain can't
advance until startInternal() (the caller) returns, and startInternal() won't
return until those re-enqueued calls resolve. Any cold start with a
confirmed/snoozed row sitting in the native disposition buffer deadlocked
permanently. Fixed by calling confirmInternal()/snoozeInternal() directly,
since hydrateNativeDispositions() is already running as the sole active
opChain task and doesn't need to re-enqueue.

Also closes the Codecov patch-coverage gap on the adapter changes from the
previous commit: onAppActive on Native/MockDeviceCapability, and the
MockLocationMonitor/MockTimeListener mocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(reminder): add application-layer coverage for LocalReminderApplication

Covers what review flagged as missing: cold-start peek/ack (batch-level
ack only after every row persists, none on partial failure), hydration for
each native disposition state (confirmed/snoozed/pending), the native/JS
dual-channel race guard, alarm rescheduling after confirm/snooze, stop/
restart cleanup, and recurring-reminder re-arming across two occurrences
(demonstrating the next_trigger_at signal / state-layer-computes-the-next-
occurrence split with actual test evidence, not just an explanation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(reminder): cover location triggering and strength delivery plans

Codecov flagged patch coverage at 66% after the application-layer test
commit -- LocalReminderApplication.ts's location-schedule path (arm on
leave, deliver on re-entry, no re-fire while still armed) and
strengthDelivery.ts's low/high branches had zero coverage. Both were only
exercised indirectly (or not at all) by the existing tests, which focused
on time-triggered schedules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(reminder): cover delivery channels by strength and native alarm event routing

Codecov target for this PR's patch is 91.16%, was at 76.78%. Adds:
- low strength delivery: system notification only, no popup/vibration/audio
- high strength delivery: popup + vibration + tts, falls back to local audio
  when tts fails
- native "snoozed"/"dismissed" events routed through alarms.subscribe() to
  snooze()/confirm(), including unsubscribe on stop()

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
AssistantConversationService.handleClose() nulled unsubscribeConnection
without calling it. Switching from push-to-talk to continuous mode makes
the shared AuthenticatedWebSocketClient drop and reopen the connection
(the two modes negotiate different voiceMode), so the old service stayed
subscribed to the new connection's TTS/PCM and pushed the same reply into
the player alongside the continuous service -- audible as one sentence
played twice, overlapping.

dispose() already unsubscribed correctly; only the close path was missing
it. Carried over from 1024XEngineer#245, which this stacked series replaces -- the fix
is not reminder-scoped so none of 1024XEngineer#264-1024XEngineer#270 picked it up.
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

1024XEngineer#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from 1024XEngineer#245,
which this stacked series replaces -- not reminder-scoped, so none of
1024XEngineer#264-1024XEngineer#270 picked it up.
gac0812 pushed a commit that referenced this pull request Aug 20, 2026
…271)

* feat(reminder): add SQLite-backed reminder data layer

Part of #263.

SqliteLocalScheduleReader / SqliteReminderStateStore read and persist
against the real local database (ScheduleLocalRepository) instead of
in-memory fixtures; geofence_radius_meters is hardcoded to 200m for
now (known simplification, see Issue #263 Out of Scope).
InMemoryLocalScheduleReader is kept as a non-persisted alternative
implementation of the same port. LocalScheduleWriter's post-write hook
refreshes the new reader after a voice-driven schedule mutation lands.

Only depends on application interfaces already on main and the
existing ScheduleLocalRepository -- independent of the audio/location/
notifications adapter PRs in this stack.

Removes MockLocalScheduleReader, MockReminderApplication,
MockReminderDispositionSync, MockReminderStateStore, mockReminderSchedules.

* feat(reminder): wire real engine into the app, drop remaining mocks

Swaps the composition root over to the real implementations added in
the previous three commits: LocalReminderApplication replaces
MockReminderApplication, SqliteLocalScheduleReader/SqliteReminderStateStore
replace their Mock counterparts, and every device port
(audio/notification/vibration/alarm/location) now points at its real
adapter. ExpoLocationMonitor (system geofencing) is used for location
monitoring; NativeLocationMonitor (Baidu SDK) stays in the repo but
unwired -- see the comment in createAppServices.ts for how to switch.

AppProviders/AppRoot gain the reminder permission-request flow
(useReminderPermissionsOnLaunch, now driven by an injected AlertDialogPort
instead of calling Alert.alert directly, with a settings-page fallback
for denied background-location permission) and rebuild() the engine
once permissions change.

Removes MockReminderPresenter, the last remaining Mock* adapter.

* fix(reminder): permission flow stalled after the first prompt

Every branch in promptNext() except the overlay/full_screen/battery
confirm-and-continue path returns from inside the try block, so the
setTimeout(runPrompt, 250) that was meant to advance to the next
missing permission -- placed after the try/finally -- was dead code
for those branches. In practice: grant notifications, and exact_alarm
(the next permission in line) would just never get prompted; same for
any declined dialog, or a granted/denied location permission. Moved
the continuation check into the finally block so it always runs
regardless of which branch returned.

Also replaces the old mock-based useReminderPermissionsOnLaunch test
(deleted upstream when this hook's signature changed to take an
injected AlertDialogPort + onPermissionsUpdated callback, with no
replacement written) and drops the now-redundant .gitkeep placeholders
left over from directories that have had real files in them since
earlier commits in this stack.

* fix(voice): remove stale push-to-talk listeners

AssistantConversationService.handleClose() nulled unsubscribeConnection
without calling it. Switching from push-to-talk to continuous mode makes
the shared AuthenticatedWebSocketClient drop and reopen the connection
(the two modes negotiate different voiceMode), so the old service stayed
subscribed to the new connection's TTS/PCM and pushed the same reply into
the player alongside the continuous service -- audible as one sentence
played twice, overlapping.

dispose() already unsubscribed correctly; only the close path was missing
it. Carried over from #245, which this stacked series replaces -- the fix
is not reminder-scoped so none of #264-#270 picked it up.

* fix(voice): preserve new TTS after interruption

A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from #245,
which this stacked series replaces -- not reminder-scoped, so none of
#264-#270 picked it up.

* fix(reminder): reset permission-prompt state when the effect unmounts

Code review (PR #271, fennoai): the cleanup only cleared the timer and
unsubscribed onAppActive, not awaitingReturnRef/skippedRef. If a user opened
settings for exact_alarm (or a denied location permission) and logged out
before returning, AppProviders reruns this effect with device=null, but the
stale awaitingReturnRef stayed true. On the next login the new effect's
promptNext() reads that same ref (it's a component-level useRef, not reset
by the effect re-running) and returns immediately every time, and since the
app is already active there's no new onAppActive event left to clear it --
every permission prompt stays disabled until the process restarts.

Reset both refs in the cleanup so a fresh login starts a clean prompt round.

* test(reminder,voice): close Codecov patch-coverage gaps

Codecov flagged 60.79% patch coverage across six files from this branch's
recent commits. Closed each:

- InMemoryLocalScheduleReader.ts: 0% because nothing in the app or tests
  actually uses it -- only re-exported from two barrels, never imported
  or instantiated anywhere in feature/reminder-wiring's own history.
  Deleted the file and its two re-exports instead of testing dead code.
- AlertReminderPresenter.ts: new AlertReminderPresenter.test.ts covers
  every reason-specific message, the title fallback, confirm/snooze
  dispatch, unsubscribe, and hide()'s suppression window. The `?? '...'`
  message fallback is unreachable (MESSAGE_BY_REASON already covers every
  ReminderTriggerReason), so it's istanbul-ignored with a stated reason
  instead of faked with an invalid reason value.
- useReminderPermissionsOnLaunch.ts: added 7 tests for branches the
  existing suite didn't reach -- denied notifications, failed
  openSettings on both the direct-settings and location paths, granted
  location, the bottom settings-redirect branch, a rejected getStatus(),
  and a dismissed (vs declined) dialog. Its own similarly-unreachable
  `prompt == null` branch (all 7 DevicePermission values already have a
  prompt) got the same istanbul-ignore treatment.
- AppProviders.tsx: new AppProviders.test.tsx isolates the
  onPermissionsUpdated -> reminder.rebuild() wiring with a mocked
  useReminderPermissionsOnLaunch, instead of relying on AppRoot.test.tsx's
  much heavier integration setup for one line.
- AssistantContinuousConversationService.ts: dismissReply() had no
  coverage at all before this branch touched one line of it (routing
  through stopPlaybackImmediately()); added a test that drives a reply
  through voice.tts.start/voice.dialogue.reply and asserts dismissReply()
  clears it and stops playback.
- backend agent.py: the interrupted()-with-nothing-ever-spoken branch
  (_last_audio_id is None) wasn't exercised; added
  test_a_barge_in_before_any_reply_started_sends_no_cancellation.

Verified: frontend tsc/eslint/prettier clean, Jest 520/520, Vitest 87/87;
backend ruff/mypy clean, pytest 97.53% coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants