Skip to content

Commit a56f8be

Browse files
authored
Guest auth (#110)
* feat: ai draft td * chore: init agent instruction * feat: guest auth * fix: code review * fix: code review
1 parent 38dd1ba commit a56f8be

31 files changed

+2129
-908
lines changed

.github/copilot-instructions.md

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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

Comments
 (0)