From expo-rn-plugin
Load coding standards and conventions for this React Native / Expo project. Use when you need guidance on TypeScript patterns, Tamagui tokens, Zustand stores, Lingui i18n, Doppler env vars, or Zustand state ownership rules.
How this skill is triggered — by the user, by Claude, or both
Slash command
/expo-rn-plugin:coding-standardsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Apply the following standards to all code in this project.
Apply the following standards to all code in this project.
If this project uses
@anicca-labs/*libraries, run/expo-rn-plugin:libsbefore writing any utility, hook, or layout code — those packages replace many standard alternatives.
Before debugging a build error, adding a package, or configuring anything native, check the reference apps — production apps built on this exact stack:
Rules:
app.config.ts, package.json, and eas.json before any other investigationThe reference apps encode hard-won lessons. Reverse-engineering Gradle/Xcode internals when the answer is already in a reference app wastes time and risks introducing hacks that the SDK would have handled correctly.
This is a managed Expo app. The android/ and ios/ folders are gitignored and fully regenerated by EAS on every build. Never commit them, never hand-edit files inside them.
All native configuration goes through config plugins in app.config.ts:
withGradleProperties from @expo/config-pluginswithInfoPlist, withEntitlementsPlistwithAndroidManifestwithDangerousMod (last resort only)If a third-party package requires native changes and has no config plugin, check whether an Expo-maintained fork or wrapper exists before reaching for bare workflow.
Always install Expo SDK packages with yarn expo install, never yarn add.
# Correct
yarn expo install expo-speech-recognition
yarn expo install react-native-pager-view @react-navigation/material-top-tabs
# Wrong — will resolve the latest npm version, which may be incompatible with the current Expo SDK
yarn add expo-speech-recognition
yarn expo install resolves the version compatible with the project's current Expo SDK (expo version in package.json). Using yarn add fetches the latest npm version, which may target a different SDK. The mismatch produces native module crashes at runtime (e.g. Cannot find native module 'ExpoSpeechRecognition') that survive multiple clean rebuilds — because the native binary is baked at build time, the only recovery is yarn expo install + a fresh dev client build.
This rule applies to any package in the Expo ecosystem: expo-*, react-native-* packages listed in the Expo SDK, and any package that ships a native module bundled with Expo.
any — use proper types, generics, or type guardsas assertions — fix types at the sourcetsc --noEmit and fix all errors (zero errors is a baseline)export default (exception: Expo Router app/ files require it). One file = one component/hook; the file name and export name match 1:1const, export in a single export { } statement at the end; never inline export constReact.FC — type props inline or with a separate type Props = {…}import React from 'react' is not needed with the new JSX transform; import only what you use: import { useState, useEffect, useRef } from 'react'React.useState, React.useEffect, React.useCallback etc. — always use the named import directlyconst MyComponent = () => … and export const MyScreen = () => …; never function declarations for components, hooks, or helperstype Status = "idle" | "loading" | "error" instead of isLoading + hasErrorT or I prefix; self-documenting plain English; no abbreviations (except universally known ones like API, URL)get* / set* / create* for synchronous; fetch* / post* / patch* / delete* for API calls; is* / are* for type guards and predicatesbaseUrl deprecationTypeScript 6.x warns that baseUrl is deprecated (removed in 7.0). Do not remove baseUrl to silence it — Expo's Metro bundler reads baseUrl from tsconfig.json to resolve the project's path aliases (@/*, @screens, @atoms, …) at runtime. Dropping it can break the bundle even though tsc still passes (TS 4.1+ resolves paths without baseUrl, Metro does not).
Silence the warning by bumping ignoreDeprecations to match the version named in the warning:
{
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0"
}
}
This is a deferral, not a fix. baseUrl is fully removed in TS 7.0 — before adopting it, drop baseUrl, make every paths entry resolve relative to the tsconfig location, and confirm the Expo SDK in use no longer needs baseUrl for alias resolution.
Prettier owns formatting — never hand-adjust quotes, semicolons, or wrapping. Run format-on-save (or prettier --write) and let it win. The project .prettierrc enforces:
'foo', never "foo" (singleQuote: true). Prettier still emits double quotes inside JSX attributes; that is expected, leave it.semi: true).trailingComma: "all"), 2-space indent (tabWidth: 2), 100-char width (printWidth: 100)..prettierignore — exclude generated Lingui catalogs (src/i18n/locales/compiled/, src/i18n/locales/exported/), the Doppler env.template.yaml (its {{ .VAR }} syntax is invalid YAML), and dist/. Without it, prettier --check fails on files it cannot (or should not) format.format:check (prettier --check .) runs in the pre-push hook next to check:tsc and lint, and again in the quality-checks CI workflow. A .prettierrc with no gate is exactly how a repo drifts from its own config.quality-checks.yml) on every PR and on pushes to long-lived branches (stg, main): check:tsc + lint + format:check, on source only (no Doppler/.env). It mirrors the pre-push hook so drift cannot slip through when hooks are bypassed (--no-verify) or uninstalled.setup-node trap: actions/setup-node@v5 defaults package-manager-cache: true, which runs yarn cache dir using the runner's global Yarn 1.x before corepack enable. Because package.json pins [email protected] via packageManager, global Yarn 1 refuses and the step fails. Every setup-node step in a Corepack/Yarn-4 project must set package-manager-cache: false.eslint-disable-next-line react-hooks/exhaustive-deps — fix the dependency issuereactCompiler: true) — write straightforward code and let it memoize. See the React Compiler section below before adding manual useRef/useCallback memoization or disabling a react-hooks rulecondition ? <X /> : null), not && — the && form renders 0 when condition is a falsy numbergap on the parent YStack/XStack, or padding on a container insteadreactCompiler: true)The compiler auto-memoizes — prefer plain, direct code and let it optimize:
useRef or wrap callbacks in useCallback just to keep them stable or to read the latest value — the compiler memoizes the closure and it always sees current state. Write a plain function.forwardRef (React 19). Pass ref as an ordinary prop: ({ ref, ...props }: Props & { ref?: Ref<Handle> }), and drop the displayName boilerplate.react-hooks/immutability flags a ref mutated during render, move the mutation into a useEffect keyed on its dependency. Only when there is genuinely no clean fix — Reanimated .value writes (stable shared refs mutated outside React's data flow), or one-time-init / command-signal effects — use a justified // eslint-disable-next-line react-hooks/immutability (or react-hooks/set-state-in-effect) with a comment explaining why. exhaustive-deps is still never disabled.Always resolve UI needs from the highest available source before reaching for lower ones:
@atoms, @molecules, @organisms)@anicca-labs/ — shared org packages; covers buttons, touchables, images, screen containers, forms, authXStack, YStack, Text, Spinner, Stack, …react-native — only when no Tamagui or @anicca-labs/ equivalent existsNever import View, Text, TouchableOpacity, Pressable, or Image from react-native when a Tamagui or @anicca-labs/ wrapper covers the use case.
@anicca-labs/Before adding a new component to the project-local layers, ask: would any other app on this stack benefit from this? If yes, and it has no app-specific tokens, data, or business logic, push it to @anicca-labs/libs instead and consume it remotely. Examples that belong upstream: generic wrappers around third-party primitives (KeyboardScrollView), shared layout primitives, utility hooks. This rule only applies when you are a member of the anicca-labs GitHub org and the consuming project already uses @anicca-labs/* packages.
Organize project-local shared components into three layers and place new components in the correct one:
@anicca-labs/ui-containers)Every screen must use Containers.Screen as its root element. It handles safe area insets automatically (via useSafeAreaInsets) so you never need SafeAreaView directly. react-navigation adjusts the inset context per navigator, so the all-edges default is self-correcting — no double-padding inside a Stack with a header or inside a Tabs screen.
import { Containers } from '@anicca-labs/ui-containers'
// Screen with its own ScrollView/KeyboardScrollView
<Containers.Screen shouldAutoResize={false}>
<KeyboardScrollView>…</KeyboardScrollView>
</Containers.Screen>
// Screen without scroll — auto-resize switches to ScrollView if content overflows
<Containers.Screen>
<Containers.SubY>…</Containers.SubY>
</Containers.Screen>
Containers.Screen — outermost; handles safe area, auto-resize to ScrollView when content overflowsContainers.SubY — vertical sub-section with standard horizontal padding ($md)Containers.SubX — horizontal sub-section with standard horizontal padding ($md)edges prop (default all four) — override only when you need to exclude specific edgesshouldAutoResize={false} — required when the screen already contains its own ScrollViewButtons specifically — @anicca-labs/ui-button takes priority over Tamagui's Button. Never use Tamagui's raw Button or react-native touchables for interactive buttons:
BaseTouchable from @anicca-labs/ui-touchables with bg="$token" — not CTAButton. CTAButton uses unstyled={true} on Tamagui's Button and the background prop does not reliably resolve theme tokens; BaseTouchable with bg does. Use opacity={disabled ? 0.4 : 1} for the disabled visual state and an inline {loading ? <Spinner /> : children} guard.CTAButton (pass backgroundColor as a literal color string, not a $token; has loading prop and spinnerColor)BasicButton (full ButtonProps pass-through, opacity=0.4 when disabled)GhostButton (transparent background, opacity=0.4 when disabled; pass color from your theme)IconButton (circular, requires icon: ReactNode, full ButtonProps pass-through)BaseButton (accepts leftIcon/rightIcon)SizingAnimatedButton from @anicca-labs/ui-button-animated (backgroundColor required; measures its own width internally)AnimatedButton from @anicca-labs/ui-button-animated (backgroundColor and width: number both required; prefer SizingAnimatedButton unless you need explicit width control)Trans + t for every hardcoded user-visible stringt`…` for prop strings (placeholders, aria labels, alert titles)Trans, useLingui from @lingui/react/macrosrc/i18n/locales/compiled/*.ts after running yarn i18n — the pre-build script reruns compilation; if the committed file differs from what the installed lingui version emits (e.g. lingui 6 adds /*eslint-disable*/), every build will produce a dirty tree@lingui/cli, run yarn i18n:compile and commit the result before the next build<Plural>, <Select>, or <SelectOrdinal> in Lingui v6 + Expo/Metro — these crash at runtime (TypeError: Cannot read property 'prototype' of undefined). Use <Trans> with a ternary: {count === 1 ? <Trans>1 item</Trans> : <Trans>{count} items</Trans>}. See docs/solutions/runtime-errors/lingui-v6-plural-macro-metro.md.@lingui/macro in a v6 project — it max-publishes at v5.9.5 and pulls duplicate v5 core/react packages. Use @lingui/react/macro which ships with v6.Four files to touch — missing any one of them silently falls back to English:
src/i18n/config/constants.ts — add { code: 'Display name' } entry to localessrc/i18n/locales/exported/<code>/screens.po — create the PO file with translations (copy structure from an existing locale)src/i18n/locales/compiled/<code>.ts — create the compiled catalog (same hashed message-ID keys as every other locale; copy from an existing compiled file and replace the translation strings)src/i18n/utils.ts — import the compiled messages and add to messagesByLocale-[NSTaggedPointerString count])Settings.set({ AppleLanguages: locale }) writes a bare string to NSUserDefaults. On the next launch, UIKit's _CFLocaleCreateLocaleIdentifierForAvailableLocalizations reads AppleLanguages expecting an NSArray and calls count on the string — crashing at ~100ms before any JS initializes. The stack points entirely at system frameworks, making the culprit invisible without reading the crash JSON.
Fix: always wrap in an array — Settings.set({ AppleLanguages: [locale] }).
The crash only manifests on the second launch because the bad value is written on the first. Clearing it requires deleting the app (wipes NSUserDefaults), then reinstalling.
When locale is detected from the device language at startup (the standard pattern using expo-localization), RTL is free: React Native reads I18nManager.isRTL from the OS before the first render and mirrors all flex layouts automatically. You do not need to call I18nManager.forceRTL.
forceRTL is only needed when the app has an in-app language switcher that can flip direction mid-session. In that case, the call must be followed by a full app restart (RNRestart or Updates.reloadAsync) because isRTL is read once at process start.
Before enabling an RTL locale, audit layouts for direction-unsafe styles:
grep -r "left:\|right:\|paddingLeft\|paddingRight\|marginLeft\|marginRight\|textAlign.*left\|textAlign.*right" src/screens --include="*.tsx"
hitSlop left/right are safe (touch area, not layout). Anything else must be replaced with start/end equivalents or removed.
import statement per module path (prevents import/no-duplicates)src/theme/tamagui.config.ts; themes: src/theme/themes.tsthemes.ts before using. Standard scale: $surface-app, $surface-card, $surface-subtle, $surface-hover, $surface-pressed, $border-subtle, $border-default, $text-disabled, $text-placeholder, $text-tertiary, $text-secondary, $text-emphasisallowedStyleValues: "strict" — only token values; raw hex/rgba will error at compile time$sm, $md, $lg from sizesSpaces; radius from radius tokensgap on XStack/YStack for whitespace between elements — not Separator. Separator renders a visible divider line and should only be used when that line is intentional UI. Using Separator purely for spacing conflates layout with visual chrome.src/components/ — no raw <Text> with style propsStyleSheet.create() — use Tamagui styled(). If a third-party component's API forces a plain style object (e.g. a style prop that only accepts StyleSheet output), add a // NOTE: StyleSheet required — <reason> comment and surface it to the user so they can decide whether to accept it.styled() from @tamagui/core first, then use token-based style props on the wrapperposition: absolute for overlays (badges, toasts, FABs) that genuinely need to float above the document flowNever put theme values in createTokens — color: themes.light in createTokens locks every $color* reference to the light value regardless of active theme, breaking dark mode. Color tokens belong only in the themes object; createTokens should only contain spacing, sizing, radius, and font tokens.
Never use as type assertions on theme objects — } as typeof defaultConfig.themes.light erases your custom token names from TypeScript's view, so $surface-card etc. won't typecheck. Remove the assertion and let TypeScript infer the full type — createTamagui propagates it automatically to GetThemeValueForKey.
| Layer | Owner |
|---|---|
| Server state | react-query / orval hooks |
| Client/UI state | Zustand |
If data comes from the backend it belongs in react-query. Zustand stores should be thin.
When the user edits remote data (profile, settings, checkout form), the handoff pattern is Fetch → Hydrate local state → Edit → Save:
Use react-query to fetch, then seed a local Zustand slice or useState with the result. The user edits the local copy; on save, call the mutation and invalidate the query. Never mutate the react-query cache directly for optimistic edits unless you have a specific reason — local state is simpler and easier to reason about.
type MyStore = MyStoreState & MyStoreFunctions;
const INITIAL_STATE: MyStoreState = { ... };
const useMyStore = create<MyStore>()(
persist(
(set) => ({
...INITIAL_STATE,
setKeyValue: (key, value) => set((state) => ({ ...state, [key]: value })),
}),
{ name: "my-storage", storage: createJSONStorage(() => createZustandMmkvStorage({ id: "my-storage" })) },
),
);
// Good
const firstName = useUserStore((state) => state.firstName);
// Bad — re-renders on any store change
const store = useUserStore();
Every app with a tab navigator must install react-native-pager-view and @react-navigation/material-top-tabs before the first native build (the pager and the precise types still come from these packages):
yarn expo install react-native-pager-view @react-navigation/material-top-tabs
SDK 56+: never import @react-navigation/* values directly — expo-router forbids it and Metro throws at bundle time. Import the navigator value from expo-router's re-export and keep precise types via a type-only import (erased at build, so it doesn't trip the runtime check):
import { createMaterialTopTabNavigator } from 'expo-router/js-top-tabs';
import { withLayoutContext } from 'expo-router';
import type { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs';
const { Navigator } = createMaterialTopTabNavigator();
const MaterialTopTabs = withLayoutContext(Navigator);
Full re-export mapping: @react-navigation/native → expo-router/react-navigation (ThemeProvider, DefaultTheme, DarkTheme); @react-navigation/bottom-tabs → expo-router/js-tabs; @react-navigation/elements → expo-router/react-navigation (PlatformPressable).
Use MaterialTopTabs in app/(tabs)/_layout.tsx for animated carousel swipe between tabs. Installing after the first build requires a full EAS rebuild — do it upfront.
Any open or expanded UI state — swipeables, accordions, bottom sheets, inline menus — must be reset when a tab loses focus. With a pager navigator, the screen stays mounted, so stale open state is visible when the user swipes back.
Reset in the useFocusEffect cleanup, and use an instant reset (not animated) to avoid a visible flash during the pager's return transition:
import { useCallback, useEffect, useState } from 'react'
const [closeKey, setCloseKey] = useState(0)
useFocusEffect(
useCallback(() => {
return () => setCloseKey(k => k + 1) // fires on blur, not on focus
}, [])
)
// In the child component:
useEffect(() => {
if (closeKey > 0) ref.current?.reset() // reset(), not close() — instant, no animation
}, [closeKey])
close() runs an animation that can still be seen as the pager slides the screen back in. reset() snaps instantly while the screen is off-screen.
With a pager navigator, screens stay mounted — a useEffect([]) inside a list item fires once at app start for all tabs simultaneously, not when the user first visits the tab. Gate the animation on first focus instead:
const [animKey, setAnimKey] = useState(0)
const hasAnimated = useRef(false)
useFocusEffect(
useCallback(() => {
if (!hasAnimated.current) {
hasAnimated.current = true
setAnimKey(1) // triggers animation exactly once
}
}, [])
)
// Pass animKey down to each animated list item:
const AnimatedEntry = ({ index, animKey, children }) => {
const tx = useSharedValue(index % 2 === 0 ? -40 : 40)
const opacity = useSharedValue(0)
useEffect(() => {
cancelAnimation(tx)
cancelAnimation(opacity)
tx.value = index % 2 === 0 ? -40 : 40
opacity.value = 0
tx.value = withDelay(index * 100, withTiming(0, { duration: 500 }))
opacity.value = withDelay(index * 100, withTiming(1, { duration: 500 }))
}, [animKey])
// ...
}
cancelAnimation before resetting the shared value is required — without it, a re-trigger while the previous animation is still running produces a jump.
Every app with a tab navigator must include a Settings tab. It is the standard home for account-level and device-level controls that don't belong in content screens.
Required items (always present):
presentPaywall(); "Manage subscription" when pro → Purchases.showManageSubscriptions(). See /expo-rn-plugin:iap for the full pattern.Granted / Denied status; when denied, tapping opens Linking.openSettings() (app-specific settings page on both platforms); uses an AppState listener + openedSettings ref to re-check the permission when the user returns. See /notifications for the full pattern.Alert.alert before calling supabase.auth.signOut(); use style: 'destructive' on the confirm button; placed at the bottom of the screen so it doesn't get tapped by accidentGrowing list — as new general-purpose controls are identified (e.g. language preference, theme toggle, account deletion), add them here before adding to any content screen.
Do not put permission or account controls in content screens (journal, feed, reflections, etc.) — they belong in Settings.
Every app must render an EnvBadge in the root layout. It reads EXPO_PUBLIC_ENV and shows an amber "STAGING" pill overlay in the top-right corner when the env is not prd. It renders nothing in production, so there is no cost to keeping it in the tree.
Mount it as a sibling of <SplashView /> in app/_layout.tsx:
import { EnvBadge } from "@atoms";
// Inside RootLayout return — EnvBadge must come before SplashView so it renders on top once the splash fades:
<EnvBadge />
<SplashView ... />
The component lives at src/components/atoms/EnvBadge/index.tsx and is exported from @atoms.
mobile, configs dev / stg / prdenv.template.yaml: EXPO_PUBLIC_FOO={{ .FOO }} (left = shell var, right = Doppler key)doppler secrets set FOO="value" --project mobile --config stgyarn sync-env-varsEXPO_PUBLIC_ on the Doppler key — that prefix belongs only on the left side of env.template.yaml. Example: Doppler key is SUPABASE_API_KEY; template maps it as EXPO_PUBLIC_SUPABASE_API_KEY={{ .SUPABASE_API_KEY }}.axios directly in componentsaxios only for non-REST endpoints or one-off authenticated file uploads@dev-plugins/react-query) before touching codeFlashList from @shopify/flash-list — never FlatListestimatedItemSize is required — omitting it causes a warning and degrades performanceburnt for all success and error toasts — never Alert.alert for transient feedbackBurnt.toast({ title: '…', preset: 'done' }) — auto-dismisses after ~2sBurnt.toast({ title: '…', preset: 'error' }) — auto-dismisses after ~4sAlert.alert dialog, not a toastWhen a push arrives while the app is open, show a top banner with a bell icon — not Burnt.alert (centered modal with heart). The notification helper in useToast encapsulates this:
// useToast.ts
function notification({ title, message, duration = 4 }) {
Burnt.toast({
title, message, preset: 'custom',
icon: { ios: { name: 'bell.fill', color: '#007AFF' } },
duration, from: 'top',
})
}
Wire it up in the root layout:
const { notification } = useToast()
useEffect(() => {
return subscribeToForegroundMessages((title, body) => {
notification({ title, message: body })
})
}, [])
Burnt.alert with preset: 'heart' is for app-initiated alerts (achievements, confirmations). The top-banner notification pattern matches what users expect from a real push notification landing while they're in the app.
date-fns — always pass the locale from expo-localization for locale-aware outputDate.toLocaleDateString() — output varies by device locale settings['expo-updates'].type field in app.config.ts:
'mandatory' — shows an alert on launch requiring the user to update before continuing'optional' — silently applies the update on the next cold start (app fully closed and reopened)find . -name "*.png" | grep -v node_modules | xargs -I {} cp {} tmp-imagesWhen using a custom schema (e.g. api instead of public), two steps are required before the app can query it:
1. Expose the schema in PostgREST — Supabase only exposes public and graphql_public by default. Add your schema via the Management API or the dashboard:
# Via Management API (scriptable — use in setup automation)
curl -X PATCH "https://api.supabase.com/v1/projects/{project_ref}/postgrest" \
-H "Authorization: Bearer {supabase_access_token}" \
-H "Content-Type: application/json" \
-d '{"db_schema":"public,graphql_public,api"}'
# Or: Supabase dashboard → Settings → API → Exposed schemas → add "api"
Without this, every query returns PGRST106: Invalid schema.
2. Grant privileges to roles — Supabase no longer auto-grants privileges to anon/authenticated/service_role on new tables (effective 2026-05-30 for new projects, 2026-10-30 for existing). Every migration must include explicit grants:
GRANT SELECT, INSERT, UPDATE, DELETE ON api.your_table TO authenticated;
GRANT ALL ON api.your_table TO service_role;
-- anon typically gets no access to app tables (requires auth)
Without this, authenticated users get 42501: permission denied for table.
The Supabase client must also declare the schema:
createClient(url, key, { db: { schema: 'api' } })
Features that require a physical device (FCM tokens, permission requests, local notification scheduling) should not be visually disabled on the simulator. Instead, keep the UI fully enabled and show a toast when tapped. This gives a realistic preview of the UI without misleading "Denied" states.
import * as Device from 'expo-device'
const isSimulator = !Device.isDevice
function showSimulatorToast() {
alert({ title: 'Physical device only', message: 'This feature is not available on the simulator.', preset: 'error', duration: 3 })
}
// In handlers:
async function handleReminderToggle() {
if (isSimulator) { showSimulatorToast(); return }
// ... real logic
}
In JSX, replace disabled={!notifPermission} with disabled={isSimulator ? false : !notifPermission} and the same pattern for opacity and backgroundColor. Skip "Enable notifications in Settings" hint copy on simulator ({!isSimulator && !notifPermission && ...}).
The service layer (requestNotificationPermission, getFCMToken) already returns false/null for !Device.isDevice — no changes needed there.
Register the FCM token whenever the auth state changes to signed-in. Fire-and-forget — do not await in the auth listener:
// in useAuthSession — onAuthStateChange handler
if (s?.user) {
identifyRevenueCatUser(s.user.id)
upsertDeviceToken(s.user.id) // fire and forget
}
device_tokens table schemaOne row per user — unique(user_id). If the user reinstalls the app they get a new FCM token; upserting on user_id updates the row in place rather than accumulating stale tokens.
create table api.device_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
fcm_token text not null,
reminder_hour int, -- local time (null = no reminder)
reminder_minute int,
timezone text, -- IANA string e.g. "America/New_York"
updated_at timestamptz not null default now(),
unique (user_id)
);
alter table api.device_tokens enable row level security;
grant select, insert, update, delete on api.device_tokens to authenticated;
grant select, insert, update, delete on api.device_tokens to service_role;
create policy "Users can manage their own device tokens"
on api.device_tokens for all
using (auth.uid() = user_id) with check (auth.uid() = user_id);
Never call requestPermissionsAsync() (or requestNotificationPermission()) on screen mount. Tab screens are pre-mounted by the tab navigator — a useEffect on the settings tab fires at app startup, which would show the OS permission dialog before the user has even opened settings. That is too aggressive.
Instead, keep two separate functions:
getNotificationPermissionStatus() — read-only, never shows a dialogrequestNotificationPermission() — shows the OS dialog (call only on explicit user action)export type NotificationPermissionStatus = 'undetermined' | 'granted' | 'denied'
export async function getNotificationPermissionStatus(): Promise<NotificationPermissionStatus> {
if (!Device.isDevice) return 'denied'
const { status } = await ExpoNotifications.getPermissionsAsync()
if (status === 'granted') return 'granted'
if (status === 'undetermined') return 'undetermined'
return 'denied'
}
In the settings screen, track NotificationPermissionStatus | null (null = loading):
const [notifPermission, setNotifPermission] = useState<NotificationPermissionStatus | null>(null)
async function refreshPermissionStatus() {
if (isSimulator) return
const status = await getNotificationPermissionStatus()
setNotifPermission(status)
if (status === 'granted') {
const token = await getFCMToken()
const { data: { user } } = await supabase.auth.getUser()
if (user) upsertDeviceToken(user.id)
}
}
The permission row tap handler covers all three states:
async function handlePermissionPress() {
if (notifPermission === 'granted') return
if (notifPermission === 'undetermined') {
// First time — show in-app OS dialog
const granted = await requestNotificationPermission()
setNotifPermission(granted ? 'granted' : 'denied')
if (granted) { /* fetch token + upsert */ }
return
}
// Denied — must go to Settings
openedSettings.current = true
await Linking.openSettings()
}
Show label and color per state:
'undetermined' → accent color, label "Enable"'granted' → green, label "Granted"'denied' → red, label "Denied — tap to open Settings"When a user denies permission then later re-enables it from iOS/Android Settings, the app must detect the return and register the FCM token immediately. Pattern: AppState listener + openedSettings ref:
const openedSettings = useRef(false)
useEffect(() => {
refreshPermissionStatus()
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active' && openedSettings.current) {
openedSettings.current = false
refreshPermissionStatus() // re-check + upsert if newly granted
}
})
return () => sub.remove()
}, [])
The OS silently drops notifications to users who have denied permission — the backend never needs to know. Store only fcm_token, reminder_hour, reminder_minute, and timezone. When the user disables a reminder in-app, set those three fields to null; the edge function skips rows with no reminder set.
Always include timezone (IANA string) when syncing reminder time so the server can fire at the correct local time regardless of UTC offset. Use Intl.DateTimeFormat().resolvedOptions().timeZone on the device:
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
await supabase.from('device_tokens').upsert(
{ user_id, fcm_token, reminder_hour: enabled ? hour : null,
reminder_minute: enabled ? minute : null,
timezone: enabled ? timezone : null, updated_at: new Date().toISOString() },
{ onConflict: 'user_id' },
)
Always apply every server-side fix — edge functions, migrations, RLS policies, DB schema changes — to both stg AND prd. Client code is shared and deployed once, but server-side changes are per-project. Never leave one Supabase project behind. Use the project's npm scripts:
yarn functions:deploy:stg # or the equivalent for migrations
yarn functions:deploy:prd
If only stg is fixed, bugs will be impossible to reproduce in production and users will be affected while staging appears healthy.
Edge functions are Deno — exclude them from the React Native tsconfig or tsc will fail on Deno globals and https://esm.sh/ imports:
"exclude": ["node_modules", "supabase/functions"]
The @anicca-labs/react-query-sdk spec generator scans every subdirectory of supabase/functions/ and generates OpenAPI 3.0 entries for each. When merged with the Supabase REST spec (Swagger 2.0), the version mismatch causes orval validation to fail.
Every edge function must start with // @openapi-internal unless it is explicitly a typed client API endpoint that should appear in the generated React Query SDK. In practice, all functions in this project use // @openapi-internal — cron jobs, webhooks, admin tools, push senders, device-facing custom protocols (e.g. OTA manifest), and dev helpers all qualify.
// @openapi-internal — <one-line description of what this function does>
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
The generator skips any index.ts that contains // @openapi-internal. Forgetting this line causes yarn start to fail with an orval OpenAPI validation error.
supabase functions deploy requires Docker only for local serving. Deploying to the hosted project works without it:
supabase login --token $SUPABASE_ACCESS_TOKEN
supabase link --project-ref <ref>
supabase functions deploy send-reminders --project-ref <ref>
Set up via SQL in the Supabase dashboard (requires pg_cron and pg_net extensions, both enabled by default):
select cron.schedule(
'send-reminders',
'* * * * *',
$$select net.http_post(
url := 'https://<ref>.supabase.co/functions/v1/send-reminders',
headers := '{"Authorization": "Bearer <service_role_key>"}'::jsonb
) as request_id;$$
);
The FCM v1 API requires a service account private key to send push notifications programmatically. This is different from what the Firebase Console UI uses — the console authenticates you as a human via Google login, but a backend function needs its own credential to call the FCM HTTP API on your behalf.
To obtain it: Firebase Console → Project Settings → Service Accounts → Generate new private key. This downloads a JSON file containing client_email and private_key.
Store those two values as Supabase secrets (FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY) and delete the JSON from your filesystem immediately. Do not revoke the key once it is in Supabase secrets — the edge function depends on it. If you suspect it is compromised, rotate it: generate a new key, update the Supabase secrets, then revoke the old one.
One service account JSON covers all Supabase environments (stg, prd) that point to the same Firebase project.
Use jose to sign the JWT assertion and exchange for an OAuth2 access token. Store FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY as Supabase secrets:
import * as jose from 'https://esm.sh/jose@5'
const privateKey = await jose.importPKCS8(FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), 'RS256')
const jwt = await new jose.SignJWT({ scope: 'https://www.googleapis.com/auth/firebase.messaging' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1000) + 3600)
.setIssuer(FIREBASE_CLIENT_EMAIL)
.setAudience('https://oauth2.googleapis.com/token')
.sign(privateKey)
const { access_token } = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
}).then(r => r.json())
Then POST to https://fcm.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/messages:send with Authorization: Bearer <access_token>.
oauth not oauth2The correct grant type for a service account JWT assertion is urn:ietf:params:oauth:grant-type:jwt-bearer. Using oauth2 (with the 2) causes Google's token endpoint to return unsupported_grant_type. This applies to both Deno (URLSearchParams) and Node (fetch).
Even with a valid service account and OAuth token, FCM returns 401 UNAUTHENTICATED / THIRD_PARTY_AUTH_ERROR if the APNs Auth Key is not configured. This has nothing to do with your service account — it means Firebase can't forward the notification to Apple.
Fix: Firebase Console → Project Settings → Cloud Messaging → Apple app configuration → upload your .p8 APNs Auth Key (from Apple Developer → Keys). One key works for both development and production.
THIRD_PARTY_AUTH_ERROR in the FCM response details array is the diagnostic signal — the generic 401 message says nothing useful.
send_test_push MCP tool — prerequisites checklistBefore calling send_test_push:
firebase-service-account.json exists in the project root (path set in mcp.config.json → firebase.serviceAccountPath)firebase-service-account.json is in .gitignore — the default pattern *firebase-adminsdk*.json does not match a file named firebase-service-account.json; add an explicit entryinspect_push_tokens MCP tool — schema flexibilityThe tool introspects information_schema.columns before querying so it handles non-standard schemas:
token or fcm_tokencreated_at or updated_atplatform column may be absent (omitted from SELECT if missing)If you extend the device_tokens table, no changes to the tool are needed as long as id, user_id, and at least one of the token column names above are present.
Use Intl.DateTimeFormat to convert UTC now to the user's local time — do not store UTC offsets (they break across DST transitions):
function matchesReminderTime(now: Date, timezone: string, hour: number, minute: number): boolean {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', hour12: false })
.formatToParts(now)
const localHour = parseInt(parts.find(p => p.type === 'hour')!.value)
const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value)
return localHour === hour && localMinute === minute
}
Anchor the streak count to yesterday if today has no entry yet, so users see their active streak before writing each day — not 0:
const cursor = new Date()
if (!daysWithEntry.has(dayKey(cursor))) {
cursor.setDate(cursor.getDate() - 1) // still within today's window
}
while (daysWithEntry.has(dayKey(cursor))) {
streak++
cursor.setDate(cursor.getDate() - 1)
}
Only reset to 0 if neither today nor yesterday has an entry.
For prompts, tips, or any content that should change daily but stay stable throughout the day:
export function getDailyPromptIndex(promptCount: number): number {
return Math.floor(Date.now() / 86400000) % promptCount
}
Place the translatable prompt strings in the screen file (not in the hook) so Lingui's extractor picks them up for the correct catalog.
__DEV__)__DEV__ is true only when Metro bundler is running (dev client / yarn start). It is false in all EAS builds — including staging and production. Use it to gate developer-only UI that should never ship to users:
{__DEV__ && fcmToken ? (
<SizingAnimatedButton onPress={handleFcmTest} ...>
<LabelLg>Send real FCM push (dev)</LabelLg>
</SizingAnimatedButton>
) : null}
Typical uses: FCM pipeline test buttons, RC customer ID display, internal debug panels. Never use __DEV__ to gate features that should exist in staging but not production — use EXPO_PUBLIC_ENV for that.
For dev-only backend tools (e.g. send-test-push), still deploy to all environments (stg + prd). The app only calls them from __DEV__ builds, so they're harmless in production but don't need special environment checks.
Mark them // @openapi-internal (required for all edge functions — see above).
Do not wrap dev-only strings in a t template literal or <Trans> — Lingui will extract them and warn about missing translations in every language. Use a plain string literal instead:
// ✅ plain string — Lingui ignores it, no translations needed
'Not available on simulator'
// ❌ extracted by Lingui — requires translations in all 5 languages
t`Not available on simulator`
The exception is strings that appear in __DEV__ blocks but are also shown to real users in some flows — those still need t\``.
For truly unavoidable cases where a dev string ends up extracted (e.g. inside a shared component), copy the English value as-is into all non-English .po files to silence the warning.
| Script | Platform | Profile | Target |
|---|---|---|---|
dev-client-ios | iOS | development | Simulator |
dev-client-android | Android | development | Emulator |
dev-client-ios-device | iOS | development-device | Physical device |
dev-client-android-device | Android | development-device | Physical device |
dev-client-ios:prd / dev-client-android:prd etc. | — | — | Same, prd env |
Required eas.json profiles:
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": false }
},
"preview": {
"distribution": "internal",
"ios": { "simulator": false }
}
development / development-device → developmentClient: true, shows Expo dev launcher, connects to Metro over Wi-Fi or USBpreview → standalone build, JS bundled at build time, no Metro — use for distribution testing (TestFlight-like), not for iterative development on devicepreview for device dev clients — the app will open once (from the embedded bundle) but has no Metro fallback and will crash on subsequent opens if the bundle has any issueFor Android, the ios.simulator flag is ignored — development would work for physical Android devices too, but use development-device for consistency.
Required eas.json submit profiles:
"submit": {
"stg": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "<stg App Store Connect app ID>"
},
"android": {
"serviceAccountKeyPath": "./eas-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"prd": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "<prd App Store Connect app ID>"
},
"android": {
"serviceAccountKeyPath": "./eas-service-account.json",
"track": "production",
"releaseStatus": "draft"
}
}
}
stg uses releaseStatus: "completed" — without it, the AAB is uploaded to the artifact library but no release is created, requiring a manual step in the Play Console.prd uses releaseStatus: "draft" — production releases should be manually reviewed in the Play Console before going live (set rollout %, check release notes, confirm everything).@anicca-labs library publishing@anicca-labs/* packages live in the anicca-labs/libs monorepo. CI publishes automatically on push to main — never publish manually unless the CI workflow is broken.
Workflow for fixing a library bug:
node_modules locally to validate the fix in the consuming appanicca-labs/libspackage.jsonmain — CI publishes and the fix is liveDo not use yarn patch (Yarn Berry's patch mechanism) for @anicca-labs/* packages — that's for third-party libraries you don't control. Fix the source instead.
npm auth token — store in ~/.yarnrc.yml (global, never committed):
npmScopes:
"@anicca-labs":
npmRegistryServer: "https://registry.npmjs.org"
npmAuthToken: npm_xxx
Also add to ~/.npmrc for plain npm publish fallback:
//registry.npmjs.org/:_authToken=npm_xxx
Never put the token in the repo's .yarnrc.yml.
Always include these entries for Expo/EAS projects:
# EAS local build artifacts
*.ipa
*.aab
*.apk
*.app
build-*.tar.gz
# Native credential files
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Firebase service account keys (admin privileges — never commit)
*firebase-adminsdk*.json
# Google Play service account key
eas-service-account.json
Build artifacts (.ipa, .aab, .apk) are produced by eas build --local and can be several hundred MB. They must never be committed.
jest-expo; render helper: @testing-library/react-nativerenderWithProviders helper that includes Tamagui, query client, and i18n providersscreen.getByText, screen.getByRole) — never on internal state/expo-rn-plugin:testing for canonical test patterns and provider setupnpx claudepluginhub anicca-labs/claude --plugin expo-rn-pluginGuides reception of code review feedback: verify before implementing, avoid performative agreement, push back with technical reasoning when needed.
Design banners for social media, ads, website heroes, and print with multiple art direction options and AI-generated visuals.