@clipboard-health/ai-rules 1.1.1 → 1.2.0

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