The Dexter app is built with Expo (React Native) and Expo Router for file-based navigation. Targets iOS, Android, and web.
app/— Expo Router routescomponents/— Shared UI (add as the app grows)hooks/— Custom hooks (optional)utils/— Helpers (optional)types/— TypeScript types (optional)
Place tests in __tests__/ next to source files. Do not put *.test.ts(x) under app/ (phantom routes).
The route tree is grouped so authenticated screens sit behind an auth boundary:
app/
_layout.tsx # Providers (QueryProvider + AuthProvider) + a headerless root Stack
index.tsx # Branches on useAuth(): login when signed out, /(app)/(tabs)/today when signed in
auth-callback.tsx # Landing route for magic-link / OAuth redirects (required on web)
(auth)/
_layout.tsx # Redirects signed-in users into the app
login.tsx # Single login/signup screen: magic-link email + "Continue with Google"
(app)/
_layout.tsx # Stack for the authenticated group; redirects signed-out users to login
new-task.tsx # Create-task modal (formSheet presentation)
(tabs)/
_layout.tsx # Expo Router native tabs (expo-router/unstable-native-tabs) — iOS/Android
_layout.web.tsx # Web override: classic JS Tabs with the tab bar hidden
today/ # "Today" tab — sun icon. Small screens: a `DayViewSwitcher` toggles the day's content between Tasks (the task list, preceded by a tappable habit tracker `components/HabitTracker` → `HabitRing`, gated on `enableHabits`), Notes (`components/NotesView`), Journal (`components/JournalView`), and Calendar (`components/CalendarView`, a scrollable timeline of the day's calendar events, gated on `enableCalendar`). Large screens (`useIsMultiPane`, ≥768dp): a multi-column layout instead — Tasks always visible, Notes/Journal/Calendar toggleable as columns (DEX-40)
settings/ # "Settings" tab — gear icon; grouped list of subview rows (Account, Appearance, Tasks, Lists, Calendars, Habits, Journal, Notes, Licenses). Log out lives in the Account subview; Appearance is the theme picker; Habits toggles habit tracking and inline-edits habits (`components/HabitRow`); Tasks (`tasks/index` + `tasks/[id]`) lists repeat-task templates and edits a repeat schedule (frequency presets that build the cron string); Lists (`lists/index` + `lists/[id]`) manages task lists — a `components/ListRow` per list (emoji, title, open-task count) with a header "+" to create and a modal to edit title/emoji or archive (archiving cancels the list's open tasks via the DB trigger); Notes toggles notes on/off and edits the daily-note template; Journal toggles the journal on/off and manages the daily prompt template (add/edit-on-blur/delete prompts); Licenses lists the app's open-source dependencies (generated by `npm run licenses` into utils/licenses.json); Calendars toggles the calendar on/off, sets the daily timeline's start/end hours (`components/TimeField.*`), and manages calendar sources via the platform-split `components/CalendarSourceList.*` (web: add/edit-on-blur/delete `.ics` feed URLs in `preferences.calendarUrls`; native: toggle which device calendars appear, saved to the device via `hooks/useEnabledDeviceCalendars`)
search/ # "Search" tab — placeholder (role="search"); search itself is not implemented yet
Tabs use native tabs (NativeTabs from expo-router/unstable-native-tabs), so they render with the platform tab bar. Icons are set per platform on NativeTabs.Trigger.Icon via sf (iOS SF Symbol) and md (Android Material). Elsewhere in the app, icons come from expo-symbols (SymbolView) or @react-native-vector-icons/ionicons. Native tabs require a dev client / native build (they do not appear in Expo Go). On web, NativeTabs renders a Radix tab bar with no supported way to hide it, so web uses a platform-specific _layout.web.tsx (the required non-extension _layout.tsx sibling is the native fallback) that renders the classic JS Tabs navigator with its bar hidden via tabBarStyle: { display: "none" } — the same three routes stay reachable by URL, just without an on-screen tab bar. The tab bar is tinted with the theme's primary color, and a NativeTabs.BottomAccessory (iOS 26+ only) hosts the "+ New Task" button (components/NewTaskButton.tsx) that opens the create-task modal — Android/web have no create entry point yet.
Modal screens get their options from utils/stackOptions.ts (createModalScreenOptions — form-sheet presentation with a native header; the .web.ts variant hides the header and screens render components/WebModalHeader instead). Modal headers put Cancel (✕) on the left and Save (✓, tinted with the primary color) on the right, wired via navigation.setOptions with unstable_headerLeftItems/unstable_headerRightItems (native iOS bar items) plus headerLeft/headerRight fallbacks from components/ModalHeaderButtons.tsx.
The create-task modal (app/(app)/new-task.tsx) pairs a useNewTaskForm hook (hooks/useNewTaskForm.ts) with a priority icon row (components/PriorityControl.tsx, expo-symbols icons tinted with the theme's priority colors), a menu-appearance list picker (@expo/ui universal Picker inside a Host), and DateField date chips (components/DateField.* — the SwiftUI compact date picker hosted with matchContents on iOS so it sizes to its chip, the community DateTimePicker on Android, and — since the community picker renders nothing on web — a themed react-day-picker calendar popover on web (DateField.web.tsx, following the legacy dexter-app's ButtonWithPopover: a "Weekday, Mon D" trigger button opens a calendar themed from useTheme); all three share the Date-based TDateFieldProps contract). Shorthand tokens typed into the title (! priority, #list-slug, due:N — parsed by utils/parseTaskShorthand.ts) drive the controls live; a manually changed control wins over tokens, and tokens are stripped from the title on save. The schedule date defaults to the day the user is viewing on the Today tab rather than always today: hooks/useViewedDay.tsx holds it in a module-scoped store (not React context — NewTaskButton renders inside the NativeTabs.BottomAccessory, which react-native-screens hosts outside the app's provider tree, so a context value wouldn't reach it). The Today screen publishes its day via usePublishViewedDay on focus and clears it on blur; because opening the modal blurs the tab, NewTaskButton reads the store with getViewedDay() at press time and passes it as the scheduledFor route param, and the modal falls back to today when no day is on screen (e.g. opened from Settings/Search).
Each tab is its own folder with a nested _layout.tsx Stack (headers/titles, room for pushed detail screens) and an index.tsx screen.
The Today tab (app/(app)/(tabs)/today/index.tsx) pairs components/DayNav.tsx (prev/today/next arrows) with day state ({date, direction}) that both the small- and large-screen layouts share; the route is a thin selector that renders components/SmallScreenToday.tsx or components/LargeScreenToday.tsx (useIsMultiPane), each owning its own view/pane state while the route keeps only the genuinely shared state — day, preferences, and the backlogAttentionFilter signal (DEX-55). DayNav's center control is dual-purpose: when viewing a day other than today it's a tap-to-reset-to-today shortcut, but when already on today it renders a DateField calendar picker so any date is one tap away (converting Date ↔ Temporal.PlainDate at the boundary; its accessibilityLabel flips between "Go to today" and "Open date picker"). Jumping to a far-off date still animates in the correct direction because today/index.tsx derives direction from Temporal.PlainDate.compare. Paging between days needs no extra fetch: hooks/useTasks.tsx's useTasks() fetches the account's tasks once under a single canonical ["tasks"] query (every incomplete task, plus anything scheduled within the last 30 days — see below), and every view derives its own slice from that one cached array client-side (utils/taskFilters.ts), so switching days is instant (DEX-57). The 30-day window is a known limitation, not a bug: paging to a day older than that shows its incomplete tasks (never excluded) but not its completed/won't-do ones — widen RECENT_TASK_WINDOW_DAYS in useTasks.tsx if that's ever too narrow.
Tasks (always visible, both layouts) is components/TasksView.tsx — the habit tracker (gated on enableHabits) plus the day's task list, extracted from the route so it's composable across layouts. Task cards (components/TaskCard.tsx) carry a minHeight floor and pin their IconMenu triggers (StatusButton, ListButton) to 32×32 so the async-sizing native menu hosts can never define a row's height; the patches/expo-modules-core patch (see the platform-split section below) is what keeps those hosts from corrupting card rendering when days are paged.
Small screens wrap the active view in components/SwipeableDay.tsx, a react-native-gesture-handler pan so swiping left/right also pages a day forward/back (react-native-reanimated's FadeInRight/FadeInLeft on a keyed remount). An inline components/DayViewSwitcher.tsx (a circular, icon-only GlassIconButton at the right of the DayNav row that opens an IconMenu) switches the content between Tasks, Notes, Journal, and Calendar — all sharing the screen's single {date} state. The button's icon reflects the active view. The Notes/Journal/Calendar entries appear only when enabled in settings (enableNotes / enableJournal / enableCalendar); its option list is built by the exported dayViewOptions helper (unit-tested without the native menu host). When incomplete tasks are overdue or left behind as of today, the trigger button shows a warning-yellow attention dot (DEX-58) — the small-screen home for the indicator, since the Backlog action lives inside this menu (its large-screen counterpart sits on the drawer-toggle button below) — and the "Backlog" menu row itself is tinted the same warning-yellow (via IconMenu's iconColor/titleColor) so it's clear what the dot refers to. Tapping "Backlog" opens the drawer pre-filtered to the preset the dot maps to (backlogAttentionFilter: Overdue if any overdue task exists, else Left Behind) by calling the sheet's imperative present(filter). Notes and Journal are wrapped in SwipeableDay with the pan suspended while a field is focused (enabled={!editing}, driven by the view's onEditingChange) so horizontal drags position the caret/selection instead of paging days; SwipeableDay is keyed by date, so switching days remounts the view — re-seeding the (uncontrolled) editor/inputs and resetting the Notes template choice. The task drawer opens from a "Backlog" action inside the DayViewSwitcher menu (passed as its onOpenDrawer prop, appended as a divided section below the view options) — folded in rather than given a second header button, which crowded DayNav's next-day arrow — see below.
Large screens (hooks/useIsMultiPane.ts, width ≥ utils/breakpoints.ts's TWO_PANE_MIN_WIDTH) show a multi-column layout instead (DEX-40): Tasks is always visible (capped at TASKS_PANE_MAX_WIDTH), and Notes/Journal/Calendar are toggleable columns to its right — no swiping (DayNav's arrows/picker are the only way to change days). components/DayPaneToggles.tsx renders one round GlassIconButton per enabled surface in the header (reusing DayViewSwitcher's VIEW_META icons/labels), tinted via the button's active prop; pressing one calls hooks/useTodayPanes.ts's togglePane, which persists which panes are open to the device (AsyncStorage, like useEnabledDeviceCalendars — not the synced preferences row, since it's a per-device layout choice) and defaults every pane open. Notes and Journal share one bordered, tabbed pane (components/NotesJournalTabs.tsx) rather than separate columns — a small manila-folder-style tab bar (rounded top corners, border on the active tab only) appears only when both are enabled; with just one, its content fills the pane with no tab bar. Only the shown NotesView/JournalView element is keyed on date (not the whole component), so an editor re-seeds on a day change without resetting which tab is selected. NotesView takes an inset prop (false here) so its card runs flush to the column and drops its own fill/border, instead of double-inset and double-bordered against the tabbed pane's own border. Calendar gets its own narrower cap (CALENDAR_PANE_MAX_WIDTH) and a bordered card, and is pinned to the row's right edge via marginLeft: "auto" so it stays rightmost even when Notes/Journal isn't rendered. A "+" GlassIconButton beside the pane toggles opens the create-task modal scheduled for the viewed day (mirroring NewTaskButton, but pushing directly since the date is already in scope here rather than read back from the module store).
The task drawer (components/TaskDrawer.tsx, DEX-33) surfaces every incomplete task not scheduled for the viewed day — unscheduled backlog, tasks left behind on earlier days, and tasks scheduled for other days — with Filter (No Filter/Overdue/Due Soon/Left Behind/Unscheduled) and Group (No Grouping/By List/By Priority/By Goal) IconMenus and a live title-only search, mirroring the legacy dexter-app's QuickPlanner. Tapping a row's "+" schedules that task onto the viewed day via updateTask; since it and the Tasks pane both read the same canonical ["tasks"] cache entry, the Tasks pane picks up the change automatically. Rather than issuing its own server query, the drawer derives its base scope from useTasks()'s canonical fetch via utils/taskFilters.ts's selectBacklogTasks(tasks, date) (incomplete tasks unscheduled or scheduled for another day), then applies whichever Filter preset is selected via filterTasks — all client-side, so switching the Filter menu, typing a search, or changing the Group menu never triggers a fetch (DEX-57). useLists/useGoals (needed once a matching Group is picked) are pre-warmed as soon as a session exists (app/(app)/_layout.tsx's prefetchQuery calls), rather than only starting on first selection, so picking "By List"/"By Goal" doesn't wait on a cold fetch either. isLoading reflects the shared canonical ["tasks"] query, which is usually already resolved by the time the drawer first mounts (the Tasks pane fetches it eagerly), showing an ActivityIndicator instead of the empty state only on a cold start.
The controls+search sit in a plain View above a @shopify/flash-list FlashList of the (potentially large, unlike a single day's list) backlog: groupTasks's {id, title, tasks}[] groups are flattened into one array of {type: "header"|"task", ...} rows (getItemType keys recycling off type) so FlashList can recycle rows instead of every TaskCard mounting at once — each row carries multiple @expo/ui native menu hosts (StatusButton always, MoreMenu/ListButton for incomplete tasks), which is expensive in bulk (see TaskCard.tsx's minHeight comment). FlashList v2 is JS-only (no native recycler module), which matters here: TasksView.tsx's own un-virtualized ScrollView explicitly avoids virtualization because a native recycler's off-viewport mount/unmount worsens those same native menu hosts' async-sizing (expo/expo#42576) — v2's rewrite is why the Backlog can virtualize without hitting that. It's hosted two ways. Small screens: components/TaskDrawerSheet.tsx wraps it in @expo/ui/community/bottom-sheet's BottomSheetModal — a native SwiftUI sheet on iOS, a Compose ModalBottomSheet on Android, and a vaul drawer on web — with fixed snapPoints (["55%", "90%"]) and a BottomSheetView filling the detent, opened imperatively via ref.current?.present() from the DayViewSwitcher menu's drawer action (BottomSheetModal has no controlled "visible" prop); it defers rendering TaskDrawer until the first open, avoiding the cost of building its content on every Today load even though the underlying ["tasks"]/["lists"]/["goals"] queries are all shared and already warm. Two things are load-bearing when hosting the drawer in a sheet: (1) the Filter/Group controlButtonInners need an explicit height (not flex: 1) — the native @expo/ui menu host sizes to its child's intrinsic height, so a flex-only child with no bounded ancestor collapses the control to ~2px (invisible); the docked pane happened to bound it, so this only bit inside a scroller. (2) TaskDrawer's root View and its FlashList both need flex: 1 to bound the list's height to the sheet/pane, or it lays out at full content height and overflows instead of scrolling. Large screens: docked inline as the pane row's rightmost column (after Calendar, DRAWER_PANE_MAX_WIDTH), toggled by a GlassIconButton in headerActions and persisted via useTodayPanes (a "drawer" pane alongside notes/journal/calendar, defaulting closed rather than open since it's an opt-in triage tool, not a glance surface — useTodayPanes merges a stored value's present keys onto the defaults, so a device's pre-DEX-33 storage keeps its notes/journal/calendar choices when the drawer key is missing). This drawer-toggle button carries the overdue/left-behind attention dot (DEX-58): today/index.tsx computes utils/taskFilters.ts's backlogAttentionFilter(tasks, today) off the shared ["tasks"] cache — the Filter preset ("overdue" if any incomplete task is overdue (dueOn < today), else "leftBehind" if any is left behind (scheduledFor < today), else null) as of the real today — and passes it to components/LargeScreenToday.tsx, which derives the dot from !== null and passes it as the button's indicator prop, which GlassIconButton renders as a warning-yellow dot (theme.colors.priority[0], shared with the small-screen DayViewSwitcher trigger via GlassIconButton.indicator.tsx). This reimplements the legacy app's yellow side-panel icon, extended to overdue tasks. Tapping either Backlog surface pre-applies that filter: on small screens TaskDrawerSheet owns the filter state and exposes present(filter?) so the DayViewSwitcher "Backlog" action can seed it; on large screens LargeScreenToday owns drawerFilterId and its header drawer-toggle applies the attention filter when opening the pane (not when closing). TaskDrawer runs controlled off whichever owner (optional filterId/onFilterChange), and the user can still change the filter in-drawer afterward.
Calendar renders components/CalendarView.tsx, a themed, scrollable vertical timeline of the day's events bounded by the user's configured start/end hours (preferences.calendarStartTime/calendarEndTime). All-day events pin to a header; timed events are positioned by utils/calendarLayout.ts, which clamps them to the window and packs overlapping events into side-by-side columns. Times are formatted manually (utils/formatPlainTime.ts) for the same Hermes-Intl reason as formatPlainDate. The event source is the platform-split hooks/useCalendarEvents.* (see the platform-split section): on native it reads the device's enabled calendars via expo-calendar (requesting permission, filtered by the device-local hooks/useEnabledDeviceCalendars); on web it fetches each .ics feed through the ics-proxy Edge Function and parses it with ical.js (utils/icsEvents.ts, expanding recurrence rules). Both normalize to a shared TCalendarEvent (useCalendarEvents.types.ts), so CalendarView is source-agnostic. This is frontend-only — the proxy and the calendar_* preference columns already existed (DEX-39). On today, the timeline auto-scrolls once on first layout so the "now" line lands in the upper third of the viewport — recent and upcoming meetings in frame without manual scrolling (DEX-54). The offset is a pure, clamped helper (utils/calendarLayout.ts's scrollOffsetForTarget), fired from the ScrollView's onLayout (which is when its viewport height is known) and guarded to run once per mount; since the view remounts per day (SwipeableDay on small screens, a date key on large ones), that single mount-time scroll covers both "view loads" and "day changed". On any other day the now line is null, so it stays at the top.
Notes render components/NotesView.tsx, which reads/writes the day's markdown blob via hooks/useDays.tsx (days.notes, one row per date) and autosaves edits debounced. When the day has no note row yet and a templateNote is configured, it first offers "Use daily note template" / "Blank note" (both write a row via upsertDay, so the choice persists across remounts/tab switches instead of re-prompting; useDays exposes exists for this and no longer auto-seeds the template into a blank day). The editor itself is components/NoteEditor.*, a platform-split wrapper over react-native-enriched-markdown (see the platform-split section). Journal renders components/JournalView.tsx, which reads/writes the day's reflection prompts via the same hooks/useDays.tsx (days.prompts, a {prompt, response}[] blob on the same row) and autosaves debounced; responses are plain text (a multiline components/TextInput — no rich-text dependency, so it's identical on web and native), and prompts auto-seed from preferences.templatePrompts (so there's no template chooser — nothing persists until the user answers, and an empty template shows an "add prompts in Settings" message). Because notes and prompts share one days row, each writes through a partial upsertDay diff that preserves the sibling column.
utils/theme.ts is the single source of truth for colors and spacing. It defines a themes registry keyed by name — dexter, light, dim, dark, abyss (colors ported from the legacy dexter-app's daisyUI tokens, oklch → hex) — each sharing one baseTheme for non-color tokens (borderRadius, fonts, gap, spacing). THEMES lists them with a mode (light/dark) for the picker.
useTheme()returns the activeThemeand is the only way components should read colors. Readthemefrom the hook and inject color values inline (style={[styles.card, { backgroundColor: theme.colors.card }]}), keeping static layout inStyleSheet.create. Don't hardcode hex/rgba colors in aStyleSheet.- Users pick their appearance. The
preferencestable storesthemeMode(EThemeMode—SYSTEM/LIGHT/DARK, fromapi/preferences.ts),lightTheme, anddarkTheme.providers/ThemeProvider.tsx(mounted in the root layout inside the auth + query providers) reads those viausePreferences(), resolves the active theme with the pureresolveTheme(preferences, systemScheme)helper, and supplies it throughThemeContext. The Settings → Appearance screen (app/(app)/(tabs)/settings/appearance.tsx) is where users set them. useTheme()falls back to an OS default with no provider.ThemeContextisnullabove the providers (root layout chrome), on unauthenticated screens, and in tests; thereuseThemeresolvesdexter(light) ordark(dark) fromuseResolvedColorScheme().SYSTEMmode follows the OS via the same hook, so the app still re-renders live when the device switches light/dark.app.jsonsetsuserInterfaceStyle: "automatic"so native chrome (status bar, tab bar) adapts too.- On web, the first paint has no reliable
prefers-color-schemesignal, souseResolvedColorSchemerenderslightthen resolves the real scheme in a layout effect (before paint, no visible flash). - Navigation surfaces must be themed explicitly. A bare
<Stack>renders a default (light) header even in dark mode, so nested tab layouts pass their screens throughcreateListScreenOptions(theme, title)and modals throughcreateModalScreenOptions(theme, title)(both fromutils/stackOptions.ts, with.web.tsvariants). The root Stack sets a themedcontentStylebackground so the gap before a screen paints (cold start, auth redirects) matches the scheme. withOpacity(color, alpha)applies/compounds an alpha channel on a hex orrgba()color — use it to derive tints from theme colors (e.g. dividers, pressed states) instead of hardcoding gray rgba.
The brand mark is a husky on cream (#FAF2E6 background, #593D31 line-art). Icons are configured in app.json:
- iOS/macOS — an Apple Icon Composer
.iconbundle atassets/app.icon/(icon.json+Assets/Vector.svg), wired viaios.icon. It carries its own light/dark/tinted (Liquid Glass) variants, so no per-appearance PNGs are needed; older iOS versions get an automatic fallback. Supported in SDK 54+. - Android —
android.adaptiveIconwith a solid creambackgroundColorplusforegroundImage(husky line-art) andmonochromeImage(silhouette for themed icons) inassets/images/. - Web + fallback — top-level
icon(assets/images/icon.png, a flat cream square) andweb.favicon(assets/images/favicon.png).
The husky vector in assets/app.icon/Assets/Vector.svg is the source of truth; the Android foreground/monochrome PNGs are rasterized from it. Native icon files are generated by prebuild/EAS (CNG) — the ios//android/ dirs are gitignored.
Auth is Supabase-backed (magic-link email + Google OAuth, PKCE flow) via hooks/useAuth.tsx, which exports AuthProvider/useAuth ({ initializing, session, userId }) and signInWithEmail / signInWithGoogle / signOut helpers. The singleton supabase client lives in utils/supabase.ts (env validation + native AppState auto-refresh) and is re-exported from hooks/useAuth.tsx for existing call sites.
-
Email login is link or code.
signInWithEmailsends a magic link, and the email template (supabase/templates/magic_link.html) also renders the{{ .Token }}code, soapp/(auth)/login.tsxshows a two-step flow: enter email → enter the 6-digit code. The code is verified withverifyEmailOtp(verifyOtp({ type: "email" })); tapping the link still works viaauth-callback. On success the session is set and(auth)/_layout.tsxredirects into the app — no manual navigation. -
Demo account login.
isDemoEmail(email)(exact match onDEMO_EMAIL, duplicated fromsupabase/functions/_shared/demoAuth.ts) routes the App Store demo account down a separate path: no email is sent, and the typed code is verified by theverify-demo-otpEdge Function viaverifyDemoOtp, which returns a session installed withsetSession. Keeps reviewer login inbox-free. Seedocs/backend.md(Demo account login). -
MCP / OAuth consent:
app/oauth/consent.tsxrenders the Supabase OAuth-server consent screen ({site_url}/oauth/consent?authorization_id=…). It sits outside the(app)group, so it guards itself — an unauthenticated visitor'sauthorization_idis stashed (utils/oauthReturn.ts) and replayed byauth-callback.tsxafter sign-in. Seedocs/backend.mdfor the server-side config and client registration. -
Guards live in the layouts:
(app)/_layout.tsxredirects signed-out users to/(auth)/login;(auth)/_layout.tsxredirects signed-in users to the app;app/index.tsxbranches on session at boot. -
Callback URL is
Linking.createURL("auth-callback")— platform-adaptive:dexter://auth-callbackon native (scheme set inapp.json),https://<origin>/auth-callbackon web.app/auth-callback.tsxexists so the web navigation doesn't 404;AuthProviderpicks the URL up and exchanges the?code=param for a session. -
Redirect allowlist: both callback forms must be registered in
supabase/config.toml(additional_redirect_urls) and the hosted Supabase project's Auth URL allowlist. -
On native, token auto-refresh is tied to
AppState(refresh on foreground), and a corrupted/revoked refresh token clears persisted auth storage (utils/authStorage.ts) so the user can sign in again.
From repository root:
cd src
npm install
npm start # dev server (QR / simulator / press w for web)
npm run web # web only
npm run ios # native build + run iOS
npm run android # native build + run Android
npm run lint # expo lint
npm run format # Prettier
npm test # Jest (jest-expo)
npm run typecheck # tsc --noEmitNative development uses an EAS-built development client rather than Expo Go. Build profiles are defined in src/eas.json (development, simulator, e2e-test, preview, production), and expo-dev-client is a dependency.
npm run dev:simulator # eas build --platform ios --profile simulator (iOS Simulator)
npm run dev:ios # eas build --platform ios --profile development (on-device)
npm run dev:android # eas build --platform android --profile developmentThe EAS project is wired up via extra.eas.projectId and owner in app.json. appVersionSource is remote, so build/version numbers are managed by EAS.
Production release to the App Store is a manual Build and Submit workflow (.github/workflows/build-and-submit.yml): eas build --profile production → eas submit → tag + GitHub release from CHANGELOG.md. See docs/appstore.md for the required credentials (EXPO_TOKEN, submit.production.ios.ascAppId, an App Store Connect API key on EAS).
- Expo SDK 57 (see
src/package.jsonfor exact versions) - iOS deployment target 26.0 — set via the
expo-build-propertiesplugin inapp.json; the tab bar's bottom accessory (and other planned features) require iOS 26+ - React 19.2 / React Native 0.86 with react-native-web for web
- React Compiler enabled via
experiments.reactCompilerinapp.json react-native-gesture-handler/react-native-reanimatedback the Today tab's swipe-to-change-days gesture (components/SwipeableDay.tsx).GestureHandlerRootViewis mounted once at the root inapp/_layout.tsx, which any future gesture-driven feature (e.g. task drag-and-drop) can build on.- TypeScript 6 —
tsconfig.jsonextendsexpo/tsconfig.base - ESLint —
expo lint(bootstrapseslint-config-expoon first run) - Web render mode —
web.output: "single"(SPA) so the Supabase-backedAuthProviderdoesn't have to run under Node SSR.
Supabase client code reads the public Expo environment variables below from local, uncommitted .env files:
EXPO_PUBLIC_SUPABASE_URL=...
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=...
EXPO_PUBLIC_SENTRY_DSN=...Generated Supabase database types live at src/types/database.types.ts. Regenerate them from /src with:
npm run supabase:typesUncommitted .env / .env.local files are normal; never commit secrets.
The app reports errors and performance data via @sentry/react-native (not the deprecated sentry-expo), wired through the @sentry/react-native/expo config plugin in app.json (organization: "cvburgess", project: "dexter-app").
- DSN:
utils/sentry.tsexportsgetSentryDsn(), which readsEXPO_PUBLIC_SENTRY_DSNand throws if it's missing — same throw-if-missing pattern asutils/supabase.ts. The DSN is a public identifier, safe to expose client-side; set it insrc/.env.localfor local dev (uncommitted, see above). - Init + wrap:
app/_layout.tsxcallsSentry.init(...)at module scope (before any component renders) and exportsSentry.wrap(RootLayout)as the default export — the earliest lifecycle hook available.Sentry.wrapis plain component composition (a touch-event boundary + profiler), so it's compatible with the React Compiler (experiments.reactCompilerinapp.json). - Navigation tracing: a module-scoped
Sentry.reactNavigationIntegration()instance is passed toSentry.initand registered against the rootStack's navigation container ref (useNavigationContainerReffromexpo-router) insideThemedStack, so screen transitions show up as Sentry spans/breadcrumbs. - Error boundary:
app/_layout.tsxalso exportsErrorBoundary({ error, retry })(Expo Router's per-layout error boundary convention), which reports the error viaSentry.captureExceptionand renders a themed fallback with a retry button. Because it can render when a provider above it (e.g.ThemeProvider) failed to mount, it relies onuseTheme()'s OS-resolved fallback rather than assuming providers are present. - React Query:
providers/QueryProvider.tsxwires aQueryCache/MutationCacheonErrorhandler that reports failed queries and mutations toSentry.captureException, in addition to React Query's normalisError/errorstate for UI handling. - Web:
Sentry.initsetsenableNative/enableNativeCrashHandlingtoPlatform.OS !== "web"explicitly (react-native-web has no native module bridge) — the SDK itself also no-ops native-only behavior on web, but this keeps intent explicit rather than relying on an internal check. - Source maps:
src/metro.config.jswraps Expo's default Metro config withgetSentryExpoConfig(from@sentry/react-native/metro) so bundles are annotated for symbolication. Actual source-map / debug-symbol upload happens via native build-phase scripts the config plugin injects into the iOS/Android projects duringeas build, gated on the build configuration:previewandproductioninsrc/eas.jsonbuild withios.buildConfiguration: "Release"and upload source maps + debug symbols. This requires aSENTRY_AUTH_TOKENEAS secret (build-time only, neverEXPO_PUBLIC_*, never hardcoded) — set it witheas secret:create --scope project --name SENTRY_AUTH_TOKEN --value <token>.development,simulator, ande2e-testsetSENTRY_DISABLE_AUTO_UPLOAD: "true"in theireas.jsonenvblock, since those Debug builds don't have (and don't need) aSENTRY_AUTH_TOKEN.
- Manual verification: trigger a test error with
setTimeout(() => { throw new Error("Sentry test"); });from anywhere in the app and confirm it appears in the Sentry dashboard for thedexter-appproject.
api/contains typed Supabase query modules.hooks/contains React Query hooks and the Expo-compatibleuseAuthprovider.utils/contains data helpers shared by the query layer and hooks.providers/QueryProvider.tsxexports the React Query provider;providers/ThemeProvider.tsxresolves the user's chosen theme intoThemeContext. Route wiring inapp/is intentionally separate from the data layer.
Tasks, notes, and other Supabase-backed data can change from web, MCP, or another device while the app is open — three layers keep the cache from going stale:
- Shared staleTime.
QueryProvidersetsdefaultOptions.queries.staleTimetoDEFAULT_STALE_TIME_MS(60s), replacing the hand-copied 10-minutestaleTimeevery Supabase-backed hook used to set individually. Device-backed hooks still override it:useTodayPanes/useEnabledDeviceCalendars(Infinity, AsyncStorage-backed) anduseCalendarEvents/.web.ts(10 min +refetchOnMount: "always", device calendar/ICS-backed) have no cross-platform staleness to bound. - Focus refetch. React Query's
refetchOnWindowFocusneeds afocusManagerevent source; the browser'svisibilitychangecovers web for free, but nothing wired one on native.QueryProvidernow tiesfocusManager.setFocusedtoAppStateon native (mirroring the auth-refreshAppStatelistener inutils/supabase.ts), so foregrounding the app refetches any query older than the staleTime above. - Realtime invalidation signal.
useRealtimeInvalidation(mounted from(app)/_layout.tsxonce a session exists) subscribes to Supabase Realtimepostgres_changeson every user-owned table and invalidates the matching query key(s) — seeREALTIME_INVALIDATIONSfor the table → key map. It is deliberately invalidation-only: event payloads are never written into the cache, so every refetch still goes through the normal RLS-scoped REST path (seedocs/backend.md's Realtime section for why — unfilterable DELETE events, PK-only old records). A burst of events for one table is coalesced into a single invalidation (~250ms debounce) instead of one refetch per row. Because Realtime does not replay events missed while disconnected, a channel rejoin after the firstSUBSCRIBEDinvalidates every mapped key once as a catch-up. - The
daysecho guard.useDays's autosave upsert echoes back as its own realtime event. Refetching a date's row while that date's mutation is still in flight would race the debounced editor the same way aninvalidateQueriescall inonSuccesswould (see the comment there) —useDaystags its mutation withdaysMutationKey(date)(scoped per date, not just the table), anduseRealtimeInvalidationinvalidates["days"]with apredicatethat skips only the date(s) currently mutating. Scoping by date (rather than blocking the whole table) matters because adaysmutation is designed to survive its component unmounting and keeps retrying in the background (e.g. after swiping to another day) — a stuck retry for one date must not suppress a genuine incoming update for a different one.
No interval polling is used — realtime plus the 60s staleTime/focus backstop was judged to cover freshness without the extra request volume.
Components that need a native module unavailable on web follow a four-file pattern, e.g. DateField (components/DateField.*):
Component.types.ts— shared prop types.Component.native.tsx(or.ios.tsx/.android.tsx) — the native implementation.Component.web.tsx— the web fallback.Component.tsx— re-exports the native file. Metro/Jest resolve.native/.webautomatically per platform and ignore this file at runtime; it exists only sotsc(which doesn't do platform-extension resolution) can resolve@/components/Component.
components/NoteEditor is built this way too: the native file wraps react-native-enriched-markdown's EnrichedMarkdownTextInput (a Fabric/New-Architecture rich editor, uncontrolled via defaultValue + onChangeMarkdown so React state never fights the caret), while the web file renders the note read-only via EnrichedMarkdownText because the library's editable input has no web support yet (upstream software-mansion/react-native-enriched-markdown#392). The library is a native module, so it needs a dev-client rebuild (npm run dev:ios) — it won't run in Expo Go. It autolinks via its own codegenConfig/react-native.config.js; its Expo config plugin is optional (it only toggles LaTeX math) so app.json is unchanged.
components/GlassIconButton is a reusable circular, icon-only action button built this way (.ios.tsx + base .tsx + .types.ts, like DateField): on iOS it uses Apple's liquid glass (expo-glass-effect GlassView, iOS 26+, with a plain-circle fallback when isLiquidGlassAvailable() is false) around an SF Symbol; on Android/web it's a plain bordered circle around an Ionicons glyph (SF Symbols don't render off-iOS, so the caller passes both an sfSymbol and an ionicon). It's used as the DayViewSwitcher trigger, the large-screen DayPaneToggles/"New Task" buttons, and is meant for further header-style action buttons; because the native MenuView host needs a fixed-size trigger, give it (and the wrapping IconMenu style) an explicit size. An optional active prop tints the icon theme.colors.primary (true) or theme.colors.text (false) on every platform, for buttons that represent an on/off state (DayPaneToggles); omitted, it keeps each platform's original hardcoded color.
A task can carry an alarmTime ("HH:MM", backed by the tasks.alarm_time column) that rings a native alarm at that time on the task's scheduledFor date. The ringing is iOS-only via expo-alarm-kit (AlarmKit, iOS 26+), so utils/alarms.* is platform-split: alarms.ios.ts calls the native module, alarms.ts is a no-op fallback (web/Android), and alarms.shared.ts holds the pure, unit-tested scheduling math (alarmFireDate, reconcileAlarms). AlarmKit is treated as a projection of DB state, not scheduled imperatively: hooks/useAlarmSync (mounted once in (app)/_layout.tsx) reconciles the alarms that should exist — open tasks with a future alarm moment — against the ones AlarmKit already holds (getAllAlarms()), scheduling new/edited alarms and cancelling stale ones, with the task id as the alarm id. That makes set/unset/complete/delete/reschedule and background-created repeat occurrences all self-heal, and re-projects DB state onto AlarmKit on every launch. The "Set alarm" item lives in MoreMenu (iOS-only, between Schedule and List) and opens components/SetAlarmModal (a themed modal hosting the shared TimeField); recurring alarms are edited in the repeat-schedule screen and copied template→occurrence by both the app and MCP recurrence generators. Because an alarm is anchored to the task's scheduled date, changing that date is not silently allowed to move or orphan it: TaskCard's onChangeSchedule intercepts a schedule change on a task that has an alarm and prompts (via useConfirmation). On a reschedule the user picks Keep alarm (leave alarmTime; the reconcile re-fires it on the new day) or Unset alarm (clear it), or Cancel. On an unschedule there is no date for the alarm to fire on, so the prompt is unset-or-cancel only. A re-tap of the current day changes nothing, so it neither prompts nor clears. Native setup: expo-alarm-kit schedules an AlarmAttributes<Meta> Live Activity, so iOS requires a Widget Extension that registers the matching ActivityConfiguration(for: AlarmAttributes<Meta>.self) — without it the scheduled alarm has no Lock Screen / Dynamic Island views (and the iOS build fails to link the module). That extension is src/targets/DexterAlarmWidget/ (Swift + expo-target.config.js), scaffolded at prebuild by the @bacons/apple-targets plugin; its struct Meta: AlarmMetadata {} must stay named exactly Meta to match the type expo-alarm-kit schedules. app.json also sets ios.appleTeamId (so the plugin can code-sign the extension), NSSupportsLiveActivities: true, the App Group entitlement, NSAlarmKitUsageDescription, and deploymentTarget: "26.1" (the expo-alarm-kit podspec's minimum); configureAlarms() runs at the top of the root _layout. Because this is native (module + a second target), it needs a dev-client rebuild, not OTA, and the App Group + widget bundle id must be on the provisioning profile (EAS credentials sync them). This mirrors magic-meal-kit's proven AlarmKit integration.
components/IconMenu is an icon menu (sections of selectable options, opened by a tap or a long-press per its trigger prop) built this way: @expo/ui's community MenuView on native, a custom modal on web. StatusButton and ListButton build their sections and render through it as a small tap-to-open trigger; MoreMenu wraps an entire task card instead, opening on long-press with no menu title. On web, right-click is the mouse equivalent of long-press (DEX-60): the web variant wraps the trigger in a layout-neutral <div onContextMenu> that, for trigger="longPress" menus only, opens the menu at the cursor and suppresses the browser's native context menu — so a MoreMenu is reachable with a mouse. Tap-triggered menus keep the browser's default context menu. MoreMenu's "Other" group offers Duplicate, a Repeat action, and Delete: Repeat reads "Repeat" for a one-off task (creating a repeat-task template from it, then opening settings/tasks/[id]) or "Edit repeat schedule" when the task already has a template; Delete warns and removes the linked template when the task repeats. Completing a repeat task creates its next occurrence — the date is computed in utils/repeatSchedule.ts (getNextTaskDate, backed by croner) from useTasks, replacing the former Postgres trigger. An option can be tinted per-platform via iconColor (icon) and titleColor (label) — used for the Backlog attention row (DEX-58). Coloring an iOS menu item's SF Symbol needs patches/@expo+ui+*.patch: @expo/ui's MenuView applied .foregroundColor to leaf action buttons, which the iOS system menu ignores, so button icons never tinted (only checkable Toggle items did, via .tint); the patch switches leaf buttons to .tint too. Without it, iconColor is a no-op on iOS action buttons. patch-package reapplies it on npm install; re-check on @expo/ui upgrades.
patches/expo-modules-core+*.patch is load-bearing for these menus. SwiftUI mutates the platform view it hosts (autoresizingMask, frame, visibility) — Menu/ContextMenu even do so asynchronously after their hierarchy is torn down (e.g. paging the Today screen to another day). When ExpoSwiftUI.UIViewHost handed SwiftUI the React-managed UIView directly, those leaked mutations corrupted Fabric rendering: cards ballooned, collapsed to zero height, or whole all-completed days rendered blank (DEX-28, related to expo/expo#40604 / #42225). The patch makes UIViewHost hand SwiftUI a disposable isolation container instead, so SwiftUI can never touch React-managed views. Two things keep this working:
patch-packageapplies the patch onnpm install(postinstall script). Ifexpo-modules-coreis upgraded, re-check whether upstream fixed the Host lifecycle before regenerating.expo-build-propertiessetsios.usePrecompiledModules: falseinapp.json— SDK 57 ships Expo modules as prebuilt XCFrameworks by default, which would silently ignore the patched source. This costs a few minutes of build time; remove it together with the patch once upstream is fixed.