|
| 1 | +# Ikuyo! (行くよ!) — Workspace Instructions for AI Coding Agents |
| 2 | + |
| 3 | +Ikuyo is a collaborative trip/itinerary planning web application with real-time sync. Users create trips containing activities, accommodations, macroplans (high-level plans), expenses, tasks, and comments. It supports role-based access (Owner, Editor, Viewer) and sharing levels (Private, Group, Public). |
| 4 | + |
| 5 | +## Tech Stack |
| 6 | + |
| 7 | +| Layer | Technology | |
| 8 | +| ------------------ | ---------------------------------------------------------- | |
| 9 | +| Language | TypeScript (strict mode, ES2022) | |
| 10 | +| Framework | React 19 | |
| 11 | +| Bundler | Rsbuild (Rspack-based — **not** Vite) | |
| 12 | +| UI Library | Radix UI Themes + Radix UI Primitives | |
| 13 | +| Styling | CSS Modules (`.module.css` / `.module.scss`), Radix CSS vars | |
| 14 | +| State Management | Zustand 5 (slice pattern with `persist` middleware) | |
| 15 | +| Database / Backend | InstantDB (`@instantdb/core` — real-time client-side DB) | |
| 16 | +| Routing | Wouter (lightweight React router) | |
| 17 | +| Date/Time | Luxon (`DateTime`) | |
| 18 | +| Maps | MapTiler SDK + Geocoding Control | |
| 19 | +| Drag & Drop | `@dnd-kit/core` + `@dnd-kit/sortable` | |
| 20 | +| Error Monitoring | Sentry (`@sentry/react`) | |
| 21 | +| Linting/Formatting | Biome (replaces ESLint + Prettier) | |
| 22 | +| Testing | Vitest + Testing Library (React) + jsdom | |
| 23 | +| Git Hooks | Lefthook (pre-commit: `biome check --write`) | |
| 24 | +| Package Manager | pnpm 10 | |
| 25 | + |
| 26 | +## Project Structure |
| 27 | + |
| 28 | +Feature-based folder structure under `src/`. Each domain feature is a **PascalCase** folder: |
| 29 | + |
| 30 | +``` |
| 31 | +src/ |
| 32 | + Activity/ # Feature folder |
| 33 | + Activity.tsx # Main component |
| 34 | + Activity.module.css # CSS Module |
| 35 | + db.ts # Database operations (InstantDB queries/mutations) |
| 36 | + time.ts # Time utilities for this feature |
| 37 | + ActivityNewDialog.tsx # "New" dialog (imperative, pushed to dialog stack) |
| 38 | + ActivityDialog/ # View/Edit/Delete dialog subfolder |
| 39 | + ActivityForm/ # Form subfolder (with co-located tests) |
| 40 | + Accommodation/ # Same pattern as Activity |
| 41 | + Trip/ # Trip feature with store/ subfolder for Zustand slices |
| 42 | + Comment/ |
| 43 | + Expense/ |
| 44 | + Macroplan/ |
| 45 | + Task/ |
| 46 | + Auth/ # Authentication components and hooks |
| 47 | + common/ # Shared utilities, hooks, reusable UI components |
| 48 | + data/ # DB init, central Zustand store, shared types |
| 49 | + Dialog/ # Dialog system (route-based and imperative) |
| 50 | + Routes/ # Route definitions and constants |
| 51 | + theme/ # Theme system (light/dark) |
| 52 | + Toast/ # Toast notification system |
| 53 | +``` |
| 54 | + |
| 55 | +Key conventions: |
| 56 | +- **Features** are top-level PascalCase folders |
| 57 | +- **Shared utilities** live in `src/common/` |
| 58 | +- **Data layer** in `src/data/` (DB init, central store, shared types) |
| 59 | +- **Tests** are **co-located** with source files: `*.test.ts` / `*.test.tsx` |
| 60 | +- Each feature has its own `db.ts` for database operations |
| 61 | + |
| 62 | +## Component Conventions |
| 63 | + |
| 64 | +### Naming |
| 65 | +- Components use named functions: `function ActivityInner({...}) {}` |
| 66 | +- Inner components use `*Inner` suffix, wrapped with `memo()`: `export const Activity = memo(ActivityInner)` |
| 67 | +- Page-level components use `Page*` prefix: `PageTrips`, `PageTrip`, `PageAccount` |
| 68 | +- **Default exports** only for lazy-loaded page components (`React.lazy()`). All other components use **named exports**. |
| 69 | + |
| 70 | +### CSS Modules |
| 71 | +- Import alias is **`s`**: `import s from './Component.module.css'` |
| 72 | +- Class names are camelCase: `s.activity`, `s.accommodationNotes` |
| 73 | +- CSS uses Radix CSS custom properties: `var(--gray-7)`, `var(--accent-9)`, `var(--color-panel-solid)` |
| 74 | +- Supports both `.module.css` and `.module.scss` |
| 75 | + |
| 76 | +### Component Structure |
| 77 | +1. External library imports (Radix, Luxon, React, etc.) |
| 78 | +2. Internal imports (relative, from other features) |
| 79 | +3. CSS module import |
| 80 | +4. Types/interfaces (inline) |
| 81 | +5. Inner component function |
| 82 | +6. Memoized export |
| 83 | + |
| 84 | +### Radix UI Usage |
| 85 | +- Layout: `Box`, `Flex` from `@radix-ui/themes` |
| 86 | +- Typography: `Text`, `Heading` from `@radix-ui/themes` |
| 87 | +- Interactive: `Button`, `Dialog`, `ContextMenu`, `Switch`, `TextField`, `TextArea` |
| 88 | +- Icons: `@radix-ui/react-icons` |
| 89 | +- Utility: `clsx` for conditional class joining |
| 90 | + |
| 91 | +## Database (InstantDB) |
| 92 | + |
| 93 | +This project uses **InstantDB** (`@instantdb/core`) — a real-time, client-side database with no REST/GraphQL API layer. All data access happens via InstantDB's real-time sync. |
| 94 | + |
| 95 | +### Singleton DB Instance |
| 96 | + |
| 97 | +Defined in `src/data/db.ts`: |
| 98 | + |
| 99 | +```typescript |
| 100 | +import { init } from '@instantdb/core'; |
| 101 | +import schema from '../../instant.schema'; |
| 102 | + |
| 103 | +export const db = init({ schema, appId: INSTANT_APP_ID, devtool: false }); |
| 104 | +``` |
| 105 | + |
| 106 | +### Schema |
| 107 | + |
| 108 | +Defined in `instant.schema.ts` at project root using `i.schema()` with `i.entity()` and `i.graph()`. |
| 109 | + |
| 110 | +### Feature db.ts Pattern |
| 111 | + |
| 112 | +Each feature's `db.ts` exports: |
| 113 | +- **Type definitions** prefixed with `Db`: `DbActivity`, `DbAccommodation`, `DbExpense` |
| 114 | +- **Async CRUD functions** prefixed with `db*`: |
| 115 | + - `dbAddActivity(...)` — creates with `db.transact(db.tx.entity[id()].update({...}).link({...}))` |
| 116 | + - `dbUpdateActivity(...)` — updates with `db.transact(db.tx.entity[id].merge({...}))` |
| 117 | + - `dbDeleteActivity(...)` — deletes with `db.transact(db.tx.entity[id].delete())` |
| 118 | +- Timestamps use `Date.now()` for `createdAt` / `lastUpdatedAt` |
| 119 | + |
| 120 | +### InstantDB Quick Reference |
| 121 | + |
| 122 | +**Reading data — Subscriptions (React):** |
| 123 | + |
| 124 | +```typescript |
| 125 | +const { isLoading, error, data } = db.useQuery({ goals: {} }); |
| 126 | +``` |
| 127 | + |
| 128 | +This project uses **Vanilla JS** (`@instantdb/core`), so subscriptions use `db.subscribeQuery`: |
| 129 | + |
| 130 | +```typescript |
| 131 | +db.subscribeQuery({ todos: {} }, (resp) => { |
| 132 | + if (resp.error) { /* handle error */ return; } |
| 133 | + if (resp.data) { /* use resp.data */ } |
| 134 | +}); |
| 135 | +``` |
| 136 | + |
| 137 | +**Reading data — One-shot queries:** |
| 138 | + |
| 139 | +```typescript |
| 140 | +const { data } = await db.queryOnce({ todos: {} }); |
| 141 | +``` |
| 142 | + |
| 143 | +**Writing data — Transactions:** |
| 144 | + |
| 145 | +```typescript |
| 146 | +import { id } from '@instantdb/core'; |
| 147 | + |
| 148 | +// Create |
| 149 | +db.transact(db.tx.todos[id()].update({ text: 'Hello', done: false, createdAt: Date.now() })); |
| 150 | + |
| 151 | +// Update |
| 152 | +db.transact(db.tx.todos[todoId].update({ done: true })); |
| 153 | + |
| 154 | +// Merge (for nested objects — preserves unmentioned keys) |
| 155 | +db.transact(db.tx.todos[todoId].merge({ preferences: { theme: 'dark' } })); |
| 156 | + |
| 157 | +// Delete |
| 158 | +db.transact(db.tx.todos[todoId].delete()); |
| 159 | + |
| 160 | +// Link |
| 161 | +db.transact(db.tx.todos[todoId].update({ title: 'Go run' }).link({ goals: goalId })); |
| 162 | + |
| 163 | +// Unlink |
| 164 | +db.transact(db.tx.goals[goalId].unlink({ todos: todoId })); |
| 165 | + |
| 166 | +// Multiple operations (atomic) |
| 167 | +db.transact([ |
| 168 | + db.tx.todos[id()].update({ text: 'Task 1' }), |
| 169 | + db.tx.todos[id()].update({ text: 'Task 2' }), |
| 170 | +]); |
| 171 | +``` |
| 172 | + |
| 173 | +**Querying with filters:** |
| 174 | + |
| 175 | +```typescript |
| 176 | +// Where clause |
| 177 | +const query = { todos: { $: { where: { done: true } } } }; |
| 178 | + |
| 179 | +// Nested associations |
| 180 | +const query = { goals: { todos: {} } }; |
| 181 | + |
| 182 | +// OR queries |
| 183 | +const query = { todos: { $: { where: { or: [{ priority: 'high' }, { done: false }] } } } }; |
| 184 | + |
| 185 | +// Comparison operators (requires indexed attribute): $gt, $lt, $gte, $lte |
| 186 | +const query = { products: { $: { where: { price: { $lt: 100 } } } } }; |
| 187 | + |
| 188 | +// Ordering (requires indexed attribute) |
| 189 | +const query = { todos: { $: { order: { serverCreatedAt: 'desc' } } } }; |
| 190 | + |
| 191 | +// Pagination |
| 192 | +const query = { todos: { $: { limit: 10, offset: 20 } } }; |
| 193 | + |
| 194 | +// Select specific fields |
| 195 | +const query = { goals: { $: { fields: ['title', 'status'] } } }; |
| 196 | +``` |
| 197 | + |
| 198 | +**Common mistakes to avoid:** |
| 199 | +- Use `merge` (not `update`) for nested objects to avoid overwriting unspecified fields |
| 200 | +- `or` and `and` in `where` take **arrays**, not objects |
| 201 | +- `limit`, `offset`, and `order` only work on **top-level** namespaces |
| 202 | +- Use `order` (not `orderBy`) for sorting |
| 203 | +- Comparison operators (`$gt`, `$lt`, etc.) require **indexed** attributes |
| 204 | +- Batch large transactions into groups of ~100 to avoid timeouts |
| 205 | +- Use `data.ref()` in permissions with string literal arguments only |
| 206 | + |
| 207 | +**Schema definition (`instant.schema.ts`):** |
| 208 | + |
| 209 | +```typescript |
| 210 | +import { i } from '@instantdb/core'; |
| 211 | + |
| 212 | +const _schema = i.schema({ |
| 213 | + entities: { |
| 214 | + todos: i.entity({ |
| 215 | + text: i.string(), |
| 216 | + done: i.boolean(), |
| 217 | + createdAt: i.date(), |
| 218 | + priority: i.number().indexed(), // indexed for ordering/comparison |
| 219 | + slug: i.string().unique(), // unique constraint |
| 220 | + notes: i.string().optional(), // optional attribute |
| 221 | + }), |
| 222 | + }, |
| 223 | + links: { |
| 224 | + todoGoal: { |
| 225 | + forward: { on: 'todos', has: 'one', label: 'goal' }, |
| 226 | + reverse: { on: 'goals', has: 'many', label: 'todos' }, |
| 227 | + }, |
| 228 | + }, |
| 229 | + rooms: {}, |
| 230 | +}); |
| 231 | + |
| 232 | +type _AppSchema = typeof _schema; |
| 233 | +interface AppSchema extends _AppSchema {} |
| 234 | +const schema: AppSchema = _schema; |
| 235 | +export type { AppSchema }; |
| 236 | +export default schema; |
| 237 | +``` |
| 238 | + |
| 239 | +**Permissions (`instant.perms.ts`):** |
| 240 | + |
| 241 | +```typescript |
| 242 | +export default { |
| 243 | + todos: { |
| 244 | + bind: ['isOwner', "auth.id == data.creatorId"], |
| 245 | + allow: { |
| 246 | + view: 'isOwner', |
| 247 | + create: 'isOwner', |
| 248 | + update: 'isOwner', |
| 249 | + delete: 'isOwner', |
| 250 | + }, |
| 251 | + }, |
| 252 | + attrs: { |
| 253 | + allow: { create: 'false' }, // Lock schema in production |
| 254 | + }, |
| 255 | +}; |
| 256 | +``` |
| 257 | + |
| 258 | +### Admin SDK (for backend scripts in `scripts/`) |
| 259 | + |
| 260 | +```typescript |
| 261 | +import { init, id } from '@instantdb/admin'; |
| 262 | + |
| 263 | +const db = init({ appId: APP_ID, adminToken: ADMIN_TOKEN }); |
| 264 | + |
| 265 | +// Async query (no loading states) |
| 266 | +const data = await db.query({ goals: {} }); |
| 267 | + |
| 268 | +// Async transact |
| 269 | +await db.transact([db.tx.todos[id()].update({ title: 'Get fit' })]); |
| 270 | +``` |
| 271 | + |
| 272 | +## State Management (Zustand) |
| 273 | + |
| 274 | +Central store in `src/data/store.ts` using slice pattern: |
| 275 | + |
| 276 | +```typescript |
| 277 | +export type BoundStoreType = ToastSlice & UserSlice & DialogSlice & TripSlice & TripsSlice & ThemeSlice; |
| 278 | + |
| 279 | +export const useBoundStore = create<BoundStoreType>()( |
| 280 | + persist((...args) => ({ |
| 281 | + ...createToastSlice(...args), |
| 282 | + ...createUserSlice(...args), |
| 283 | + // ...more slices |
| 284 | + }), { name: 'ikuyo-storage', version: 3, partialize: ... }) |
| 285 | +); |
| 286 | +``` |
| 287 | + |
| 288 | +Each slice is a `StateCreator<BoundStoreType, [], [], SliceType>`. |
| 289 | + |
| 290 | +Custom hooks: |
| 291 | +- `useBoundStore` — direct Zustand selector |
| 292 | +- `useDeepBoundStore` — wraps with `useDeepEqual` (uses `react-fast-compare`) for complex objects |
| 293 | + |
| 294 | +## Routing (Wouter) |
| 295 | + |
| 296 | +Route definitions in `src/Routes/` use a custom `createRouteParam()` factory: |
| 297 | + |
| 298 | +```typescript |
| 299 | +export const RouteTrip = createRouteParam('/trip/:id', replaceId); |
| 300 | +``` |
| 301 | + |
| 302 | +Navigation: `setLocation(RouteTripListViewActivity.asRouteTarget(activityId))`. |
| 303 | + |
| 304 | +Page components are lazy-loaded via `React.lazy()` + `withLoading()` HOC with View Transitions API support. |
| 305 | + |
| 306 | +## Dialog Patterns |
| 307 | + |
| 308 | +Two dialog systems: |
| 309 | + |
| 310 | +1. **Route-based dialogs** (`createDialogRoute`) — for View/Edit/Delete of existing entities. Mode stored in `history.state?.mode`. |
| 311 | +2. **Imperative/stack-based dialogs** (`DialogRoot`) — for "New" entity forms via `pushDialog(Component, props)` / `popDialog()`. |
| 312 | + |
| 313 | +## Type Conventions |
| 314 | + |
| 315 | +**Enum-like values** — use `const` object + derived type (never TypeScript `enum`): |
| 316 | + |
| 317 | +```typescript |
| 318 | +export const TripViewMode = { Timetable: 'Timetable', List: 'List', Home: 'Home' } as const; |
| 319 | +export type TripViewModeType = (typeof TripViewMode)[keyof typeof TripViewMode]; |
| 320 | +``` |
| 321 | + |
| 322 | +**Type prefixes:** |
| 323 | +- `Db` for database types: `DbActivity`, `DbTrip` |
| 324 | +- `TripSlice` for store types: `TripSliceActivity`, `TripSliceTrip` |
| 325 | +- Use `import type` for type-only imports |
| 326 | + |
| 327 | +## Code Style (enforced by Biome) |
| 328 | + |
| 329 | +- **Single quotes** for strings |
| 330 | +- **Spaces** for indentation (2 spaces) |
| 331 | +- Imports auto-sorted by Biome (`organizeImports: "on"`) |
| 332 | +- Strict linting: `noUnusedImports`, `noUnusedVariables`, `noUndeclaredDependencies`, `noUndeclaredVariables` (all `error`) |
| 333 | +- `useHookAtTopLevel: error` |
| 334 | +- Strict TypeScript: `strict: true`, `noUnusedLocals`, `noUnusedParameters` |
| 335 | + |
| 336 | +## Testing |
| 337 | + |
| 338 | +- **Framework:** Vitest with jsdom, globals enabled |
| 339 | +- **Libraries:** `@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom` |
| 340 | +- **Style:** `describe()` / `test()` (not `it()`) |
| 341 | +- **Co-located** test files: `*.test.ts` / `*.test.tsx` |
| 342 | +- **Setup:** `vitest.setup.ts` mocks `ResizeObserver`, imports jest-dom matchers, calls `cleanup()` after each test |
| 343 | +- Run tests: `pnpm test` |
| 344 | + |
| 345 | +## Common Commands |
| 346 | + |
| 347 | +```bash |
| 348 | +pnpm dev # Start dev server |
| 349 | +pnpm build # Production build |
| 350 | +pnpm test # Run tests (Vitest) |
| 351 | +pnpm biome:check # Lint and format with Biome |
| 352 | +pnpm typecheck # TypeScript type checking (tsc --noEmit) |
| 353 | +``` |
| 354 | + |
| 355 | +## Other Conventions |
| 356 | + |
| 357 | +- **`memo()`** for leaf components with complex props |
| 358 | +- **Accessibility:** include `role`, `tabIndex`, keyboard handlers (`onKeyDown`/`onKeyUp` for Enter/Space) |
| 359 | +- **Time handling:** Timestamps stored as milliseconds (`Date.now()`), timezone-aware display via Luxon `DateTime.setZone()` |
| 360 | +- **Bitmask flags:** `ActivityFlag` uses bitwise operations |
| 361 | +- **Environment variables:** Injected at build time via `process.env.*` (Rsbuild) |
| 362 | +- **Permissions:** Defined in `instant.perms.ts` (InstantDB permissions with CEL expressions) |
| 363 | +- **Schema:** Defined in `instant.schema.ts` — push changes with `npx instant-cli push schema` |
| 364 | +- **`dangerToken`** from `src/common/ui.ts` = `'yellow'` — used as Radix color token for destructive actions |
0 commit comments