@clipboard-health/ai-rules 0.2.0 → 0.2.3

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