local-notifications
Schedules on-device reminders with expo-notifications: rows as truth, one idempotent sync, DST-safe triggers, Android channels.
Skill by zakariaf
Install
Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.
npx agentmarket install skill/zakariaf-local-notifications
Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.
Get the weekly diff
The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.
About
Reviews
Write a reviewNo reviews yet. Installed it? Reviews come from verified installers only. How reviews work
Permissions
What this skill can do and what it asks for, from its SKILL.md and code. Checked Oct 26, 2026.
- Tools
- No tools found
- Secrets
- None asked for
- Install scripts
- None found
- Runs as
- Node.js package
Network hosts it talks to 1
codeload.github.com· found in install command
Files it touches
~/.claude/skills/local-notifications
What's inside
- Skill name
local-notifications- Description
- Schedules on-device reminders with expo-notifications: rows as truth, one idempotent sync, DST-safe triggers, Android channels. Use when adding a reminder, snooze or local notification. Not for remote push (push-notifications).
- License
- MIT
- Instructions
- 245 lines in SKILL.md · view on GitHub
Before installing
- Ships executable scripts
Files (68)
- SKILL.md
- shared.json · assets/
- create-root-kits.ts · examples/
- migration-reminders.ts · examples/
- sqlite-reminder-repository.ts · examples/
- use-task-reminder-model.ts · examples/
- checkers.md · references/
- expo-notifications-api.md · references/
- platform-behaviour.md · references/
- scheduling-model.md · references/
- taps-and-routing.md · references/
- testing-and-devices.md · references/
- check-lib.mjs · scripts/
- check-reminder-code.mjs · scripts/
- check-reminder-logic.mjs · scripts/
- app-modules.mjs · scripts/lib/
- reminder-checks.mjs · scripts/lib/
- reminder-paths.mjs · scripts/lib/
- source-scan.mjs · scripts/lib/
- selftest.mjs · scripts/
- date-key.test.ts · templates/src/core/dates/
- date-key.ts · templates/src/core/dates/
- plan-reminders.test.ts · templates/src/core/reminders/
- plan-reminders.ts · templates/src/core/reminders/
- reconcile-reminders.test.ts · templates/src/core/reminders/
- reconcile-reminders.ts · templates/src/core/reminders/
- reminder-tap.test.ts · templates/src/core/reminders/
- reminder-tap.ts · templates/src/core/reminders/
- reminder.test.ts · templates/src/core/reminders/
- reminder.ts · templates/src/core/reminders/
- wall-clock.test.ts · templates/src/core/reminders/
- wall-clock.ts · templates/src/core/reminders/
- use-reminder-sync.test.ts · templates/src/hooks/
- use-reminder-sync.ts · templates/src/hooks/
- use-reminder-taps.test.ts · templates/src/hooks/
- use-reminder-taps.ts · templates/src/hooks/
- ar.json · templates/src/i18n/catalogs/
- en.json · templates/src/i18n/catalogs/
- expo-local-notifications-adapter.test.ts · templates/src/services/local-notifications/
- expo-local-notifications-adapter.ts · templates/src/services/local-notifications/
- fake-local-notifications.test.ts · templates/src/services/local-notifications/
- fake-local-notifications.ts · templates/src/services/local-notifications/
- local-notifications-port.test.ts · templates/src/services/local-notifications/
- local-notifications-port.ts · templates/src/services/local-notifications/
- local-notifications-root-kit.test.tsx · templates/src/services/local-notifications/
- local-notifications-root-kit.tsx · templates/src/services/local-notifications/
- reminder-sync-context.test.tsx · templates/src/services/local-notifications/
- reminder-sync-context.tsx · templates/src/services/local-notifications/
- reminder-sync.test.ts · templates/src/services/local-notifications/
- reminder-sync.ts · templates/src/services/local-notifications/
- reminder-texts.test.ts · templates/src/services/local-notifications/
- reminder-texts.ts · templates/src/services/local-notifications/
- fake-local-zone.test.ts · templates/src/services/local-zone/
- fake-local-zone.ts · templates/src/services/local-zone/
- local-zone-port.ts · templates/src/services/local-zone/
- system-local-zone-adapter.test.ts · templates/src/services/local-zone/
- system-local-zone-adapter.ts · templates/src/services/local-zone/
- reminder-rows.ts · templates/test/reminders/
- rule-zone.ts · templates/test/reminders/
- package.json · tests/bases/app/
- …and 8 more
SKILL.md (first lines)
# Local notifications
Every on-device reminder in an Expo app is a database row; one idempotent sync turns the rows into
the OS's pending notifications (one-shot triggers at instants resolved per day through the local
zone, at most 50 nearest, on Android channels, with localized text), refills them on every return
to the foreground, and opens the row's current screen when one is tapped, on iOS and Android alike;
two checkers prove it on the app's own code.
## Rules that must hold
1. **The database is the source of truth; the OS pending set is a cache.** Reminders are rows
(`Reminder`, `src/core/reminders/reminder.ts`); nothing reads `getAllScheduledNotificationsAsync`
to decide what the person asked for. Why: iOS keeps only 64, OEM battery managers drop alarms, a
restore or reinstall empties the set; only rows survive all of it. (`schedule-api`, `cancel-all`)
2. **One idempotent sync is the only writer.** `createReminderSync` (`reminder-sync.ts`): channels,
permission, rows, `planReminders`, `reconcileReminders`, cancel, schedule; runs never overlap
(single flight). Features write the row, then call `sync.request()`. Why: a second writer makes
the diff wrong for both and cannot be made idempotent. (`sync-only-writer`, `reconcile-diff`)
3. **expo-notifications lives only in adapters.** The local adapter
(`expo-local-notifications-adapter.ts`) is the only file that schedules, lists or cancels; tests
drive it through `jest.mock` + `jest.requireMock`, never a value import (type-only imports are
erased and allowed). Why: Jest never touches native code, and one file owns every platform
difference. (`sdk-import`, `schedule-api`)
4. **A recurring time is a wall-clock minute plus a rule, resolved per day through the zone.**
`instantOfWallClock(day, minuteOfDay, zone.offsetAt)`; days by `addDays` on date keys; no
`Date.now()`, `new Date(` or `+ 86_400_000` in reminder code (time comes from `ClockPort` and
`LocalZonePort`). Why: a local day lasts 23, 24 or 25 hours; 09:00 stored as an instant is 08:00
all winter. (`wall-clock`, `wall-clock-dst`, `plan-dst`)
5. **Every notification is a one-shot DATE trigger on an explicit channel.** No DAILY, WEEKLY,
CALENDAR, TIME_INTERVAL or `repeats: true`; `channelId` on every trigger. Why: a repeating trigger
cannot skip a done day, keeps one text for ever, and hides its next instant from the diff; without
a channel Android posts to an unnamed fallback channel. (`repeating-trigger`, `trigger-channel`)
6. **Identifiers encode reminder, instant and text, and the sync touches only its own.**
`reminder:<id>:<instant>:<hash of channel, title, body>`; only `REMINDER_ID_PREFIX` identifiers are
cancelled; never `cancelAllScheduledNotificationsAsync`. Why: the same input must be an empty diff,
an edit must replace (iOS reports a pending date only as an interval), and other features'
notifications are not this sync's. (`plan-idempotent`, `plan-edit`, `reconcile-diff`, `cancel-all`)
7. **Plan the nearest 50 (never above 64), nothing in the past, and refill on every foreground.**
`maxPending` default 50, `IOS_PENDING_LIMIT` 64, a 5-second lead (a pending occurrence inside it is
kept: `imminentIds`); `useReminderSync` runs on mount, on return to the foreground and on a
language change. Why: iOS silently drops everything past the 64 soonest, rejects a past date
(Android drops it silently), a sync in a reminder's last seconds must not cancel it, and a window
runs out unless refilled. (`plan-max-pending`, `plan-past`, `reconcile-diff`, `sync-on-foreground`)
8. **A bad row is rejected and recorded, never fatal; skip days and weekdays are honoured.** Why: one
corrupt row must not silence every reminder, and "done for today" must hold. (`plan-reject`,
`plan-skip`)
9. **Notification text comes from `t()` at sync time, the `t()` the app shows.** `reminderTexts(t)`
builds the channels and texts of a run from one `t()`, which `useReminderSync(sync, t)` hands over
(`sync.request({ t })`); the person's label goes in a `*Text` placeholder (bidi-isolated). Why: a
literal, or a template with words around its `${}`, is never translated; the text hash in each
identifier re-plans every pending notification after a language switch; and a language read from
module state in a child's effect is still the old one. (`literal-text`)
10. **The permission is asked from a person's action, channels first.** `sync.enable()` from "Remind
me": channels, then the prompt only while the OS still shows one, then a sync; `blocked` offers
the system Settings. Never in a layout, the root navigator, the entry or any effect (an effect
runs on mount). Why: iOS asks once, an app targeting Android 12L or lower gets no prompt before a
channel exists, and a launch prompt is refused more often. (`permission-at-launch`)
11. **Exactly one foreground handler.** One `setNotificationHandler` call: the local adapter's, or
push-notifications' adapter's when push is adopted (the composition root then passes
`installForegroundHandler: false` to the local adapter). Why: on SDK 57 a notification that fires
while the app is open is not shown without it, and a second call silently replaces the first.
(`foreground-handler`)
12. **A tap carries only `{ kind: 'reminder', reminderId }` and opens a typed route.**
`parseReminderTap` treats data as untrusted; `useReminderTaps` reads the launch tap
(`getLastNotificationResponse`) and the listener, handles each identifier once, clears it, and the
app's `openReminder` reads the row's subject again and maps it to a typed href (missing → home);
no row stores a path. Why: a notification outlives routes and rows, a cold-start tap reaches no
listener, and a stored path needs an `as Href` cast. (`plan-data`, `tap-parse`, `tap-handling`)
13. **Android: inexact alarms by default, never `USE_EXACT_ALARM`.** `SCHEDULE_EXACT_ALARM` only as a
deliberate opt-in with its denial handled. Why: Play allows `USE_EXACT_ALARM` only for alarm-clock,
timer and calendar apps; Android 14 denies the exact-alarm grant by default. (`exact-alarm`)
## Workflow
1. **Read the reference for the task:**
| Task | Read |
|---|---|
| a new reminder kind, the sync, the window, DST or travel, restore, "fired at the wrong time" | [references/scheduling-model.md](references/scheduling-model.md) |
| an expo-notifications call, trigger, handler, permission or channel detail | [references/expo-notifications-api.md](references/expo-notifications-api.md) |
| iOS vs Android delivery, the 64 limit, exact alarms, Doze, OEMs, lock-screen privacy | [references/platform-behaviour.md](references/platform-behaviour.md) |
| tapping a reminder, cold start, coexisting with push, action buttons | [references/taps-and-routing.md](references/taps-and-routing.md) |
| tests, rule zones, device probes | [references/testing-and-devices.md](references/testing-and-devices.md) |
| a checker failure, configuration, NOT APPLICABLE | [references/checkers.md](references/checkers.md) |
2. **Check the prerequisites.** `ClockPort` with its fake and the composition root
(architecture-and-boundaries), `Result` (error-handling-typed-results), `date-key.ts`
(seeded-determinism-and-golden-vectors; a synced copy is in `templates/src/core/dates/`, copy it
only when the app lacks it), `useIsAppActive` (react-components-and-hooks), `t()` and the
catalogs (i18n-strings-and-catalogs). Install the SDK with `npx expo install expo-notifications`
and add its config plugin with an Android `icon` and `color` (expo-prebuild-and-config-plugins).
3. **Classify the SDK.** Add `"expo-notifications": "LocalNotificationsPort"` to `vendorSdks` in
`contracts/architecture.json` and `vendor('expo-notifications', 'LocalNotificationsPort')` to the
ESLint `VENDOR_SDK_PATHS` (architecture-and-boundaries, typescript-and-lint-rules). When
push-notifications is adopted too, both adapters import the SDK, so the label names both ports:
`"LocalNotificationsPort/PushPort"` in both places.
4. **Store the rows.** Add the reminder tables in a forward-only migration and a repository whose
`loadActiveReminders()` returns `Reminder[]` (recording malformed rows) and `findReminder(id)` the
row's subject (`{ taskId }`, null when gone), created in `openDeviceData` as `reminders` on
`AppData` over the one connection
([examples/migration-reminders.ts](examples/migration-reminders.ts),
[examples/sqlite-reminder-repository.ts](examples/sqlite-reminder-repository.ts);
persistence-and-migrations owns the format).
5. **Copy the templates** to the paths their first lines name, each with its test:
`templates/src/core/reminders/` (the pure planner), `templates/src/services/local-zone/`,
`templates/src/services/local-notifications/` (port, adapter, fake, texts, sync, the root kit and
`reminder-sync-context.tsx`),
`templates/src/hooks/` (sync and tap hooks), `templates/test/reminders/` (rule zones, rows) and the
catalog keys in `templates/src/i18n/catalogs/` (merge into the app's catalogs; translate every
locale).
6. **Register the root kit** in app-startup-and-bootstrap's registry `src/services/create-root-kits.ts`
([examples/create-root-kits.ts](examples/create-root-kits.ts)), which `startApp` runs once after
the data layer opened: create the notification and zone adapters (if push-notifications is
adopted, pass `installForegroundHandler: false` to the local adapter, rule 11), the one
`reminderSync` over `data.reminders.loadActiveReminders`, and add
`createLocalNotificationsKit({ notifications, sync, hrefForReminder })` to the list. The root files
stay unchanged; the registry never opens the database.
7. **What the kit mounts:** its provider (`ReminderSyncProvider`, so models call
`useReminderSyncService()`) and its effects beside the root Stack, inside the navigator:From zakariaf/ReactNative-Skills, MIT.
More from zakariaf/ReactNative-Skills
- 0.606score
debug-menu-and-test-hooks
zakariaf
Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
accessibility-as-code
zakariaf
Enforces a11y labels, 44pt targets, 200% text, VoiceOver, TalkBack.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
adaptive-layout
zakariaf
Lays screens out by window, not device: breakpoints, master-detail panes, readable widths, grids.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
admob-ads-and-consent
zakariaf
Builds AdMob ads behind a port - banners, interstitials, rewarded, UMP consent then ATT, pacing, ADS_MODE test IDs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
analytics-and-consent
zakariaf
Gates analytics on opt-in consent: no SDK before a yes, typed events without personal data, ATT.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
android-release-play
zakariaf
Ships Android App Bundles to Google Play: upload key, Play App Signing, AAB gate, versionCode, tracks, rollout.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
architecture-and-boundaries
zakariaf
Places app code in the src/ layout and checks import boundaries: one-way zones, pure core, ports with fakes, one composition root.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
async-safety
zakariaf
Prevents silent async bugs: effect races, stale results, lost promises, leaked listeners and timers, AbortSignal gaps.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
audio-and-haptics-feedback
zakariaf
Builds SFX and haptics behind ports.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
auth-and-secure-storage
zakariaf
Builds sign-in - tokens in expo-secure-store, shared refresh, protected routes, OAuth PKCE, Apple sign-in.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
background-tasks
zakariaf
Builds expo-background-task jobs: idempotent, budgeted runs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
canvas-gestures-and-hit-testing
zakariaf
Turns canvas taps, swipes, drags and aim into typed intents: one gesture module, hit-testing on the drawn layout.
SkillAFreshness: A★ 11Claude Code +9
Signals
- Stars
- 11
- Forks
- 2
- Open issues
- 0
- Last commit (this folder)
- Oct 17, 2026 (11 days ago)
From GitHub (fetched Oct 24, 2026).
Details
- License
- MIT
- Repository
- github.com/zakariaf/ReactNative-Skills
- First seen
- Oct 24, 2026
- Last checked
- Oct 24, 2026
- Source
- GitHubgithub-graphql
More from zakariaf
- 0.606score
admob-ads-and-consent
zakariaf
Builds AdMob ads behind a port - banners, interstitials, rewarded, UMP consent then ATT, pacing, ADS_MODE test IDs.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
accessibility-as-code
zakariaf
Enforces a11y labels, 44pt targets, 200% text, VoiceOver, TalkBack.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
canvas-gestures-and-hit-testing
zakariaf
Turns canvas taps, swipes, drags and aim into typed intents: one gesture module, hit-testing on the drawn layout.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
adaptive-layout
zakariaf
Lays screens out by window, not device: breakpoints, master-detail panes, readable widths, grids.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
debug-menu-and-test-hooks
zakariaf
Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline.
SkillAFreshness: A★ 11Claude Code +9 - 0.606score
analytics-and-consent
zakariaf
Gates analytics on opt-in consent: no SDK before a yes, typed events without personal data, ATT.
SkillAFreshness: A★ 11Claude Code +9
Cite this page
local-notifications skill. Agent Mart. https://agentmart.dev/skills/zakariaf-local-notifications (last updated 2026-10-24)