This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Nicky is a React Native journaling app built with Expo and Expo Router. It features native iOS UI via @expo/ui/swift-ui (SwiftUI components), native tab navigation, and SQLite persistence via Drizzle ORM.
pnpm expo run:ios # Build and run on iOS simulator
pnpm expo start --clear # Start Expo dev server (clear cache)
pnpm lint # Run ESLint
pnpm lint-fix # Run ESLint with auto-fix
pnpm typecheck # TypeScript type check
pnpm drizzle-kit generate # Generate migration files from schemaEnforced by lefthook (pre-commit: lint, commit-msg: format):
feat: add new feature
fix: bug fix
refactor: refactoring
chore: tooling / config changes
src/app/
_layout.tsx # Root — wraps everything in DrizzleProvider + NativeTabs
(journal)/
_layout.tsx # Stack — scoped to journal tab
index.tsx # Journal list /
create.tsx # Create journal /create
edit.tsx # Edit journal /edit?journalId=...
[id].tsx # Entry list /[id]
entry/
create.tsx # Create entry /entry/create?journalId=...&journalName=...
[id].tsx # Entry detail /entry/[id]?journalName=...
explore.tsx # Report tab
Navigation flow: Journal list → [id] (entry list) → entry/[id] (entry detail)
Key rule: NativeTabs is the root navigator; Stack lives inside (journal) group. This keeps the tab bar visible when pushing screens.
Drizzle ORM + expo-sqlite. Schema is split by domain in src/db/schemas/:
| File | Tables |
|---|---|
journals.ts |
journals |
fields.ts |
fields (field definitions per journal) |
entries.ts |
entries, entry_values |
src/db/schemas/index.ts re-exports all schemas. src/components/drizzle-provider.tsx opens the DB, runs migrations via useMigrations, and exports db.
Adding a schema change: edit the relevant schema file → pnpm drizzle-kit generate → commit the generated files in drizzle/.
Drizzle config notes:
- Schema files must not import React Native packages (
expo-crypto,expo-symbolsruntime imports) — drizzle-kit runs in Node.js. Useimport typefor RN types. $defaultFnwithCrypto.randomUUID()cannot be used in schema — generate IDs at the application layer instead.- SQLite column names in Drizzle are camelCase (e.g.
sortOrder, notsort_order). When writing raw SQL inonConflictDoUpdate, quote them:excluded."sortOrder".
useLiveQuery from drizzle-orm/expo-sqlite only watches the root table of a query — it does NOT automatically detect changes to tables joined via with:. The second argument is just useEffect deps, not additional table watchers.
Pattern used in this codebase: When a transaction modifies a related table (e.g. fields), also touch the root-table row so useLiveQuery re-runs:
// In updateJournal — touch entries.updatedAt so entry-list useLiveQuery detects the change
await tx.update(entries).set({ updatedAt: Date.now() }).where(eq(entries.journalId, journalId));This means:
getEntriesQuery(entriesroot) re-runs after anyfieldsorentry_valueschange because those transactions touchentries.updatedAtgetFieldsQuery(fieldsroot) re-runs directly whenfieldsis written — no touch needed
Hooks that use useRef + an initialized flag (e.g. useEntry, useJournalField) only initialize once per mount. To ensure they pick up fresh data when switching to edit mode, mount the form as a separate child component rather than toggling visibility on the parent:
// ✅ Correct — EntryEditForm mounts fresh each time editMode becomes true
{editMode && <EntryEditForm entry={entry} onSave={...} />}
// ❌ Wrong — form is always mounted; useRef init won't re-run with new data
<EntryCreateView ... /> // toggled visible/hiddenThis pattern is used in entry/[id].tsx (EntryEditForm) and edit.tsx (JournalEditForm).
All field values are stored as text | null in entry_values.value. Serialization/deserialization lives in src/utils/entry/use-entry.ts:
serializeValue(FieldValue) → string | null— converts to DB textdeserializeValue(string | null, FieldType) → FieldValue— converts from DB textFieldTypevalues:"text" | "longText" | "number" | "media" | "check" | "date" | "time" | "location"- Dates/times are stored as millisecond timestamps (
String(date.getTime()))
buildEntryFormData in src/utils/entry/entry-form.ts extracts fields (sorted by sortOrder) and initial values from an EntryDetailObj.
EntryDetailObj (raw DB join) → PreviewEntryObj (display) via buildPreviewEntry in src/utils/entry/preview.ts:
- First field value = title (falls back to formatted
createdAt) - Remaining field values joined with spaces = preview (also used for search)
- Entries grouped by month for the list view (
groupByMonth)
Always call Keyboard.dismiss() before any async save operation that triggers navigation. Skipping this causes a RemoteTextInput session crash on iOS when the keyboard is mid-input as the screen unmounts.
| Package | Usage |
|---|---|
expo-router |
File-based routing, useRouter, useLocalSearchParams |
@expo/ui/swift-ui |
SwiftUI components: Host, ZStack, VStack, HStack, Spacer, Grid, ScrollView, List, Section, Button, Image, Text, RoundedRectangle, ColorPicker, BottomSheet, ContextMenu |
@expo/ui/swift-ui/modifiers |
frame, padding, foregroundStyle, onTapGesture, listStyle, presentationDetents, environment, fixedSize, font, lineLimit |
expo-router/unstable-native-tabs |
NativeTabs — iOS native tab bar |
expo-symbols |
SymbolView — SF Symbols in RN (non-SwiftUI) header components |
expo-sqlite + drizzle-orm |
Local SQLite persistence |
expo-crypto |
Crypto.randomUUID() for ID generation at the app layer |
PlatformColor |
Adaptive system colors: "label", "systemBackground", "systemIndigo" |
- Always wrap SwiftUI components in
<Host>withuseViewportSizeMeasurement - Use
onTapGesturemodifier for taps —onPressprop does NOT work on layout components ButtoninsideListgets a blue tint by default — useforegroundStyle({ type: "hierarchical", style: "primary" })to keep the tap highlight without the blue colorListmanages its own scrolling — never nest it insideScrollView- Adaptive colors: use
PlatformColor("label")directly — no need foruseColorScheme fixedSize()on a component prevents it from stretching in an HStack, allowing siblings to fill remaining space- Gradients:
RoundedRectangle+foregroundStyle({ type: "linearGradient", ... })+clipShapeon parentZStack - Native navigation bar buttons: use
unstable_headerRightItemsonStack.Screen(notheaderRight+ RNPressable)
| Term | Meaning |
|---|---|
| Journal | A category/collection (shown as a card in the grid) |
| Entry | An individual record within a journal |
- Import order: external → internal (
@/) → relative; always separated by newlines - Path alias:
@/*→src/* - TypeScript: strict mode enabled
- Formatter: Prettier (enforced via ESLint)
- React Compiler: enabled — do not manually add
useMemo/useCallbackunless there is a specific reason - Event handler note: extract
e.nativeEvent.textsynchronously before passing to async state updaters (React synthetic event pooling)