Skip to content

feat(reminder): wire real engine into the app, drop remaining mocks - #271

Merged
gac0812 merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-wiring
Aug 20, 2026
Merged

feat(reminder): wire real engine into the app, drop remaining mocks#271
gac0812 merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-wiring

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

关联 Issue

Closes #263
Closes #262

依赖 #267#268#269#270 全部先合(先合并前面所有适配器 PR,这个才是干净的接线 diff;现在这个 draft 的 diff 包含它们还没合的全部内容)。替代 #245(原来的整体合并 PR,会关掉指向这个拆分序列)。

改动

  • AppProviders/AppRoot/createAppServices.ts:MockReminderApplication 换成真的 LocalReminderApplication,全部端口指向真实适配器
  • features/reminder/presentation/**(AlertReminderPresenter、useReminderPermissionsOnLaunch),删除最后一个 Mock(MockReminderPresenter)
  • 顺带修 bug(reminder): 权限申请流程弹完第一项就卡住,不会自动续弹 #262:useReminderPermissionsOnLaunch.ts 的 promptNext() 里除了"用户确认悬浮窗/全屏通知/电池优化"这条兜底分支,其它每条分支都在 try 块里直接 return,导致 try/finally 后面负责续弹下一项权限的 setTimeout 全是死代码——弹完第一项就卡住。挪进 finally 后修复,新增 5 个用例的测试(旧测试在合并时被删、没有替换版本,这次补上)

验证

  • npx tsc --noEmitnpx eslint . 全绿
  • npm run test:34 个套件、253 个用例全过
  • 原生模块无法完整真机验证,如实说明

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

  • 云端日程同步触发的刷新(SqliteScheduleSyncService)——生产代码零调用点,另开 issue

@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 10:22am

@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 51b58e0 to 726361f Compare August 17, 2026 05:23
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 726361f to 828327a Compare August 17, 2026 07:00
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 828327a to 6ddfa0a Compare August 17, 2026 07:26
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 6ddfa0a to 6314d1e Compare August 17, 2026 07:53
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 6314d1e to 87f8782 Compare August 17, 2026 10:22
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 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.
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 19, 2026
This PR's own commits removed MockLocalScheduleReader, MockReminderApplication,
MockReminderDispositionSync, and MockReminderStateStore, but createAppServices.ts
and features/reminder/index.ts (owned by the wiring PR, 1024XEngineer#271) still referenced
them, so npm run check failed standalone -- expected for a mid-stack PR per its
own description, but not acceptable for merging with a crashing composition
root (11 test failures: "MockLocalScheduleReader is not a constructor").

Swapped in this PR's own real/interim replacements: InMemoryLocalScheduleReader,
MemoryReminderStateStore, LocalReminderDispositionSync, and the real
LocalReminderApplication instead of the mock. 1024XEngineer#271 will replace
InMemoryLocalScheduleReader with the real SQLite-backed reader this PR also
adds; this just keeps the branch green on its own in the meantime, mirroring
what a057e5e already did for 1024XEngineer#266.
Part of 1024XEngineer#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 1024XEngineer#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.
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.
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.
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-wiring branch from 87f8782 to 4776bbf Compare August 20, 2026 08:08
@LUPENGHAN
LUPENGHAN marked this pull request as ready for review August 20, 2026 08:10
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.60140% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...lication/AssistantContinuousConversationService.ts 95.65% 1 Missing ⚠️
...der/presentation/useReminderPermissionsOnLaunch.ts 98.64% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@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.

审阅了组合根接线、提醒数据层、展示器以及启动权限流程。整体接线方向与 PR 描述一致;git diff --check 通过。当前环境没有可执行的 node_modules 测试/类型检查二进制,因此未能运行 Jest 或 TypeScript 验证。

Additional findings

  • frontend/src/features/reminder/presentation/AlertReminderPresenter.ts:?: [P1] Make hide dismiss the native alert, not only suppress actions: LocalReminderApplication.teardownDelivery() calls presenter.hide() during stop, rebuild, and replacement of an active delivery. This implementation only records the schedule as suppressed; the AlertDialogPort has no dismiss operation and the already displayed React Native Alert remains on screen. A logout, runtime stop, or schedule teardown can therefore leave a stale reminder dialog covering the next screen, with buttons that no longer have an active application listener. The presenter/dialog contract needs a real dismissal path (or a dismissible custom presenter) and hide() should use it.

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.
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.
Code review (PR 1024XEngineer#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.
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.
@gac0812 gac0812 self-assigned this Aug 20, 2026

@gac0812 gac0812 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.

ok

@gac0812
gac0812 merged commit ea86100 into 1024XEngineer:main Aug 20, 2026
4 checks passed
@LUPENGHAN LUPENGHAN self-assigned this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants