@clipboard-health/ai-rules 0.2.0 → 0.2.4

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.
@@ -0,0 +1,4203 @@
1
+ <!-- Generated by Ruler -->
2
+
3
+ <!-- Source: .ruler/backend/nestJsApis.md -->
4
+
5
+ # NestJS APIs
6
+
7
+ - Use a three-tier architecture:
8
+ - Controllers in the entrypoints tier translate from data transfer objects (DTOs) to domain objects (DOs) and call the logic tier.
9
+ - 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.
10
+ - 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.
11
+ - Use ts-rest to define contracts using Zod schemas, one contract per resource.
12
+ - A controller implements each ts-rest contract.
13
+ - Requests and responses follow the JSON:API specification, including pagination for listings.
14
+ - Use TypeDoc to document public functions, classes, methods, and complex code blocks.
15
+
16
+ <!-- Source: .ruler/common/codeStyleAndStructure.md -->
17
+
18
+ # Code style and structure
19
+
20
+ - Write concise, technical TypeScript code with accurate examples.
21
+ - Use functional and declarative programming patterns.
22
+ - Prefer iteration and modularization over code duplication.
23
+ - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
24
+ - Structure files: constants, types, exported functions, non-exported functions.
25
+ - Avoid magic strings and numbers; define constants.
26
+ - Use camelCase for files and directories (e.g., modules/shiftOffers.ts).
27
+ - When declaring functions, use the `function` keyword, not `const`.
28
+ - Prefer data immutability.
29
+
30
+ <!-- Source: .ruler/common/errorHandlingAndValidation.md -->
31
+
32
+ # Error handling and validation
33
+
34
+ - Sanitize user input.
35
+ - Handle errors and edge cases at the beginning of functions.
36
+ - Use early returns for error conditions to avoid deeply nested if statements.
37
+ - Place the happy path last in the function for improved readability.
38
+ - Avoid unnecessary else statements; use the if-return pattern instead.
39
+ - Use guard clauses to handle preconditions and invalid states early.
40
+ - Implement proper error logging and user-friendly error messages.
41
+ - Favor `@clipboard-health/util-ts`'s `Either` type for expected errors instead of `try`/`catch`.
42
+
43
+ <!-- Source: .ruler/common/keyConventions.md -->
44
+
45
+ # Key conventions
46
+
47
+ - You are familiar with the latest features and best practices.
48
+ - You carefully provide accurate, factual, thoughtful answers and are a genius at reasoning.
49
+ - You always write correct, up-to-date, bug-free, fully functional, working, secure, easy-to-read, and efficient code.
50
+ - If there might not be a correct answer or do not know the answer, say so instead of guessing.
51
+
52
+ <!-- Source: .ruler/common/testing.md -->
53
+
54
+ # Testing
55
+
56
+ - Follow the Arrange-Act-Assert convention for tests with newlines between each section.
57
+ - Name test variables using the `mockX`, `input`, `expected`, `actual` convention.
58
+ - Aim for high test coverage, writing both positive and negative test cases.
59
+ - Prefer `it.each` for multiple test cases.
60
+ - Avoid conditional logic in tests.
61
+
62
+ <!-- Source: .ruler/common/typeScript.md -->
63
+
64
+ # TypeScript usage
65
+
66
+ - Use strict-mode TypeScript for all code; prefer interfaces over types.
67
+ - Avoid enums; use const maps instead.
68
+ - Strive for precise types. Look for type definitions in the codebase and create your own if none exist.
69
+ - Avoid using type assertions like `as` or `!` unless absolutely necessary.
70
+ - Use the `unknown` type instead of `any` when the type is truly unknown.
71
+ - Use an object to pass multiple function params and to return results.
72
+ - Leverage union types, intersection types, and conditional types for complex type definitions.
73
+ - Use mapped types and utility types (e.g., `Partial<T>`, `Pick<T>`, `Omit<T>`) to transform existing types.
74
+ - Implement generic types to create reusable, flexible type definitions.
75
+ - Utilize the `keyof` operator and index access types for dynamic property access.
76
+ - Implement discriminated unions for type-safe handling of different object shapes where appropriate.
77
+ - Use the `infer` keyword in conditional types for type inference.
78
+ - Leverage `readonly` properties for function parameter immutability.
79
+ - Prefer narrow types whenever possible with `as const` assertions, `typeof`, `instanceof`, `satisfies`, and custom type guards.
80
+ - Implement exhaustiveness checking using `never`.
81
+
82
+ <!-- Source: .ruler/frontend/custom-hooks.md -->
83
+
84
+ # Custom Hook Standards
85
+
86
+ ## Hook Structure
87
+
88
+ ```typescript
89
+ interface UseFeatureOptions {
90
+ enabled?: boolean;
91
+ refetchInterval?: number;
92
+ }
93
+
94
+ export function useFeature(params: Params, options: UseFeatureOptions = {}) {
95
+ const { enabled = true } = options;
96
+
97
+ // Multiple queries/hooks
98
+ const query1 = useQuery(...);
99
+ const query2 = useQuery(...);
100
+
101
+ // Derived state
102
+ const computed = useMemo(() => {
103
+ // Combine data
104
+ return transformData(query1.data, query2.data);
105
+ }, [query1.data, query2.data]);
106
+
107
+ // Callbacks
108
+ const handleAction = useCallback(async () => {
109
+ // Perform action
110
+ }, [dependencies]);
111
+
112
+ // Return structured object
113
+ return {
114
+ // Data
115
+ data: computed,
116
+
117
+ // Loading states
118
+ isLoading: query1.isLoading || query2.isLoading,
119
+ isError: query1.isError || query2.isError,
120
+
121
+ // Actions
122
+ refetch: async () => {
123
+ await Promise.all([query1.refetch(), query2.refetch()]);
124
+ },
125
+ handleAction,
126
+ };
127
+ }
128
+ ```
129
+
130
+ ## Naming Rules
131
+
132
+ - **Always** prefix with `use`
133
+ - Boolean hooks: `useIsFeatureEnabled`, `useShouldShowModal`, `useHasPermission`
134
+ - Data hooks: `useGetData`, `useFeatureData`
135
+ - Action hooks: `useBookShift`, `useSubmitForm`
136
+
137
+ ## Return Values
138
+
139
+ - Return objects (not arrays) for complex hooks
140
+ - Name properties clearly: `isLoading` not `loading`, `refetch` not `refresh`
141
+ - Group related properties together
142
+
143
+ ```typescript
144
+ // Good - clear property names
145
+ return {
146
+ data,
147
+ isLoading,
148
+ isError,
149
+ refetch,
150
+ };
151
+
152
+ // Avoid - array destructuring for complex returns
153
+ return [data, isLoading, refetch]; // Only for very simple hooks
154
+ ```
155
+
156
+ ## State Management with Constate
157
+
158
+ For local/shared state management, use **`constate`** to create context with minimal boilerplate:
159
+
160
+ ```typescript
161
+ import constate from "constate";
162
+ import { useState, useCallback } from "react";
163
+
164
+ // Define your hook with state logic
165
+ function useShiftFilters() {
166
+ const [filters, setFilters] = useState<Filters>({});
167
+ const [isLoading, setIsLoading] = useState(false);
168
+
169
+ const applyFilters = useCallback((newFilters: Filters) => {
170
+ setFilters(newFilters);
171
+ }, []);
172
+
173
+ const clearFilters = useCallback(() => {
174
+ setFilters({});
175
+ }, []);
176
+
177
+ return {
178
+ filters,
179
+ isLoading,
180
+ applyFilters,
181
+ clearFilters,
182
+ };
183
+ }
184
+
185
+ // Create provider and hook with constate
186
+ export const [ShiftFiltersProvider, useShiftFiltersContext] = constate(useShiftFilters);
187
+ ```
188
+
189
+ **Usage:**
190
+
191
+ ```typescript
192
+ // Wrap components that need access
193
+ function ShiftsPage() {
194
+ return (
195
+ <ShiftFiltersProvider>
196
+ <ShiftList />
197
+ <FilterPanel />
198
+ </ShiftFiltersProvider>
199
+ );
200
+ }
201
+
202
+ // Use in child components
203
+ function ShiftList() {
204
+ const { filters, applyFilters } = useShiftFiltersContext();
205
+
206
+ // Use shared state
207
+ return <div>{/* ... */}</div>;
208
+ }
209
+ ```
210
+
211
+ **Benefits:**
212
+
213
+ - Minimal boilerplate compared to raw Context API
214
+ - TypeScript support out of the box
215
+ - Avoids prop drilling
216
+ - Clean separation of state logic
217
+
218
+ **When to use Constate:**
219
+
220
+ - Sharing state between multiple sibling components
221
+ - Complex feature-level state (filters, UI state, form state)
222
+ - Alternative to prop drilling
223
+
224
+ **When NOT to use Constate:**
225
+
226
+ - Server state (use React Query instead)
227
+ - Global app state (consider if it's truly needed)
228
+ - Simple parent-child communication (use props)
229
+
230
+ ## Boolean Hooks
231
+
232
+ ```typescript
233
+ // Pattern: useIs*, useHas*, useShould*
234
+ export function useIsFeatureEnabled(): boolean {
235
+ const flags = useFeatureFlags();
236
+ return flags.includes("new-feature");
237
+ }
238
+
239
+ export function useHasPermission(permission: string): boolean {
240
+ const user = useUser();
241
+ return user.permissions.includes(permission);
242
+ }
243
+
244
+ export function useShouldShowModal(): boolean {
245
+ const hasSeenModal = usePreference("hasSeenModal");
246
+ return !hasSeenModal;
247
+ }
248
+ ```
249
+
250
+ ## Data Transformation Hooks
251
+
252
+ ```typescript
253
+ export function useTransformedData() {
254
+ const { data: rawData, isLoading, isError } = useGetRawData();
255
+
256
+ const transformedData = useMemo(() => {
257
+ if (!rawData) return undefined;
258
+
259
+ return rawData.map((item) => ({
260
+ ...item,
261
+ displayName: formatName(item),
262
+ }));
263
+ }, [rawData]);
264
+
265
+ return {
266
+ data: transformedData,
267
+ isLoading,
268
+ isError,
269
+ };
270
+ }
271
+ ```
272
+
273
+ ## Side Effect Hooks
274
+
275
+ ```typescript
276
+ // Hooks that perform side effects (logging, tracking, etc.)
277
+ export function useTrackPageView(pageName: string) {
278
+ useEffect(() => {
279
+ logEvent("PAGE_VIEWED", { pageName });
280
+ }, [pageName]);
281
+ }
282
+
283
+ // Usage
284
+ export function MyPage() {
285
+ useTrackPageView("MyPage");
286
+ // ... rest of component
287
+ }
288
+ ```
289
+
290
+ ## Composite Hooks
291
+
292
+ ```typescript
293
+ // Combines multiple hooks and data sources
294
+ export function useWorkerBookingsData() {
295
+ const worker = useDefinedWorker();
296
+ const isFeatureEnabled = useIsFeatureEnabled("new-bookings");
297
+
298
+ const {
299
+ data: shifts,
300
+ isLoading: isLoadingShifts,
301
+ refetch: refetchShifts,
302
+ } = useGetShifts(worker.id);
303
+
304
+ const {
305
+ data: invites,
306
+ isLoading: isLoadingInvites,
307
+ refetch: refetchInvites,
308
+ } = useGetInvites(worker.id, { enabled: isFeatureEnabled });
309
+
310
+ // Combine data
311
+ const bookings = useMemo(() => {
312
+ return [...(shifts ?? []), ...(invites ?? [])].sort(sortByDate);
313
+ }, [shifts, invites]);
314
+
315
+ // Combined loading state
316
+ const isLoading = isLoadingShifts || isLoadingInvites;
317
+
318
+ // Combined refetch
319
+ async function refreshAllData() {
320
+ await Promise.all([refetchShifts(), refetchInvites()]);
321
+ }
322
+
323
+ return {
324
+ // Data
325
+ bookings,
326
+
327
+ // States
328
+ isLoading,
329
+ isFeatureEnabled,
330
+
331
+ // Actions
332
+ refreshAllData,
333
+ };
334
+ }
335
+ ```
336
+
337
+ ## Co-location
338
+
339
+ - Place hooks in `hooks/` folder within feature directory
340
+ - Place API hooks in `api/` folder
341
+ - Keep generic/shared hooks in `lib/` or `utils/`
342
+
343
+ ```text
344
+ FeatureName/
345
+ ├── api/
346
+ │ ├── useGetFeature.ts # API data fetching
347
+ │ └── useUpdateFeature.ts
348
+ ├── hooks/
349
+ │ ├── useFeatureLogic.ts # Business logic hooks
350
+ │ ├── useFeatureState.ts # Constate state hooks
351
+ │ └── useFeatureFilters.ts
352
+ └── components/
353
+ └── FeatureComponent.tsx
354
+ ```
355
+
356
+ ## Options Pattern
357
+
358
+ ```typescript
359
+ // Always use options object for flexibility
360
+ interface UseFeatureOptions {
361
+ enabled?: boolean;
362
+ refetchInterval?: number;
363
+ onSuccess?: (data: Data) => void;
364
+ }
365
+
366
+ export function useFeature(params: Params, options: UseFeatureOptions = {}) {
367
+ const { enabled = true, refetchInterval, onSuccess } = options;
368
+
369
+ // Use options in queries
370
+ return useQuery({
371
+ enabled,
372
+ refetchInterval,
373
+ onSuccess,
374
+ // ...
375
+ });
376
+ }
377
+ ```
378
+
379
+ ## Hook Dependencies
380
+
381
+ ```typescript
382
+ // Be explicit about dependencies
383
+ export function useFormattedData(rawData: Data[]) {
384
+ return useMemo(() => {
385
+ return rawData.map(format);
386
+ }, [rawData]); // Clear dependency
387
+ }
388
+
389
+ // Avoid creating functions in dependency arrays
390
+ export function useHandler() {
391
+ const [value, setValue] = useState();
392
+
393
+ // Good - stable reference
394
+ const handleChange = useCallback((newValue: string) => {
395
+ setValue(newValue);
396
+ }, []); // No dependencies on value
397
+
398
+ return handleChange;
399
+ }
400
+ ```
401
+
402
+ ## Best Practices
403
+
404
+ - **Prefix with `use`** - Always
405
+ - **Return objects, not arrays** - For hooks with multiple values
406
+ - **Use constate for shared state** - Avoid prop drilling
407
+ - **API hooks in `api/`** - Logic hooks in `hooks/`
408
+ - **Clear property names** - `isLoading`, not `loading`
409
+ - **Options pattern** - For flexible configuration
410
+ - **Explicit dependencies** - In useMemo/useCallback
411
+
412
+ <!-- Source: .ruler/frontend/data-fetching.md -->
413
+
414
+ # Data Fetching Standards
415
+
416
+ ## Technology Stack
417
+
418
+ - **React Query** (@tanstack/react-query) for all API calls
419
+ - **Axios** for HTTP requests
420
+ - **Zod** for response validation
421
+
422
+ ## Core Principles
423
+
424
+ 1. **Use URL and query parameters in query keys** - Makes cache invalidation predictable
425
+ 2. **Always use `useGetQuery` hook** - Provides consistent structure, logging, and validation
426
+ 3. **Define Zod schemas** for all API requests and responses
427
+ 4. **Log errors with centralized constants** - From `APP_V2_APP_EVENTS`, never inline strings
428
+ 5. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess` - don't reinvent state management
429
+ 6. **Use `enabled` for conditional fetching** - With `isDefined()` helper
430
+ 7. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
431
+
432
+ ## Hook Patterns
433
+
434
+ ### Simple Query
435
+
436
+ ```typescript
437
+ export function useGetUser(userId: string) {
438
+ return useGetQuery({
439
+ url: `/api/users/${userId}`,
440
+ responseSchema: userSchema,
441
+ enabled: isDefined(userId),
442
+ staleTime: minutesToMilliseconds(5),
443
+ meta: {
444
+ logErrorMessage: APP_V2_APP_EVENTS.GET_USER_FAILURE,
445
+ },
446
+ });
447
+ }
448
+ ```
449
+
450
+ ### Infinite/Paginated Query
451
+
452
+ ```typescript
453
+ export function usePaginatedShifts(params: Params) {
454
+ return useInfiniteQuery({
455
+ queryKey: ["shifts", params],
456
+ queryFn: async ({ pageParam }) => {
457
+ const response = await get({
458
+ url: "/api/shifts",
459
+ queryParams: { cursor: pageParam, ...params },
460
+ responseSchema: shiftsResponseSchema,
461
+ });
462
+ return response.data;
463
+ },
464
+ getNextPageParam: (lastPage) => lastPage.links.nextCursor,
465
+ });
466
+ }
467
+ ```
468
+
469
+ ### Composite Data Fetching
470
+
471
+ ```typescript
472
+ // Hook that combines multiple queries
473
+ export function useWorkerBookingsData() {
474
+ const { data: shifts, isLoading: isLoadingShifts, refetch: refetchShifts } = useGetShifts();
475
+ const { data: invites, isLoading: isLoadingInvites, refetch: refetchInvites } = useGetInvites();
476
+
477
+ // Combine data
478
+ const bookings = useMemo(() => {
479
+ return [...(shifts ?? []), ...(invites ?? [])];
480
+ }, [shifts, invites]);
481
+
482
+ // Combine loading states
483
+ const isLoading = isLoadingShifts || isLoadingInvites;
484
+
485
+ // Combine refetch functions
486
+ async function refreshAllData() {
487
+ await Promise.all([refetchShifts(), refetchInvites()]);
488
+ }
489
+
490
+ return {
491
+ bookings,
492
+ isLoading,
493
+ refreshAllData,
494
+ };
495
+ }
496
+ ```
497
+
498
+ ## Error Handling
499
+
500
+ Always use centralized error constants and handle expected errors gracefully:
501
+
502
+ ```typescript
503
+ useGetQuery({
504
+ url: "/api/resource",
505
+ responseSchema: schema,
506
+ meta: {
507
+ logErrorMessage: APP_V2_APP_EVENTS.GET_RESOURCE_FAILURE, // ✅ Centralized
508
+ userErrorMessage: "Failed to load data", // Shows alert to user
509
+ },
510
+ useErrorBoundary: (error) => {
511
+ // Don't show error boundary for 404s (expected errors)
512
+ return !(axios.isAxiosError(error) && error.response?.status === 404);
513
+ },
514
+ retry: (failureCount, error) => {
515
+ // Don't retry 404s or 401s
516
+ if (axios.isAxiosError(error)) {
517
+ const status = error.response?.status;
518
+ return ![404, 401].includes(status ?? 0);
519
+ }
520
+ return failureCount < 3;
521
+ },
522
+ });
523
+ ```
524
+
525
+ ## State Management - Don't Reinvent the Wheel
526
+
527
+ ❌ **Don't** create your own loading/error state:
528
+
529
+ ```typescript
530
+ const [data, setData] = useState();
531
+ const [isLoading, setIsLoading] = useState(false);
532
+ const [error, setError] = useState();
533
+
534
+ useEffect(() => {
535
+ async function fetchData() {
536
+ try {
537
+ setIsLoading(true);
538
+ const result = await api.get("/data");
539
+ setData(result);
540
+ } catch (err) {
541
+ setError(err);
542
+ } finally {
543
+ setIsLoading(false);
544
+ }
545
+ }
546
+ fetchData();
547
+ }, []);
548
+ ```
549
+
550
+ ✅ **Do** use React Query states:
551
+
552
+ ```typescript
553
+ const { data, isLoading, isError, isSuccess } = useGetQuery({...});
554
+
555
+ if (isLoading) return <Loading />;
556
+ if (isError) return <Error />;
557
+ if (isSuccess) return <div>{data.property}</div>;
558
+ ```
559
+
560
+ ## Mutations
561
+
562
+ ```typescript
563
+ export function useCreateDocument() {
564
+ const queryClient = useQueryClient();
565
+
566
+ return useMutation({
567
+ mutationFn: async (data: CreateDocumentRequest) => {
568
+ return await post({
569
+ url: "/api/documents",
570
+ data,
571
+ responseSchema: documentSchema,
572
+ });
573
+ },
574
+ onSuccess: () => {
575
+ // Invalidate queries to refetch
576
+ queryClient.invalidateQueries(["documents"]);
577
+ },
578
+ onError: (error) => {
579
+ logEvent(APP_V2_APP_EVENTS.CREATE_DOCUMENT_FAILURE, { error });
580
+ },
581
+ });
582
+ }
583
+ ```
584
+
585
+ ## Query Keys
586
+
587
+ Always include URL and parameters in query keys:
588
+
589
+ ```typescript
590
+ // ❌ Don't use static strings
591
+ useQuery({ queryKey: "users", ... });
592
+
593
+ // ✅ Do include URL and params
594
+ useQuery({ queryKey: [`/api/users?${status}`, { status }], ... });
595
+
596
+ // Consistent query key structure
597
+ export const queryKeys = {
598
+ users: ["users"] as const,
599
+ user: (id: string) => ["users", id] as const,
600
+ userShifts: (id: string) => ["users", id, "shifts"] as const,
601
+ };
602
+
603
+ // Usage
604
+ useQuery({
605
+ queryKey: queryKeys.user(userId),
606
+ // ...
607
+ });
608
+ ```
609
+
610
+ ## Refetch Intervals
611
+
612
+ ```typescript
613
+ useGetQuery({
614
+ url: "/api/resource",
615
+ responseSchema: schema,
616
+ refetchInterval: (data) => {
617
+ // Dynamic refetch based on data state
618
+ if (!data?.isComplete) {
619
+ return 1000; // Poll every second until complete
620
+ }
621
+ return 0; // Stop refetching
622
+ },
623
+ });
624
+ ```
625
+
626
+ ## Conditional Fetching
627
+
628
+ Use `enabled` option with `isDefined()` helper:
629
+
630
+ ```typescript
631
+ import { isDefined } from "@/lib/utils";
632
+
633
+ const { data } = useGetQuery({
634
+ url: "/api/resource",
635
+ responseSchema: schema,
636
+ // Only fetch when conditions are met
637
+ enabled: isDefined(userId) && isFeatureEnabled,
638
+ });
639
+ ```
640
+
641
+ ## Refetch vs InvalidateQueries
642
+
643
+ **Important:** For disabled queries, use `invalidateQueries` instead of `refetch`:
644
+
645
+ ❌ **Don't** use `refetch()` on disabled queries:
646
+
647
+ ```typescript
648
+ const { refetch, data } = useGetQuery({
649
+ enabled: isDefined(shift.agentId),
650
+ ...
651
+ });
652
+
653
+ // Will fetch even if agentId is undefined!
654
+ refetch();
655
+ ```
656
+
657
+ ✅ **Do** use `invalidateQueries`:
658
+
659
+ ```typescript
660
+ const queryClient = useQueryClient();
661
+
662
+ const { data } = useGetQuery({
663
+ enabled: isDefined(shift.agentId),
664
+ ...
665
+ });
666
+
667
+ // Respects the enabled state
668
+ queryClient.invalidateQueries({ queryKey: [myQueryKey] });
669
+ ```
670
+
671
+ ## Query Cancellation
672
+
673
+ ```typescript
674
+ export function usePaginatedData() {
675
+ const queryClient = useQueryClient();
676
+
677
+ return useInfiniteQuery({
678
+ queryKey: ["data"],
679
+ queryFn: async ({ pageParam }) => {
680
+ // Cancel previous in-flight requests
681
+ await queryClient.cancelQueries({ queryKey: ["data"] });
682
+
683
+ const response = await get({
684
+ url: "/api/data",
685
+ queryParams: { cursor: pageParam },
686
+ });
687
+ return response.data;
688
+ },
689
+ // ...
690
+ });
691
+ }
692
+ ```
693
+
694
+ ## Naming Conventions
695
+
696
+ - **useGet\*** - Simple queries: `useGetUser`, `useGetShift`
697
+ - **usePaginated\*** - Infinite queries: `usePaginatedPlacements`
698
+ - **useFetch\*** - Complex fetching logic: `useFetchPaginatedInterviews`
699
+ - **Mutations**: `useCreateDocument`, `useUpdateShift`, `useDeletePlacement`
700
+
701
+ ## Response Transformation
702
+
703
+ ```typescript
704
+ export function useGetUser(userId: string) {
705
+ return useGetQuery({
706
+ url: `/api/users/${userId}`,
707
+ responseSchema: userResponseSchema,
708
+ select: (data) => {
709
+ // Transform response data
710
+ return {
711
+ ...data,
712
+ fullName: `${data.firstName} ${data.lastName}`,
713
+ };
714
+ },
715
+ });
716
+ }
717
+ ```
718
+
719
+ ## Hook Location
720
+
721
+ - **API hooks** → Place in `api/` folder within feature directory
722
+ - **One endpoint = one hook** principle
723
+ - Export types inferred from Zod: `export type User = z.infer<typeof userSchema>`
724
+
725
+ Example:
726
+
727
+ ```text
728
+ Feature/
729
+ ├── api/
730
+ │ ├── useGetResource.ts
731
+ │ ├── useCreateResource.ts
732
+ │ └── schemas.ts (optional)
733
+ ```
734
+
735
+ <!-- Source: .ruler/frontend/error-handling.md -->
736
+
737
+ # Error Handling Standards
738
+
739
+ ## React Query Error Handling
740
+
741
+ ### Basic Error Configuration
742
+
743
+ ```typescript
744
+ useGetQuery({
745
+ url: "/api/resource",
746
+ responseSchema: schema,
747
+ meta: {
748
+ logErrorMessage: APP_V2_APP_EVENTS.GET_RESOURCE_FAILURE,
749
+ },
750
+ useErrorBoundary: (error) => {
751
+ // Show error boundary for 500s, not for 404s
752
+ return !(axios.isAxiosError(error) && error.response?.status === 404);
753
+ },
754
+ retry: (failureCount, error) => {
755
+ // Don't retry 404s or 401s
756
+ if (axios.isAxiosError(error)) {
757
+ const status = error.response?.status;
758
+ return ![404, 401].includes(status ?? 0);
759
+ }
760
+ return failureCount < 3;
761
+ },
762
+ });
763
+ ```
764
+
765
+ ### Error Boundary Strategy
766
+
767
+ - **Show error boundary** for unexpected errors (500s, network failures)
768
+ - **Don't show error boundary** for expected errors (404s, validation errors)
769
+ - Use `useErrorBoundary` to control this behavior
770
+
771
+ ```typescript
772
+ useErrorBoundary: (error) => {
773
+ // Only show error boundary for server errors
774
+ if (axios.isAxiosError(error)) {
775
+ const status = error.response?.status;
776
+ return status !== undefined && status >= 500;
777
+ }
778
+ return true; // Show for non-Axios errors
779
+ };
780
+ ```
781
+
782
+ ### Retry Configuration
783
+
784
+ ```typescript
785
+ // Don't retry client errors
786
+ retry: (failureCount, error) => {
787
+ if (axios.isAxiosError(error)) {
788
+ const status = error.response?.status;
789
+ // Don't retry 4xx errors
790
+ if (status !== undefined && status >= 400 && status < 500) {
791
+ return false;
792
+ }
793
+ }
794
+ // Retry server errors up to 3 times
795
+ return failureCount < 3;
796
+ };
797
+ ```
798
+
799
+ ### Exponential Backoff
800
+
801
+ ```typescript
802
+ import { type QueryClient } from "@tanstack/react-query";
803
+
804
+ const queryClient = new QueryClient({
805
+ defaultOptions: {
806
+ queries: {
807
+ retry: 3,
808
+ retryDelay: (attemptIndex) => {
809
+ // Exponential backoff: 1s, 2s, 4s
810
+ return Math.min(1000 * 2 ** attemptIndex, 30000);
811
+ },
812
+ },
813
+ },
814
+ });
815
+ ```
816
+
817
+ ## Component Error States
818
+
819
+ ### Pattern: Loading → Error → Success
820
+
821
+ ```typescript
822
+ export function DataComponent() {
823
+ const { data, isLoading, isError, error, refetch } = useGetData();
824
+
825
+ if (isLoading) {
826
+ return <LoadingState />;
827
+ }
828
+
829
+ if (isError) {
830
+ return <ErrorState message="Failed to load data" onRetry={refetch} error={error} />;
831
+ }
832
+
833
+ // Happy path
834
+ return <DataDisplay data={data} />;
835
+ }
836
+ ```
837
+
838
+ ### Inline Error Messages
839
+
840
+ ```typescript
841
+ export function FormComponent() {
842
+ const mutation = useCreateResource();
843
+
844
+ return (
845
+ <form onSubmit={handleSubmit}>
846
+ {mutation.isError && <Alert severity="error">Failed to save. Please try again.</Alert>}
847
+
848
+ <Button type="submit" loading={mutation.isLoading} disabled={mutation.isLoading}>
849
+ Save
850
+ </Button>
851
+ </form>
852
+ );
853
+ }
854
+ ```
855
+
856
+ ### Graceful Degradation
857
+
858
+ ```typescript
859
+ export function OptionalDataComponent() {
860
+ const { data, isError } = useGetOptionalData();
861
+
862
+ // Don't block UI for optional data
863
+ if (isError) {
864
+ logError("Failed to load optional data");
865
+ return null; // or show simplified version
866
+ }
867
+
868
+ if (!data) {
869
+ return null;
870
+ }
871
+
872
+ return <EnhancedView data={data} />;
873
+ }
874
+ ```
875
+
876
+ ## Mutation Error Handling
877
+
878
+ ### onError Callback
879
+
880
+ ```typescript
881
+ export function useCreateDocument() {
882
+ const queryClient = useQueryClient();
883
+
884
+ return useMutation({
885
+ mutationFn: createDocumentApi,
886
+ onSuccess: (data) => {
887
+ queryClient.invalidateQueries(["documents"]);
888
+ showSuccessToast("Document created");
889
+ },
890
+ onError: (error) => {
891
+ logEvent(APP_V2_APP_EVENTS.CREATE_DOCUMENT_FAILURE, {
892
+ error: error.message,
893
+ });
894
+ showErrorToast("Failed to create document");
895
+ },
896
+ });
897
+ }
898
+ ```
899
+
900
+ ### Handling Specific Errors
901
+
902
+ ```typescript
903
+ export function useUpdateProfile() {
904
+ return useMutation({
905
+ mutationFn: updateProfileApi,
906
+ onError: (error: AxiosError) => {
907
+ if (error.response?.status === 409) {
908
+ showErrorToast("Email already exists");
909
+ } else if (error.response?.status === 422) {
910
+ showErrorToast("Invalid data provided");
911
+ } else {
912
+ showErrorToast("Failed to update profile");
913
+ }
914
+ },
915
+ });
916
+ }
917
+ ```
918
+
919
+ ## Logging and Monitoring
920
+
921
+ ### Event Logging
922
+
923
+ ```typescript
924
+ import { logEvent } from '@/lib/analytics';
925
+ import { APP_EVENTS } from '@/constants/events';
926
+
927
+ // In query configuration
928
+ meta: {
929
+ logErrorMessage: APP_V2_APP_EVENTS.GET_SHIFTS_FAILURE,
930
+ }
931
+
932
+ // In error handlers
933
+ onError: (error) => {
934
+ logEvent(APP_V2_APP_EVENTS.BOOKING_FAILED, {
935
+ shiftId,
936
+ error: error.message,
937
+ userId: worker.id,
938
+ });
939
+ }
940
+ ```
941
+
942
+ ### Error Context
943
+
944
+ Always include relevant context when logging errors:
945
+
946
+ ```typescript
947
+ logEvent(APP_V2_APP_EVENTS.API_ERROR, {
948
+ endpoint: "/api/shifts",
949
+ method: "GET",
950
+ statusCode: error.response?.status,
951
+ errorMessage: error.message,
952
+ userId: worker.id,
953
+ timestamp: new Date().toISOString(),
954
+ });
955
+ ```
956
+
957
+ ## Validation Errors
958
+
959
+ ### Zod Validation
960
+
961
+ ```typescript
962
+ import { z } from "zod";
963
+
964
+ const formSchema = z.object({
965
+ email: z.string().email("Invalid email address"),
966
+ age: z.number().min(18, "Must be 18 or older"),
967
+ });
968
+
969
+ try {
970
+ const validated = formSchema.parse(formData);
971
+ // Use validated data
972
+ } catch (error) {
973
+ if (error instanceof z.ZodError) {
974
+ // Handle validation errors
975
+ error.errors.forEach((err) => {
976
+ showFieldError(err.path.join("."), err.message);
977
+ });
978
+ }
979
+ }
980
+ ```
981
+
982
+ ### API Validation Errors
983
+
984
+ ```typescript
985
+ interface ApiValidationError {
986
+ field: string;
987
+ message: string;
988
+ }
989
+
990
+ function handleApiValidationError(error: AxiosError) {
991
+ const validationErrors = error.response?.data?.errors as ApiValidationError[];
992
+
993
+ if (validationErrors) {
994
+ validationErrors.forEach(({ field, message }) => {
995
+ setFieldError(field, message);
996
+ });
997
+ }
998
+ }
999
+ ```
1000
+
1001
+ ## Network Errors
1002
+
1003
+ ### Offline Detection
1004
+
1005
+ ```typescript
1006
+ export function useNetworkStatus() {
1007
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
1008
+
1009
+ useEffect(() => {
1010
+ function handleOnline() {
1011
+ setIsOnline(true);
1012
+ }
1013
+
1014
+ function handleOffline() {
1015
+ setIsOnline(false);
1016
+ }
1017
+
1018
+ window.addEventListener("online", handleOnline);
1019
+ window.addEventListener("offline", handleOffline);
1020
+
1021
+ return () => {
1022
+ window.removeEventListener("online", handleOnline);
1023
+ window.removeEventListener("offline", handleOffline);
1024
+ };
1025
+ }, []);
1026
+
1027
+ return isOnline;
1028
+ }
1029
+ ```
1030
+
1031
+ ### Offline UI
1032
+
1033
+ ```typescript
1034
+ export function AppContainer() {
1035
+ const isOnline = useNetworkStatus();
1036
+
1037
+ return (
1038
+ <>
1039
+ {!isOnline && (
1040
+ <Banner severity="warning">You are offline. Some features may not be available.</Banner>
1041
+ )}
1042
+ <App />
1043
+ </>
1044
+ );
1045
+ }
1046
+ ```
1047
+
1048
+ ## Timeout Handling
1049
+
1050
+ ### Request Timeouts
1051
+
1052
+ ```typescript
1053
+ import axios from "axios";
1054
+
1055
+ const api = axios.create({
1056
+ timeout: 30000, // 30 seconds
1057
+ });
1058
+
1059
+ api.interceptors.response.use(
1060
+ (response) => response,
1061
+ (error) => {
1062
+ if (error.code === "ECONNABORTED") {
1063
+ showErrorToast("Request timed out. Please try again.");
1064
+ }
1065
+ return Promise.reject(error);
1066
+ },
1067
+ );
1068
+ ```
1069
+
1070
+ ## Error Boundaries
1071
+
1072
+ ### React Error Boundary
1073
+
1074
+ ```typescript
1075
+ import { Component, type ReactNode } from "react";
1076
+
1077
+ interface Props {
1078
+ children: ReactNode;
1079
+ fallback?: ReactNode;
1080
+ }
1081
+
1082
+ interface State {
1083
+ hasError: boolean;
1084
+ error?: Error;
1085
+ }
1086
+
1087
+ export class ErrorBoundary extends Component<Props, State> {
1088
+ constructor(props: Props) {
1089
+ super(props);
1090
+ this.state = { hasError: false };
1091
+ }
1092
+
1093
+ static getDerivedStateFromError(error: Error): State {
1094
+ return { hasError: true, error };
1095
+ }
1096
+
1097
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
1098
+ logEvent("ERROR_BOUNDARY_TRIGGERED", {
1099
+ error: error.message,
1100
+ componentStack: errorInfo.componentStack,
1101
+ });
1102
+ }
1103
+
1104
+ render() {
1105
+ if (this.state.hasError) {
1106
+ return this.props.fallback || <ErrorFallback />;
1107
+ }
1108
+
1109
+ return this.props.children;
1110
+ }
1111
+ }
1112
+ ```
1113
+
1114
+ ## Best Practices
1115
+
1116
+ ### 1. Always Handle Errors
1117
+
1118
+ ```typescript
1119
+ // ❌ Don't ignore errors
1120
+ const { data } = useGetData();
1121
+
1122
+ // ✅ Handle error states
1123
+ const { data, isError, error } = useGetData();
1124
+ if (isError) {
1125
+ return <ErrorState error={error} />;
1126
+ }
1127
+ ```
1128
+
1129
+ ### 2. Provide User-Friendly Messages
1130
+
1131
+ ```typescript
1132
+ // ❌ Show technical errors to users
1133
+ <Alert>{error.message}</Alert>
1134
+
1135
+ // ✅ Show helpful messages
1136
+ <Alert>
1137
+ We couldn't load your shifts. Please check your connection and try again.
1138
+ </Alert>
1139
+ ```
1140
+
1141
+ ### 3. Log Errors for Debugging
1142
+
1143
+ ```typescript
1144
+ // Always log errors for monitoring
1145
+ onError: (error) => {
1146
+ logEvent(APP_V2_APP_EVENTS.ERROR, {
1147
+ context: "shift-booking",
1148
+ error: error.message,
1149
+ });
1150
+ showErrorToast("Booking failed");
1151
+ };
1152
+ ```
1153
+
1154
+ ### 4. Provide Recovery Actions
1155
+
1156
+ ```typescript
1157
+ // ✅ Give users a way to recover
1158
+ <ErrorState message="Failed to load data" onRetry={refetch} onDismiss={() => navigate("/home")} />
1159
+ ```
1160
+
1161
+ ### 5. Different Strategies for Different Errors
1162
+
1163
+ ```typescript
1164
+ // Critical errors: Show error boundary
1165
+ useErrorBoundary: (error) => isCriticalError(error);
1166
+
1167
+ // Expected errors: Show inline message
1168
+ if (isError && error.response?.status === 404) {
1169
+ return <NotFoundMessage />;
1170
+ }
1171
+
1172
+ // Transient errors: Auto-retry with backoff
1173
+ retry: (failureCount) => failureCount < 3;
1174
+ ```
1175
+
1176
+ <!-- Source: .ruler/frontend/file-organization.md -->
1177
+
1178
+ # File Organization Standards
1179
+
1180
+ ## Feature-Based Structure
1181
+
1182
+ ```text
1183
+ FeatureName/
1184
+ ├── api/ # Data fetching hooks
1185
+ │ ├── useGetFeature.ts
1186
+ │ ├── useUpdateFeature.ts
1187
+ │ └── useDeleteFeature.ts
1188
+ ├── components/ # Feature-specific components
1189
+ │ ├── FeatureCard.tsx
1190
+ │ ├── FeatureList.tsx
1191
+ │ └── FeatureHeader.tsx
1192
+ ├── hooks/ # Feature-specific hooks (non-API)
1193
+ │ ├── useFeatureLogic.ts
1194
+ │ └── useFeatureState.ts
1195
+ ├── utils/ # Feature utilities
1196
+ │ ├── formatFeature.ts
1197
+ │ ├── formatFeature.test.ts
1198
+ │ └── validateFeature.ts
1199
+ ├── __tests__/ # Integration tests (optional)
1200
+ │ └── FeatureFlow.test.tsx
1201
+ ├── Page.tsx # Main page component
1202
+ ├── Router.tsx # Feature routes
1203
+ ├── paths.ts # Route paths
1204
+ ├── types.ts # Shared types
1205
+ ├── constants.ts # Constants
1206
+ └── README.md # Feature documentation (optional)
1207
+ ```
1208
+
1209
+ ## File Naming Conventions
1210
+
1211
+ ### React Components
1212
+
1213
+ - **PascalCase** for all React components
1214
+ - Examples: `Button.tsx`, `UserProfile.tsx`, `ShiftCard.tsx`
1215
+
1216
+ ### Avoid Path Stuttering
1217
+
1218
+ Don't repeat directory names in file names - the full path provides enough context:
1219
+
1220
+ ❌ **Bad** - Path stuttering:
1221
+
1222
+ ```text
1223
+ Shift/
1224
+ ShiftInvites/
1225
+ ShiftInviteCard.tsx // ❌ "Shift" repeated 3 times in path
1226
+ ShiftInviteList.tsx // ❌ Import: Shift/ShiftInvites/ShiftInviteCard
1227
+ ```
1228
+
1229
+ ✅ **Good** - Clean, concise:
1230
+
1231
+ ```text
1232
+ Shift/
1233
+ Invites/
1234
+ Card.tsx // ✅ Path: Shift/Invites/Card
1235
+ List.tsx // ✅ Import: Shift/Invites/List
1236
+ ```
1237
+
1238
+ **Reasoning:**
1239
+
1240
+ - The full path already provides context (`Shift/Invites/Card` is clear)
1241
+ - Repeating names makes imports verbose: `import { ShiftInviteCard } from 'Shift/ShiftInvites/ShiftInviteCard'`
1242
+ - Shorter names are easier to work with in the editor
1243
+
1244
+ ### Utilities and Hooks
1245
+
1246
+ - **camelCase** for utilities, hooks, and non-component files
1247
+ - Examples: `formatDate.ts`, `useAuth.ts`, `calculateTotal.ts`
1248
+
1249
+ ### Multi-word Non-Components
1250
+
1251
+ - **camelCase** for multi-word utility and configuration files
1252
+ - Examples: `userProfileUtils.ts`, `apiHelpers.ts`
1253
+
1254
+ ### Test Files
1255
+
1256
+ - Co-locate with source: `Button.test.tsx` next to `Button.tsx`
1257
+ - Test folder: `__tests__/` for integration tests
1258
+ - Pattern: `*.test.ts` or `*.test.tsx`
1259
+
1260
+ ### Constants and Types
1261
+
1262
+ - `types.ts` - Shared types for the feature
1263
+ - `constants.ts` - Feature constants
1264
+ - `paths.ts` - Route path constants
1265
+
1266
+ ## Import Organization
1267
+
1268
+ ### Import Order
1269
+
1270
+ ```typescript
1271
+ // 1. External dependencies (React, third-party)
1272
+ import { useState, useEffect } from "react";
1273
+ import { useQuery } from "@tanstack/react-query";
1274
+ import { parseISO, format } from "date-fns";
1275
+
1276
+ // 2. Internal packages (@clipboard-health, @src)
1277
+ import { Button } from "@clipboard-health/ui-components";
1278
+ import { CbhIcon } from "@clipboard-health/ui-components";
1279
+ import { formatDate } from "@src/appV2/lib/dates";
1280
+ import { useDefinedWorker } from "@src/appV2/Worker/useDefinedWorker";
1281
+
1282
+ // 3. Relative imports (same feature)
1283
+ import { useFeatureData } from "./hooks/useFeatureData";
1284
+ import { FeatureCard } from "./components/FeatureCard";
1285
+ import { FEATURE_PATHS } from "./paths";
1286
+ import { type FeatureData } from "./types";
1287
+ ```
1288
+
1289
+ ### Import Grouping Rules
1290
+
1291
+ - Add blank lines between groups
1292
+ - Sort alphabetically within each group (optional but recommended)
1293
+ - Group type imports with their module: `import { type User } from './types'`
1294
+
1295
+ ## Path Management
1296
+
1297
+ ### Defining Paths
1298
+
1299
+ ```typescript
1300
+ // paths.ts
1301
+ import { APP_PATHS } from "@/constants/paths";
1302
+
1303
+ export const FEATURE_BASE_PATH = "feature";
1304
+ export const FEATURE_FULL_PATH = `${APP_PATHS.APP_V2_HOME}/${FEATURE_BASE_PATH}`;
1305
+ export const FEATURE_PATHS = {
1306
+ ROOT: FEATURE_FULL_PATH,
1307
+ DETAILS: `${FEATURE_FULL_PATH}/:id`,
1308
+ EDIT: `${FEATURE_FULL_PATH}/:id/edit`,
1309
+ CREATE: `${FEATURE_FULL_PATH}/create`,
1310
+ } as const;
1311
+ ```
1312
+
1313
+ ### Using Paths
1314
+
1315
+ ```typescript
1316
+ import { useHistory } from "react-router-dom";
1317
+ import { FEATURE_PATHS } from "./paths";
1318
+
1319
+ // Navigation
1320
+ history.push(FEATURE_PATHS.DETAILS.replace(":id", featureId));
1321
+
1322
+ // Route definition
1323
+ <Route path={FEATURE_PATHS.DETAILS} component={FeatureDetailsPage} />;
1324
+ ```
1325
+
1326
+ ## Types and Interfaces
1327
+
1328
+ ### Separate vs Co-located Types
1329
+
1330
+ ```typescript
1331
+ // Option 1: Separate types.ts for shared types
1332
+ // types.ts
1333
+ export interface Feature {
1334
+ id: string;
1335
+ name: string;
1336
+ }
1337
+
1338
+ export type FeatureStatus = "active" | "inactive";
1339
+
1340
+ // Option 2: Co-located with component for component-specific types
1341
+ // FeatureCard.tsx
1342
+ interface FeatureCardProps {
1343
+ feature: Feature;
1344
+ onSelect: (id: string) => void;
1345
+ }
1346
+
1347
+ export function FeatureCard(props: FeatureCardProps) {
1348
+ // ...
1349
+ }
1350
+ ```
1351
+
1352
+ ## Constants
1353
+
1354
+ ### Defining Constants
1355
+
1356
+ ```typescript
1357
+ // constants.ts
1358
+ export const FEATURE_STATUS = {
1359
+ ACTIVE: "active",
1360
+ INACTIVE: "inactive",
1361
+ PENDING: "pending",
1362
+ } as const;
1363
+
1364
+ export type FeatureStatus = (typeof FEATURE_STATUS)[keyof typeof FEATURE_STATUS];
1365
+
1366
+ export const MAX_ITEMS = 100;
1367
+ export const DEFAULT_PAGE_SIZE = 20;
1368
+
1369
+ export const FEATURE_EVENTS = {
1370
+ VIEWED: "FEATURE_VIEWED",
1371
+ CREATED: "FEATURE_CREATED",
1372
+ UPDATED: "FEATURE_UPDATED",
1373
+ } as const;
1374
+ ```
1375
+
1376
+ ## Folder Depth Guidelines
1377
+
1378
+ - **Maximum 3 levels deep** for feature folders
1379
+ - For deeper nesting, consider splitting into separate features
1380
+ - Use meaningful folder names that describe the content
1381
+
1382
+ ```text
1383
+ Good:
1384
+ ├── Shift/
1385
+ │ ├── Calendar/
1386
+ │ │ └── ShiftCalendarCore.tsx
1387
+ │ └── Card/
1388
+ │ └── ShiftCard.tsx
1389
+
1390
+ Avoid:
1391
+ ├── Shift/
1392
+ │ ├── Components/
1393
+ │ │ ├── Display/
1394
+ │ │ │ ├── Calendar/
1395
+ │ │ │ │ └── Core/
1396
+ │ │ │ │ └── ShiftCalendarCore.tsx # Too deep!
1397
+ ```
1398
+
1399
+ ## Module Exports
1400
+
1401
+ ### Index Files
1402
+
1403
+ Avoid using `index.ts` files in the redesign folder. Prefer explicit imports.
1404
+
1405
+ ```typescript
1406
+ // Avoid
1407
+ // index.ts
1408
+ export * from "./Button";
1409
+ export * from "./Card";
1410
+
1411
+ // Prefer explicit imports
1412
+ import { Button } from "@/components/Button";
1413
+ import { Card } from "@/components/Card";
1414
+ ```
1415
+
1416
+ ### Named Exports
1417
+
1418
+ Always use named exports (not default exports) in redesign code.
1419
+
1420
+ ```typescript
1421
+ // Good
1422
+ export function Button(props: ButtonProps) {}
1423
+
1424
+ // Avoid
1425
+ export default function Button(props: ButtonProps) {}
1426
+ ```
1427
+
1428
+ ## API Folder Structure
1429
+
1430
+ ```text
1431
+ api/
1432
+ ├── useGetFeatures.ts # GET requests
1433
+ ├── useCreateFeature.ts # POST requests
1434
+ ├── useUpdateFeature.ts # PUT/PATCH requests
1435
+ ├── useDeleteFeature.ts # DELETE requests
1436
+ └── schemas.ts # Zod schemas (optional)
1437
+ ```
1438
+
1439
+ ## Utils Folder Guidelines
1440
+
1441
+ - Keep utilities pure functions when possible
1442
+ - Co-locate tests with utilities
1443
+ - Export individual functions (not as objects)
1444
+
1445
+ ```typescript
1446
+ // Good
1447
+ // utils/formatFeatureName.ts
1448
+ export function formatFeatureName(name: string): string {
1449
+ return name.trim().toUpperCase();
1450
+ }
1451
+
1452
+ // utils/formatFeatureName.test.ts
1453
+ import { formatFeatureName } from "./formatFeatureName";
1454
+
1455
+ describe("formatFeatureName", () => {
1456
+ it("should format name correctly", () => {
1457
+ expect(formatFeatureName(" test ")).toBe("TEST");
1458
+ });
1459
+ });
1460
+ ```
1461
+
1462
+ <!-- Source: .ruler/frontend/imports.md -->
1463
+
1464
+ # Import Standards
1465
+
1466
+ ## Component Wrapper Pattern
1467
+
1468
+ Many projects wrap third-party UI library components to add app-specific functionality. This can be enforced via ESLint rules.
1469
+
1470
+ ## Restricted MUI Imports
1471
+
1472
+ ### ❌ Component Restrictions (Example)
1473
+
1474
+ Some projects restrict direct imports of certain components from `@mui/material`:
1475
+
1476
+ ```typescript
1477
+ // ❌ Forbidden
1478
+ import {
1479
+ Avatar,
1480
+ Accordion,
1481
+ AccordionDetails,
1482
+ AccordionSummary,
1483
+ Badge,
1484
+ Button,
1485
+ Card,
1486
+ Chip,
1487
+ Dialog,
1488
+ DialogTitle,
1489
+ Divider,
1490
+ Drawer,
1491
+ FilledInput,
1492
+ Icon,
1493
+ IconButton,
1494
+ InputBase,
1495
+ List,
1496
+ ListItem,
1497
+ ListItemButton,
1498
+ ListItemIcon,
1499
+ ListItemText,
1500
+ Modal,
1501
+ OutlinedInput,
1502
+ Rating,
1503
+ Slider,
1504
+ TextField,
1505
+ Typography,
1506
+ Tab,
1507
+ Tabs,
1508
+ SvgIcon,
1509
+ } from "@mui/material";
1510
+ ```
1511
+
1512
+ ### ✅ Use Internal Wrappers
1513
+
1514
+ ```typescript
1515
+ // ✅ Correct - Use project wrappers
1516
+ import { Button } from "@clipboard-health/ui-components/Button";
1517
+ import { IconButton } from "@clipboard-health/ui-components/IconButton";
1518
+ import { LoadingButton } from "@clipboard-health/ui-components/LoadingButton";
1519
+ ```
1520
+
1521
+ ### Rationale
1522
+
1523
+ 1. Wrappers provide app-specific functionality and consistent behavior
1524
+ 2. Use `sx` prop for custom styles with theme access
1525
+ 3. Prefer app-specific dialog components over generic Modal components
1526
+
1527
+ ## Icon Restrictions
1528
+
1529
+ ### ❌ Third-Party Icons (Example)
1530
+
1531
+ ```typescript
1532
+ // ❌ Avoid direct imports from icon libraries
1533
+ import SearchIcon from "@mui/icons-material/Search";
1534
+ import CloseIcon from "@mui/icons-material/Close";
1535
+ import AddIcon from "@mui/icons-material/Add";
1536
+ ```
1537
+
1538
+ ### ✅ Use Project Icon Component
1539
+
1540
+ ```typescript
1541
+ // ✅ Correct - Use project's icon system
1542
+ import { Icon } from '@clipboard-health/ui-components';
1543
+
1544
+ <Icon type="search" size="large" />
1545
+ <Icon type="close" size="medium" />
1546
+ <Icon type="plus" size="small" />
1547
+ ```
1548
+
1549
+ Many projects maintain their own icon system for consistency and customization.
1550
+
1551
+ ## Allowed MUI Imports
1552
+
1553
+ ### ✅ Safe to Import from MUI
1554
+
1555
+ These components can be imported directly:
1556
+
1557
+ ```typescript
1558
+ import {
1559
+ Box,
1560
+ Stack,
1561
+ Container,
1562
+ Grid,
1563
+ Paper,
1564
+ Skeleton,
1565
+ CircularProgress,
1566
+ LinearProgress,
1567
+ BottomNavigation,
1568
+ BottomNavigationAction,
1569
+ ThemeProvider,
1570
+ useTheme,
1571
+ useMediaQuery,
1572
+ } from "@mui/material";
1573
+ ```
1574
+
1575
+ Layout and utility components are generally safe.
1576
+
1577
+ ## Path Aliases
1578
+
1579
+ ### Use @ Prefix for Absolute Imports
1580
+
1581
+ ```typescript
1582
+ // ✅ Use path aliases configured in tsconfig
1583
+ import { formatDate } from "@/lib/dates";
1584
+ import { useUser } from "@/features/user/hooks/useUser";
1585
+ import { Button } from "@clipboard-health/ui-components/Button";
1586
+ import { theme } from "@/theme";
1587
+
1588
+ // ❌ Avoid relative paths to distant folders
1589
+ import { formatDate } from "../../../lib/dates";
1590
+ ```
1591
+
1592
+ ### Relative Imports for Same Feature
1593
+
1594
+ ```typescript
1595
+ // ✅ Relative imports within the same feature
1596
+ import { FeatureCard } from "./components/FeatureCard";
1597
+ import { useFeatureData } from "./hooks/useFeatureData";
1598
+ import { FEATURE_PATHS } from "./paths";
1599
+ import { type FeatureData } from "./types";
1600
+ ```
1601
+
1602
+ ## Import Grouping
1603
+
1604
+ ### Standard Order
1605
+
1606
+ ```typescript
1607
+ // 1. External dependencies (React, third-party libraries)
1608
+ import { useState, useEffect, useMemo } from "react";
1609
+ import { useQuery } from "@tanstack/react-query";
1610
+ import { parseISO, format } from "date-fns";
1611
+ import { z } from "zod";
1612
+
1613
+ // 2. Internal absolute imports (via path aliases)
1614
+ import { Button, Icon } from "@clipboard-health/ui-components";
1615
+ import { theme } from "@/theme";
1616
+ import { formatDate } from "@/lib/dates";
1617
+ import { useUser } from "@/features/user/hooks/useUser";
1618
+ import { APP_PATHS } from "@/constants/paths";
1619
+
1620
+ // 3. Relative imports (same feature/module)
1621
+ import { useFeatureData } from "./hooks/useFeatureData";
1622
+ import { FeatureCard } from "./components/FeatureCard";
1623
+ import { FEATURE_PATHS } from "./paths";
1624
+ import { type FeatureData, type FeatureOptions } from "./types";
1625
+ ```
1626
+
1627
+ ### Blank Lines Between Groups
1628
+
1629
+ - Add blank line between each group
1630
+ - Helps with readability and organization
1631
+ - ESLint/Prettier can auto-format this
1632
+
1633
+ ## Type Imports
1634
+
1635
+ ### Inline Type Imports
1636
+
1637
+ ```typescript
1638
+ // ✅ Preferred: Inline type imports
1639
+ import { type User, type UserOptions } from "./types";
1640
+ import { formatUser } from "./utils";
1641
+ ```
1642
+
1643
+ ### Separate Type Imports
1644
+
1645
+ ```typescript
1646
+ // ✅ Also acceptable
1647
+ import type { User, UserOptions } from "./types";
1648
+ import { formatUser } from "./utils";
1649
+ ```
1650
+
1651
+ ## Barrel Exports (index.ts)
1652
+
1653
+ ### Consider Avoiding Barrel Exports
1654
+
1655
+ ```typescript
1656
+ // ❌ Barrel exports can slow build times
1657
+ // index.ts
1658
+ export * from "./Button";
1659
+ export * from "./Card";
1660
+ ```
1661
+
1662
+ ### ✅ Use Explicit Imports
1663
+
1664
+ ```typescript
1665
+ // ✅ Import directly from files for better tree-shaking
1666
+ import { Button } from "@clipboard-health/ui-components/Button";
1667
+ import { Card } from "@clipboard-health/ui-components/Card";
1668
+ ```
1669
+
1670
+ Note: Barrel exports can cause issues with circular dependencies and slow down builds, especially in large projects.
1671
+
1672
+ ## Dynamic Imports
1673
+
1674
+ ### Code Splitting
1675
+
1676
+ ```typescript
1677
+ // For large components or routes
1678
+ const HeavyComponent = lazy(() => import("./HeavyComponent"));
1679
+
1680
+ // In component
1681
+ <Suspense fallback={<Loading />}>
1682
+ <HeavyComponent />
1683
+ </Suspense>;
1684
+ ```
1685
+
1686
+ ## Common Import Patterns
1687
+
1688
+ ### API Utilities
1689
+
1690
+ ```typescript
1691
+ import { useQuery } from "@/lib/api/hooks";
1692
+ import { api } from "@/lib/api/client";
1693
+ ```
1694
+
1695
+ ### React Query
1696
+
1697
+ ```typescript
1698
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
1699
+ import { type UseQueryOptions, type UseQueryResult } from "@tanstack/react-query";
1700
+ ```
1701
+
1702
+ ### Routing
1703
+
1704
+ ```typescript
1705
+ import { useHistory, useLocation, useParams } from "react-router-dom";
1706
+ import { type LocationState } from "history";
1707
+ ```
1708
+
1709
+ ### Date Utilities
1710
+
1711
+ ```typescript
1712
+ import { parseISO, format, addDays, isBefore } from "date-fns";
1713
+ ```
1714
+
1715
+ ### Validation
1716
+
1717
+ ```typescript
1718
+ import { z } from "zod";
1719
+ import { zodResolver } from "@hookform/resolvers/zod";
1720
+ ```
1721
+
1722
+ ## ESLint Configuration
1723
+
1724
+ Import restrictions can be enforced with ESLint's `no-restricted-imports` rule:
1725
+
1726
+ ```javascript
1727
+ module.exports = {
1728
+ rules: {
1729
+ "no-restricted-imports": [
1730
+ "error",
1731
+ {
1732
+ paths: [
1733
+ {
1734
+ name: "@mui/material",
1735
+ importNames: ["Button", "TextField", "Dialog"],
1736
+ message: "Use wrapper components from @/components",
1737
+ },
1738
+ ],
1739
+ patterns: [
1740
+ {
1741
+ group: ["@mui/icons-material/*"],
1742
+ message: "Use project icon component instead",
1743
+ },
1744
+ ],
1745
+ },
1746
+ ],
1747
+ },
1748
+ };
1749
+ ```
1750
+
1751
+ ## Checking for Violations
1752
+
1753
+ ```bash
1754
+ # Lint your code
1755
+ npm run lint
1756
+
1757
+ # Auto-fix import issues
1758
+ npm run lint:fix
1759
+ ```
1760
+
1761
+ ## Migration Guide
1762
+
1763
+ ### When You See Import Errors
1764
+
1765
+ 1. **Wrapper Component Error**
1766
+ - Check if wrapper exists in `@/components/`
1767
+ - If yes, import from there
1768
+ - If no, discuss with team about creating wrapper
1769
+
1770
+ 2. **Icon Error**
1771
+ - Find equivalent in project icon system
1772
+ - Use project's icon component
1773
+ - Check available icon names in documentation
1774
+
1775
+ 3. **Deprecated Pattern Error**
1776
+ - Follow the suggested replacement pattern
1777
+ - Move to modern alternatives (e.g., `sx` prop instead of `styled`)
1778
+
1779
+ ## Summary
1780
+
1781
+ ✅ **DO**:
1782
+
1783
+ - Use project wrapper components instead of third-party components directly
1784
+ - Use project icon system for consistency
1785
+ - Use path aliases (e.g., `@/`) for absolute imports
1786
+ - Group imports by external, internal, relative
1787
+ - Consider avoiding barrel exports for better build performance
1788
+
1789
+ ❌ **DON'T**:
1790
+
1791
+ - Import third-party UI components directly if wrappers exist
1792
+ - Import icon libraries directly if project has custom icon system
1793
+ - Use deprecated styling patterns (check project guidelines)
1794
+ - Use relative imports for distant files
1795
+ - Create excessive barrel exports that hurt tree-shaking
1796
+
1797
+ <!-- Source: .ruler/frontend/performance.md -->
1798
+
1799
+ # Performance Standards
1800
+
1801
+ ## React Query Optimization
1802
+
1803
+ ### Stale Time Configuration
1804
+
1805
+ ```typescript
1806
+ // Set appropriate staleTime to avoid unnecessary refetches
1807
+ useGetQuery({
1808
+ url: "/api/resource",
1809
+ responseSchema: schema,
1810
+ staleTime: minutesToMilliseconds(5), // Don't refetch for 5 minutes
1811
+ cacheTime: minutesToMilliseconds(30), // Keep in cache for 30 minutes
1812
+ });
1813
+ ```
1814
+
1815
+ ### Conditional Fetching
1816
+
1817
+ ```typescript
1818
+ // Only fetch when conditions are met
1819
+ const { data } = useGetQuery({
1820
+ url: `/api/users/${userId}`,
1821
+ responseSchema: userSchema,
1822
+ enabled: isDefined(userId) && isFeatureEnabled, // Don't fetch until ready
1823
+ });
1824
+ ```
1825
+
1826
+ ### Query Cancellation
1827
+
1828
+ ```typescript
1829
+ export function usePaginatedData(params: Params) {
1830
+ const queryClient = useQueryClient();
1831
+
1832
+ return useInfiniteQuery({
1833
+ queryKey: ["data", params],
1834
+ queryFn: async ({ pageParam }) => {
1835
+ // Cancel previous in-flight requests
1836
+ await queryClient.cancelQueries({ queryKey: ["data"] });
1837
+
1838
+ const response = await get({
1839
+ url: "/api/data",
1840
+ queryParams: { cursor: pageParam, ...params },
1841
+ });
1842
+ return response.data;
1843
+ },
1844
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
1845
+ });
1846
+ }
1847
+ ```
1848
+
1849
+ ### Prefetching
1850
+
1851
+ ```typescript
1852
+ export function usePreloadNextPage(nextUserId: string) {
1853
+ const queryClient = useQueryClient();
1854
+
1855
+ useEffect(() => {
1856
+ if (nextUserId) {
1857
+ // Prefetch next page in background
1858
+ queryClient.prefetchQuery({
1859
+ queryKey: ["user", nextUserId],
1860
+ queryFn: () => fetchUser(nextUserId),
1861
+ staleTime: minutesToMilliseconds(5),
1862
+ });
1863
+ }
1864
+ }, [nextUserId, queryClient]);
1865
+ }
1866
+ ```
1867
+
1868
+ ## React Optimization
1869
+
1870
+ ### useMemo for Expensive Computations
1871
+
1872
+ ```typescript
1873
+ export function DataList({ items }: { items: Item[] }) {
1874
+ // Memoize expensive sorting/filtering
1875
+ const sortedItems = useMemo(() => {
1876
+ return items.filter((item) => item.isActive).sort((a, b) => a.priority - b.priority);
1877
+ }, [items]);
1878
+
1879
+ return <List items={sortedItems} />;
1880
+ }
1881
+ ```
1882
+
1883
+ ### useCallback for Event Handlers
1884
+
1885
+ ```typescript
1886
+ export function ParentComponent() {
1887
+ const [selected, setSelected] = useState<string>();
1888
+
1889
+ // Memoize callback to prevent child re-renders
1890
+ const handleSelect = useCallback((id: string) => {
1891
+ setSelected(id);
1892
+ logEvent("ITEM_SELECTED", { id });
1893
+ }, []); // Stable reference
1894
+
1895
+ return <ChildComponent onSelect={handleSelect} />;
1896
+ }
1897
+ ```
1898
+
1899
+ ### React.memo for Pure Components
1900
+
1901
+ ```typescript
1902
+ import { memo } from "react";
1903
+
1904
+ interface ItemCardProps {
1905
+ item: Item;
1906
+ onSelect: (id: string) => void;
1907
+ }
1908
+
1909
+ // Memoize component to prevent unnecessary re-renders
1910
+ export const ItemCard = memo(function ItemCard({ item, onSelect }: ItemCardProps) {
1911
+ return (
1912
+ <Card onClick={() => onSelect(item.id)}>
1913
+ <h3>{item.name}</h3>
1914
+ </Card>
1915
+ );
1916
+ });
1917
+ ```
1918
+
1919
+ ### Avoid Inline Object/Array Creation
1920
+
1921
+ ```typescript
1922
+ // ❌ Creates new object on every render
1923
+ <Component style={{ padding: 8 }} />
1924
+ <Component items={[1, 2, 3]} />
1925
+
1926
+ // ✅ Define outside or use useMemo
1927
+ const style = { padding: 8 };
1928
+ const items = [1, 2, 3];
1929
+ <Component style={style} items={items} />
1930
+
1931
+ // ✅ Or use useMemo for dynamic values
1932
+ const style = useMemo(() => ({ padding: spacing }), [spacing]);
1933
+ ```
1934
+
1935
+ ## List Rendering
1936
+
1937
+ ### Key Prop for Lists
1938
+
1939
+ ```typescript
1940
+ // ✅ Use stable, unique keys
1941
+ items.map((item) => <ItemCard key={item.id} item={item} />);
1942
+
1943
+ // ❌ Don't use index as key (unless list never changes)
1944
+ items.map((item, index) => <ItemCard key={index} item={item} />);
1945
+ ```
1946
+
1947
+ ### Virtualization for Long Lists
1948
+
1949
+ ```typescript
1950
+ import { FixedSizeList } from "react-window";
1951
+
1952
+ export function VirtualizedList({ items }: { items: Item[] }) {
1953
+ return (
1954
+ <FixedSizeList height={600} itemCount={items.length} itemSize={80} width="100%">
1955
+ {({ index, style }) => (
1956
+ <div style={style}>
1957
+ <ItemCard item={items[index]} />
1958
+ </div>
1959
+ )}
1960
+ </FixedSizeList>
1961
+ );
1962
+ }
1963
+ ```
1964
+
1965
+ ### Pagination Instead of Infinite Data
1966
+
1967
+ ```typescript
1968
+ // For large datasets, use pagination
1969
+ export function PaginatedList() {
1970
+ const [page, setPage] = useState(1);
1971
+ const pageSize = 20;
1972
+
1973
+ const { data, isLoading } = useGetPaginatedData({
1974
+ page,
1975
+ pageSize,
1976
+ });
1977
+
1978
+ return (
1979
+ <>
1980
+ <List items={data?.items} />
1981
+ <Pagination page={page} total={data?.total} pageSize={pageSize} onChange={setPage} />
1982
+ </>
1983
+ );
1984
+ }
1985
+ ```
1986
+
1987
+ ## Data Fetching Patterns
1988
+
1989
+ ### Parallel Queries
1990
+
1991
+ ```typescript
1992
+ export function useParallelData() {
1993
+ // Fetch in parallel
1994
+ const users = useGetUsers();
1995
+ const shifts = useGetShifts();
1996
+ const workplaces = useGetWorkplaces();
1997
+
1998
+ // All queries run simultaneously
1999
+ const isLoading = users.isLoading || shifts.isLoading || workplaces.isLoading;
2000
+
2001
+ return {
2002
+ users: users.data,
2003
+ shifts: shifts.data,
2004
+ workplaces: workplaces.data,
2005
+ isLoading,
2006
+ };
2007
+ }
2008
+ ```
2009
+
2010
+ ### Dependent Queries
2011
+
2012
+ ```typescript
2013
+ export function useDependentData(userId?: string) {
2014
+ // First query
2015
+ const { data: user } = useGetUser(userId, {
2016
+ enabled: isDefined(userId),
2017
+ });
2018
+
2019
+ // Second query depends on first
2020
+ const { data: shifts } = useGetUserShifts(user?.id, {
2021
+ enabled: isDefined(user?.id), // Only fetch when user is loaded
2022
+ });
2023
+
2024
+ return { user, shifts };
2025
+ }
2026
+ ```
2027
+
2028
+ ### Debouncing Search Queries
2029
+
2030
+ ```typescript
2031
+ import { useDebouncedValue } from "@/lib/hooks";
2032
+
2033
+ export function SearchComponent() {
2034
+ const [searchTerm, setSearchTerm] = useState("");
2035
+ const debouncedSearch = useDebouncedValue(searchTerm, 300);
2036
+
2037
+ const { data } = useSearchQuery(debouncedSearch, {
2038
+ enabled: debouncedSearch.length > 2,
2039
+ });
2040
+
2041
+ return (
2042
+ <>
2043
+ <input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
2044
+ <Results data={data} />
2045
+ </>
2046
+ );
2047
+ }
2048
+ ```
2049
+
2050
+ ## Code Splitting
2051
+
2052
+ ### Route-Based Splitting
2053
+
2054
+ ```typescript
2055
+ import { lazy, Suspense } from "react";
2056
+
2057
+ // Lazy load route components
2058
+ const ShiftDetailsPage = lazy(() => import("./Shift/DetailsPage"));
2059
+ const ProfilePage = lazy(() => import("./Profile/Page"));
2060
+
2061
+ export function Router() {
2062
+ return (
2063
+ <Suspense fallback={<LoadingScreen />}>
2064
+ <Routes>
2065
+ <Route path="/shifts/:id" element={<ShiftDetailsPage />} />
2066
+ <Route path="/profile" element={<ProfilePage />} />
2067
+ </Routes>
2068
+ </Suspense>
2069
+ );
2070
+ }
2071
+ ```
2072
+
2073
+ ### Component-Based Splitting
2074
+
2075
+ ```typescript
2076
+ // Split large components that aren't immediately needed
2077
+ const HeavyChart = lazy(() => import("./HeavyChart"));
2078
+
2079
+ export function Dashboard() {
2080
+ const [showChart, setShowChart] = useState(false);
2081
+
2082
+ return (
2083
+ <div>
2084
+ <Summary />
2085
+ {showChart && (
2086
+ <Suspense fallback={<ChartSkeleton />}>
2087
+ <HeavyChart />
2088
+ </Suspense>
2089
+ )}
2090
+ </div>
2091
+ );
2092
+ }
2093
+ ```
2094
+
2095
+ ## Image Optimization
2096
+
2097
+ ### Lazy Loading Images
2098
+
2099
+ ```typescript
2100
+ <img
2101
+ src={imageUrl}
2102
+ alt={alt}
2103
+ loading="lazy" // Native lazy loading
2104
+ />
2105
+ ```
2106
+
2107
+ ### Responsive Images
2108
+
2109
+ ```typescript
2110
+ <img
2111
+ srcSet={`
2112
+ ${image_small} 480w,
2113
+ ${image_medium} 800w,
2114
+ ${image_large} 1200w
2115
+ `}
2116
+ sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px"
2117
+ src={image_medium}
2118
+ alt={alt}
2119
+ />
2120
+ ```
2121
+
2122
+ ## State Management
2123
+
2124
+ ### Avoid Prop Drilling
2125
+
2126
+ ```typescript
2127
+ // ❌ Prop drilling
2128
+ <Parent>
2129
+ <Child data={data}>
2130
+ <GrandChild data={data}>
2131
+ <GreatGrandChild data={data} />
2132
+ </GrandChild>
2133
+ </Child>
2134
+ </Parent>;
2135
+
2136
+ // ✅ Use context for deeply nested data
2137
+ const DataContext = createContext<Data | undefined>(undefined);
2138
+
2139
+ <DataContext.Provider value={data}>
2140
+ <Parent>
2141
+ <Child>
2142
+ <GrandChild>
2143
+ <GreatGrandChild />
2144
+ </GrandChild>
2145
+ </Child>
2146
+ </Parent>
2147
+ </DataContext.Provider>;
2148
+ ```
2149
+
2150
+ ### Local State Over Global State
2151
+
2152
+ ```typescript
2153
+ // ✅ Keep state as local as possible
2154
+ export function FormComponent() {
2155
+ const [formData, setFormData] = useState({}); // Local state
2156
+ // Only lift state up when needed by multiple components
2157
+ }
2158
+ ```
2159
+
2160
+ ## Bundle Size Optimization
2161
+
2162
+ ### Tree Shaking
2163
+
2164
+ ```typescript
2165
+ // ✅ Import only what you need
2166
+ import { format } from "date-fns";
2167
+
2168
+ // ❌ Imports entire library
2169
+ import * as dateFns from "date-fns";
2170
+ ```
2171
+
2172
+ ### Avoid Large Dependencies
2173
+
2174
+ ```typescript
2175
+ // Check bundle size before adding dependencies
2176
+ // Use lighter alternatives when possible
2177
+
2178
+ // ❌ Heavy library for simple task
2179
+ import moment from "moment";
2180
+
2181
+ // ✅ Lighter alternative
2182
+ import { format } from "date-fns";
2183
+ ```
2184
+
2185
+ ## Monitoring Performance
2186
+
2187
+ ### React DevTools Profiler
2188
+
2189
+ ```typescript
2190
+ // Wrap components to profile
2191
+ <Profiler id="ShiftList" onRender={onRenderCallback}>
2192
+ <ShiftList />
2193
+ </Profiler>
2194
+ ```
2195
+
2196
+ ### Web Vitals
2197
+
2198
+ ```typescript
2199
+ import { getCLS, getFID, getFCP, getLCP, getTTFB } from "web-vitals";
2200
+
2201
+ // Track Core Web Vitals
2202
+ getCLS(console.log);
2203
+ getFID(console.log);
2204
+ getFCP(console.log);
2205
+ getLCP(console.log);
2206
+ getTTFB(console.log);
2207
+ ```
2208
+
2209
+ ## Best Practices Summary
2210
+
2211
+ ### ✅ DO
2212
+
2213
+ - Set appropriate `staleTime` and `cacheTime` for queries
2214
+ - Use `useMemo` for expensive computations
2215
+ - Use `useCallback` for callbacks passed to children
2216
+ - Use React.memo for pure components
2217
+ - Virtualize long lists
2218
+ - Lazy load routes and heavy components
2219
+ - Use pagination for large datasets
2220
+ - Debounce search inputs
2221
+ - Cancel queries when component unmounts
2222
+ - Prefetch data when predictable
2223
+
2224
+ ### ❌ DON'T
2225
+
2226
+ - Fetch data unnecessarily
2227
+ - Create objects/arrays inline in props
2228
+ - Use index as key for dynamic lists
2229
+ - Render all items in very long lists
2230
+ - Import entire libraries when only need parts
2231
+ - Keep all state global
2232
+ - Ignore performance warnings in console
2233
+ - Skip memoization for expensive operations
2234
+
2235
+ <!-- Source: .ruler/frontend/react-patterns.md -->
2236
+
2237
+ # React Component Patterns
2238
+
2239
+ ## Core Principles
2240
+
2241
+ - **Storybook is the single source of truth** for UI components, not Figma
2242
+ - **Named exports only** (prefer named exports over default exports)
2243
+ - **Explicit types** for all props and return values
2244
+ - **Handle all states**: loading, error, empty, success
2245
+ - **Composition over configuration** - prefer children over complex props
2246
+
2247
+ ## Component Structure
2248
+
2249
+ Follow this consistent structure for all components:
2250
+
2251
+ ```typescript
2252
+ // 1. Imports (grouped with blank lines between groups)
2253
+ import { useState, useMemo, useCallback } from "react";
2254
+ import { Box, Typography } from "@mui/material";
2255
+
2256
+ import { useGetUser } from "@/api/hooks/useGetUser";
2257
+ import { formatDate } from "@/utils/date";
2258
+
2259
+ import { ChildComponent } from "./ChildComponent";
2260
+
2261
+ // 2. Types (interfaces for props, types for unions)
2262
+ interface UserCardProps {
2263
+ userId: string;
2264
+ onAction: (action: string) => void;
2265
+ isHighlighted?: boolean;
2266
+ }
2267
+
2268
+ // 3. Component definition
2269
+ export function UserCard({ userId, onAction, isHighlighted = false }: UserCardProps) {
2270
+ // A. React Query hooks first
2271
+ const { data: user, isLoading, isError } = useGetUser(userId);
2272
+
2273
+ // B. Local state
2274
+ const [isExpanded, setIsExpanded] = useState(false);
2275
+
2276
+ // C. Derived values (with useMemo for expensive computations)
2277
+ const displayName = useMemo(
2278
+ () => (user ? `${user.firstName} ${user.lastName}` : "Unknown"),
2279
+ [user]
2280
+ );
2281
+
2282
+ // D. Event handlers (with useCallback when passed to children)
2283
+ const handleToggle = useCallback(() => {
2284
+ setIsExpanded((prev) => !prev);
2285
+ onAction("toggle");
2286
+ }, [onAction]);
2287
+
2288
+ // E. Early returns for loading/error states
2289
+ if (isLoading) return <LoadingState />;
2290
+ if (isError) return <ErrorState message="Failed to load user" />;
2291
+ if (!user) return <EmptyState message="User not found" />;
2292
+
2293
+ // F. Render
2294
+ return (
2295
+ <Box sx={{ padding: 2, backgroundColor: isHighlighted ? "primary.light" : "background.paper" }}>
2296
+ <Typography variant="h6">{displayName}</Typography>
2297
+ <ChildComponent onClick={handleToggle} />
2298
+ </Box>
2299
+ );
2300
+ }
2301
+ ```
2302
+
2303
+ ## Why This Structure?
2304
+
2305
+ 1. **Imports grouped** - Easy to see dependencies
2306
+ 2. **Types first** - Documents component API
2307
+ 3. **Hooks at top** - React rules of hooks
2308
+ 4. **Derived values next** - Shows data flow
2309
+ 5. **Handlers together** - Easy to find event logic
2310
+ 6. **Early returns** - Fail fast, reduce nesting
2311
+ 7. **Render last** - Main component logic
2312
+
2313
+ ## Component Naming
2314
+
2315
+ - **PascalCase** for components: `UserProfile`, `DataCard`
2316
+ - **camelCase** for hooks and utilities: `useUserData`, `formatDate`
2317
+ - Prefer named exports over default exports for better refactoring support
2318
+
2319
+ ## Props
2320
+
2321
+ - Always define explicit prop types
2322
+ - Use destructuring with types
2323
+ - Avoid prop spreading unless wrapping a component
2324
+
2325
+ ```typescript
2326
+ // Good
2327
+ interface ButtonProps {
2328
+ onClick: () => void;
2329
+ label: string;
2330
+ disabled?: boolean;
2331
+ }
2332
+
2333
+ export function Button({ onClick, label, disabled = false }: ButtonProps) {
2334
+ return (
2335
+ <button onClick={onClick} disabled={disabled}>
2336
+ {label}
2337
+ </button>
2338
+ );
2339
+ }
2340
+
2341
+ // Acceptable for wrappers
2342
+ export function CustomButton(props: ButtonProps) {
2343
+ return <ButtonBase {...props} LinkComponent={InternalLink} />;
2344
+ }
2345
+ ```
2346
+
2347
+ ## Wrappers
2348
+
2349
+ - Wrap third-party components to add app-specific functionality
2350
+ - Example: Wrapping a third-party Button with custom link handling
2351
+
2352
+ ```typescript
2353
+ import {
2354
+ Button as ButtonBase,
2355
+ type ButtonProps as ButtonPropsBase,
2356
+ } from "@some-ui-library/components";
2357
+ import { type LocationState } from "history";
2358
+
2359
+ import { InternalLink } from "./InternalLink";
2360
+
2361
+ interface ButtonProps extends Omit<ButtonPropsBase, "LinkComponent"> {
2362
+ locationState?: LocationState;
2363
+ }
2364
+
2365
+ export function Button(props: ButtonProps) {
2366
+ return <ButtonBase {...props} LinkComponent={InternalLink} />;
2367
+ }
2368
+ ```
2369
+
2370
+ ## State Management
2371
+
2372
+ - Use `useState` for local component state
2373
+ - Use `useMemo` for expensive computations
2374
+ - Use `useCallback` for functions passed to children
2375
+ - Lift state up when needed by multiple components
2376
+
2377
+ ## Conditional Rendering
2378
+
2379
+ ```typescript
2380
+ // Early returns for loading/error states
2381
+ if (isLoading) return <LoadingState />;
2382
+ if (isError) return <ErrorState />;
2383
+
2384
+ // Ternary for simple conditions
2385
+ return isActive ? <ActiveView /> : <InactiveView />;
2386
+
2387
+ // && for conditional rendering
2388
+ return <div>{hasData && <DataDisplay data={data} />}</div>;
2389
+ ```
2390
+
2391
+ ## Component Composition
2392
+
2393
+ Prefer composition over complex props. Build small, focused components that compose together.
2394
+
2395
+ ```typescript
2396
+ // ✅ Good - Composition pattern
2397
+ interface CardProps {
2398
+ children: ReactNode;
2399
+ }
2400
+
2401
+ export function Card({ children }: CardProps) {
2402
+ return <Box sx={{ padding: 3, borderRadius: 2, boxShadow: 1 }}>{children}</Box>;
2403
+ }
2404
+
2405
+ export function CardHeader({ children }: { children: ReactNode }) {
2406
+ return (
2407
+ <Box sx={{ marginBottom: 2 }}>
2408
+ <Typography variant="h6">{children}</Typography>
2409
+ </Box>
2410
+ );
2411
+ }
2412
+
2413
+ export function CardContent({ children }: { children: ReactNode }) {
2414
+ return <Box>{children}</Box>;
2415
+ }
2416
+
2417
+ // Usage - Flexible and composable
2418
+ <Card>
2419
+ <CardHeader>User Profile</CardHeader>
2420
+ <CardContent>
2421
+ <UserDetails user={user} />
2422
+ <UserActions onEdit={handleEdit} />
2423
+ </CardContent>
2424
+ </Card>;
2425
+
2426
+ // ❌ Bad - Complex props that limit flexibility
2427
+ interface ComplexCardProps {
2428
+ title: string;
2429
+ subtitle?: string;
2430
+ actions?: ReactNode;
2431
+ content: ReactNode;
2432
+ variant?: "default" | "highlighted" | "error";
2433
+ showBorder?: boolean;
2434
+ elevation?: number;
2435
+ }
2436
+
2437
+ export function ComplexCard(props: ComplexCardProps) {
2438
+ // Too many props, hard to extend
2439
+ // ...
2440
+ }
2441
+ ```
2442
+
2443
+ ## Children Patterns
2444
+
2445
+ ### Render Props Pattern
2446
+
2447
+ Use when child components need access to parent state:
2448
+
2449
+ ```typescript
2450
+ interface DataProviderProps {
2451
+ children: (data: Data, isLoading: boolean) => ReactNode;
2452
+ }
2453
+
2454
+ export function DataProvider({ children }: DataProviderProps) {
2455
+ const { data, isLoading } = useGetData();
2456
+
2457
+ return <>{children(data, isLoading)}</>;
2458
+ }
2459
+
2460
+ // Usage
2461
+ <DataProvider>
2462
+ {(data, isLoading) => (isLoading ? <Loading /> : <DataDisplay data={data} />)}
2463
+ </DataProvider>;
2464
+ ```
2465
+
2466
+ ### Compound Components Pattern
2467
+
2468
+ For components that work together:
2469
+
2470
+ ```typescript
2471
+ interface TabsContextValue {
2472
+ activeTab: string;
2473
+ setActiveTab: (tab: string) => void;
2474
+ }
2475
+
2476
+ const TabsContext = createContext<TabsContextValue | undefined>(undefined);
2477
+
2478
+ export function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
2479
+ const [activeTab, setActiveTab] = useState(defaultTab);
2480
+
2481
+ return (
2482
+ <TabsContext.Provider value={{ activeTab, setActiveTab }}>
2483
+ <Box>{children}</Box>
2484
+ </TabsContext.Provider>
2485
+ );
2486
+ }
2487
+
2488
+ export function TabList({ children }: { children: ReactNode }) {
2489
+ return <Box sx={{ display: "flex", gap: 1 }}>{children}</Box>;
2490
+ }
2491
+
2492
+ export function Tab({ value, children }: { value: string; children: ReactNode }) {
2493
+ const context = useContext(TabsContext);
2494
+ if (!context) throw new Error("Tab must be used within Tabs");
2495
+
2496
+ const { activeTab, setActiveTab } = context;
2497
+ const isActive = activeTab === value;
2498
+
2499
+ return (
2500
+ <Button onClick={() => setActiveTab(value)} variant={isActive ? "contained" : "text"}>
2501
+ {children}
2502
+ </Button>
2503
+ );
2504
+ }
2505
+
2506
+ export function TabPanel({ value, children }: { value: string; children: ReactNode }) {
2507
+ const context = useContext(TabsContext);
2508
+ if (!context) throw new Error("TabPanel must be used within Tabs");
2509
+
2510
+ if (context.activeTab !== value) return null;
2511
+ return <Box sx={{ padding: 2 }}>{children}</Box>;
2512
+ }
2513
+
2514
+ // Usage - Intuitive API
2515
+ <Tabs defaultTab="profile">
2516
+ <TabList>
2517
+ <Tab value="profile">Profile</Tab>
2518
+ <Tab value="settings">Settings</Tab>
2519
+ </TabList>
2520
+ <TabPanel value="profile">
2521
+ <ProfileContent />
2522
+ </TabPanel>
2523
+ <TabPanel value="settings">
2524
+ <SettingsContent />
2525
+ </TabPanel>
2526
+ </Tabs>;
2527
+ ```
2528
+
2529
+ ## Error Boundaries
2530
+
2531
+ Use error boundaries for graceful error handling:
2532
+
2533
+ ```typescript
2534
+ interface ErrorBoundaryProps {
2535
+ children: ReactNode;
2536
+ fallback?: ReactNode;
2537
+ }
2538
+
2539
+ interface ErrorBoundaryState {
2540
+ hasError: boolean;
2541
+ error?: Error;
2542
+ }
2543
+
2544
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
2545
+ constructor(props: ErrorBoundaryProps) {
2546
+ super(props);
2547
+ this.state = { hasError: false };
2548
+ }
2549
+
2550
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
2551
+ return { hasError: true, error };
2552
+ }
2553
+
2554
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
2555
+ console.error("Error caught by boundary:", error, errorInfo);
2556
+ // Log to error tracking service
2557
+ }
2558
+
2559
+ render() {
2560
+ if (this.state.hasError) {
2561
+ return this.props.fallback || <ErrorFallback error={this.state.error} />;
2562
+ }
2563
+
2564
+ return this.props.children;
2565
+ }
2566
+ }
2567
+
2568
+ // Usage
2569
+ <ErrorBoundary fallback={<ErrorPage />}>
2570
+ <App />
2571
+ </ErrorBoundary>;
2572
+ ```
2573
+
2574
+ ## Handling Lists
2575
+
2576
+ ### Key Props
2577
+
2578
+ Always use stable, unique keys (never index):
2579
+
2580
+ ```typescript
2581
+ // ✅ Good - Unique, stable ID
2582
+ {
2583
+ users.map((user) => <UserCard key={user.id} user={user} />);
2584
+ }
2585
+
2586
+ // ❌ Bad - Index as key (causes bugs when list changes)
2587
+ {
2588
+ users.map((user, index) => <UserCard key={index} user={user} />);
2589
+ }
2590
+
2591
+ // ✅ Acceptable - Composite key when no ID available
2592
+ {
2593
+ items.map((item) => <ItemCard key={`${item.type}-${item.name}`} item={item} />);
2594
+ }
2595
+ ```
2596
+
2597
+ ### Empty States
2598
+
2599
+ Always handle empty lists:
2600
+
2601
+ ```typescript
2602
+ export function UserList({ users }: { users: User[] }) {
2603
+ if (users.length === 0) {
2604
+ return (
2605
+ <EmptyState
2606
+ icon={<PersonIcon />}
2607
+ title="No users found"
2608
+ description="Try adjusting your search criteria"
2609
+ />
2610
+ );
2611
+ }
2612
+
2613
+ return (
2614
+ <Box>
2615
+ {users.map((user) => (
2616
+ <UserCard key={user.id} user={user} />
2617
+ ))}
2618
+ </Box>
2619
+ );
2620
+ }
2621
+ ```
2622
+
2623
+ ## Conditional Rendering Best Practices
2624
+
2625
+ ```typescript
2626
+ export function ItemCard({ item }: { item: Item }) {
2627
+ // ✅ Good - Early returns for invalid states
2628
+ if (!item) return null;
2629
+ if (item.isDeleted) return null;
2630
+
2631
+ // ✅ Good - Ternary for simple either/or
2632
+ const statusColor = item.isUrgent ? "error" : "default";
2633
+
2634
+ return (
2635
+ <Box>
2636
+ <Typography color={statusColor}>{item.title}</Typography>
2637
+
2638
+ {/* ✅ Good - && for optional elements */}
2639
+ {item.isFeatured && <FeaturedBadge />}
2640
+
2641
+ {/* ✅ Good - Ternary for alternate content */}
2642
+ {item.isAvailable ? <AvailableStatus /> : <UnavailableStatus />}
2643
+
2644
+ {/* ❌ Bad - Nested ternaries (hard to read) */}
2645
+ {item.status === "urgent" ? (
2646
+ <UrgentBadge />
2647
+ ) : item.status === "normal" ? (
2648
+ <NormalBadge />
2649
+ ) : (
2650
+ <DefaultBadge />
2651
+ )}
2652
+
2653
+ {/* ✅ Good - Extract to variable or switch */}
2654
+ <StatusBadge status={item.status} />
2655
+ </Box>
2656
+ );
2657
+ }
2658
+ ```
2659
+
2660
+ ## Performance Optimization
2661
+
2662
+ ### When to Use `useMemo`
2663
+
2664
+ ```typescript
2665
+ // ✅ Do - Expensive computation
2666
+ const sortedUsers = useMemo(() => [...users].sort((a, b) => a.name.localeCompare(b.name)), [users]);
2667
+
2668
+ // ❌ Don't - Simple operations (premature optimization)
2669
+ const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
2670
+ ```
2671
+
2672
+ ### When to Use `useCallback`
2673
+
2674
+ ```typescript
2675
+ // ✅ Do - Function passed to memoized child
2676
+ const MemoizedChild = React.memo(ChildComponent);
2677
+
2678
+ function Parent() {
2679
+ const handleClick = useCallback(() => {
2680
+ console.log("clicked");
2681
+ }, []);
2682
+
2683
+ return <MemoizedChild onClick={handleClick} />;
2684
+ }
2685
+
2686
+ // ❌ Don't - Function not passed to children
2687
+ const handleSubmit = useCallback(() => {
2688
+ // No child components use this
2689
+ }, []);
2690
+ ```
2691
+
2692
+ <!-- Source: .ruler/frontend/react.md -->
2693
+
2694
+ # React
2695
+
2696
+ - Destructure props in function body rather than in function signature
2697
+ - Prefer inline JSX rather than extracting variables and functions as variables outside of JSX
2698
+ - Use useModalState for any showing/hiding functionality like dialogs
2699
+ - Utilize custom hooks to encapsulate and reuse stateful logic
2700
+ - When performing data-fetching in a custom hook, always use Zod to define any request and response schemas
2701
+ - Use react-hook-form for all form UIs and use zod resolver for form schema validation
2702
+ - Use date-fns for any Date based operations like formatting
2703
+
2704
+ <!-- Source: .ruler/frontend/styling.md -->
2705
+
2706
+ # Styling Standards
2707
+
2708
+ ## Technology Stack
2709
+
2710
+ - **Material UI (MUI)** with custom theme
2711
+ - **sx prop** for custom styles (NOT `styled()`)
2712
+ - Custom component wrappers for consistent UI
2713
+ - **Storybook** as single source of truth for UI components
2714
+
2715
+ ## Core Principles
2716
+
2717
+ 1. **Always use `sx` prop** - Never CSS/SCSS/SASS files
2718
+ 2. **Use theme tokens** - Never hardcode colors, spacing, or sizes
2719
+ 3. **Leverage meaningful tokens** - Use semantic names like `theme.palette.text.primary`, not `common.white`
2720
+ 4. **Type-safe theme access** - Use `sx={(theme) => ({...})}`, not string paths like `"text.secondary"`
2721
+ 5. **Follow spacing system** - Use indices 1-12 (4px-64px)
2722
+ 6. **Storybook is source of truth** - Check Storybook before Figma
2723
+
2724
+ ## Restricted Patterns
2725
+
2726
+ ### ❌ DO NOT USE
2727
+
2728
+ - `styled()` from MUI (deprecated in our codebase)
2729
+ - `makeStyles()` from MUI (deprecated)
2730
+ - CSS/SCSS/SASS files
2731
+ - Direct MUI icons from `@mui/icons-material`
2732
+ - Direct MUI components without wrappers (except layout primitives; see list below)
2733
+ - Inline styles via `style` prop (use `sx` instead)
2734
+ - String paths for theme tokens (not type-safe)
2735
+
2736
+ ### Rationale
2737
+
2738
+ 1. Component wrappers provide consistent behavior and app-specific functionality
2739
+ 2. `sx` prop provides type-safe access to theme tokens
2740
+ 3. Project-specific dialog components ensure consistent UX patterns
2741
+
2742
+ ## Storybook as Source of Truth
2743
+
2744
+ **Important:** Storybook reflects what's actually implemented, not Figma designs.
2745
+
2746
+ ### When Storybook Differs from Figma
2747
+
2748
+ 1. **Check Storybook first** - It shows real, implemented components
2749
+ 2. **Use closest existing variant** - Don't create one-off font sizes/colors
2750
+ 3. **Confirm changes are intentional** - Consult with team before updating components
2751
+ 4. **Create follow-up ticket** - If component needs updating but you're short on time
2752
+ 5. **Make changes system-wide** - Component updates should benefit entire app
2753
+
2754
+ ### Process
2755
+
2756
+ - **Minor differences** (font sizes, colors) → Stick to Storybook
2757
+ - **Component looks different** → Confirm with team, update component intentionally
2758
+ - **Missing component** → Check with team - it may exist with a different name
2759
+
2760
+ ## Use Internal Components
2761
+
2762
+ ### ✅ ALWAYS USE Project Wrappers
2763
+
2764
+ Instead of importing directly from `@mui/material`, use project wrappers:
2765
+
2766
+ ```typescript
2767
+ // ❌ Don't
2768
+ import { Button, IconButton } from "@mui/material";
2769
+
2770
+ // ✅ Do
2771
+ import { Button } from "@/components/Button";
2772
+ import { IconButton } from "@clipboard-health/ui-components";
2773
+ ```
2774
+
2775
+ ### Component Wrapper List (Example)
2776
+
2777
+ Common components that may have wrappers:
2778
+
2779
+ - `Button`, `LoadingButton`, `IconButton`
2780
+ - `Avatar`, `Accordion`, `Badge`
2781
+ - `Card`, `Chip`, `Dialog`
2782
+ - `Drawer`, `List`, `ListItem`
2783
+ - `Rating`, `Slider`, `Switch`
2784
+ - `TextField`, `Typography`, `Tab`, `Tabs`
2785
+
2786
+ Check your project's ESLint configuration for the full list.
2787
+
2788
+ ## Icons
2789
+
2790
+ ### Use Project Icon Component
2791
+
2792
+ ```typescript
2793
+ // ❌ Don't use third-party icons directly
2794
+ import SearchIcon from '@mui/icons-material/Search';
2795
+
2796
+ // ✅ Use project's icon system
2797
+ import { CbhIcon } from '@clipboard-health/ui-components';
2798
+
2799
+ <CbhIcon type="search" size="large" />
2800
+ <CbhIcon type="search-colored" size="medium" />
2801
+ ```
2802
+
2803
+ ### Icon Variants
2804
+
2805
+ - Many icon systems have variants for different states (e.g., `-colored` for active states)
2806
+ - Check your project's icon documentation for available variants
2807
+
2808
+ ## Styling with sx Prop
2809
+
2810
+ ### Basic Usage - Type-Safe Theme Access
2811
+
2812
+ ❌ **Never** hardcode values or use string paths:
2813
+
2814
+ ```typescript
2815
+ <Box
2816
+ sx={{
2817
+ backgroundColor: "red", // ❌ Raw color
2818
+ color: "#ADFF11", // ❌ Hex code
2819
+ padding: "16px", // ❌ Raw size
2820
+ color: "text.secondary", // ❌ String path (no TypeScript support)
2821
+ }}
2822
+ />
2823
+ ```
2824
+
2825
+ ✅ **Always** use theme with type safety:
2826
+
2827
+ ```typescript
2828
+ <Box
2829
+ sx={(theme) => ({
2830
+ backgroundColor: theme.palette.background.primary, // ✅ Semantic token
2831
+ color: theme.palette.text.secondary, // ✅ Type-safe
2832
+ padding: theme.spacing(4), // or just: padding: 4 // ✅ Spacing system
2833
+ })}
2834
+ />
2835
+ ```
2836
+
2837
+ ### Use Meaningful Tokens
2838
+
2839
+ ❌ **Avoid** non-descriptive tokens:
2840
+
2841
+ ```typescript
2842
+ theme.palette.common.white; // ❌ No context about usage
2843
+ theme.palette.green300; // ❌ Which green? When to use?
2844
+ ```
2845
+
2846
+ ✅ **Use** semantic tokens:
2847
+
2848
+ ```typescript
2849
+ theme.palette.background.tertiary; // ✅ Clear purpose
2850
+ theme.palette.instantPay.background; // ✅ Intent is obvious
2851
+ theme.palette.text.primary; // ✅ Meaningful
2852
+ ```
2853
+
2854
+ ## Spacing System
2855
+
2856
+ We use a strict index-based spacing system:
2857
+
2858
+ | Index | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
2859
+ | ----- | --- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
2860
+ | Size | 4px | 6px | 8px | 12px | 16px | 20px | 24px | 32px | 40px | 48px | 56px | 64px |
2861
+
2862
+ **Usage:**
2863
+
2864
+ ```typescript
2865
+ <Box sx={{ padding: 5 }} /> // → 16px
2866
+ <Box sx={{ marginX: 4 }} /> // → 12px left and right
2867
+ <Box sx={{ gap: 3 }} /> // → 8px
2868
+ ```
2869
+
2870
+ **Use `rem` for fonts and heights:**
2871
+
2872
+ ```typescript
2873
+ <Box
2874
+ sx={(theme) => ({
2875
+ height: "3rem", // ✅ Scales with user zoom
2876
+ fontSize: theme.typography.body1.fontSize, // ✅ From theme
2877
+ padding: 5, // ✅ px prevents overflow when zoomed
2878
+ })}
2879
+ />
2880
+ ```
2881
+
2882
+ **Reasoning:** Users who adjust device-wide zoom need `rem` for fonts/heights to scale properly, but `px` spacing prevents layout overflow.
2883
+
2884
+ ## Theme Integration
2885
+
2886
+ ### Responsive Styles
2887
+
2888
+ ```typescript
2889
+ <Box
2890
+ sx={{
2891
+ width: {
2892
+ xs: "100%", // Mobile
2893
+ sm: "75%", // Tablet
2894
+ md: "50%", // Desktop
2895
+ },
2896
+ padding: {
2897
+ xs: 1,
2898
+ md: 3,
2899
+ },
2900
+ }}
2901
+ />
2902
+ ```
2903
+
2904
+ ### Pseudo-classes and Hover States
2905
+
2906
+ ```typescript
2907
+ <Box
2908
+ sx={(theme) => ({
2909
+ "&:hover": {
2910
+ backgroundColor: theme.palette.primary.dark,
2911
+ cursor: "pointer",
2912
+ },
2913
+ "&:disabled": {
2914
+ opacity: 0.5,
2915
+ },
2916
+ "&.active": {
2917
+ borderColor: theme.palette.primary.main,
2918
+ },
2919
+ })}
2920
+ />
2921
+ ```
2922
+
2923
+ ### Nested Selectors
2924
+
2925
+ ```typescript
2926
+ <Box
2927
+ sx={(theme) => ({
2928
+ "& .child-element": {
2929
+ color: theme.palette.text.secondary,
2930
+ },
2931
+ "& > div": {
2932
+ marginBottom: 1,
2933
+ },
2934
+ // Target nested MUI components
2935
+ "& .MuiTypography-root": {
2936
+ color: theme.palette.intent?.disabled.text,
2937
+ },
2938
+ })}
2939
+ />
2940
+ ```
2941
+
2942
+ ## Shorthand Properties
2943
+
2944
+ MUI provides shorthand properties - use full names, not abbreviations:
2945
+
2946
+ ✅ **Use full names:**
2947
+
2948
+ ```typescript
2949
+ <Box
2950
+ sx={{
2951
+ padding: 2, // ✅ Clear
2952
+ paddingX: 4, // ✅ Readable
2953
+ marginY: 2, // ✅ Explicit
2954
+ }}
2955
+ />
2956
+ ```
2957
+
2958
+ ❌ **Avoid abbreviations** (per naming conventions best practice):
2959
+
2960
+ ```typescript
2961
+ <Box
2962
+ sx={{
2963
+ p: 2, // ❌ Too terse
2964
+ px: 4, // ❌ Not clear
2965
+ my: 2, // ❌ What does this mean?
2966
+ }}
2967
+ />
2968
+ ```
2969
+
2970
+ [Full list of shorthand properties](https://mui.com/system/properties/)
2971
+
2972
+ ## Merging sx Props
2973
+
2974
+ For generic components accepting an `sx` prop, merge default styles with custom styles:
2975
+
2976
+ ```typescript
2977
+ // Option 1: Array syntax (MUI native)
2978
+ <Box
2979
+ sx={[
2980
+ (theme) => ({
2981
+ backgroundColor: theme.palette.background.tertiary,
2982
+ padding: 2,
2983
+ }),
2984
+ sx, // User's custom sx prop
2985
+ ]}
2986
+ {...restProps}
2987
+ />
2988
+
2989
+ // Option 2: Use a utility function if your project provides one
2990
+ import { mergeSxProps } from "@clipboard-health/ui-components";
2991
+
2992
+ <Box
2993
+ sx={mergeSxProps(defaultStyles, sx)}
2994
+ {...restProps}
2995
+ />;
2996
+ ```
2997
+
2998
+ ## Theme Access
2999
+
3000
+ ### Using useTheme Hook
3001
+
3002
+ ```typescript
3003
+ import { useTheme } from "@mui/material";
3004
+
3005
+ export function Component() {
3006
+ const theme = useTheme();
3007
+
3008
+ return (
3009
+ <Box sx={{ color: theme.palette.primary.main }}>
3010
+ {/* Your components */}
3011
+ </Box>
3012
+ );
3013
+ }
3014
+ ```
3015
+
3016
+ ### Theme Properties
3017
+
3018
+ ```typescript
3019
+ const theme = useTheme();
3020
+
3021
+ // Colors
3022
+ theme.palette.primary.main;
3023
+ theme.palette.secondary.main;
3024
+ theme.palette.error.main;
3025
+ theme.palette.text.primary;
3026
+ theme.palette.background.default;
3027
+
3028
+ // Spacing
3029
+ theme.spacing(1); // Project-defined (see spacing system above)
3030
+ theme.spacing(2); // Project-defined (see spacing system above)
3031
+
3032
+ // Typography
3033
+ theme.typography.h1;
3034
+ theme.typography.body1;
3035
+
3036
+ // Breakpoints
3037
+ theme.breakpoints.up("md");
3038
+ theme.breakpoints.down("sm");
3039
+ ```
3040
+
3041
+ ## Modal Patterns
3042
+
3043
+ ### ❌ Avoid Generic Modal
3044
+
3045
+ ```typescript
3046
+ // Avoid generic Modal when project has specific dialog components
3047
+ import { Modal } from "@mui/material";
3048
+ ```
3049
+
3050
+ ### ✅ Use Project-Specific Dialog Components
3051
+
3052
+ ```typescript
3053
+ // For mobile-friendly modals
3054
+ import { BottomSheet } from "@/components/BottomSheet";
3055
+
3056
+ // For full-screen views
3057
+ import { FullScreenDialog } from "@/components/FullScreenDialog";
3058
+
3059
+ // For standard dialogs
3060
+ import { Dialog } from "@/components/Dialog";
3061
+ ```
3062
+
3063
+ Check your project's component library for available dialog/modal components.
3064
+
3065
+ ## Layout Components
3066
+
3067
+ ### Use MUI Layout Components
3068
+
3069
+ These are safe to import directly from MUI:
3070
+
3071
+ ```typescript
3072
+ import { Box, Stack, Container, Grid } from '@mui/material';
3073
+
3074
+ // Stack for vertical/horizontal layouts
3075
+ <Stack spacing={2} direction="row">
3076
+ <Item />
3077
+ <Item />
3078
+ </Stack>
3079
+
3080
+ // Box for flexible containers
3081
+ <Box sx={{ display: 'flex', gap: 2 }}>
3082
+ <Child />
3083
+ </Box>
3084
+
3085
+ // Grid for responsive layouts
3086
+ <Grid container spacing={2}>
3087
+ <Grid item xs={12} md={6}>
3088
+ <Content />
3089
+ </Grid>
3090
+ </Grid>
3091
+ ```
3092
+
3093
+ ## MUI Theme Augmentation
3094
+
3095
+ ### Type Safety
3096
+
3097
+ Projects often augment MUI's theme with custom properties:
3098
+
3099
+ ```typescript
3100
+ // Example: Custom theme augmentation
3101
+ declare module "@mui/material/styles" {
3102
+ interface Theme {
3103
+ customSpacing: {
3104
+ large: string;
3105
+ xlarge: string;
3106
+ };
3107
+ }
3108
+ interface ThemeOptions {
3109
+ customSpacing?: {
3110
+ large?: string;
3111
+ xlarge?: string;
3112
+ };
3113
+ }
3114
+ }
3115
+ ```
3116
+
3117
+ Check your project's type definitions for available theme augmentations.
3118
+
3119
+ ## Common Patterns
3120
+
3121
+ ### Card with Custom Styling
3122
+
3123
+ ```typescript
3124
+ import { Card, CardContent } from "@mui/material";
3125
+
3126
+ <Card
3127
+ sx={{
3128
+ borderRadius: 2,
3129
+ boxShadow: 2,
3130
+ "&:hover": {
3131
+ boxShadow: 4,
3132
+ },
3133
+ }}
3134
+ >
3135
+ <CardContent>Content here</CardContent>
3136
+ </Card>;
3137
+ ```
3138
+
3139
+ ### Buttons with Theme Colors
3140
+
3141
+ ```typescript
3142
+ import { Button } from "@/components/Button";
3143
+
3144
+ <Button
3145
+ variant="contained"
3146
+ color="primary"
3147
+ sx={{
3148
+ textTransform: "none", // Override uppercase
3149
+ fontWeight: "bold",
3150
+ }}
3151
+ >
3152
+ Submit
3153
+ </Button>;
3154
+ ```
3155
+
3156
+ ### Conditional Styles
3157
+
3158
+ ```typescript
3159
+ <Box
3160
+ sx={(theme) => ({
3161
+ backgroundColor: isActive
3162
+ ? theme.palette.primary.main
3163
+ : theme.palette.grey[200],
3164
+ padding: 2,
3165
+ })}
3166
+ >
3167
+ {content}
3168
+ </Box>
3169
+ ```
3170
+
3171
+ ## Best Practices
3172
+
3173
+ - **Check Storybook first** - It's the single source of truth
3174
+ - **Use theme tokens** - Never hardcode colors/spacing
3175
+ - **Type-safe access** - Function form: `sx={(theme) => ({...})}`
3176
+ - **Meaningful tokens** - Semantic names over raw colors
3177
+ - **Spacing system** - Indices 1-12 (or `theme.spacing(n)`)
3178
+ - **Use shorthand props** - `paddingX`, `marginY` (full names, not `px`, `my`)
3179
+ - **Leverage pseudo-classes** - For hover, focus, disabled states
3180
+ - **Prefer `sx` over direct props** - `sx` takes priority and is more flexible
3181
+
3182
+ <!-- Source: .ruler/frontend/testing.md -->
3183
+
3184
+ # Testing Standards
3185
+
3186
+ ## The Testing Trophy Philosophy
3187
+
3188
+ Our testing strategy follows the **Testing Trophy** model:
3189
+
3190
+ ```text
3191
+ /\_
3192
+ /E2E\ ← End-to-End (smallest layer)
3193
+ /-----\
3194
+ / Integ \ ← Integration (largest layer - FOCUS HERE!)
3195
+ /---------\
3196
+ / Unit \ ← Unit Tests (helpers/utilities only)
3197
+ /-------------\
3198
+ / Static \ ← TypeScript + ESLint (foundation)
3199
+ ```
3200
+
3201
+ **Investment Priority:**
3202
+
3203
+ 1. **Static** (TypeScript/ESLint) - Free confidence, catches typos and type errors
3204
+ 2. **Integration** - Most valuable, test how components work together as users experience them
3205
+ 3. **Unit** - For pure helpers/utilities, NOT UI components
3206
+ 4. **E2E** - Critical user flows only, slow and expensive
3207
+
3208
+ **Key Principle:** Test as close to how users interact with your app as possible. Users don't shallow-render components or call functions in isolation - they interact with features.
3209
+
3210
+ ## Technology Stack
3211
+
3212
+ - **Vitest** for test runner
3213
+ - **@testing-library/react** for component testing
3214
+ - **@testing-library/user-event** for user interactions
3215
+ - **Mock Service Worker (MSW)** for API mocking
3216
+
3217
+ ## Test File Structure
3218
+
3219
+ ```typescript
3220
+ import { render, screen, waitFor } from "@testing-library/react";
3221
+ import { renderHook } from "@testing-library/react";
3222
+ import userEvent from "@testing-library/user-event";
3223
+ import { describe, expect, it, vi, beforeEach } from "vitest";
3224
+
3225
+ describe("ComponentName", () => {
3226
+ beforeEach(() => {
3227
+ vi.clearAllMocks();
3228
+ });
3229
+
3230
+ it("should render correctly", () => {
3231
+ render(<Component />);
3232
+ expect(screen.getByText("Expected")).toBeInTheDocument();
3233
+ });
3234
+
3235
+ it("should handle user interaction", async () => {
3236
+ const user = userEvent.setup();
3237
+ render(<Component />);
3238
+
3239
+ await user.click(screen.getByRole("button", { name: "Submit" }));
3240
+
3241
+ await waitFor(() => {
3242
+ expect(screen.getByText("Success")).toBeInTheDocument();
3243
+ });
3244
+ });
3245
+ });
3246
+ ```
3247
+
3248
+ ## Test Naming Conventions
3249
+
3250
+ ### Describe Blocks
3251
+
3252
+ - Use the component/function name: `describe('ComponentName', ...)`
3253
+ - Nest describe blocks for complex scenarios
3254
+
3255
+ ```typescript
3256
+ describe("ShiftCard", () => {
3257
+ describe("when shift is urgent", () => {
3258
+ it("should display urgent badge", () => {
3259
+ // ...
3260
+ });
3261
+ });
3262
+
3263
+ describe("when shift is booked", () => {
3264
+ it("should show booked status", () => {
3265
+ // ...
3266
+ });
3267
+ });
3268
+ });
3269
+ ```
3270
+
3271
+ ### Test Names
3272
+
3273
+ - Pattern: `'should [expected behavior] when [condition]'`
3274
+ - Be descriptive and specific
3275
+
3276
+ ```typescript
3277
+ // Good
3278
+ it("should show loading spinner when data is fetching", () => {});
3279
+ it("should display error message when API call fails", () => {});
3280
+ it("should enable submit button when form is valid", () => {});
3281
+
3282
+ // Avoid
3283
+ it("works", () => {});
3284
+ it("loading state", () => {});
3285
+ ```
3286
+
3287
+ ## Parameterized Tests
3288
+
3289
+ ### Using it.each
3290
+
3291
+ ```typescript
3292
+ it.each([
3293
+ { input: { isUrgent: true }, expected: "URGENT" },
3294
+ { input: { isUrgent: false }, expected: "REGULAR" },
3295
+ ])("should return $expected when isUrgent is $input.isUrgent", ({ input, expected }) => {
3296
+ expect(getShiftType(input)).toBe(expected);
3297
+ });
3298
+ ```
3299
+
3300
+ ### Table-Driven Tests
3301
+
3302
+ ```typescript
3303
+ describe("calculateShiftPay", () => {
3304
+ it.each([
3305
+ { hours: 8, rate: 30, expected: 240 },
3306
+ { hours: 10, rate: 25, expected: 250 },
3307
+ { hours: 12, rate: 35, expected: 420 },
3308
+ ])("should calculate $expected for $hours hours at $rate/hr", ({ hours, rate, expected }) => {
3309
+ expect(calculateShiftPay(hours, rate)).toBe(expected);
3310
+ });
3311
+ });
3312
+ ```
3313
+
3314
+ ## Component Testing (Integration Tests)
3315
+
3316
+ Integration tests form the largest part of the Testing Trophy. Test features, not isolated components.
3317
+
3318
+ ### Rendering Components
3319
+
3320
+ ```typescript
3321
+ import { render, screen } from "@testing-library/react";
3322
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3323
+
3324
+ function renderWithProviders(component: ReactElement) {
3325
+ const queryClient = new QueryClient({
3326
+ defaultOptions: {
3327
+ queries: { retry: false },
3328
+ },
3329
+ });
3330
+
3331
+ return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
3332
+ }
3333
+
3334
+ it("should render user name", () => {
3335
+ renderWithProviders(<UserProfile userId="123" />);
3336
+ expect(screen.getByText("John Doe")).toBeInTheDocument();
3337
+ });
3338
+ ```
3339
+
3340
+ ### Querying Elements - Priority Order
3341
+
3342
+ Follow [Testing Library's query priority](https://testing-library.com/docs/queries/about#priority):
3343
+
3344
+ 1. **`getByRole`** - Best for accessibility (buttons, links, inputs)
3345
+ 2. **`getByLabelText`** - For form fields with labels
3346
+ 3. **`getByPlaceholderText`** - For inputs without labels
3347
+ 4. **`getByText`** - For non-interactive content
3348
+ 5. **`getByDisplayValue`** - For current input values
3349
+ 6. **`getByAltText`** - For images
3350
+ 7. **`getByTitle`** - Less common
3351
+ 8. **`getByTestId`** - ⚠️ **LAST RESORT** - Use only when no other option exists
3352
+
3353
+ ```typescript
3354
+ // ✅ Prefer accessible queries
3355
+ screen.getByRole("button", { name: /submit/i });
3356
+ screen.getByLabelText("Email address");
3357
+ screen.getByText("Welcome back");
3358
+
3359
+ // ❌ Avoid CSS selectors and implementation details
3360
+ screen.getByClassName("user-card"); // Users don't see classes
3361
+ wrapper.find("UserCard").prop("user"); // Testing implementation
3362
+ screen.getByTestId("custom-element"); // Last resort only
3363
+ ```
3364
+
3365
+ ### User Interactions
3366
+
3367
+ ```typescript
3368
+ import userEvent from "@testing-library/user-event";
3369
+
3370
+ it("should handle form submission", async () => {
3371
+ const user = userEvent.setup();
3372
+ const onSubmit = vi.fn();
3373
+
3374
+ render(<Form onSubmit={onSubmit} />);
3375
+
3376
+ // Type in input
3377
+ await user.type(screen.getByLabelText("Name"), "John Doe");
3378
+
3379
+ // Click button
3380
+ await user.click(screen.getByRole("button", { name: "Submit" }));
3381
+
3382
+ // Assert
3383
+ expect(onSubmit).toHaveBeenCalledWith({ name: "John Doe" });
3384
+ });
3385
+ ```
3386
+
3387
+ ## Hook Testing (Unit Tests)
3388
+
3389
+ Only write unit tests for hooks that contain business logic, not for UI components.
3390
+
3391
+ ### Using renderHook
3392
+
3393
+ ```typescript
3394
+ import { renderHook, waitFor } from "@testing-library/react";
3395
+
3396
+ describe("useCustomHook", () => {
3397
+ it("should return loading state initially", () => {
3398
+ const { result } = renderHook(() => useCustomHook());
3399
+
3400
+ expect(result.current.isLoading).toBe(true);
3401
+ });
3402
+
3403
+ it("should return data after loading", async () => {
3404
+ const { result } = renderHook(() => useCustomHook());
3405
+
3406
+ await waitFor(() => {
3407
+ expect(result.current.isLoading).toBe(false);
3408
+ });
3409
+
3410
+ expect(result.current.data).toEqual(expectedData);
3411
+ });
3412
+ });
3413
+ ```
3414
+
3415
+ ### Testing Hook Updates
3416
+
3417
+ ```typescript
3418
+ it("should update when dependencies change", async () => {
3419
+ const { result, rerender } = renderHook(({ userId }) => useGetUser(userId), {
3420
+ initialProps: { userId: "1" },
3421
+ });
3422
+
3423
+ await waitFor(() => {
3424
+ expect(result.current.data?.id).toBe("1");
3425
+ });
3426
+
3427
+ // Update props
3428
+ rerender({ userId: "2" });
3429
+
3430
+ await waitFor(() => {
3431
+ expect(result.current.data?.id).toBe("2");
3432
+ });
3433
+ });
3434
+ ```
3435
+
3436
+ ## MSW (Mock Service Worker)
3437
+
3438
+ ### Factory Functions Pattern - IMPORTANT
3439
+
3440
+ **Always export factory functions, not static handlers**. This allows tests to customize mock responses.
3441
+
3442
+ ❌ **Don't** export static handlers (ties tests to single response):
3443
+
3444
+ ```typescript
3445
+ // Bad - can only return this one mock
3446
+ export const facilityNotesSuccessScenario = rest.get(
3447
+ `${TEST_API_URL}/facilityNotes`,
3448
+ async (_, res, ctx) => res(ctx.status(200), ctx.json(mockFacilityNotes)),
3449
+ );
3450
+ ```
3451
+
3452
+ ✅ **Do** export factory functions (flexible per test):
3453
+
3454
+ ```typescript
3455
+ // Good - each test can provide custom data
3456
+ export const createFacilityNotesTestHandler = (facilityNotes: FacilityNote[]) => {
3457
+ return rest.get<string, Record<string, string>, FacilityNotesResponse>(
3458
+ `${TEST_API_URL}/facilityNotes/:facilityId`,
3459
+ async (_req, res, ctx) => {
3460
+ return res(ctx.status(200), ctx.json(facilityNotes));
3461
+ },
3462
+ );
3463
+ };
3464
+
3465
+ // Export default success scenario for convenience
3466
+ export const facilityNotesTestHandlers = [createFacilityNotesTestHandler(mockFacilityNotes)];
3467
+ ```
3468
+
3469
+ **Usage in tests:**
3470
+
3471
+ ```typescript
3472
+ // In test setup
3473
+ mockApiServer.use(
3474
+ createFacilityNotesTestHandler(myCustomFacilityNotes),
3475
+ createExtraTimePaySettingsTestHandler({ payload: customSettings }),
3476
+ );
3477
+ ```
3478
+
3479
+ **Rationale:** When endpoints need different responses for different test scenarios, factory functions avoid duplication and inline mocks that become hard to maintain.
3480
+
3481
+ ## Mocking
3482
+
3483
+ ### Mocking Modules
3484
+
3485
+ ```typescript
3486
+ import { vi } from "vitest";
3487
+ import * as useUserModule from "@/features/user/hooks/useUser";
3488
+
3489
+ // Mock entire module
3490
+ vi.mock("@/features/user/hooks/useUser");
3491
+
3492
+ // Spy on specific function
3493
+ const useDefinedWorkerSpy = vi.spyOn(useDefinedWorkerModule, "useDefinedWorker");
3494
+ useDefinedWorkerSpy.mockReturnValue(getMockWorker({ id: "123" }));
3495
+ ```
3496
+
3497
+ ### Mocking Functions
3498
+
3499
+ ```typescript
3500
+ it("should call callback on success", async () => {
3501
+ const onSuccess = vi.fn();
3502
+
3503
+ render(<Component onSuccess={onSuccess} />);
3504
+
3505
+ await user.click(screen.getByRole("button"));
3506
+
3507
+ await waitFor(() => {
3508
+ expect(onSuccess).toHaveBeenCalledWith(expectedData);
3509
+ });
3510
+ });
3511
+ ```
3512
+
3513
+ ### Mocking API Calls
3514
+
3515
+ ```typescript
3516
+ import { vi } from "vitest";
3517
+
3518
+ vi.mock("@/lib/api", () => ({
3519
+ get: vi.fn().mockResolvedValue({
3520
+ data: { id: "1", name: "Test" },
3521
+ }),
3522
+ }));
3523
+ ```
3524
+
3525
+ ## Async Testing
3526
+
3527
+ ### Using waitFor
3528
+
3529
+ ```typescript
3530
+ it("should display data after loading", async () => {
3531
+ render(<AsyncComponent />);
3532
+
3533
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
3534
+
3535
+ await waitFor(() => {
3536
+ expect(screen.getByText("Data loaded")).toBeInTheDocument();
3537
+ });
3538
+ });
3539
+ ```
3540
+
3541
+ ### Using findBy Queries
3542
+
3543
+ ```typescript
3544
+ it("should display user name", async () => {
3545
+ render(<UserProfile />);
3546
+
3547
+ // findBy automatically waits
3548
+ const name = await screen.findByText("John Doe");
3549
+ expect(name).toBeInTheDocument();
3550
+ });
3551
+ ```
3552
+
3553
+ ## Test Organization
3554
+
3555
+ ### Co-location
3556
+
3557
+ - Place test files next to source files
3558
+ - Use same name with `.test.ts` or `.test.tsx` extension
3559
+
3560
+ ```text
3561
+ Feature/
3562
+ ├── Component.tsx
3563
+ ├── Component.test.tsx
3564
+ ├── utils.ts
3565
+ └── utils.test.ts
3566
+ ```
3567
+
3568
+ ### Test Helpers
3569
+
3570
+ - Create test utilities in `testUtils.ts` or `test-utils.ts`
3571
+ - Reusable mocks in `mocks/` folder
3572
+
3573
+ ```typescript
3574
+ // testUtils.ts
3575
+ export function getMockShift(overrides = {}): Shift {
3576
+ return {
3577
+ id: "1",
3578
+ title: "Test Shift",
3579
+ ...overrides,
3580
+ };
3581
+ }
3582
+ ```
3583
+
3584
+ ## What to Test
3585
+
3586
+ ### ✅ Do Test
3587
+
3588
+ - **Integration tests for features** - Multiple components working together
3589
+ - **Unit tests for helpers/utilities** - Pure business logic
3590
+ - **All states** - Loading, success, error
3591
+ - **User interactions** - Clicks, typing, form submissions
3592
+ - **Conditional rendering** - Different states/permissions
3593
+
3594
+ ### ❌ Don't Test
3595
+
3596
+ - **UI components in isolation** - Users never shallow-render
3597
+ - **Implementation details** - Internal state, function calls
3598
+ - **Third-party libraries** - Trust they're tested
3599
+ - **Styles/CSS** - Visual regression tests are separate
3600
+
3601
+ ## Coverage Guidelines
3602
+
3603
+ - Aim for high coverage on business logic and utilities
3604
+ - Don't obsess over 100% coverage on UI components
3605
+ - **Focus on testing behavior**, not implementation
3606
+ - If you can't query it the way a user would, you're testing wrong
3607
+
3608
+ ## Common Patterns
3609
+
3610
+ ### Testing Loading States
3611
+
3612
+ ```typescript
3613
+ it("should show loading state", () => {
3614
+ render(<Component />);
3615
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
3616
+ });
3617
+ ```
3618
+
3619
+ ### Testing Error States
3620
+
3621
+ ```typescript
3622
+ it("should display error message on failure", async () => {
3623
+ // Mock API error
3624
+ vi.mocked(get).mockRejectedValue(new Error("API Error"));
3625
+
3626
+ render(<Component />);
3627
+
3628
+ expect(await screen.findByText("Error loading data")).toBeInTheDocument();
3629
+ });
3630
+ ```
3631
+
3632
+ ### Testing Conditional Rendering
3633
+
3634
+ ```typescript
3635
+ it("should show premium badge when user is premium", () => {
3636
+ render(<UserCard user={{ ...mockUser, isPremium: true }} />);
3637
+ expect(screen.getByText("Premium")).toBeInTheDocument();
3638
+ });
3639
+
3640
+ it("should not show premium badge when user is not premium", () => {
3641
+ render(<UserCard user={{ ...mockUser, isPremium: false }} />);
3642
+ expect(screen.queryByText("Premium")).not.toBeInTheDocument();
3643
+ });
3644
+ ```
3645
+
3646
+ <!-- Source: .ruler/frontend/typeScript.md -->
3647
+
3648
+ # TypeScript Standards
3649
+
3650
+ ## Core Principles
3651
+
3652
+ - **Strict mode enabled** - no `any` unless absolutely necessary (document why)
3653
+ - **Prefer type inference** - let TypeScript infer when possible
3654
+ - **Explicit return types** for exported functions
3655
+ - **Zod for runtime validation** - single source of truth for types and validation
3656
+ - **No type assertions** unless unavoidable (prefer type guards)
3657
+
3658
+ ## Type vs Interface
3659
+
3660
+ Use the right tool for the job:
3661
+
3662
+ ### Use `interface` for:
3663
+
3664
+ - **Component props**
3665
+ - **Object shapes**
3666
+ - **Class definitions**
3667
+ - **Anything that might be extended**
3668
+
3669
+ ```typescript
3670
+ // ✅ Good - Interface for props
3671
+ interface UserCardProps {
3672
+ user: User;
3673
+ onSelect: (id: string) => void;
3674
+ isHighlighted?: boolean;
3675
+ }
3676
+
3677
+ // ✅ Good - Interface can be extended
3678
+ interface BaseEntity {
3679
+ id: string;
3680
+ createdAt: string;
3681
+ }
3682
+
3683
+ interface User extends BaseEntity {
3684
+ name: string;
3685
+ email: string;
3686
+ }
3687
+ ```
3688
+
3689
+ ### Use `type` for:
3690
+
3691
+ - **Unions**
3692
+ - **Intersections**
3693
+ - **Tuples**
3694
+ - **Derived/conditional types**
3695
+ - **Type aliases**
3696
+
3697
+ ```typescript
3698
+ // ✅ Good - Type for unions
3699
+ type Status = "pending" | "active" | "completed" | "failed";
3700
+
3701
+ // ✅ Good - Type for intersections
3702
+ type UserWithPermissions = User & { permissions: string[] };
3703
+
3704
+ // ✅ Good - Type for tuples
3705
+ type Coordinate = [number, number];
3706
+
3707
+ // ✅ Good - Derived types
3708
+ type UserKeys = keyof User;
3709
+ type PartialUser = Partial<User>;
3710
+ ```
3711
+
3712
+ ## Naming Conventions
3713
+
3714
+ ### Types and Interfaces
3715
+
3716
+ - **Suffix with purpose**: `Props`, `Response`, `Request`, `Options`, `Params`, `State`
3717
+ - **No `I` or `T` prefix** (we're not in C# or Java)
3718
+ - **PascalCase** for type names
3719
+
3720
+ ```typescript
3721
+ // ✅ Good
3722
+ interface ButtonProps { ... }
3723
+ type ApiResponse = { ... };
3724
+ type UserOptions = { ... };
3725
+
3726
+ // ❌ Bad
3727
+ interface IButton { ... } // No I prefix
3728
+ type TResponse = { ... }; // No T prefix
3729
+ interface buttonProps { ... } // Wrong case
3730
+ ```
3731
+
3732
+ ### Boolean Properties
3733
+
3734
+ Always prefix with `is`, `has`, `should`, `can`, `will`:
3735
+
3736
+ ```typescript
3737
+ // ✅ Good
3738
+ interface User {
3739
+ isActive: boolean;
3740
+ hasPermission: boolean;
3741
+ shouldNotify: boolean;
3742
+ canEdit: boolean;
3743
+ willExpire: boolean;
3744
+ }
3745
+
3746
+ // ❌ Bad
3747
+ interface User {
3748
+ active: boolean; // Unclear
3749
+ permission: boolean; // Unclear
3750
+ notify: boolean; // Unclear
3751
+ }
3752
+ ```
3753
+
3754
+ ## Zod Integration
3755
+
3756
+ ```typescript
3757
+ // Define schema first
3758
+ const userSchema = z.object({
3759
+ id: z.string(),
3760
+ name: z.string(),
3761
+ });
3762
+
3763
+ // Infer type from schema
3764
+ export type User = z.infer<typeof userSchema>;
3765
+
3766
+ // Use for API validation
3767
+ const response = await get({
3768
+ url: "/api/users",
3769
+ responseSchema: userSchema,
3770
+ });
3771
+ ```
3772
+
3773
+ ## Type Guards
3774
+
3775
+ ```typescript
3776
+ export function isEventKey(key: string): key is EventKey {
3777
+ return VALID_EVENT_KEYS.includes(key as EventKey);
3778
+ }
3779
+ ```
3780
+
3781
+ ## Constants
3782
+
3783
+ ```typescript
3784
+ // Use const assertions for readonly values
3785
+ export const STAGES = {
3786
+ DRAFT: "draft",
3787
+ PUBLISHED: "published",
3788
+ } as const;
3789
+
3790
+ export type Stage = (typeof STAGES)[keyof typeof STAGES];
3791
+ ```
3792
+
3793
+ ## Function Types
3794
+
3795
+ ```typescript
3796
+ // Explicit return types for exported functions
3797
+ export function calculateTotal(items: Item[]): number {
3798
+ return items.reduce((sum, item) => sum + item.price, 0);
3799
+ }
3800
+
3801
+ // Prefer interfaces for function props
3802
+ interface HandleSubmitProps {
3803
+ userId: string;
3804
+ data: FormData;
3805
+ }
3806
+
3807
+ export function handleSubmit(props: HandleSubmitProps): Promise<void> {
3808
+ // ...
3809
+ }
3810
+ ```
3811
+
3812
+ ## Utility Types
3813
+
3814
+ TypeScript provides powerful built-in utility types. Use them:
3815
+
3816
+ ```typescript
3817
+ // Partial - Make all properties optional
3818
+ type PartialUser = Partial<User>;
3819
+
3820
+ // Required - Make all properties required
3821
+ type RequiredUser = Required<User>;
3822
+
3823
+ // Readonly - Make all properties readonly
3824
+ type ReadonlyUser = Readonly<User>;
3825
+
3826
+ // Pick - Select specific properties
3827
+ type UserIdOnly = Pick<User, "id" | "name">;
3828
+
3829
+ // Omit - Remove specific properties
3830
+ type UserWithoutPassword = Omit<User, "password">;
3831
+
3832
+ // Record - Create object type with specific key/value types
3833
+ type UserMap = Record<string, User>;
3834
+
3835
+ // NonNullable - Remove null and undefined
3836
+ type DefinedString = NonNullable<string | null | undefined>; // string
3837
+
3838
+ // ReturnType - Extract return type from function
3839
+ function getUser() { return { id: '1', name: 'John' }; }
3840
+ type GetUserResult = ReturnType<typeof getUser>;
3841
+
3842
+ // Parameters - Extract parameter types from function
3843
+ function updateUser(id: string, data: UserData) { ... }
3844
+ type UpdateUserParams = Parameters<typeof updateUser>; // [string, UserData]
3845
+ ```
3846
+
3847
+ ## Avoiding `any`
3848
+
3849
+ The `any` type defeats the purpose of TypeScript. Use alternatives:
3850
+
3851
+ ```typescript
3852
+ // ❌ Bad - Loses all type safety
3853
+ function process(data: any) {
3854
+ return data.value; // No error if value doesn't exist!
3855
+ }
3856
+
3857
+ // ✅ Good - Use unknown for truly unknown types
3858
+ function process(data: unknown) {
3859
+ if (typeof data === "object" && data !== null && "value" in data) {
3860
+ return (data as { value: string }).value;
3861
+ }
3862
+ throw new Error("Invalid data");
3863
+ }
3864
+
3865
+ // ✅ Better - Use generics
3866
+ function process<T extends { value: string }>(data: T) {
3867
+ return data.value; // Type safe!
3868
+ }
3869
+
3870
+ // ✅ Best - Use Zod for runtime validation
3871
+ const dataSchema = z.object({ value: z.string() });
3872
+ function process(data: unknown) {
3873
+ const parsed = dataSchema.parse(data); // Throws if invalid
3874
+ return parsed.value; // Type safe!
3875
+ }
3876
+ ```
3877
+
3878
+ ## Generics
3879
+
3880
+ Use generics for reusable, type-safe code:
3881
+
3882
+ ```typescript
3883
+ // ✅ Good - Generic function
3884
+ function getFirst<T>(array: T[]): T | undefined {
3885
+ return array[0];
3886
+ }
3887
+
3888
+ const firstNumber = getFirst([1, 2, 3]); // number | undefined
3889
+ const firstName = getFirst(["a", "b"]); // string | undefined
3890
+
3891
+ // ✅ Good - Generic component
3892
+ interface ListProps<T> {
3893
+ items: T[];
3894
+ renderItem: (item: T) => ReactNode;
3895
+ keyExtractor: (item: T) => string;
3896
+ }
3897
+
3898
+ export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
3899
+ return (
3900
+ <Box>
3901
+ {items.map((item) => (
3902
+ <Box key={keyExtractor(item)}>{renderItem(item)}</Box>
3903
+ ))}
3904
+ </Box>
3905
+ );
3906
+ }
3907
+
3908
+ // Usage - Type inferred!
3909
+ <List
3910
+ items={users}
3911
+ renderItem={(user) => <UserCard user={user} />} // user is User
3912
+ keyExtractor={(user) => user.id}
3913
+ />;
3914
+
3915
+ // ✅ Good - Constrained generics
3916
+ interface HasId {
3917
+ id: string;
3918
+ }
3919
+
3920
+ function findById<T extends HasId>(items: T[], id: string): T | undefined {
3921
+ return items.find((item) => item.id === id);
3922
+ }
3923
+ ```
3924
+
3925
+ ## Runtime Type Checking
3926
+
3927
+ Use type guards for runtime type checking:
3928
+
3929
+ ```typescript
3930
+ // ✅ Good - Type predicate
3931
+ export function isUser(value: unknown): value is User {
3932
+ return (
3933
+ typeof value === "object" &&
3934
+ value !== null &&
3935
+ "id" in value &&
3936
+ "name" in value &&
3937
+ typeof value.id === "string" &&
3938
+ typeof value.name === "string"
3939
+ );
3940
+ }
3941
+
3942
+ // Usage
3943
+ function processData(data: unknown) {
3944
+ if (isUser(data)) {
3945
+ console.log(data.name); // TypeScript knows data is User
3946
+ }
3947
+ }
3948
+
3949
+ // ✅ Better - Use Zod (runtime validation + type guard)
3950
+ const userSchema = z.object({
3951
+ id: z.string(),
3952
+ name: z.string(),
3953
+ });
3954
+
3955
+ export function isUser(value: unknown): value is User {
3956
+ return userSchema.safeParse(value).success;
3957
+ }
3958
+
3959
+ // ✅ Good - Discriminated unions
3960
+ type ApiResponse =
3961
+ | { status: "success"; data: User }
3962
+ | { status: "error"; error: string }
3963
+ | { status: "loading" };
3964
+
3965
+ function handleResponse(response: ApiResponse) {
3966
+ switch (response.status) {
3967
+ case "success":
3968
+ console.log(response.data); // TypeScript knows data exists
3969
+ break;
3970
+ case "error":
3971
+ console.log(response.error); // TypeScript knows error exists
3972
+ break;
3973
+ case "loading":
3974
+ // TypeScript knows no data or error exists
3975
+ break;
3976
+ }
3977
+ }
3978
+ ```
3979
+
3980
+ ## Const Assertions
3981
+
3982
+ Use `as const` for readonly literal types:
3983
+
3984
+ ```typescript
3985
+ // ✅ Good - Const assertion for object
3986
+ export const STATUSES = {
3987
+ PENDING: "pending",
3988
+ ACTIVE: "active",
3989
+ COMPLETED: "completed",
3990
+ } as const;
3991
+
3992
+ // Type: 'pending' | 'active' | 'completed'
3993
+ export type Status = (typeof STATUSES)[keyof typeof STATUSES];
3994
+
3995
+ // ✅ Good - Const assertion for array
3996
+ export const COLORS = ["red", "blue", "green"] as const;
3997
+ export type Color = (typeof COLORS)[number]; // 'red' | 'blue' | 'green'
3998
+
3999
+ // ❌ Bad - Without const assertion
4000
+ export const STATUSES = {
4001
+ PENDING: "pending", // Type: string (too loose!)
4002
+ ACTIVE: "active",
4003
+ };
4004
+ ```
4005
+
4006
+ ## Discriminated Unions
4007
+
4008
+ Use for state machines and API responses:
4009
+
4010
+ ```typescript
4011
+ // ✅ Good - Discriminated union for query state
4012
+ type QueryState<T> =
4013
+ | { status: "idle" }
4014
+ | { status: "loading" }
4015
+ | { status: "success"; data: T }
4016
+ | { status: "error"; error: Error };
4017
+
4018
+ function useQueryState<T>(): QueryState<T> {
4019
+ // ...
4020
+ }
4021
+
4022
+ // Usage - Type narrowing works automatically
4023
+ const state = useQueryState<User>();
4024
+
4025
+ if (state.status === "success") {
4026
+ console.log(state.data); // TypeScript knows data exists
4027
+ }
4028
+
4029
+ // ✅ Good - Discriminated union for actions
4030
+ type Action =
4031
+ | { type: "SET_USER"; payload: User }
4032
+ | { type: "CLEAR_USER" }
4033
+ | { type: "UPDATE_USER"; payload: Partial<User> };
4034
+
4035
+ function reducer(state: State, action: Action) {
4036
+ switch (action.type) {
4037
+ case "SET_USER":
4038
+ return { ...state, user: action.payload }; // payload is User
4039
+ case "CLEAR_USER":
4040
+ return { ...state, user: null }; // no payload
4041
+ case "UPDATE_USER":
4042
+ return { ...state, user: { ...state.user, ...action.payload } };
4043
+ }
4044
+ }
4045
+ ```
4046
+
4047
+ ## Function Overloads
4048
+
4049
+ Use for functions with different parameter/return combinations:
4050
+
4051
+ ```typescript
4052
+ // ✅ Good - Function overloads
4053
+ function formatValue(value: string): string;
4054
+ function formatValue(value: number): string;
4055
+ function formatValue(value: Date): string;
4056
+ function formatValue(value: string | number | Date): string {
4057
+ if (typeof value === "string") return value;
4058
+ if (typeof value === "number") return value.toString();
4059
+ return value.toISOString();
4060
+ }
4061
+
4062
+ // TypeScript knows the return type based on input
4063
+ const str1 = formatValue("hello"); // string
4064
+ const str2 = formatValue(123); // string
4065
+ const str3 = formatValue(new Date()); // string
4066
+ ```
4067
+
4068
+ ## Template Literal Types
4069
+
4070
+ Use for type-safe string patterns:
4071
+
4072
+ ```typescript
4073
+ // ✅ Good - Event handler props with enforced naming
4074
+ type EventHandlers<T extends string> = {
4075
+ [K in T as `on${Capitalize<K>}`]: () => void;
4076
+ };
4077
+
4078
+ type Props = EventHandlers<"click" | "hover" | "submit">;
4079
+ // Results in: { onClick: () => void; onHover: () => void; onSubmit: () => void; }
4080
+
4081
+ // ✅ Good - Validate event handler keys
4082
+ type ValidateEventKey<K extends string> = K extends `on${Capitalize<string>}` ? K : never;
4083
+
4084
+ interface ComponentProps {
4085
+ onClick: () => void; // ✅ Valid
4086
+ onHover: () => void; // ✅ Valid
4087
+ // click: () => void; // ❌ Does not satisfy ValidateEventKey
4088
+ }
4089
+
4090
+ // ✅ Good - Route paths
4091
+ type Route = `/users/${string}` | `/posts/${string}` | "/";
4092
+
4093
+ const validRoute: Route = "/users/123"; // ✅
4094
+ const invalidRoute: Route = "users/123"; // ❌ Error
4095
+
4096
+ // ✅ Good - CSS properties
4097
+ type CSSProperty = `${"margin" | "padding"}${"Top" | "Bottom" | "Left" | "Right"}`;
4098
+ // 'marginTop' | 'marginBottom' | 'marginLeft' | 'marginRight' | 'paddingTop' | ...
4099
+ ```
4100
+
4101
+ ## Mapped Types
4102
+
4103
+ Create new types by transforming existing ones:
4104
+
4105
+ ```typescript
4106
+ // ✅ Good - Make all properties optional
4107
+ type Optional<T> = {
4108
+ [K in keyof T]?: T[K];
4109
+ };
4110
+
4111
+ // ✅ Good - Make all properties readonly
4112
+ type Immutable<T> = {
4113
+ readonly [K in keyof T]: T[K];
4114
+ };
4115
+
4116
+ // ✅ Good - Add suffix to all keys
4117
+ type Prefixed<T, P extends string> = {
4118
+ [K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
4119
+ };
4120
+
4121
+ type User = { name: string; age: number };
4122
+ type PrefixedUser = Prefixed<User, "user">;
4123
+ // { userName: string; userAge: number }
4124
+ ```
4125
+
4126
+ ## Type Narrowing
4127
+
4128
+ Let TypeScript narrow types automatically:
4129
+
4130
+ ```typescript
4131
+ // ✅ Good - Typeof narrowing
4132
+ function format(value: string | number) {
4133
+ if (typeof value === "string") {
4134
+ return value.toUpperCase(); // TypeScript knows value is string
4135
+ }
4136
+ return value.toFixed(2); // TypeScript knows value is number
4137
+ }
4138
+
4139
+ // ✅ Good - Truthiness narrowing
4140
+ function getLength(value: string | null) {
4141
+ if (value) {
4142
+ return value.length; // TypeScript knows value is string
4143
+ }
4144
+ return 0;
4145
+ }
4146
+
4147
+ // ✅ Good - In narrowing
4148
+ interface Fish {
4149
+ swim: () => void;
4150
+ }
4151
+ interface Bird {
4152
+ fly: () => void;
4153
+ }
4154
+
4155
+ function move(animal: Fish | Bird) {
4156
+ if ("swim" in animal) {
4157
+ animal.swim(); // TypeScript knows animal is Fish
4158
+ } else {
4159
+ animal.fly(); // TypeScript knows animal is Bird
4160
+ }
4161
+ }
4162
+
4163
+ // ✅ Good - Instanceof narrowing
4164
+ function handleError(error: unknown) {
4165
+ if (error instanceof Error) {
4166
+ console.log(error.message); // TypeScript knows error is Error
4167
+ }
4168
+ }
4169
+ ```
4170
+
4171
+ ## Common Patterns
4172
+
4173
+ ### Optional Chaining & Nullish Coalescing
4174
+
4175
+ ```typescript
4176
+ // ✅ Good - Optional chaining
4177
+ const userName = user?.profile?.name;
4178
+
4179
+ // ✅ Good - Nullish coalescing (only null/undefined, not '')
4180
+ const displayName = userName ?? "Anonymous";
4181
+
4182
+ // ❌ Bad - Logical OR (treats '' and 0 as falsy)
4183
+ const displayName = userName || "Anonymous";
4184
+ ```
4185
+
4186
+ ### Non-null Assertion (Use Sparingly)
4187
+
4188
+ ```typescript
4189
+ // ⚠️ Use sparingly - Only when you're 100% sure
4190
+ const user = getUser()!; // Tells TS: trust me, it's not null
4191
+
4192
+ // ✅ Better - Handle null case
4193
+ const user = getUser();
4194
+ if (!user) throw new Error("User not found");
4195
+ // Now TypeScript knows user exists
4196
+ ```
4197
+
4198
+ <!-- Source: .ruler/frontend/uiAndStyling.md -->
4199
+
4200
+ # UI and Styling
4201
+
4202
+ - Use Material UI for components and styling and a mobile-first approach.
4203
+ - Favor TanStack Query over "useEffect".