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