@clipboard-health/ai-rules 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1195 +1 @@
1
- <!-- Source: .ruler/backend/nestJsApis.md -->
2
-
3
- # NestJS APIs
4
-
5
- - Use a three-tier architecture:
6
- - Controllers in the entrypoints tier translate from data transfer objects (DTOs) to domain objects (DOs) and call the logic tier.
7
- - Logic tier services call other services in the logic tier and repos and gateways at the data tier. The logic tier operates only on DOs.
8
- - Data tier repos translate from DOs to data access objects (DAOs), call the database using either Prisma for Postgres or Mongoose for MongoDB, and then translate from DAOs to DOs before returning to the logic tier.
9
- - Use ts-rest to define contracts using Zod schemas, one contract per resource.
10
- - A controller implements each ts-rest contract.
11
- - Requests and responses follow the JSON:API specification, including pagination for listings.
12
- - Use TypeDoc to document public functions, classes, methods, and complex code blocks.
13
-
14
- <!-- Source: .ruler/backend/notifications.md -->
15
-
16
- # Notifications
17
-
18
- Send notifications through [Knock](https://docs.knock.app) using the `@clipboard-health/notifications` NPM library.
19
-
20
- ## Usage
21
-
22
- <embedex source="packages/notifications/examples/usage.md">
23
-
24
- 1. Search your service for a `NotificationJobEnqueuer` instance. If there isn't one, create and export it:
25
-
26
- ```ts
27
- import { NotificationJobEnqueuer } from "@clipboard-health/notifications";
28
-
29
- import { BackgroundJobsService } from "./setup";
30
-
31
- // Create and export one instance of this in your microservice.
32
- export const notificationJobEnqueuer = new NotificationJobEnqueuer({
33
- // Use your instance of `@clipboard-health/mongo-jobs` or `@clipboard-health/background-jobs-postgres` here.
34
- adapter: new BackgroundJobsService(),
35
- });
36
- ```
37
-
38
- 1. Implement a minimal job, calling off to a NestJS service for any business logic and to send the notification.
39
-
40
- ```ts
41
- import { type BaseHandler } from "@clipboard-health/background-jobs-adapter";
42
- import { type NotificationData } from "@clipboard-health/notifications";
43
- import { isFailure, toError } from "@clipboard-health/util-ts";
44
-
45
- import { type ExampleNotificationService } from "./exampleNotification.service";
46
-
47
- export type ExampleNotificationData = NotificationData<{
48
- workplaceId: string;
49
- }>;
50
-
51
- export const EXAMPLE_NOTIFICATION_JOB_NAME = "ExampleNotificationJob";
52
-
53
- // For mongo-jobs, you'll implement HandlerInterface<ExampleNotificationData["Job"]>
54
- // For background-jobs-postgres, you'll implement Handler<ExampleNotificationData["Job"]>
55
- export class ExampleNotificationJob implements BaseHandler<ExampleNotificationData["Job"]> {
56
- public name = EXAMPLE_NOTIFICATION_JOB_NAME;
57
-
58
- constructor(private readonly service: ExampleNotificationService) {}
59
-
60
- async perform(data: ExampleNotificationData["Job"], job: { attemptsCount: number }) {
61
- const result = await this.service.sendNotification({
62
- ...data,
63
- // Include the job's attempts count for debugging, this is called `retryAttempts` in `background-jobs-postgres`.
64
- attempt: job.attemptsCount + 1,
65
- });
66
-
67
- if (isFailure(result)) {
68
- throw toError(result.error);
69
- }
70
- }
71
- }
72
- ```
73
-
74
- 1. Search your service for a constant that stores workflow keys. If there isn't one, create and export it:
75
-
76
- ```ts
77
- export const WORKFLOW_KEYS = {
78
- eventStartingReminder: "event-starting-reminder",
79
- } as const;
80
- ```
81
-
82
- 1. Enqueue your job:
83
-
84
- ```ts
85
- import {
86
- EXAMPLE_NOTIFICATION_JOB_NAME,
87
- type ExampleNotificationData,
88
- } from "./exampleNotification.job";
89
- import { notificationJobEnqueuer } from "./notificationJobEnqueuer";
90
- import { WORKFLOW_KEYS } from "./workflowKeys";
91
-
92
- async function enqueueNotificationJob() {
93
- await notificationJobEnqueuer.enqueueOneOrMore<ExampleNotificationData["Enqueue"]>(
94
- EXAMPLE_NOTIFICATION_JOB_NAME,
95
- // Important: Read the TypeDoc documentation for additional context.
96
- {
97
- /**
98
- * Set expiresAt at enqueue-time so it remains stable across job retries. Use date-fns in your
99
- * service instead of this manual calculation.
100
- */
101
- expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
102
- // Set idempotencyKey at enqueue-time so it remains stable across job retries.
103
- idempotencyKey: {
104
- resourceId: "event-123",
105
- },
106
- // Set recipients at enqueue-time so they respect our notification provider's limits.
107
- recipients: ["userId-1"],
108
-
109
- workflowKey: WORKFLOW_KEYS.eventStartingReminder,
110
-
111
- // Any additional enqueue-time data passed to the job:
112
- workplaceId: "workplaceId-123",
113
- },
114
- );
115
- }
116
-
117
- // eslint-disable-next-line unicorn/prefer-top-level-await
118
- void enqueueNotificationJob();
119
- ```
120
-
121
- 1. Trigger the job in your NestJS service:
122
-
123
- ```ts
124
- import { type NotificationClient } from "@clipboard-health/notifications";
125
-
126
- import { type ExampleNotificationData } from "./exampleNotification.job";
127
-
128
- type ExampleNotificationDo = ExampleNotificationData["Job"] & { attempt: number };
129
-
130
- export class ExampleNotificationService {
131
- constructor(private readonly client: NotificationClient) {}
132
-
133
- async sendNotification(params: ExampleNotificationDo) {
134
- const { attempt, expiresAt, idempotencyKey, recipients, workflowKey, workplaceId } = params;
135
-
136
- // Assume this comes from a database and are used as template variables...
137
- // Use @clipboard-health/date-time's formatShortDateTime in your service for consistency.
138
- const data = { favoriteColor: "blue", favoriteAt: new Date().toISOString(), secret: "2" };
139
-
140
- // Important: Read the TypeDoc documentation for additional context.
141
- return await this.client.trigger({
142
- attempt,
143
- body: {
144
- data,
145
- recipients,
146
- workplaceId,
147
- },
148
- expiresAt: new Date(expiresAt),
149
- idempotencyKey,
150
- keysToRedact: ["secret"],
151
- workflowKey,
152
- });
153
- }
154
- }
155
- ```
156
-
157
- </embedex>
158
-
159
- <!-- Source: .ruler/common/codeStyleAndStructure.md -->
160
-
161
- # Code style and structure
162
-
163
- - Write concise, technical TypeScript code with accurate examples.
164
- - Use functional and declarative programming patterns.
165
- - Prefer iteration and modularization over code duplication.
166
- - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
167
- - Structure files: constants, types, exported functions, non-exported functions.
168
- - Avoid magic strings and numbers; define constants.
169
- - Use camelCase for files and directories (e.g., modules/shiftOffers.ts).
170
- - When declaring functions, use the `function` keyword, not `const`.
171
- - Files should read from top to bottom: `export`ed items live on top and the internal functions and methods they call go below them.
172
- - Prefer data immutability.
173
-
174
- # Commit messages
175
-
176
- - Follow the Conventional Commits 1.0 spec for commit messages and in pull request titles.
177
-
178
- <!-- Source: .ruler/common/errorHandlingAndValidation.md -->
179
-
180
- # Error handling and validation
181
-
182
- - Sanitize user input.
183
- - Handle errors and edge cases at the beginning of functions.
184
- - Use early returns for error conditions to avoid deeply nested if statements.
185
- - Place the happy path last in the function for improved readability.
186
- - Avoid unnecessary else statements; use the if-return pattern instead.
187
- - Use guard clauses to handle preconditions and invalid states early.
188
- - Implement proper error logging and user-friendly error messages.
189
- - Favor `@clipboard-health/util-ts`'s `Either` type for expected errors instead of `try`/`catch`.
190
-
191
- <!-- Source: .ruler/common/testing.md -->
192
-
193
- # Testing
194
-
195
- - Follow the Arrange-Act-Assert convention for tests with newlines between each section.
196
- - Name test variables using the `mockX`, `input`, `expected`, `actual` convention.
197
- - Aim for high test coverage, writing both positive and negative test cases.
198
- - Prefer `it.each` for multiple test cases.
199
- - Avoid conditional logic in tests.
200
-
201
- <!-- Source: .ruler/common/typeScript.md -->
202
-
203
- # TypeScript usage
204
-
205
- - Use strict-mode TypeScript for all code; prefer interfaces over types.
206
- - Avoid enums; use const maps instead.
207
- - Strive for precise types. Look for type definitions in the codebase and create your own if none exist.
208
- - Avoid using type assertions like `as` or `!` unless absolutely necessary.
209
- - Use the `unknown` type instead of `any` when the type is truly unknown.
210
- - Use an object to pass multiple function params and to return results.
211
- - Leverage union types, intersection types, and conditional types for complex type definitions.
212
- - Use mapped types and utility types (e.g., `Partial<T>`, `Pick<T>`, `Omit<T>`) to transform existing types.
213
- - Implement generic types to create reusable, flexible type definitions.
214
- - Utilize the `keyof` operator and index access types for dynamic property access.
215
- - Implement discriminated unions for type-safe handling of different object shapes where appropriate.
216
- - Use the `infer` keyword in conditional types for type inference.
217
- - Leverage `readonly` properties for function parameter immutability.
218
- - Prefer narrow types whenever possible with `as const` assertions, `typeof`, `instanceof`, `satisfies`, and custom type guards.
219
- - Implement exhaustiveness checking using `never`.
220
-
221
- <!-- Source: .ruler/frontend/custom-hooks.md -->
222
-
223
- # Custom Hook Standards
224
-
225
- ## Hook Structure
226
-
227
- ```typescript
228
- export function useFeature(params: Params, options: UseFeatureOptions = {}) {
229
- const query = useQuery(...);
230
- const computed = useMemo(() => transformData(query.data), [query.data]);
231
- const handleAction = useCallback(async () => { /* ... */ }, []);
232
-
233
- return { data: computed, isLoading: query.isLoading, handleAction };
234
- }
235
- ```
236
-
237
- ## Naming Rules
238
-
239
- - **Always** prefix with `use`
240
- - Boolean: Use `useIs*`, `useHas*`, or `useCan*` (e.g., `useIsEnabled`, `useHasPermission`, `useCanEdit`)
241
- - Data: `useGetUser`, `useFeatureData`
242
- - Actions: `useSubmitForm`
243
-
244
- ## Return Values
245
-
246
- - Return objects (not arrays) for complex hooks
247
- - Name clearly: `isLoading` not `loading`
248
-
249
- ## State Management with Constate
250
-
251
- Use `constate` for shared state between components:
252
-
253
- ```typescript
254
- import constate from "constate";
255
-
256
- function useFilters() {
257
- const [filters, setFilters] = useState<Filters>({});
258
- return { filters, setFilters };
259
- }
260
-
261
- export const [FiltersProvider, useFiltersContext] = constate(useFilters);
262
- ```
263
-
264
- **When to use:** Sharing state between siblings, feature-level state
265
- **When NOT:** Server state (use React Query), simple parent-child (use props)
266
-
267
- ## Hook Patterns
268
-
269
- ```typescript
270
- // Boolean hooks
271
- export function useIsFeatureEnabled(): boolean {
272
- return useFeatureFlags().includes("feature");
273
- }
274
-
275
- // Data transformation
276
- export function useTransformedData() {
277
- const { data, isLoading } = useGetRawData();
278
- const transformed = useMemo(() => data?.map(format), [data]);
279
- return { data: transformed, isLoading };
280
- }
281
-
282
- // Composite hooks
283
- export function useBookingsData() {
284
- const shifts = useGetShifts();
285
- const invites = useGetInvites();
286
-
287
- const combined = useMemo(
288
- () => [...(shifts.data ?? []), ...(invites.data ?? [])],
289
- [shifts.data, invites.data],
290
- );
291
-
292
- return { data: combined, isLoading: shifts.isLoading || invites.isLoading };
293
- }
294
- ```
295
-
296
- ## Best Practices
297
-
298
- - Use options object for flexibility
299
- - Be explicit about dependencies
300
- - API hooks in `api/`, logic hooks in `hooks/`
301
- - Return stable references with `useCallback`/`useMemo`
302
-
303
- <!-- Source: .ruler/frontend/data-fetching.md -->
304
-
305
- # Data Fetching Standards
306
-
307
- ## Technology Stack
308
-
309
- - **React Query** (@tanstack/react-query) for all API calls
310
- - **Zod** for response validation
311
-
312
- ## Core Principles
313
-
314
- 1. **Use URL and query parameters in query keys** - Makes cache invalidation predictable
315
- 2. **Define Zod schemas** for all API requests/responses
316
- 3. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess`
317
- 4. **Use `enabled` for conditional fetching**
318
- 5. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
319
-
320
- ## Hook Patterns
321
-
322
- ```typescript
323
- // Simple query
324
- export function useGetUser(userId: string) {
325
- return useQuery({
326
- queryKey: ["users", userId],
327
- queryFn: () => api.get(`/users/${userId}`),
328
- responseSchema: userSchema,
329
- enabled: !!userId,
330
- });
331
- }
332
-
333
- // Paginated query
334
- export function usePaginatedItems(params: Params) {
335
- return useInfiniteQuery({
336
- queryKey: ["items", params],
337
- queryFn: ({ pageParam }) => api.get("/items", { cursor: pageParam, ...params }),
338
- getNextPageParam: (lastPage) => lastPage.nextCursor,
339
- });
340
- }
341
-
342
- // Mutations
343
- export function useCreateItem() {
344
- const queryClient = useQueryClient();
345
- return useMutation({
346
- mutationFn: (data: CreateItemRequest) => api.post("/items", data),
347
- onSuccess: () => queryClient.invalidateQueries(["items"]),
348
- });
349
- }
350
- ```
351
-
352
- ## Error Handling
353
-
354
- For detailed error handling strategies, see `error-handling.md`.
355
-
356
- ```typescript
357
- useQuery({
358
- queryKey: ["resource"],
359
- queryFn: fetchResource,
360
- useErrorBoundary: (error) => {
361
- // Show error boundary for 500s, not 404s
362
- return !(axios.isAxiosError(error) && error.response?.status === 404);
363
- },
364
- retry: (failureCount, error) => {
365
- // Don't retry 4xx errors
366
- if (axios.isAxiosError(error) && error.response?.status < 500) return false;
367
- return failureCount < 3;
368
- },
369
- });
370
- ```
371
-
372
- ## Query Keys
373
-
374
- ```typescript
375
- // Include URL and params for predictable cache invalidation
376
- export const queryKeys = {
377
- users: ["users"] as const,
378
- user: (id: string) => ["users", id] as const,
379
- userPosts: (id: string) => ["users", id, "posts"] as const,
380
- };
381
- ```
382
-
383
- ## Conditional Fetching
384
-
385
- ```typescript
386
- const { data } = useQuery({
387
- queryKey: ["resource", id],
388
- queryFn: () => fetchResource(id),
389
- enabled: !!id, // Only fetch when id exists
390
- });
391
- ```
392
-
393
- ## Naming Conventions
394
-
395
- - `useGetX` - Simple queries
396
- - `usePaginatedX` - Infinite queries
397
- - `useCreateX`, `useUpdateX`, `useDeleteX` - Mutations
398
-
399
- ## Hook Location
400
-
401
- Place in `api/` folder within feature directory. One endpoint = one hook.
402
-
403
- <!-- Source: .ruler/frontend/error-handling.md -->
404
-
405
- # Error Handling Standards
406
-
407
- ## React Query Error Handling
408
-
409
- ```typescript
410
- useQuery({
411
- queryKey: ["resource"],
412
- queryFn: fetchResource,
413
- useErrorBoundary: (error) => {
414
- // Show error boundary for 500s, not 404s
415
- return !(axios.isAxiosError(error) && error.response?.status === 404);
416
- },
417
- retry: (failureCount, error) => {
418
- // Don't retry 4xx errors
419
- if (axios.isAxiosError(error) && error.response?.status < 500) return false;
420
- return failureCount < 3;
421
- },
422
- });
423
- ```
424
-
425
- ## Component Error States
426
-
427
- ```typescript
428
- export function DataComponent() {
429
- const { data, isLoading, isError, refetch } = useGetData();
430
-
431
- if (isLoading) return <LoadingState />;
432
- if (isError) return <ErrorState onRetry={refetch} />;
433
-
434
- return <DataDisplay data={data} />;
435
- }
436
- ```
437
-
438
- ## Mutation Error Handling
439
-
440
- ```typescript
441
- export function useCreateDocument() {
442
- return useMutation({
443
- mutationFn: createDocumentApi,
444
- onSuccess: () => {
445
- queryClient.invalidateQueries(["documents"]);
446
- showSuccessToast("Document created");
447
- },
448
- onError: (error) => {
449
- logError("CREATE_DOCUMENT_FAILURE", error);
450
- showErrorToast("Failed to create document");
451
- },
452
- });
453
- }
454
- ```
455
-
456
- ## Validation Errors
457
-
458
- ```typescript
459
- // Zod validation
460
- const formSchema = z.object({
461
- email: z.string().email("Invalid email"),
462
- age: z.number().min(18, "Must be 18+"),
463
- });
464
-
465
- try {
466
- const validated = formSchema.parse(formData);
467
- } catch (error) {
468
- if (error instanceof z.ZodError) {
469
- error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
470
- }
471
- }
472
- ```
473
-
474
- ## Error Boundaries
475
-
476
- ```typescript
477
- export class ErrorBoundary extends Component<Props, State> {
478
- state = { hasError: false };
479
-
480
- static getDerivedStateFromError(error: Error) {
481
- return { hasError: true, error };
482
- }
483
-
484
- componentDidCatch(error: Error, errorInfo: ErrorInfo) {
485
- logEvent("ERROR_BOUNDARY", { error: error.message });
486
- }
487
-
488
- render() {
489
- return this.state.hasError ? <ErrorFallback /> : this.props.children;
490
- }
491
- }
492
- ```
493
-
494
- ## Best Practices
495
-
496
- - **Always handle errors** - Check `isError` state
497
- - **User-friendly messages** - Don't show technical errors
498
- - **Log for debugging** - Include context
499
- - **Provide recovery actions** - Retry, dismiss, navigate
500
- - **Different strategies** - Error boundary vs inline vs retry
501
-
502
- <!-- Source: .ruler/frontend/file-organization.md -->
503
-
504
- # File Organization Standards
505
-
506
- ## Feature-based Structure
507
-
508
- ```text
509
- FeatureName/
510
- ├── api/ # Data fetching hooks
511
- │ ├── useGetFeature.ts
512
- │ ├── useUpdateFeature.ts
513
- │ └── useDeleteFeature.ts
514
- ├── components/ # Feature-specific components
515
- │ ├── FeatureCard.tsx
516
- │ ├── FeatureList.tsx
517
- │ └── FeatureHeader.tsx
518
- ├── hooks/ # Feature-specific hooks (non-API)
519
- │ ├── useFeatureLogic.ts
520
- │ └── useFeatureState.ts
521
- ├── utils/ # Feature utilities
522
- │ ├── formatFeature.ts
523
- │ ├── formatFeature.test.ts
524
- │ └── validateFeature.ts
525
- ├── __tests__/ # Integration tests (optional)
526
- │ └── FeatureFlow.test.tsx
527
- ├── Page.tsx # Main page component
528
- ├── Router.tsx # Feature routes
529
- ├── paths.ts # Route paths
530
- ├── types.ts # Shared types
531
- ├── constants.ts # Constants
532
- └── README.md # Feature documentation (optional)
533
- ```
534
-
535
- ## Naming Conventions
536
-
537
- - Components: `PascalCase.tsx` (`UserProfile.tsx`)
538
- - Hooks: `camelCase.ts` (`useUserProfile.ts`)
539
- - Utils: `camelCase.ts` (`formatDate.ts`)
540
- - Types: `camelCase.types.ts` (`user.types.ts`)
541
- - Tests: `ComponentName.test.tsx`
542
-
543
- <!-- Source: .ruler/frontend/react-patterns.md -->
544
-
545
- # React Component Patterns
546
-
547
- ## Component Structure
548
-
549
- ```typescript
550
- interface Props {
551
- userId: string;
552
- onUpdate?: (user: User) => void;
553
- }
554
-
555
- export function UserProfile({ userId, onUpdate }: Props) {
556
- // 1. Hooks
557
- const { data, isLoading, error } = useGetUser(userId);
558
- const [isEditing, setIsEditing] = useState(false);
559
-
560
- // 2. Derived state (avoid useMemo for inexpensive operations)
561
- const displayName = formatName(data);
562
-
563
- // 3. Event handlers
564
- const handleSave = useCallback(async () => {
565
- await saveUser(data);
566
- onUpdate?.(data);
567
- }, [data, onUpdate]);
568
-
569
- // 4. Early returns
570
- if (isLoading) return <Loading />;
571
- if (error) return <Error message={error.message} />;
572
- if (!data) return <NotFound />;
573
-
574
- // 5. Main render
575
- return (
576
- <Card>
577
- <Typography>{displayName}</Typography>
578
- <Button onClick={handleSave}>Save</Button>
579
- </Card>
580
- );
581
- }
582
- ```
583
-
584
- ## Naming Conventions
585
-
586
- - Components: `PascalCase` (`UserProfile`)
587
- - Props interface: `Props` (co-located with component) or `ComponentNameProps` (exported/shared)
588
- - Event handlers: `handle*` (`handleClick`, `handleSubmit`)
589
- - Boolean props: `is*`, `has*`, `should*`
590
-
591
- ## Props Patterns
592
-
593
- ```typescript
594
- // Simple props
595
- interface Props {
596
- title: string;
597
- count: number;
598
- onAction: () => void;
599
- }
600
-
601
- // Discriminated unions for variants
602
- type ButtonProps = { variant: "link"; href: string } | { variant: "button"; onClick: () => void };
603
-
604
- // Optional callbacks
605
- interface Props {
606
- onSuccess?: (data: Data) => void;
607
- onError?: (error: Error) => void;
608
- }
609
- ```
610
-
611
- ## Composition Patterns
612
-
613
- ```typescript
614
- // Container/Presentational
615
- export function UserListContainer() {
616
- const { data, isLoading, error } = useUsers();
617
- if (isLoading) return <Loading />;
618
- if (error) return <Error />;
619
- return <UserList users={data} />;
620
- }
621
-
622
- // Compound components
623
- <Card>
624
- <Card.Header title="User" />
625
- <Card.Body>Content</Card.Body>
626
- <Card.Actions>
627
- <Button>Save</Button>
628
- </Card.Actions>
629
- </Card>
630
-
631
- // Render props
632
- <DataProvider>
633
- {({ data, isLoading }) => (
634
- isLoading ? <Loading /> : <Display data={data} />
635
- )}
636
- </DataProvider>
637
- ```
638
-
639
- ## Children Patterns
640
-
641
- ```typescript
642
- // Typed children
643
- interface Props {
644
- children: ReactNode;
645
- }
646
-
647
- // Render prop pattern
648
- interface Props {
649
- children: (data: Data) => ReactNode;
650
- }
651
-
652
- // Element restrictions
653
- interface Props {
654
- children: ReactElement<ButtonProps>;
655
- }
656
- ```
657
-
658
- ## List Rendering
659
-
660
- ```typescript
661
- // ✅ Proper keys
662
- {users.map((user) => <UserCard key={user.id} user={user} />)}
663
-
664
- // ✅ Empty states
665
- {users.length === 0 ? <EmptyState /> : users.map(...)}
666
-
667
- // ❌ Never use index as key when list can change
668
- {items.map((item, index) => <Item key={index} />)} // Wrong!
669
- ```
670
-
671
- ## Conditional Rendering
672
-
673
- ```typescript
674
- // Simple conditional
675
- {isLoading && <Spinner />}
676
-
677
- // If-else
678
- {isError ? <Error /> : <Success />}
679
-
680
- // Multiple conditions
681
- {isLoading ? <Loading /> : isError ? <Error /> : <Data />}
682
-
683
- // With early returns (preferred for complex logic)
684
- if (isLoading) return <Loading />;
685
- if (isError) return <Error />;
686
- return <Data />;
687
- ```
688
-
689
- ## Performance
690
-
691
- ```typescript
692
- // Memoize expensive components
693
- const MemoItem = memo(({ item }: Props) => <div>{item.name}</div>);
694
-
695
- // Split state to avoid re-renders
696
- function Parent() {
697
- const [count, setCount] = useState(0);
698
- const [text, setText] = useState("");
699
-
700
- // Only CountDisplay re-renders when count changes
701
- return (
702
- <>
703
- <CountDisplay count={count} />
704
- <TextInput value={text} onChange={setText} />
705
- </>
706
- );
707
- }
708
- ```
709
-
710
- ## Best Practices
711
-
712
- - **Single Responsibility** - One component, one purpose
713
- - **Composition over Props** - Use children and compound components
714
- - **Colocate State** - Keep state close to where it's used
715
- - **Type Everything** - Full TypeScript coverage
716
- - **Test Behavior** - Test user interactions, not implementation
717
-
718
- <!-- Source: .ruler/frontend/react.md -->
719
-
720
- # React
721
-
722
- - Destructure props in function body (improves readability and prop documentation)
723
- - Prefer inline JSX over extracted variables
724
- - Use custom hooks to encapsulate and reuse stateful logic
725
- - Use Zod for request/response schemas in data-fetching hooks
726
- - Use react-hook-form with Zod resolver for forms
727
- - Use date-fns for date operations
728
-
729
- <!-- Source: .ruler/frontend/styling.md -->
730
-
731
- # Styling Standards
732
-
733
- ## Core Principles
734
-
735
- 1. **Always use `sx` prop** - Never CSS/SCSS/SASS files
736
- 2. **Use theme tokens** - Never hardcode colors/spacing
737
- 3. **Type-safe theme access** - Use `sx={(theme) => ({...})}`
738
- 4. **Use semantic names** - `theme.palette.text.primary`, not `common.white`
739
- 5. **Check Storybook first** - It's the single source of truth
740
-
741
- ## Restricted Patterns
742
-
743
- ❌ **DO NOT USE:**
744
-
745
- - `styled()` or `makeStyles()` from MUI
746
- - CSS/SCSS/SASS files
747
- - Inline `style` prop (use `sx` instead)
748
- - String paths for theme (`"text.secondary"` - not type-safe)
749
-
750
- ## Type-Safe Theme Access
751
-
752
- ```typescript
753
- // ✅ Always use function form for type safety
754
- <Box sx={(theme) => ({
755
- backgroundColor: theme.palette.background.primary,
756
- color: theme.palette.text.secondary,
757
- padding: theme.spacing(4), // or just: 4
758
- })} />
759
-
760
- // ❌ Never hardcode
761
- <Box sx={{
762
- backgroundColor: "red", // ❌ Raw color
763
- padding: "16px", // ❌ Raw size
764
- color: "text.secondary", // ❌ String path
765
- }} />
766
- ```
767
-
768
- ## Spacing System
769
-
770
- Use indices 1-12 (4px-64px):
771
-
772
- ```typescript
773
- <Box sx={{ padding: 5 }} /> // → 16px
774
- <Box sx={{ marginX: 4 }} /> // → 12px left and right
775
- <Box sx={{ gap: 3 }} /> // → 8px
776
- ```
777
-
778
- **Use `rem` for fonts/heights (scales with user zoom), `px` for spacing:**
779
-
780
- ```typescript
781
- <Box sx={(theme) => ({
782
- height: "3rem", // ✅ Scales
783
- fontSize: theme.typography.body1.fontSize,
784
- padding: 5, // ✅ Prevents overflow
785
- })} />
786
- ```
787
-
788
- ## Responsive Styles
789
-
790
- ```typescript
791
- <Box sx={{
792
- width: { xs: "100%", md: "50%" },
793
- padding: { xs: 1, md: 3 },
794
- }} />
795
- ```
796
-
797
- ## Pseudo-classes
798
-
799
- ```typescript
800
- <Box sx={(theme) => ({
801
- "&:hover": { backgroundColor: theme.palette.primary.dark },
802
- "&:disabled": { opacity: 0.5 },
803
- "& .child": { color: theme.palette.text.secondary },
804
- })} />
805
- ```
806
-
807
- ## Shorthand Properties
808
-
809
- ✅ Use full names: `padding`, `paddingX`, `marginY`
810
- ❌ Avoid abbreviations: `p`, `px`, `my`
811
-
812
- ## Layout Components
813
-
814
- Safe to import directly from MUI:
815
-
816
- ```typescript
817
- import { Box, Stack, Container, Grid } from "@mui/material";
818
- ```
819
-
820
- ## Best Practices
821
-
822
- - Type-safe access with `sx={(theme) => ({...})}`
823
- - Use semantic token names
824
- - Full property names, not abbreviations
825
-
826
- <!-- Source: .ruler/frontend/testing.md -->
827
-
828
- # Testing Standards
829
-
830
- ## Testing Trophy Philosophy
831
-
832
- Focus on **Integration tests** - test how components work together as users experience them.
833
-
834
- **Investment Priority:**
835
-
836
- 1. **Static** (TypeScript/ESLint) - Free confidence
837
- 2. **Integration** - Most valuable, test features not isolated components
838
- 3. **Unit** - For pure helpers/utilities only
839
- 4. **E2E** - Critical flows only
840
-
841
- **Key Principle:** Test as close to how users interact with your app as possible.
842
-
843
- ## Technology Stack
844
-
845
- - **Vitest** for test runner
846
- - **@testing-library/react** for component testing
847
- - **@testing-library/user-event** for user interactions
848
- - **Mock Service Worker (MSW)** for API mocking
849
-
850
- ## Test Structure
851
-
852
- ```typescript
853
- describe("ComponentName", () => {
854
- it("should render correctly", () => {
855
- render(<Component />);
856
- expect(screen.getByText("Expected")).toBeInTheDocument();
857
- });
858
-
859
- it("should handle user interaction", async () => {
860
- const user = userEvent.setup();
861
- render(<Component />);
862
- await user.click(screen.getByRole("button", { name: "Submit" }));
863
- await waitFor(() => expect(screen.getByText("Success")).toBeInTheDocument());
864
- });
865
- });
866
- ```
867
-
868
- ## Test Naming
869
-
870
- Pattern: `should [behavior] when [condition]`
871
-
872
- ## Querying Elements - Priority Order
873
-
874
- 1. `getByRole` - Best for accessibility
875
- 2. `getByLabelText` - For form fields
876
- 3. `getByText` - For non-interactive content
877
- 4. `getByTestId` - ⚠️ **LAST RESORT**
878
-
879
- ```typescript
880
- // ✅ Prefer
881
- screen.getByRole("button", { name: /submit/i });
882
- screen.getByLabelText("Email");
883
-
884
- // ❌ Avoid
885
- screen.getByClassName("user-card");
886
- screen.getByTestId("element");
887
- ```
888
-
889
- ## User Interactions
890
-
891
- ```typescript
892
- it("should handle form submission", async () => {
893
- const user = userEvent.setup();
894
- const onSubmit = vi.fn();
895
- render(<Form onSubmit={onSubmit} />);
896
-
897
- await user.type(screen.getByLabelText("Name"), "John");
898
- await user.click(screen.getByRole("button", { name: "Submit" }));
899
-
900
- expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
901
- });
902
- ```
903
-
904
- ## Hook Testing
905
-
906
- ```typescript
907
- describe("useCustomHook", () => {
908
- it("should return data after loading", async () => {
909
- const { result } = renderHook(() => useCustomHook());
910
- await waitFor(() => expect(result.current.isLoading).toBe(false));
911
- expect(result.current.data).toEqual(expectedData);
912
- });
913
- });
914
- ```
915
-
916
- ## MSW Pattern
917
-
918
- **Always export factory functions, not static handlers:**
919
-
920
- ```typescript
921
- // ✅ Good - flexible per test
922
- export const createTestHandler = (data: Data[]) =>
923
- rest.get(`/api/resource`, (req, res, ctx) => res(ctx.json(data)));
924
-
925
- // Usage
926
- mockServer.use(createTestHandler(customData));
927
- ```
928
-
929
- ## Mocking
930
-
931
- ```typescript
932
- vi.mock("@/lib/api", () => ({
933
- get: vi.fn().mockResolvedValue({ data: { id: "1" } }),
934
- }));
935
- ```
936
-
937
- ## What to Test
938
-
939
- **✅ Do Test:**
940
-
941
- - Integration tests for features
942
- - Unit tests for utilities
943
- - All states (loading, success, error)
944
- - User interactions
945
-
946
- **❌ Don't Test:**
947
-
948
- - Implementation details
949
- - Third-party libraries
950
- - Styles/CSS
951
-
952
- <!-- Source: .ruler/frontend/typeScript.md -->
953
-
954
- # TypeScript Standards
955
-
956
- ## Core Principles
957
-
958
- - **Strict mode enabled** - Avoid `any`
959
- - **Prefer type inference** - Let TypeScript infer when possible
960
- - **Explicit return types** for exported functions
961
- - **Zod for runtime validation** - Single source of truth
962
- - **No type assertions** unless unavoidable
963
-
964
- ## Type vs Interface
965
-
966
- ```typescript
967
- // ✅ Use interface for: props, object shapes, extensible types
968
- interface UserCardProps {
969
- user: User;
970
- onSelect: (id: string) => void;
971
- }
972
-
973
- interface BaseEntity {
974
- id: string;
975
- createdAt: string;
976
- }
977
-
978
- interface User extends BaseEntity {
979
- name: string;
980
- }
981
-
982
- // ✅ Use type for: unions, intersections, tuples, derived types
983
- type Status = "pending" | "active" | "completed";
984
- type UserWithRoles = User & { roles: string[] };
985
- type Coordinate = [number, number];
986
- type UserKeys = keyof User;
987
- ```
988
-
989
- ## Naming Conventions
990
-
991
- ```typescript
992
- // ✅ Suffix with purpose (no I or T prefix)
993
- interface ButtonProps { ... }
994
- type ApiResponse = { ... };
995
- type UserOptions = { ... };
996
-
997
- // ✅ Boolean properties
998
- interface User {
999
- isActive: boolean;
1000
- hasPermission: boolean;
1001
- shouldNotify: boolean;
1002
- }
1003
- ```
1004
-
1005
- ## Zod Integration
1006
-
1007
- ```typescript
1008
- // Define schema, infer type
1009
- const userSchema = z.object({
1010
- id: z.string(),
1011
- name: z.string(),
1012
- });
1013
-
1014
- export type User = z.infer<typeof userSchema>;
1015
-
1016
- // Validate API responses
1017
- const response = await get({
1018
- url: "/api/users",
1019
- responseSchema: userSchema,
1020
- });
1021
- ```
1022
-
1023
- ## Type Guards
1024
-
1025
- ```typescript
1026
- export function isUser(value: unknown): value is User {
1027
- return userSchema.safeParse(value).success;
1028
- }
1029
-
1030
- // Usage
1031
- if (isUser(data)) {
1032
- console.log(data.name); // TypeScript knows data is User
1033
- }
1034
- ```
1035
-
1036
- ## Constants
1037
-
1038
- ```typescript
1039
- // Use const assertions
1040
- export const STATUSES = {
1041
- DRAFT: "draft",
1042
- PUBLISHED: "published",
1043
- } as const;
1044
-
1045
- export type Status = (typeof STATUSES)[keyof typeof STATUSES];
1046
- ```
1047
-
1048
- ## Utility Types
1049
-
1050
- ```typescript
1051
- // Built-in utilities
1052
- type PartialUser = Partial<User>;
1053
- type RequiredUser = Required<User>;
1054
- type ReadonlyUser = Readonly<User>;
1055
- type UserIdOnly = Pick<User, "id" | "name">;
1056
- type UserWithoutPassword = Omit<User, "password">;
1057
- type UserMap = Record<string, User>;
1058
- type DefinedString = NonNullable<string | null>;
1059
-
1060
- // Function utilities
1061
- type GetUserResult = ReturnType<typeof getUser>;
1062
- type UpdateUserParams = Parameters<typeof updateUser>;
1063
- ```
1064
-
1065
- ## Avoiding `any`
1066
-
1067
- ```typescript
1068
- // ❌ Bad - Loses type safety
1069
- function process(data: any) { ... }
1070
-
1071
- // ✅ Use unknown for truly unknown types
1072
- function process(data: unknown) {
1073
- if (typeof data === "object" && data !== null) {
1074
- // Type guard
1075
- }
1076
- }
1077
-
1078
- // ✅ Better - Use generics
1079
- function process<T extends { value: string }>(data: T) {
1080
- return data.value;
1081
- }
1082
-
1083
- // ✅ Best - Use Zod
1084
- const dataSchema = z.object({ value: z.string() });
1085
- function process(data: unknown) {
1086
- const parsed = dataSchema.parse(data);
1087
- return parsed.value;
1088
- }
1089
- ```
1090
-
1091
- ## Generics
1092
-
1093
- ```typescript
1094
- // Generic function
1095
- function getFirst<T>(array: T[]): T | undefined {
1096
- return array[0];
1097
- }
1098
-
1099
- // Generic component
1100
- interface ListProps<T> {
1101
- items: T[];
1102
- renderItem: (item: T) => ReactNode;
1103
- }
1104
-
1105
- export function List<T>({ items, renderItem }: ListProps<T>) {
1106
- return <>{items.map((item) => renderItem(item))}</>;
1107
- }
1108
-
1109
- // Constrained generics
1110
- function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
1111
- return items.find((item) => item.id === id);
1112
- }
1113
- ```
1114
-
1115
- ## Discriminated Unions
1116
-
1117
- ```typescript
1118
- // State machine
1119
- type QueryState<T> =
1120
- | { status: "idle" }
1121
- | { status: "loading" }
1122
- | { status: "success"; data: T }
1123
- | { status: "error"; error: Error };
1124
-
1125
- // Automatic type narrowing
1126
- if (state.status === "success") {
1127
- console.log(state.data); // TypeScript knows data exists
1128
- }
1129
-
1130
- // Actions
1131
- type Action = { type: "SET_USER"; payload: User } | { type: "CLEAR_USER" };
1132
-
1133
- function reducer(state: State, action: Action) {
1134
- switch (action.type) {
1135
- case "SET_USER":
1136
- return { ...state, user: action.payload };
1137
- case "CLEAR_USER":
1138
- return { ...state, user: null };
1139
- }
1140
- }
1141
- ```
1142
-
1143
- ## Type Narrowing
1144
-
1145
- ```typescript
1146
- // typeof
1147
- function format(value: string | number) {
1148
- if (typeof value === "string") {
1149
- return value.toUpperCase();
1150
- }
1151
- return value.toFixed(2);
1152
- }
1153
-
1154
- // in operator
1155
- function move(animal: Fish | Bird) {
1156
- if ("swim" in animal) {
1157
- animal.swim();
1158
- } else {
1159
- animal.fly();
1160
- }
1161
- }
1162
-
1163
- // instanceof
1164
- function handleError(error: unknown) {
1165
- if (error instanceof Error) {
1166
- console.log(error.message);
1167
- }
1168
- }
1169
- ```
1170
-
1171
- ## Common Patterns
1172
-
1173
- ```typescript
1174
- // Optional chaining
1175
- const userName = user?.profile?.name;
1176
-
1177
- // Nullish coalescing
1178
- const displayName = userName ?? "Anonymous";
1179
-
1180
- // Non-null assertion (use sparingly)
1181
- const user = getUser()!;
1182
-
1183
- // Template literal types
1184
- type Route = `/users/${string}` | `/posts/${string}`;
1185
- type EventHandler = `on${Capitalize<string>}`;
1186
- ```
1187
-
1188
- ## Best Practices
1189
-
1190
- - **Never use `any`** - Use `unknown` or generics
1191
- - **Discriminated unions** for state machines
1192
- - **Zod** for runtime validation
1193
- - **Type guards** over type assertions
1194
- - **Const assertions** for readonly values
1195
- - **Utility types** to transform existing types
1
+ @AGENTS.md