@clipboard-health/ai-rules 1.6.43 → 1.7.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.
@@ -1,1251 +1,1138 @@
1
1
  <!-- Generated by Ruler -->
2
2
 
3
- <!-- Source: .ruler/backend/nestJsApis.md -->
3
+ <!-- Source: .ruler/backend/architecture.md -->
4
4
 
5
- # NestJS APIs
5
+ # Architecture
6
6
 
7
- - Use a three-tier architecture:
8
- - Controllers in the entrypoints tier translate from data transfer objects (DTOs) to domain objects (DOs) and call the logic tier.
9
- - Logic tier services call other services in the logic tier and repos and gateways at the data tier. The logic tier operates only on DOs.
10
- - Data tier repos translate from DOs to data access objects (DAOs), call the database using either Prisma for Postgres or Mongoose for MongoDB, and then translate from DAOs to DOs before returning to the logic tier.
11
- - Use ts-rest to define contracts using Zod schemas, one contract per resource.
12
- - A controller implements each ts-rest contract.
13
- - Requests and responses follow the JSON:API specification, including pagination for listings.
14
- - Use TypeDoc to document public functions, classes, methods, and complex code blocks.
7
+ ## Three-Tier Architecture
15
8
 
16
- <!-- Source: .ruler/backend/notifications.md -->
17
-
18
- # Notifications
19
-
20
- Send notifications through [Knock](https://docs.knock.app) using the `@clipboard-health/notifications` NPM library.
21
-
22
- ## Usage
23
-
24
- <embedex source="packages/notifications/examples/usage.md">
25
-
26
- ### `triggerChunked`
27
-
28
- `triggerChunked` stores the full, immutable trigger request at job enqueue time, eliminating issues with stale data, chunking requests to stay under provider limits, and idempotency key conflicts that can occur if the request is updated at job execution time.
29
-
30
- 1. Search your service for `triggerNotification.constants.ts`, `triggerNotification.job.ts` and `notifications.service.ts`. If they don't exist, create them:
31
-
32
- ```ts
33
- // triggerNotification.constants.ts
34
- export const TRIGGER_NOTIFICATION_JOB_NAME = "TriggerNotificationJob";
35
- ```
36
-
37
- ```ts
38
- // triggerNotification.job.ts
39
- import { type BaseHandler } from "@clipboard-health/background-jobs-adapter";
40
- import {
41
- type SerializableTriggerChunkedRequest,
42
- toTriggerChunkedRequest,
43
- } from "@clipboard-health/notifications";
44
- import { isFailure } from "@clipboard-health/util-ts";
45
-
46
- import { type NotificationsService } from "./notifications.service";
47
- import { CBHLogger } from "./setup";
48
- import { TRIGGER_NOTIFICATION_JOB_NAME } from "./triggerNotification.constants";
49
-
50
- /**
51
- * For @clipboard-health/mongo-jobs:
52
- * 1. Implement `HandlerInterface<SerializableTriggerChunkedRequest>`.
53
- * 2. The 10 default `maxAttempts` with exponential backoff of `2^attemptsCount` means ~17 minutes
54
- * of cumulative delay. If your notification could be stale before this, set
55
- * `SerializableTriggerChunkedRequest.expiresAt` when enqueueing.
56
- *
57
- * For @clipboard-health/background-jobs-postgres:
58
- * 1. Implement `Handler<SerializableTriggerChunkedRequest>`.
59
- * 2. The 20 default `maxRetryAttempts` with exponential backoff of `10s * 2^(attempt - 1)` means
60
- * ~121 days of cumulative delay. If your notification could be stale before this, set
61
- * `maxRetryAttempts` (and `SerializableTriggerChunkedRequest.expiresAt`) when enqueueing.
62
- */
63
- export class TriggerNotificationJob implements BaseHandler<SerializableTriggerChunkedRequest> {
64
- // For background-jobs-postgres, use `public static queueName = TRIGGER_NOTIFICATION_JOB_NAME;`
65
- public name = TRIGGER_NOTIFICATION_JOB_NAME;
66
- private readonly logger = new CBHLogger({
67
- defaultMeta: {
68
- logContext: TRIGGER_NOTIFICATION_JOB_NAME,
69
- },
70
- });
71
-
72
- public constructor(private readonly service: NotificationsService) {}
73
-
74
- public async perform(
75
- data: SerializableTriggerChunkedRequest,
76
- /**
77
- * For mongo-jobs, implement `BackgroundJobType<SerializableTriggerChunkedRequest>`, which has
78
- * `_id`, `attemptsCount`, and `uniqueKey`.
79
- *
80
- * For background-jobs-postgres, implement `Job<SerializableTriggerChunkedRequest>`, which has
81
- * `id`, `retryAttempts`, and `idempotencyKey`.
82
- */
83
- job: { _id: string; attemptsCount: number; uniqueKey?: string },
84
- ) {
85
- const metadata = {
86
- // For background-jobs-postgres, this is called `retryAttempts`.
87
- attempt: job.attemptsCount + 1,
88
- jobId: job._id,
89
- recipientCount: data.body.recipients.length,
90
- workflowKey: data.workflowKey,
91
- };
92
- this.logger.info("TriggerNotificationJob processing", metadata);
93
-
94
- try {
95
- const request = toTriggerChunkedRequest(data, {
96
- attempt: metadata.attempt,
97
- idempotencyKey: job.uniqueKey ?? metadata.jobId,
98
- });
99
- const result = await this.service.triggerChunked(request);
100
-
101
- if (isFailure(result)) {
102
- throw result.error;
103
- }
104
-
105
- const success = "TriggerNotificationJob success";
106
- this.logger.info(success, { ...metadata, response: result.value });
107
- // For background-jobs-postgres, return the `success` string result.
108
- } catch (error) {
109
- this.logger.error("TriggerNotificationJob failure", { ...metadata, error });
110
- throw error;
111
- }
112
- }
113
- }
114
- ```
115
-
116
- ```ts
117
- // notifications.service.ts
118
- import { NotificationClient } from "@clipboard-health/notifications";
119
-
120
- import { CBHLogger, toLogger, tracer } from "./setup";
121
-
122
- export class NotificationsService {
123
- private readonly client: NotificationClient;
124
-
125
- constructor() {
126
- this.client = new NotificationClient({
127
- apiKey: "YOUR_KNOCK_API_KEY",
128
- logger: toLogger(new CBHLogger()),
129
- tracer,
130
- });
131
- }
132
-
133
- async triggerChunked(
134
- params: Parameters<NotificationClient["triggerChunked"]>[0],
135
- ): ReturnType<NotificationClient["triggerChunked"]> {
136
- return await this.client.triggerChunked(params);
137
- }
138
- }
139
- ```
140
-
141
- 2. Search the service for a constant that stores workflow keys. If there isn't one, create it:
142
-
143
- ```ts
144
- /* eslint sort-keys: "error" */
145
-
146
- /**
147
- * Alphabetical list of workflow keys.
148
- */
149
- export const WORKFLOW_KEYS = {
150
- eventStartingReminder: "event-starting-reminder",
151
- } as const;
152
- ```
153
-
154
- 3. Build your `SerializableTriggerChunkedRequest` and enqueue your job. Think of queuing `TriggerNotificationJob` as a function call to send notifications in a best practices way. You should NOT call `triggerChunked` directly. If, for example, your notification is delayed, create a background job that runs in the future, does any necessary checks to ensure you should notify, and then queue `TriggerNotificationJob`.
155
-
156
- ```ts
157
- import { type BackgroundJobsAdapter } from "@clipboard-health/background-jobs-adapter";
158
- import { type SerializableTriggerChunkedRequest } from "@clipboard-health/notifications";
159
-
160
- import { BackgroundJobsService } from "./setup";
161
- import { TRIGGER_NOTIFICATION_JOB_NAME } from "./triggerNotification.constants";
162
- import { WORKFLOW_KEYS } from "./workflowKeys";
163
-
164
- /**
165
- * Enqueue a notification job in the same database transaction as the changes it's notifying about.
166
- * The `session` option is called `transaction` in `background-jobs-postgres`.
167
- */
168
- async function enqueueTriggerNotificationJob(adapter: BackgroundJobsAdapter) {
169
- // Assume this comes from a database and are used as template variables...
170
- const notificationData = {
171
- favoriteColor: "blue",
172
- // Use @clipboard-health/date-time's formatShortDateTime in your service for consistency.
173
- favoriteAt: new Date().toISOString(),
174
- secret: "2",
175
- };
176
-
177
- const jobData: SerializableTriggerChunkedRequest = {
178
- // Important: Read the TypeDoc documentation for additional context.
179
- body: {
180
- recipients: ["userId-1", "userId-2"],
181
- data: notificationData,
182
- },
183
- // Helpful when controlling notifications with feature flags.
184
- dryRun: false,
185
- // Set expiresAt at enqueue-time so it remains stable across job retries. Use date-fns in your
186
- // service instead of this manual calculation.
187
- expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
188
- // Keys to redact from logs
189
- keysToRedact: ["secret"],
190
- workflowKey: WORKFLOW_KEYS.eventStartingReminder,
191
- };
192
-
193
- // Option 1 (default): Automatically use background job ID as idempotency key.
194
- await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, { session: "..." });
195
-
196
- // Option 2 (advanced): Provide custom idempotency key to job and notification libraries for more
197
- // control. You'd use this to provide enqueue-time deduplication. For example, if you enqueue when
198
- // a user clicks a button and only want them to receive one notification.
199
- await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, {
200
- // Called `idempotencyKey` in `background-jobs-postgres`.
201
- unique: `meeting-123-reminder`,
202
- session: "...",
203
- });
204
- }
205
-
206
- // eslint-disable-next-line unicorn/prefer-top-level-await
207
- void enqueueTriggerNotificationJob(
208
- // Use your instance of `@clipboard-health/mongo-jobs` or `@clipboard-health/background-jobs-postgres` here.
209
- new BackgroundJobsService(),
210
- );
211
- ```
212
-
213
- </embedex>
214
-
215
- <!-- Source: .ruler/common/codeStyleAndStructure.md -->
216
-
217
- # Code style and structure
218
-
219
- - Write concise, technical TypeScript code with accurate examples.
220
- - Use functional and declarative programming patterns.
221
- - Prefer iteration and modularization over code duplication.
222
- - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
223
- - Structure files: constants, types, exported functions, non-exported functions.
224
- - Avoid magic strings and numbers; define constants.
225
- - Use camelCase for files and directories (e.g., modules/shiftOffers.ts).
226
- - When declaring functions, use the `function` keyword, not `const`.
227
- - Files should read from top to bottom: `export`ed items live on top and the internal functions and methods they call go below them.
228
- - Prefer data immutability.
229
-
230
- # Commit messages
231
-
232
- - Follow the Conventional Commits 1.0 spec for commit messages and in pull request titles.
233
-
234
- <!-- Source: .ruler/common/errorHandlingAndValidation.md -->
235
-
236
- # Error handling and validation
237
-
238
- - Sanitize user input.
239
- - Handle errors and edge cases at the beginning of functions.
240
- - Use early returns for error conditions to avoid deeply nested if statements.
241
- - Place the happy path last in the function for improved readability.
242
- - Avoid unnecessary else statements; use the if-return pattern instead.
243
- - Use guard clauses to handle preconditions and invalid states early.
244
- - Implement proper error logging and user-friendly error messages.
245
- - Favor `@clipboard-health/util-ts`'s `Either` type for expected errors instead of `try`/`catch`.
9
+ All NestJS microservices follow a three-tier layered architecture:
246
10
 
247
- <!-- Source: .ruler/common/testing.md -->
248
-
249
- # Testing
250
-
251
- - Follow the Arrange-Act-Assert convention for tests with newlines between each section.
252
- - Name test variables using the `mockX`, `input`, `expected`, `actual` convention.
253
- - Aim for high test coverage, writing both positive and negative test cases.
254
- - Prefer `it.each` for multiple test cases.
255
- - Avoid conditional logic in tests.
11
+ ```text
12
+ ┌─────────────────────────────────────────────────────────────────┐
13
+ │ Entrypoints (Controllers, message consumers) │
14
+ │ - HTTP request/response, JSON:API DTO translation, auth │
15
+ ├─────────────────────────────────────────────────────────────────┤
16
+ │ Logic (NestJS services, message publishers, background jobs) │
17
+ - ALL business logic; works with DOs only │
18
+ - Knows nothing about HTTP or database specifics │
19
+ ├─────────────────────────────────────────────────────────────────┤
20
+ │ Data (Data repositories, gateways) │
21
+ │ - Database via ORM (Prisma/Mongoose), DAO ↔ DO translation │
22
+ │ - External service integrations (Gateways) │
23
+ └─────────────────────────────────────────────────────────────────┘
24
+ ```
256
25
 
257
- <!-- Source: .ruler/common/typeScript.md -->
26
+ **Module Structure:**
258
27
 
259
- # TypeScript usage
28
+ ```text
29
+ modules/
30
+ └── example/
31
+ ├── data/
32
+ │ ├── example.dao.mapper.ts
33
+ │ ├── example.repo.ts
34
+ │ └── notification.gateway.ts
35
+ ├── entrypoints/
36
+ │ ├── example.controller.ts
37
+ │ ├── example.consumer.ts
38
+ │ └── example.dto.mapper.ts
39
+ ├── logic/
40
+ │ ├── jobs/
41
+ │ │ └── exampleCreated.job.ts
42
+ │ ├── example.do.ts
43
+ │ └── example.service.ts
44
+ └── example.module.ts
45
+ ```
260
46
 
261
- - Use strict-mode TypeScript for all code; prefer interfaces over types.
262
- - Avoid enums; use const maps instead.
263
- - Strive for precise types. Look for type definitions in the codebase and create your own if none exist.
264
- - Avoid using type assertions like `as` or `!` unless absolutely necessary.
265
- - Use the `unknown` type instead of `any` when the type is truly unknown.
266
- - Use an object to pass multiple function params and to return results.
267
- - Leverage union types, intersection types, and conditional types for complex type definitions.
268
- - Use mapped types and utility types (e.g., `Partial<T>`, `Pick<T>`, `Omit<T>`) to transform existing types.
269
- - Implement generic types to create reusable, flexible type definitions.
270
- - Utilize the `keyof` operator and index access types for dynamic property access.
271
- - Implement discriminated unions for type-safe handling of different object shapes where appropriate.
272
- - Use the `infer` keyword in conditional types for type inference.
273
- - Leverage `readonly` properties for function parameter immutability.
274
- - Prefer narrow types whenever possible with `as const` assertions, `typeof`, `instanceof`, `satisfies`, and custom type guards.
275
- - Implement exhaustiveness checking using `never`.
47
+ **File Patterns:**
276
48
 
277
- <!-- Source: .ruler/frontend/custom-hooks.md -->
49
+ ```text
50
+ *.controller.ts - HTTP controllers (entrypoints)
51
+ *.consumer.ts - Message consumers (entrypoints)
52
+ *.service.ts - Business logic (logic)
53
+ *.job.ts - Background jobs (logic)
54
+ *.repo.ts - Database access (data)
55
+ *.gateway.ts - External services (data)
56
+ *.do.ts - Domain objects
57
+ *.dto.mapper.ts - DTO transformation
58
+ *.dao.mapper.ts - DAO transformation
59
+ ```
278
60
 
279
- # Custom Hook Standards
61
+ **Tier Rules:**
280
62
 
281
- ## Hook Structure
63
+ - Controllers → Services (never repos directly)
64
+ - Services → Repos/Gateways within module (never controllers)
65
+ - Repos → Database only (never services/repos/controllers)
66
+ - Entry points are thin layers calling services
67
+ - Enforce with `dependency-cruiser`
282
68
 
283
- ```typescript
284
- export function useFeature(params: Params, options: UseFeatureOptions = {}) {
285
- const query = useQuery(...);
286
- const computed = useMemo(() => transformData(query.data), [query.data]);
287
- const handleAction = useCallback(async () => { /* ... */ }, []);
69
+ **Microservices Principles:**
288
70
 
289
- return { data: computed, isLoading: query.isLoading, handleAction };
290
- }
291
- ```
71
+ - One domain = one module (bounded contexts)
72
+ - Specific modules know about generic, not vice versa
73
+ - Don't block Node.js thread—use background jobs for expensive operations
292
74
 
293
- ## Naming Rules
75
+ <!-- Source: .ruler/backend/asyncMessagingBackgroundJobs.md -->
294
76
 
295
- - **Always** prefix with `use`
296
- - Boolean: Use `useIs*`, `useHas*`, or `useCan*` (e.g., `useIsEnabled`, `useHasPermission`, `useCanEdit`)
297
- - Data: `useGetUser`, `useFeatureData`
298
- - Actions: `useSubmitForm`
77
+ # Async Messaging & Background Jobs
299
78
 
300
- ## Return Values
79
+ ## When to Use
301
80
 
302
- - Return objects (not arrays) for complex hooks
303
- - Name clearly: `isLoading` not `loading`
81
+ | Scenario | Solution |
82
+ | -------------------------------- | ----------------- |
83
+ | Same service producer/consumer | Background Jobs |
84
+ | Cross-service communication | EventBridge + SQS |
85
+ | Deferred work from API path | Background Jobs |
86
+ | Replacing `void` fire-and-forget | Background Jobs |
87
+ | Scaling CRON jobs | Background Jobs |
304
88
 
305
- ## State Management with Constate
89
+ ## Background Jobs
306
90
 
307
- Use `constate` for shared state between components:
91
+ **Creation with Transaction:**
308
92
 
309
93
  ```typescript
310
- import constate from "constate";
311
-
312
- function useFilters() {
313
- const [filters, setFilters] = useState<Filters>({});
314
- return { filters, setFilters };
94
+ async function createLicense() {
95
+ await db.transaction(async (tx) => {
96
+ const license = await tx.license.create({ data });
97
+ await jobs.enqueue(VerificationJob, { licenseId: license.id }, { transaction: tx });
98
+ });
315
99
  }
316
-
317
- export const [FiltersProvider, useFiltersContext] = constate(useFilters);
318
100
  ```
319
101
 
320
- **When to use:** Sharing state between siblings, feature-level state
321
- **When NOT:** Server state (use React Query), simple parent-child (use props)
322
-
323
- ## Hook Patterns
102
+ **Handler Pattern:**
324
103
 
325
104
  ```typescript
326
- // Boolean hooks
327
- export function useIsFeatureEnabled(): boolean {
328
- return useFeatureFlags().includes("feature");
105
+ class ShiftReminderJob implements Handler<ShiftReminderPayload> {
106
+ static queueName = "shift.reminder";
107
+
108
+ async perform(payload: ShiftReminderPayload, job: Job): Promise<string> {
109
+ const { shiftId } = payload;
110
+
111
+ // Fetch fresh data—don't trust stale payload
112
+ const shift = await this.shiftRepo.findById({ id: shiftId });
113
+
114
+ if (!shift || shift.isCancelled) {
115
+ return `Skipping: shift ${shiftId} not found or cancelled`;
116
+ }
117
+
118
+ try {
119
+ await this.notificationService.performSideEffect(shift);
120
+ return `Reminder sent for shift ${shiftId}`;
121
+ } catch (error) {
122
+ if (error instanceof KnownRecoverableError) throw error; // Retry
123
+ return `Skipping: ${error.message}`; // No retry
124
+ }
125
+ }
329
126
  }
127
+ ```
330
128
 
331
- // Data transformation
332
- export function useTransformedData() {
333
- const { data, isLoading } = useGetRawData();
334
- const transformed = useMemo(() => data?.map(format), [data]);
335
- return { data: transformed, isLoading };
336
- }
129
+ **Key Practices:**
337
130
 
338
- // Composite hooks
339
- export function useBookingsData() {
340
- const shifts = useGetShifts();
341
- const invites = useGetInvites();
131
+ - Pass minimal arguments (IDs, not objects)
132
+ - Fetch fresh data in handler
133
+ - Implement idempotency
134
+ - Check state before action
135
+ - Use Expand/Contract for job code updates
342
136
 
343
- const combined = useMemo(
344
- () => [...(shifts.data ?? []), ...(invites.data ?? [])],
345
- [shifts.data, invites.data],
346
- );
137
+ **Avoid Circular Dependencies:**
347
138
 
348
- return { data: combined, isLoading: shifts.isLoading || invites.isLoading };
139
+ ```typescript
140
+ // Shared types file
141
+ export const NOTIFICATION_JOB = "shift-notification";
142
+ export interface NotificationJobPayload {
143
+ shiftId: string;
349
144
  }
145
+
146
+ // Enqueue by string name
147
+ await jobs.enqueue<NotificationJobPayload>(NOTIFICATION_JOB, { shiftId });
350
148
  ```
351
149
 
352
- ## Best Practices
150
+ ## SQS/EventBridge
353
151
 
354
- - Use options object for flexibility
355
- - Be explicit about dependencies
356
- - API hooks in `api/`, logic hooks in `hooks/`
357
- - Return stable references with `useCallback`/`useMemo`
152
+ **Producer:** Single producer per message type, publish atomically (use jobs as outbox), deterministic message IDs, don't rely on strict ordering.
358
153
 
359
- <!-- Source: .ruler/frontend/data-fetching.md -->
154
+ **Consumer:** Own queue per consumer, must be idempotent, separate process from API, don't auto-consume DLQs.
360
155
 
361
- # Data Fetching Standards
156
+ <!-- Source: .ruler/backend/databasePatterns.md -->
362
157
 
363
- ## Technology Stack
158
+ # Database Patterns
364
159
 
365
- - **React Query** (@tanstack/react-query) for all API calls
366
- - **Zod** for response validation
160
+ ## MongoDB/Mongoose
367
161
 
368
- ## Core Principles
162
+ **ObjectId:**
369
163
 
370
- 1. **Use URL and query parameters in query keys** - Makes cache invalidation predictable
371
- 2. **Define Zod schemas** for all API requests/responses
372
- 3. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess`
373
- 4. **Use `enabled` for conditional fetching**
374
- 5. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
164
+ ```typescript
165
+ import mongoose, { Types, Schema } from "mongoose";
375
166
 
376
- ## Hook Patterns
167
+ const id = new Types.ObjectId();
377
168
 
378
- ```typescript
379
- // Simple query
380
- export function useGetUser(userId: string) {
381
- return useQuery({
382
- queryKey: ["users", userId],
383
- queryFn: () => api.get(`/users/${userId}`),
384
- responseSchema: userSchema,
385
- enabled: !!userId,
386
- });
387
- }
169
+ // In schemas
170
+ const schema = new Schema({
171
+ authorId: { type: Schema.Types.ObjectId, ref: "User" },
172
+ });
388
173
 
389
- // Paginated query
390
- export function usePaginatedItems(params: Params) {
391
- return useInfiniteQuery({
392
- queryKey: ["items", params],
393
- queryFn: ({ pageParam }) => api.get("/items", { cursor: pageParam, ...params }),
394
- getNextPageParam: (lastPage) => lastPage.nextCursor,
395
- });
174
+ // In interfaces
175
+ interface Post {
176
+ authorId: Types.ObjectId;
396
177
  }
397
178
 
398
- // Mutations
399
- export function useCreateItem() {
400
- const queryClient = useQueryClient();
401
- return useMutation({
402
- mutationFn: (data: CreateItemRequest) => api.post("/items", data),
403
- onSuccess: () => queryClient.invalidateQueries(["items"]),
404
- });
179
+ // Validation
180
+ if (mongoose.isObjectIdOrHexString(value)) {
405
181
  }
406
182
  ```
407
183
 
408
- ## Error Handling
409
-
410
- For detailed error handling strategies, see `error-handling.md`.
184
+ **Aggregations:**
411
185
 
412
186
  ```typescript
413
- useQuery({
414
- queryKey: ["resource"],
415
- queryFn: fetchResource,
416
- useErrorBoundary: (error) => {
417
- // Show error boundary for 500s, not 404s
418
- return !(axios.isAxiosError(error) && error.response?.status === 404);
419
- },
420
- retry: (failureCount, error) => {
421
- // Don't retry 4xx errors
422
- if (axios.isAxiosError(error) && error.response?.status < 500) return false;
423
- return failureCount < 3;
424
- },
425
- });
187
+ const result = await Users.aggregate<ResultType>()
188
+ .match({ active: true })
189
+ .group({ _id: "$department", count: { $sum: 1 } })
190
+ .project({ department: "$_id", count: 1 });
426
191
  ```
427
192
 
428
- ## Query Keys
193
+ **Indexes:**
429
194
 
430
- ```typescript
431
- // Include URL and params for predictable cache invalidation
432
- export const queryKeys = {
433
- users: ["users"] as const,
434
- user: (id: string) => ["users", id] as const,
435
- userPosts: (id: string) => ["users", id, "posts"] as const,
436
- };
195
+ - Add only when needed (slower writes tradeoff)
196
+ - Apply via code only
197
+ - Separate `indexes.ts` from `schema.ts`
198
+ - Index definitions only in owning service
199
+ - Set `autoIndex: false`
200
+ - Design covering indexes for high-traffic queries
201
+
202
+ ```text
203
+ models/User/
204
+ ├── schema.ts # Schema only
205
+ ├── indexes.ts # Indexes only
206
+ └── index.ts # Model export
437
207
  ```
438
208
 
439
- ## Conditional Fetching
209
+ **Verify query plans:**
440
210
 
441
211
  ```typescript
442
- const { data } = useQuery({
443
- queryKey: ["resource", id],
444
- queryFn: () => fetchResource(id),
445
- enabled: !!id, // Only fetch when id exists
446
- });
212
+ const explanation = await ShiftModel.find(query).explain("executionStats");
213
+ // Check: totalDocsExamined ≈ totalDocsReturned
214
+ // Good: stage 'IXSCAN'; Bad: stage 'COLLSCAN'
447
215
  ```
448
216
 
449
- ## Naming Conventions
217
+ **Transactions:**
450
218
 
451
- - `useGetX` - Simple queries
452
- - `usePaginatedX` - Infinite queries
453
- - `useCreateX`, `useUpdateX`, `useDeleteX` - Mutations
219
+ ```typescript
220
+ const session = await mongoose.startSession();
221
+ try {
222
+ session.startTransaction();
223
+ await Model.create([data], { session });
224
+ await session.commitTransaction();
225
+ } catch (error) {
226
+ await session.abortTransaction();
227
+ throw error;
228
+ } finally {
229
+ await session.endSession();
230
+ }
231
+ ```
454
232
 
455
- ## Hook Location
233
+ ## Repository Pattern
456
234
 
457
- Place in `api/` folder within feature directory. One endpoint = one hook.
235
+ ```typescript
236
+ class UserRepo {
237
+ // Named methods over generic CRUD
238
+ async findById(request: { id: UserId }): Promise<UserDo> {}
239
+ async findByEmail(request: { email: string }): Promise<UserDo> {}
240
+ async updateEmail(request: { id: UserId; email: string }): Promise<UserDo> {}
241
+ }
242
+ ```
458
243
 
459
- <!-- Source: .ruler/frontend/error-handling.md -->
244
+ <!-- Source: .ruler/backend/errorHandling.md -->
460
245
 
461
- # Error Handling Standards
246
+ # Error Handling
462
247
 
463
- ## React Query Error Handling
248
+ ## Service Errors
464
249
 
465
250
  ```typescript
466
- useQuery({
467
- queryKey: ["resource"],
468
- queryFn: fetchResource,
469
- useErrorBoundary: (error) => {
470
- // Show error boundary for 500s, not 404s
471
- return !(axios.isAxiosError(error) && error.response?.status === 404);
472
- },
473
- retry: (failureCount, error) => {
474
- // Don't retry 4xx errors
475
- if (axios.isAxiosError(error) && error.response?.status < 500) return false;
476
- return failureCount < 3;
477
- },
251
+ import { ServiceError } from "@clipboard-health/util-ts";
252
+
253
+ throw new ServiceError({
254
+ code: "SHIFT_NOT_FOUND",
255
+ message: `Shift ${shiftId} not found`,
256
+ httpStatus: 404,
478
257
  });
479
258
  ```
480
259
 
481
- ## Component Error States
482
-
483
- ```typescript
484
- export function DataComponent() {
485
- const { data, isLoading, isError, refetch } = useGetData();
260
+ - Favor `Either` type for expected errors over `try/catch`
261
+ - Guard clauses for preconditions
262
+ - Early returns for error conditions
263
+ - Happy path last
486
264
 
487
- if (isLoading) return <LoadingState />;
488
- if (isError) return <ErrorState onRetry={refetch} />;
265
+ ## Controller Translation
489
266
 
490
- return <DataDisplay data={data} />;
491
- }
267
+ ```typescript
268
+ @UseFilters(HttpExceptionFilter)
269
+ @Controller("shifts")
270
+ export class ShiftController {}
492
271
  ```
493
272
 
494
- ## Mutation Error Handling
273
+ ## Background Job Errors
495
274
 
496
275
  ```typescript
497
- export function useCreateDocument() {
498
- return useMutation({
499
- mutationFn: createDocumentApi,
500
- onSuccess: () => {
501
- queryClient.invalidateQueries(["documents"]);
502
- showSuccessToast("Document created");
503
- },
504
- onError: (error) => {
505
- logError("CREATE_DOCUMENT_FAILURE", error);
506
- showErrorToast("Failed to create document");
507
- },
508
- });
276
+ async perform(payload: JobPayload): Promise<string> {
277
+ try {
278
+ // Main logic
279
+ } catch (error) {
280
+ if (error instanceof RecoverableError) throw error; // Retry
281
+ return `Skipping: ${error.message}`; // No retry
282
+ }
509
283
  }
510
284
  ```
511
285
 
512
- ## Validation Errors
286
+ <!-- Source: .ruler/backend/notifications.md -->
287
+
288
+ # Notifications
289
+
290
+ Send via [Knock](https://docs.knock.app) using `@clipboard-health/notifications`.
291
+
292
+ Use `triggerChunked` to store full trigger request at job enqueue time. See package documentation for setup of `triggerNotification.job.ts`, `notifications.service.ts`, and workflow keys.
293
+
294
+ **Job enqueue pattern:**
513
295
 
514
296
  ```typescript
515
- // Zod validation
516
- const formSchema = z.object({
517
- email: z.string().email("Invalid email"),
518
- age: z.number().min(18, "Must be 18+"),
519
- });
297
+ const jobData: SerializableTriggerChunkedRequest = {
298
+ body: { recipients: ["userId-1"], data: notificationData },
299
+ expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
300
+ keysToRedact: ["secret"],
301
+ workflowKey: WORKFLOW_KEYS.eventStartingReminder,
302
+ };
520
303
 
521
- try {
522
- const validated = formSchema.parse(formData);
523
- } catch (error) {
524
- if (error instanceof z.ZodError) {
525
- error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
526
- }
527
- }
304
+ await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, { session });
528
305
  ```
529
306
 
530
- ## Error Boundaries
307
+ <!-- Source: .ruler/backend/restApiDesign.md -->
531
308
 
532
- ```typescript
533
- export class ErrorBoundary extends Component<Props, State> {
534
- state = { hasError: false };
309
+ # REST API Design
535
310
 
536
- static getDerivedStateFromError(error: Error) {
537
- return { hasError: true, error };
538
- }
311
+ ## JSON:API Specification
539
312
 
540
- componentDidCatch(error: Error, errorInfo: ErrorInfo) {
541
- logEvent("ERROR_BOUNDARY", { error: error.message });
542
- }
313
+ Follow [JSON:API spec](https://jsonapi.org/).
543
314
 
544
- render() {
545
- return this.state.hasError ? <ErrorFallback /> : this.props.children;
546
- }
315
+ ```json
316
+ {
317
+ "data": [
318
+ {
319
+ "id": "1",
320
+ "type": "worker",
321
+ "attributes": { "firstName": "Alex", "lastName": "Smith" }
322
+ }
323
+ ]
547
324
  }
548
325
  ```
549
326
 
550
- ## Best Practices
551
-
552
- - **Always handle errors** - Check `isError` state
553
- - **User-friendly messages** - Don't show technical errors
554
- - **Log for debugging** - Include context
555
- - **Provide recovery actions** - Retry, dismiss, navigate
556
- - **Different strategies** - Error boundary vs inline vs retry
327
+ - Singular `type` values: `"worker"` not `"workers"`
328
+ - Links optional (use only for pagination)
329
+ - Use `include` for related resources
330
+ - Avoid `meta` unless necessary
557
331
 
558
- <!-- Source: .ruler/frontend/file-organization.md -->
332
+ ## URLs
559
333
 
560
- # File Organization Standards
334
+ ```text
335
+ GET /urgent-shifts # lowercase kebab-case, plural nouns
336
+ GET /workers/:workerId/shifts
337
+ POST /workers/:workerId/referral-codes
338
+ ```
561
339
 
562
- ## Feature-based Structure
340
+ ## HTTP Methods
341
+
342
+ | Method | Usage |
343
+ | ------ | ---------------------------------- |
344
+ | GET | Retrieve (idempotent) |
345
+ | POST | Create single resource, return DTO |
346
+ | PATCH | Update, return updated resource |
347
+ | DELETE | Remove |
348
+ | PUT | Not supported |
349
+
350
+ ## HTTP Status Codes
351
+
352
+ | Code | Meaning |
353
+ | ---- | ------------------------------------------------------- |
354
+ | 200 | OK (GET, PATCH, DELETE) |
355
+ | 201 | Created (POST) |
356
+ | 202 | Accepted (async started) |
357
+ | 400 | Bad Request (syntax error) |
358
+ | 401 | Unauthorized (auth failed) |
359
+ | 403 | Forbidden (authz failed) |
360
+ | 404 | Not Found |
361
+ | 409 | Conflict (already exists) |
362
+ | 422 | Unprocessable (semantic error, unsupported filter/sort) |
363
+ | 429 | Rate limited |
364
+ | 500 | Server error |
365
+
366
+ ## Filtering, Sorting, Pagination
563
367
 
564
368
  ```text
565
- FeatureName/
566
- ├── api/ # Data fetching hooks
567
- │ ├── useGetFeature.ts
568
- │ ├── useUpdateFeature.ts
569
- │ └── useDeleteFeature.ts
570
- ├── components/ # Feature-specific components
571
- │ ├── FeatureCard.tsx
572
- │ ├── FeatureList.tsx
573
- │ └── FeatureHeader.tsx
574
- ├── hooks/ # Feature-specific hooks (non-API)
575
- │ ├── useFeatureLogic.ts
576
- │ └── useFeatureState.ts
577
- ├── utils/ # Feature utilities
578
- │ ├── formatFeature.ts
579
- │ ├── formatFeature.test.ts
580
- │ └── validateFeature.ts
581
- ├── __tests__/ # Integration tests (optional)
582
- │ └── FeatureFlow.test.tsx
583
- ├── Page.tsx # Main page component
584
- ├── Router.tsx # Feature routes
585
- ├── paths.ts # Route paths
586
- ├── types.ts # Shared types
587
- ├── constants.ts # Constants
588
- └── README.md # Feature documentation (optional)
369
+ GET /shifts?filter[verified]=true&sort=startDate,-urgency&page[cursor]=abc&page[size]=50
589
370
  ```
590
371
 
591
- ## Naming Conventions
372
+ - Cursor-based pagination only (not offset)
373
+ - Avoid count totals (performance)
374
+ - Only implement filters/sorts clients need
375
+
376
+ ## Contracts
592
377
 
593
- - Components: `PascalCase.tsx` (`UserProfile.tsx`)
594
- - Hooks: `camelCase.ts` (`useUserProfile.ts`)
595
- - Utils: `camelCase.ts` (`formatDate.ts`)
596
- - Types: `camelCase.types.ts` (`user.types.ts`)
597
- - Tests: `ComponentName.test.tsx`
378
+ - Add contracts to `contract-<repo-name>` package
379
+ - Use `ts-rest` with composable Zod schemas
598
380
 
599
- <!-- Source: .ruler/frontend/react-patterns.md -->
381
+ <!-- Source: .ruler/backend/serviceTests.md -->
600
382
 
601
- # React Component Patterns
383
+ # Service Tests (Primary Testing Approach)
602
384
 
603
- ## Component Structure
385
+ Test the public contract (REST endpoints, events) with real local dependencies (Postgres, Mongo, Redis). Fake slow/external services (Zendesk, Stripe).
604
386
 
605
387
  ```typescript
606
- interface Props {
607
- userId: string;
608
- onUpdate?: (user: User) => void;
609
- }
388
+ describe("Documents", () => {
389
+ let tc: TestContext;
390
+
391
+ describe("GET /documents", () => {
392
+ it("returns existing documents for authenticated user", async () => {
393
+ // Arrange
394
+ const authToken = await tc.auth.createUser({ role: "employee" });
395
+ await tc.fixtures.createDocument({ name: "doc-1" });
396
+
397
+ // Act
398
+ const response = await tc.http.get("/documents", {
399
+ headers: { authorization: authToken },
400
+ });
401
+
402
+ // Assert
403
+ expect(response.statusCode).toBe(200);
404
+ expect(response.parsedBody.data).toHaveLength(1);
405
+ });
406
+ });
407
+ });
408
+ ```
610
409
 
611
- export function UserProfile({ userId, onUpdate }: Props) {
612
- // 1. Hooks
613
- const { data, isLoading, error } = useGetUser(userId);
614
- const [isEditing, setIsEditing] = useState(false);
410
+ **Qualities:** One behavior per test, no shared setup, no mocking, <1 second, parallelizable.
615
411
 
616
- // 2. Derived state (avoid useMemo for inexpensive operations)
617
- const displayName = formatName(data);
412
+ <!-- Source: .ruler/common/commitMessages.md -->
618
413
 
619
- // 3. Event handlers
620
- const handleSave = useCallback(async () => {
621
- await saveUser(data);
622
- onUpdate?.(data);
623
- }, [data, onUpdate]);
414
+ # Commit Messages
624
415
 
625
- // 4. Early returns
626
- if (isLoading) return <Loading />;
627
- if (error) return <Error message={error.message} />;
628
- if (!data) return <NotFound />;
416
+ Follow Conventional Commits 1.0 spec for commit messages and PR titles.
629
417
 
630
- // 5. Main render
631
- return (
632
- <Card>
633
- <Typography>{displayName}</Typography>
634
- <Button onClick={handleSave}>Save</Button>
635
- </Card>
636
- );
637
- }
418
+ <!-- Source: .ruler/common/configuration.md -->
419
+
420
+ # Configuration
421
+
422
+ ```text
423
+ Contains secrets?
424
+ └── Yes → SSM Parameter Store
425
+ └── No → Engineers-only, tolerate 1hr propagation?
426
+ └── Yes → Hardcode with @clipboard-health/config
427
+ └── No → 1:1 with DB entity OR needs custom UI?
428
+ └── Yes → Database
429
+ └── No → LaunchDarkly feature flag
638
430
  ```
639
431
 
640
- ## Naming Conventions
432
+ <!-- Source: .ruler/common/featureFlags.md -->
641
433
 
642
- - Components: `PascalCase` (`UserProfile`)
643
- - Props interface: `Props` (co-located with component) or `ComponentNameProps` (exported/shared)
644
- - Event handlers: `handle*` (`handleClick`, `handleSubmit`)
645
- - Boolean props: `is*`, `has*`, `should*`
434
+ # Feature Flags
646
435
 
647
- ## Props Patterns
436
+ **Naming:** `YYYY-MM-[kind]-[subject]` (e.g., `2024-03-release-new-booking-flow`)
648
437
 
649
- ```typescript
650
- // Simple props
651
- interface Props {
652
- title: string;
653
- count: number;
654
- onAction: () => void;
655
- }
438
+ | Kind | Purpose |
439
+ | ------------ | ------------------------ |
440
+ | `release` | Gradual rollout to 100% |
441
+ | `enable` | Kill switch |
442
+ | `experiment` | Trial for small audience |
443
+ | `configure` | Runtime config |
656
444
 
657
- // Discriminated unions for variants
658
- type ButtonProps = { variant: "link"; href: string } | { variant: "button"; onClick: () => void };
445
+ **Rules:**
659
446
 
660
- // Optional callbacks
661
- interface Props {
662
- onSuccess?: (data: Data) => void;
663
- onError?: (error: Error) => void;
664
- }
665
- ```
447
+ - "Off" = default/safer value
448
+ - No permanent flags (except `configure`)
449
+ - Create archival ticket when creating flag
450
+ - Validate staging before production
451
+ - Always provide default values in code
452
+ - Clean up after full launch
666
453
 
667
- ## Composition Patterns
454
+ <!-- Source: .ruler/common/loggingObservability.md -->
668
455
 
669
- ```typescript
670
- // Container/Presentational
671
- export function UserListContainer() {
672
- const { data, isLoading, error } = useUsers();
673
- if (isLoading) return <Loading />;
674
- if (error) return <Error />;
675
- return <UserList users={data} />;
676
- }
456
+ # Logging & Observability
677
457
 
678
- // Compound components
679
- <Card>
680
- <Card.Header title="User" />
681
- <Card.Body>Content</Card.Body>
682
- <Card.Actions>
683
- <Button>Save</Button>
684
- </Card.Actions>
685
- </Card>
686
-
687
- // Render props
688
- <DataProvider>
689
- {({ data, isLoading }) => (
690
- isLoading ? <Loading /> : <Display data={data} />
691
- )}
692
- </DataProvider>
693
- ```
458
+ ## Log Levels
459
+
460
+ | Level | When |
461
+ | ----- | ------------------------------------------ |
462
+ | ERROR | Required functionality broken (2am pager?) |
463
+ | WARN | Optional broken OR recovered from failure |
464
+ | INFO | Informative, ignorable during normal ops |
465
+ | DEBUG | Local only, not production |
694
466
 
695
- ## Children Patterns
467
+ ## Best Practices
696
468
 
697
469
  ```typescript
698
- // Typed children
699
- interface Props {
700
- children: ReactNode;
701
- }
470
+ // Bad
471
+ logger.error("Operation failed");
472
+ logger.error(`Operation failed for workplace ${workplaceId}`);
473
+
474
+ // Good—structured context
475
+ logger.error("Exporting urgent shifts to CSV failed", {
476
+ workplaceId,
477
+ startDate,
478
+ endDate,
479
+ });
480
+ ```
702
481
 
703
- // Render prop pattern
704
- interface Props {
705
- children: (data: Data) => ReactNode;
706
- }
482
+ **Never log:** PII, PHI, tokens, secrets, SSN, account numbers, entire request/response/headers.
707
483
 
708
- // Element restrictions
709
- interface Props {
710
- children: ReactElement<ButtonProps>;
711
- }
484
+ Use metrics for counting:
485
+
486
+ ```typescript
487
+ datadogMetrics.increment("negotiation.errors", { state: "New York" });
712
488
  ```
713
489
 
714
- ## List Rendering
490
+ <!-- Source: .ruler/common/pullRequests.md -->
715
491
 
716
- ```typescript
717
- // ✅ Proper keys
718
- {users.map((user) => <UserCard key={user.id} user={user} />)}
492
+ # Pull Requests
719
493
 
720
- // ✅ Empty states
721
- {users.length === 0 ? <EmptyState /> : users.map(...)}
494
+ **Requirements:**
722
495
 
723
- // Never use index as key when list can change
724
- {items.map((item, index) => <Item key={index} />)} // Wrong!
725
- ```
496
+ 1. Clear title: change summary + ticket; Conventional Commits 1.0
497
+ 2. Thorough description: why, not just what
498
+ 3. Small & focused: single concept
499
+ 4. Tested: service tests + validation proof
500
+ 5. Passing CI
726
501
 
727
- ## Conditional Rendering
502
+ **Description:** Link ticket, context, reasoning, areas of concern.
728
503
 
729
- ```typescript
730
- // Simple conditional
731
- {isLoading && <Spinner />}
504
+ <!-- Source: .ruler/common/security.md -->
732
505
 
733
- // If-else
734
- {isError ? <Error /> : <Success />}
506
+ # Security
735
507
 
736
- // Multiple conditions
737
- {isLoading ? <Loading /> : isError ? <Error /> : <Data />}
508
+ **Secrets:**
738
509
 
739
- // With early returns (preferred for complex logic)
740
- if (isLoading) return <Loading />;
741
- if (isError) return <Error />;
742
- return <Data />;
743
- ```
510
+ - `.env` locally (gitignored)
511
+ - Production: AWS SSM Parameter Store
512
+ - Prefer short-lived tokens
513
+
514
+ **Naming:** `[ENV]_[VENDOR]_[TYPE]_usedBy_[CLIENT]_[SCOPE]_[CREATED_AT]_[OWNER]`
515
+
516
+ <!-- Source: .ruler/common/structuredConcurrency.md -->
744
517
 
745
- ## Performance
518
+ # Structured Concurrency
746
519
 
747
520
  ```typescript
748
- // Memoize expensive components
749
- const MemoItem = memo(({ item }: Props) => <div>{item.name}</div>);
750
-
751
- // Split state to avoid re-renders
752
- function Parent() {
753
- const [count, setCount] = useState(0);
754
- const [text, setText] = useState("");
755
-
756
- // Only CountDisplay re-renders when count changes
757
- return (
758
- <>
759
- <CountDisplay count={count} />
760
- <TextInput value={text} onChange={setText} />
761
- </>
762
- );
521
+ // Cancellation propagation
522
+ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
523
+ const controller = new AbortController();
524
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
525
+ try {
526
+ return await fetch(url, { signal: controller.signal });
527
+ } finally {
528
+ clearTimeout(timeoutId);
529
+ }
763
530
  }
531
+
532
+ // All results regardless of failures
533
+ const results = await Promise.allSettled(operations);
534
+ const succeeded = results.filter((r) => r.status === "fulfilled");
535
+ const failed = results.filter((r) => r.status === "rejected");
764
536
  ```
765
537
 
766
- ## Best Practices
538
+ <!-- Source: .ruler/common/testing.md -->
767
539
 
768
- - **Single Responsibility** - One component, one purpose
769
- - **Composition over Props** - Use children and compound components
770
- - **Colocate State** - Keep state close to where it's used
771
- - **Type Everything** - Full TypeScript coverage
772
- - **Test Behavior** - Test user interactions, not implementation
540
+ # Testing
773
541
 
774
- <!-- Source: .ruler/frontend/react.md -->
542
+ ## Unit Tests
775
543
 
776
- # React
544
+ Use when: error handling hard to trigger black-box, concurrency scenarios, >5 variations, pure function logic.
777
545
 
778
- - Destructure props in function body (improves readability and prop documentation)
779
- - Prefer inline JSX over extracted variables
780
- - Use custom hooks to encapsulate and reuse stateful logic
781
- - Use Zod for request/response schemas in data-fetching hooks
782
- - Use react-hook-form with Zod resolver for forms
783
- - Use date-fns for date operations
546
+ ## Conventions
784
547
 
785
- <!-- Source: .ruler/frontend/styling.md -->
548
+ - Use `it` not `test`; `describe` for grouping
549
+ - Arrange-Act-Assert with newlines between
550
+ - Variables: `mockX`, `input`, `expected`, `actual`
551
+ - Prefer `it.each` for multiple cases
552
+ - No conditional logic in tests
786
553
 
787
- # Styling Standards
554
+ <!-- Source: .ruler/common/typeScript.md -->
788
555
 
789
- ## Core Principles
556
+ # TypeScript
790
557
 
791
- 1. **Always use `sx` prop** - Never CSS/SCSS/SASS files
792
- 2. **Use theme tokens** - Never hardcode colors/spacing
793
- 3. **Type-safe theme access** - Use `sx={(theme) => ({...})}`
794
- 4. **Use semantic names** - `theme.palette.text.primary`, not `common.white`
795
- 5. **Check Storybook first** - It's the single source of truth
558
+ ## Naming Conventions
796
559
 
797
- ## Restricted Patterns
560
+ | Element | Convention | Example |
561
+ | --------------------- | --------------------- | ---------------------------- |
562
+ | File-scope constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
563
+ | Acronyms in camelCase | Lowercase after first | `httpRequest`, `gpsPosition` |
564
+ | Files | Singular, dotted | `user.service.ts` |
798
565
 
799
- **DO NOT USE:**
566
+ ## Core Rules
800
567
 
801
- - `styled()` or `makeStyles()` from MUI
802
- - CSS/SCSS/SASS files
803
- - Inline `style` prop (use `sx` instead)
804
- - String paths for theme (`"text.secondary"` - not type-safe)
568
+ - Strict-mode TypeScript; prefer interfaces over types
569
+ - Avoid enums—use const maps
570
+ - NEVER use `any`—use `unknown` or generics
571
+ - Avoid type assertions (`as`, `!`) unless absolutely necessary
572
+ - Use `function` keyword for declarations, not `const`
573
+ - Prefer `undefined` over `null`
574
+ - Explicit return types on functions
575
+ - Files read top-to-bottom: exports first, internal helpers below
576
+ - Boolean props: `is*`, `has*`, `should*`, `can*`
577
+ - Use const assertions for constants: `as const`
805
578
 
806
- ## Type-Safe Theme Access
579
+ ## Types
807
580
 
808
581
  ```typescript
809
- // Always use function form for type safety
810
- <Box sx={(theme) => ({
811
- backgroundColor: theme.palette.background.primary,
812
- color: theme.palette.text.secondary,
813
- padding: theme.spacing(4), // or just: 4
814
- })} />
815
-
816
- // Never hardcode
817
- <Box sx={{
818
- backgroundColor: "red", // ❌ Raw color
819
- padding: "16px", // Raw size
820
- color: "text.secondary", // String path
821
- }} />
582
+ // Strong typing
583
+ function process(arg: unknown) {} // Better than any
584
+ function process<T>(arg: T) {} // Best
585
+
586
+ // Nullable checks
587
+ if (foo == null) {
588
+ } // Clear intent
589
+ if (isDefined(foo)) {
590
+ } // Better with utility
591
+
592
+ // Quantity values—always unambiguous
593
+ const money = { amountInMinorUnits: 500, currencyCode: "USD" };
594
+ const durationMinutes = 30;
822
595
  ```
823
596
 
824
- ## Spacing System
597
+ **Type Techniques:**
825
598
 
826
- Use indices 1-12 (4px-64px):
599
+ - Union, intersection, conditional types for complex definitions
600
+ - Mapped types: `Partial<T>`, `Pick<T>`, `Omit<T>`
601
+ - `keyof`, index access types, discriminated unions
602
+ - `as const`, `typeof`, `instanceof`, `satisfies`, type guards
603
+ - Exhaustiveness checking with `never`
604
+ - `readonly` for parameter immutability
605
+
606
+ ## Functions
827
607
 
828
608
  ```typescript
829
- <Box sx={{ padding: 5 }} /> // → 16px
830
- <Box sx={{ marginX: 4 }} /> // → 12px left and right
831
- <Box sx={{ gap: 3 }} /> // → 8px
609
+ // Object arguments with interfaces
610
+ interface CreateUserRequest {
611
+ email: string;
612
+ name?: string;
613
+ }
614
+
615
+ function createUser(request: CreateUserRequest): User {
616
+ const { email, name } = request; // Destructure inside
617
+ // ...
618
+ }
619
+
620
+ // Guard clauses for early returns
621
+ function processOrder(order: Order): Result {
622
+ if (!order.isValid) {
623
+ return { error: "Invalid order" };
624
+ }
625
+ // Main logic
626
+ }
832
627
  ```
833
628
 
834
- **Use `rem` for fonts/heights (scales with user zoom), `px` for spacing:**
629
+ ## Objects & Arrays
835
630
 
836
631
  ```typescript
837
- <Box sx={(theme) => ({
838
- height: "3rem", // ✅ Scales
839
- fontSize: theme.typography.body1.fontSize,
840
- padding: 5, // Prevents overflow
841
- })} />
632
+ // Spread over Object.assign
633
+ const updated = { ...original, name: "New Name" };
634
+
635
+ // Array methods over loops (unless breaking early)
636
+ const doubled = items.map((item) => item * 2);
637
+ const sorted = items.toSorted((a, b) => a - b); // Immutable
638
+
639
+ // For early exit
640
+ for (const item of items) {
641
+ if (condition) break;
642
+ }
842
643
  ```
843
644
 
844
- ## Responsive Styles
645
+ ## Async
845
646
 
846
647
  ```typescript
847
- <Box sx={{
848
- width: { xs: "100%", md: "50%" },
849
- padding: { xs: 1, md: 3 },
850
- }} />
648
+ // async/await over .then()
649
+ async function fetchData(): Promise<Data> {
650
+ const response = await api.get("/data");
651
+ return response.data;
652
+ }
653
+
654
+ // Parallel
655
+ const results = await Promise.all(items.map(processItem));
656
+
657
+ // Sequential (when needed)
658
+ for (const item of items) {
659
+ // eslint-disable-next-line no-await-in-loop
660
+ await processItem(item);
661
+ }
851
662
  ```
852
663
 
853
- ## Pseudo-classes
664
+ ## Classes
854
665
 
855
666
  ```typescript
856
- <Box sx={(theme) => ({
857
- "&:hover": { backgroundColor: theme.palette.primary.dark },
858
- "&:disabled": { opacity: 0.5 },
859
- "& .child": { color: theme.palette.text.secondary },
860
- })} />
667
+ class UserService {
668
+ public async findById(request: FindByIdRequest): Promise<User> {}
669
+ private validateUser(user: User): boolean {}
670
+ }
671
+
672
+ // Extract pure functions outside classes
673
+ function formatUserName(first: string, last: string): string {
674
+ return `${first} ${last}`;
675
+ }
861
676
  ```
862
677
 
863
- ## Shorthand Properties
678
+ <!-- Source: .ruler/frontend/customHooks.md -->
679
+
680
+ # Custom Hooks
864
681
 
865
- Use full names: `padding`, `paddingX`, `marginY`
866
- ❌ Avoid abbreviations: `p`, `px`, `my`
682
+ ## Naming
867
683
 
868
- ## Layout Components
684
+ - Prefix with `use`
685
+ - Boolean: `useIs*`, `useHas*`, `useCan*`
686
+ - Data: `useGet*`, `use*Data`
687
+ - Actions: `useSubmit*`, `useCreate*`
869
688
 
870
- Safe to import directly from MUI:
689
+ ## Structure
871
690
 
872
691
  ```typescript
873
- import { Box, Stack, Container, Grid } from "@mui/material";
692
+ export function useFeature(params: Params, options: Options = {}) {
693
+ const query = useQuery(...);
694
+ const computed = useMemo(() => transform(query.data), [query.data]);
695
+ const handleAction = useCallback(async () => { ... }, []);
696
+
697
+ return { data: computed, isLoading: query.isLoading, handleAction };
698
+ }
874
699
  ```
875
700
 
876
- ## Best Practices
701
+ ## Shared State with Constate
877
702
 
878
- - Type-safe access with `sx={(theme) => ({...})}`
879
- - Use semantic token names
880
- - Full property names, not abbreviations
703
+ ```typescript
704
+ import constate from "constate";
881
705
 
882
- <!-- Source: .ruler/frontend/testing.md -->
706
+ function useFilters() {
707
+ const [filters, setFilters] = useState<Filters>({});
708
+ return { filters, setFilters };
709
+ }
883
710
 
884
- # Testing Standards
711
+ export const [FiltersProvider, useFiltersContext] = constate(useFilters);
712
+ ```
885
713
 
886
- ## Testing Trophy Philosophy
714
+ Use constate for: sharing state between siblings, feature-level state.
715
+ Don't use for: server state (use React Query), simple parent-child (use props).
887
716
 
888
- Focus on **Integration tests** - test how components work together as users experience them.
717
+ <!-- Source: .ruler/frontend/dataFetching.md -->
889
718
 
890
- **Investment Priority:**
719
+ # Data Fetching
891
720
 
892
- 1. **Static** (TypeScript/ESLint) - Free confidence
893
- 2. **Integration** - Most valuable, test features not isolated components
894
- 3. **Unit** - For pure helpers/utilities only
895
- 4. **E2E** - Critical flows only
721
+ ## Core Rules
896
722
 
897
- **Key Principle:** Test as close to how users interact with your app as possible.
723
+ 1. Use React Query for all API calls
724
+ 2. Define Zod schemas for all request/response types
725
+ 3. Use `isLoading`, `isError`, `isSuccess`—don't create custom state
726
+ 4. Use `enabled` option for conditional fetching
727
+ 5. Use `invalidateQueries` (not `refetch`) for disabled queries
898
728
 
899
- ## Technology Stack
729
+ ## Hook Pattern
900
730
 
901
- - **Vitest** for test runner
902
- - **@testing-library/react** for component testing
903
- - **@testing-library/user-event** for user interactions
904
- - **Mock Service Worker (MSW)** for API mocking
731
+ ```typescript
732
+ // Define in: FeatureName/api/useGetFeature.ts
733
+ const responseSchema = z.object({
734
+ id: z.string(),
735
+ name: z.string(),
736
+ });
905
737
 
906
- ## Test Structure
738
+ export type FeatureResponse = z.infer<typeof responseSchema>;
907
739
 
908
- ```typescript
909
- describe("ComponentName", () => {
910
- it("should render correctly", () => {
911
- render(<Component />);
912
- expect(screen.getByText("Expected")).toBeInTheDocument();
740
+ export function useGetFeature(id: string, options = {}) {
741
+ return useGetQuery({
742
+ url: `feature/${id}`,
743
+ responseSchema,
744
+ enabled: !!id,
745
+ meta: { logErrorMessage: APP_EVENTS.GET_FEATURE_FAILURE },
746
+ ...options,
913
747
  });
748
+ }
749
+ ```
914
750
 
915
- it("should handle user interaction", async () => {
916
- const user = userEvent.setup();
917
- render(<Component />);
918
- await user.click(screen.getByRole("button", { name: "Submit" }));
919
- await waitFor(() => expect(screen.getByText("Success")).toBeInTheDocument());
751
+ ## Query Keys
752
+
753
+ Include URL and params for predictable cache invalidation:
754
+
755
+ ```typescript
756
+ queryKey: ["users", userId];
757
+ queryKey: ["users", userId, "posts"];
758
+ ```
759
+
760
+ ## Conditional Fetching
761
+
762
+ ```typescript
763
+ const { data } = useGetFeature(
764
+ { id: dependencyData?.id },
765
+ { enabled: isDefined(dependencyData?.id) },
766
+ );
767
+ ```
768
+
769
+ ## Mutations
770
+
771
+ ```typescript
772
+ export function useCreateItem() {
773
+ const queryClient = useQueryClient();
774
+ return useMutation({
775
+ mutationFn: (data: CreateItemRequest) => api.post("/items", data),
776
+ onSuccess: () => queryClient.invalidateQueries(["items"]),
777
+ onError: (error) => logError("CREATE_ITEM_FAILURE", error),
920
778
  });
921
- });
779
+ }
922
780
  ```
923
781
 
924
- ## Test Naming
782
+ <!-- Source: .ruler/frontend/e2eTesting.md -->
783
+
784
+ # E2E Testing (Playwright)
785
+
786
+ ## Core Rules
925
787
 
926
- Pattern: `should [behavior] when [condition]`
788
+ - Test critical user flows only—not exhaustive scenarios
789
+ - Each test sets up its own data (no shared state between tests)
790
+ - Mock feature flags and third-party services
791
+ - Use user-centric locators (role, label, text)—avoid CSS selectors
927
792
 
928
- ## Querying Elements - Priority Order
793
+ ## Locator Priority
929
794
 
930
- 1. `getByRole` - Best for accessibility
931
- 2. `getByLabelText` - For form fields
932
- 3. `getByText` - For non-interactive content
933
- 4. `getByTestId` - ⚠️ **LAST RESORT**
795
+ 1. `page.getByRole()`
796
+ 2. `page.getByLabel()`
797
+ 3. `page.getByPlaceholder()`
798
+ 4. `page.getByText()`
799
+ 5. `page.getByTestId()` (last resort)
800
+
801
+ ## Assertions
934
802
 
935
803
  ```typescript
936
- // ✅ Prefer
937
- screen.getByRole("button", { name: /submit/i });
938
- screen.getByLabelText("Email");
804
+ // ✅ Assert visibility
805
+ await expect(page.getByText("Submit")).toBeVisible();
939
806
 
940
- // ❌ Avoid
941
- screen.getByClassName("user-card");
942
- screen.getByTestId("element");
807
+ // ❌ Don't assert DOM attachment
808
+ await expect(page.getByText("Submit")).toBeAttached();
943
809
  ```
944
810
 
945
- ## User Interactions
811
+ ## Avoid
812
+
813
+ - Hard-coded timeouts (`page.waitForTimeout`)
814
+ - Testing loading states (non-deterministic)
815
+ - Shared data between tests
816
+ - CSS/XPath selectors
817
+
818
+ <!-- Source: .ruler/frontend/errorHandling.md -->
819
+
820
+ # Error Handling
821
+
822
+ ## Component Level
946
823
 
947
824
  ```typescript
948
- it("should handle form submission", async () => {
949
- const user = userEvent.setup();
950
- const onSubmit = vi.fn();
951
- render(<Form onSubmit={onSubmit} />);
825
+ export function DataComponent() {
826
+ const { data, isLoading, isError, refetch } = useGetData();
952
827
 
953
- await user.type(screen.getByLabelText("Name"), "John");
954
- await user.click(screen.getByRole("button", { name: "Submit" }));
828
+ if (isLoading) return <LoadingState />;
829
+ if (isError) return <ErrorState onRetry={refetch} />;
955
830
 
956
- expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
957
- });
831
+ return <DataDisplay data={data} />;
832
+ }
958
833
  ```
959
834
 
960
- ## Hook Testing
835
+ ## Mutation Level
961
836
 
962
837
  ```typescript
963
- describe("useCustomHook", () => {
964
- it("should return data after loading", async () => {
965
- const { result } = renderHook(() => useCustomHook());
966
- await waitFor(() => expect(result.current.isLoading).toBe(false));
967
- expect(result.current.data).toEqual(expectedData);
968
- });
838
+ useMutation({
839
+ mutationFn: createItem,
840
+ onSuccess: () => showSuccessToast("Created"),
841
+ onError: (error) => {
842
+ logError("CREATE_FAILURE", error);
843
+ showErrorToast("Failed to create");
844
+ },
969
845
  });
970
846
  ```
971
847
 
972
- ## MSW Pattern
973
-
974
- **Always export factory functions, not static handlers:**
848
+ ## Validation
975
849
 
976
850
  ```typescript
977
- // ✅ Good - flexible per test
978
- export const createTestHandler = (data: Data[]) =>
979
- rest.get(`/api/resource`, (req, res, ctx) => res(ctx.json(data)));
980
-
981
- // Usage
982
- mockServer.use(createTestHandler(customData));
851
+ try {
852
+ const validated = formSchema.parse(formData);
853
+ } catch (error) {
854
+ if (error instanceof z.ZodError) {
855
+ error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
856
+ }
857
+ }
983
858
  ```
984
859
 
985
- ## Mocking
860
+ <!-- Source: .ruler/frontend/fileOrganization.md -->
986
861
 
987
- ```typescript
988
- vi.mock("@/lib/api", () => ({
989
- get: vi.fn().mockResolvedValue({ data: { id: "1" } }),
990
- }));
862
+ # File Organization
863
+
864
+ ```text
865
+ FeatureName/
866
+ ├── api/ # Data fetching hooks
867
+ │ └── useGetFeature.ts
868
+ ├── components/ # Feature-specific components
869
+ │ └── FeatureCard.tsx
870
+ ├── hooks/ # Non-API hooks
871
+ │ └── useFeatureLogic.ts
872
+ ├── utils/ # Utilities + tests
873
+ │ ├── formatFeature.ts
874
+ │ └── formatFeature.test.ts
875
+ ├── Page.tsx # Main page
876
+ ├── Router.tsx # Routes
877
+ ├── paths.ts # Route constants
878
+ └── types.ts # Shared types
991
879
  ```
992
880
 
993
- ## What to Test
881
+ ## Naming
882
+
883
+ - Components: `PascalCase.tsx`
884
+ - Hooks: `camelCase.ts` (prefixed with `use`)
885
+ - Utils: `camelCase.ts`
886
+ - Tests: `*.test.tsx`
887
+
888
+ <!-- Source: .ruler/frontend/frontendTechnologyStack.md -->
994
889
 
995
- **✅ Do Test:**
890
+ # Frontend Technology Stack
996
891
 
997
- - Integration tests for features
998
- - Unit tests for utilities
999
- - All states (loading, success, error)
1000
- - User interactions
892
+ - **React** with TypeScript (strict mode)
893
+ - **MUI** for UI components
894
+ - **React Query** (@tanstack/react-query) for data fetching
895
+ - **Zod** for runtime validation
896
+ - **Vitest** + **@testing-library/react** for testing
897
+ - **MSW** for API mocking
898
+ - **Playwright** for E2E tests
899
+ - **constate** for shared state
900
+
901
+ <!-- Source: .ruler/frontend/interactiveElements.md -->
902
+
903
+ # Interactive Elements
1001
904
 
1002
- **❌ Don't Test:**
905
+ Never add `onClick` to `div` or `span`. Use:
1003
906
 
1004
- - Implementation details
1005
- - Third-party libraries
1006
- - Styles/CSS
907
+ - `<button>` for actions
908
+ - `<a>` (Link) for navigation
909
+ - MUI interactive components (`Button`, `IconButton`, `ListItemButton`)
1007
910
 
1008
- <!-- Source: .ruler/frontend/typeScript.md -->
911
+ This ensures proper accessibility: focus states, keyboard navigation, ARIA roles.
1009
912
 
1010
- # TypeScript Standards
913
+ <!-- Source: .ruler/frontend/modalRoutes.md -->
1011
914
 
1012
- ## Core Principles
915
+ # Modal Routes
1013
916
 
1014
- - **Strict mode enabled** - Avoid `any`
1015
- - **Prefer type inference** - Let TypeScript infer when possible
1016
- - **Explicit return types** for exported functions
1017
- - **Zod for runtime validation** - Single source of truth
1018
- - **No type assertions** unless unavoidable
917
+ Modal visibility is driven by URL, not local state:
918
+
919
+ ```typescript
920
+ <ModalRoute
921
+ path={`${basePath}/confirm`}
922
+ closeModalPath={basePath}
923
+ render={({ modalState }) => (
924
+ <ConfirmDialog modalState={modalState} />
925
+ )}
926
+ />
927
+ ```
928
+
929
+ Use `history.replace` (not `push`) when navigating between modals to avoid awkward back-button behavior.
930
+
931
+ <!-- Source: .ruler/frontend/reactComponents.md -->
932
+
933
+ # React Components
1019
934
 
1020
935
  ## Type vs Interface
1021
936
 
1022
937
  ```typescript
1023
- // Use interface for: props, object shapes, extensible types
938
+ // Use interface for: props, object shapes, extensible types
1024
939
  interface UserCardProps {
1025
940
  user: User;
1026
941
  onSelect: (id: string) => void;
1027
942
  }
943
+ ```
1028
944
 
1029
- interface BaseEntity {
1030
- id: string;
1031
- createdAt: string;
1032
- }
945
+ ## Structure Order
1033
946
 
1034
- interface User extends BaseEntity {
1035
- name: string;
1036
- }
947
+ ```typescript
948
+ export function Component({ userId, onUpdate }: Props) {
949
+ // 1. Hooks
950
+ const { data, isLoading } = useGetUser(userId);
951
+ const [isEditing, setIsEditing] = useState(false);
952
+
953
+ // 2. Derived state (no useMemo for cheap operations)
954
+ const displayName = formatName(data);
1037
955
 
1038
- // Use type for: unions, intersections, tuples, derived types
1039
- type Status = "pending" | "active" | "completed";
1040
- type UserWithRoles = User & { roles: string[] };
1041
- type Coordinate = [number, number];
1042
- type UserKeys = keyof User;
956
+ // 3. Event handlers
957
+ const handleSave = useCallback(async () => { ... }, [deps]);
958
+
959
+ // 4. Early returns for loading/error/empty
960
+ if (isLoading) return <Loading />;
961
+ if (!data) return <NotFound />;
962
+
963
+ // 5. Main render
964
+ return <Card>...</Card>;
965
+ }
1043
966
  ```
1044
967
 
1045
968
  ## Naming Conventions
1046
969
 
970
+ - Components: `PascalCase`
971
+ - Event handlers: `handle*` (e.g., `handleClick`)
972
+ - Props interface: `Props` (co-located) or `ComponentNameProps` (exported)
973
+
974
+ ## Component Guidelines
975
+
976
+ - **One file per component**—never extract JSX into local variables
977
+ - **Composition over configuration**—prefer `children` over many props
978
+ - **Presentational components**: stateless, UI-focused, no API calls
979
+ - **Container components**: feature logic, data fetching, state management
980
+ - Pass primitives as props, not entire API response objects
981
+
1047
982
  ```typescript
1048
- // Suffix with purpose (no I or T prefix)
1049
- interface ButtonProps { ... }
1050
- type ApiResponse = { ... };
1051
- type UserOptions = { ... };
1052
-
1053
- // ✅ Boolean properties
1054
- interface User {
1055
- isActive: boolean;
1056
- hasPermission: boolean;
1057
- shouldNotify: boolean;
983
+ // Bad—coupled to API shape
984
+ interface Props {
985
+ shift: ShiftApiResponse;
986
+ }
987
+
988
+ // ✅ Good—only required fields
989
+ interface Props {
990
+ shiftId: string;
991
+ shiftPay: number;
992
+ workplaceId: string;
1058
993
  }
1059
994
  ```
1060
995
 
1061
- ## Zod Integration
996
+ ## Inline JSX and Handlers
1062
997
 
1063
998
  ```typescript
1064
- // Define schema, infer type
1065
- const userSchema = z.object({
1066
- id: z.string(),
1067
- name: z.string(),
1068
- });
999
+ // Don't extract JSX to variables
1000
+ const content = <p>Content</p>;
1001
+ return <>{content}</>;
1069
1002
 
1070
- export type User = z.infer<typeof userSchema>;
1003
+ // Keep inline or extract to new component file
1004
+ return <p>Content</p>;
1071
1005
 
1072
- // Validate API responses
1073
- const response = await get({
1074
- url: "/api/users",
1075
- responseSchema: userSchema,
1076
- });
1006
+ // Don't extract handlers unnecessarily
1007
+ const handleChange = (e) => { ... };
1008
+ return <Input onChange={handleChange} />;
1009
+
1010
+ // ✅ Keep inline
1011
+ return <Input onChange={(e) => { ... }} />;
1077
1012
  ```
1078
1013
 
1079
- ## Type Guards
1014
+ ## List Rendering
1080
1015
 
1081
1016
  ```typescript
1082
- export function isUser(value: unknown): value is User {
1083
- return userSchema.safeParse(value).success;
1084
- }
1017
+ // Always use stable keys
1018
+ {users.map((user) => <UserCard key={user.id} user={user} />)}
1085
1019
 
1086
- // Usage
1087
- if (isUser(data)) {
1088
- console.log(data.name); // TypeScript knows data is User
1089
- }
1020
+ // ❌ Never use index as key for dynamic lists
1021
+ {items.map((item, i) => <Item key={i} />)}
1090
1022
  ```
1091
1023
 
1092
- ## Constants
1024
+ <!-- Source: .ruler/frontend/styling.md -->
1025
+
1026
+ # Styling
1027
+
1028
+ ## Core Rules
1029
+
1030
+ 1. **Always use `sx` prop**—never CSS/SCSS/SASS/styled()/makeStyles()
1031
+ 2. **Use theme tokens**—never use raw string values for colors/spacing
1032
+ 3. **Type-safe theme access**—use `sx={(theme) => ({...})}`
1033
+ 4. **Use semantic token names**—`theme.palette.text.primary`, not `common.white`
1034
+
1035
+ ## Patterns
1093
1036
 
1094
1037
  ```typescript
1095
- // Use const assertions
1096
- export const STATUSES = {
1097
- DRAFT: "draft",
1098
- PUBLISHED: "published",
1099
- } as const;
1038
+ // Correct
1039
+ <Box sx={(theme) => ({
1040
+ backgroundColor: theme.palette.background.primary,
1041
+ color: theme.palette.text.secondary,
1042
+ padding: theme.spacing(4), // or just: padding: 4
1043
+ })} />
1100
1044
 
1101
- export type Status = (typeof STATUSES)[keyof typeof STATUSES];
1045
+ // Wrong
1046
+ <Box sx={{
1047
+ backgroundColor: "red", // raw color
1048
+ padding: "16px", // raw size
1049
+ color: "text.secondary", // string path (not type-safe)
1050
+ }} />
1102
1051
  ```
1103
1052
 
1104
- ## Utility Types
1053
+ ## Spacing
1054
+
1055
+ Use theme spacing indices 1-12:
1105
1056
 
1106
1057
  ```typescript
1107
- // Built-in utilities
1108
- type PartialUser = Partial<User>;
1109
- type RequiredUser = Required<User>;
1110
- type ReadonlyUser = Readonly<User>;
1111
- type UserIdOnly = Pick<User, "id" | "name">;
1112
- type UserWithoutPassword = Omit<User, "password">;
1113
- type UserMap = Record<string, User>;
1114
- type DefinedString = NonNullable<string | null>;
1115
-
1116
- // Function utilities
1117
- type GetUserResult = ReturnType<typeof getUser>;
1118
- type UpdateUserParams = Parameters<typeof updateUser>;
1058
+ <Box sx={{ padding: 5 }} />
1059
+ <Box sx={{ marginX: 4 }} />
1119
1060
  ```
1120
1061
 
1121
- ## Avoiding `any`
1062
+ Use `rem` for fonts/heights (scales with user zoom), spacing indices for padding/margin.
1122
1063
 
1123
- ```typescript
1124
- // ❌ Bad - Loses type safety
1125
- function process(data: any) { ... }
1064
+ ## Property Names
1126
1065
 
1127
- // ✅ Use unknown for truly unknown types
1128
- function process(data: unknown) {
1129
- if (typeof data === "object" && data !== null) {
1130
- // Type guard
1131
- }
1132
- }
1066
+ - ✅ Use full names: `padding`, `paddingX`, `marginY`
1067
+ - ❌ Avoid abbreviations: `p`, `px`, `my`
1133
1068
 
1134
- // ✅ Better - Use generics
1135
- function process<T extends { value: string }>(data: T) {
1136
- return data.value;
1137
- }
1069
+ ## Pseudo-classes
1138
1070
 
1139
- // ✅ Best - Use Zod
1140
- const dataSchema = z.object({ value: z.string() });
1141
- function process(data: unknown) {
1142
- const parsed = dataSchema.parse(data);
1143
- return parsed.value;
1144
- }
1071
+ ```typescript
1072
+ <Box sx={(theme) => ({
1073
+ "&:hover": { backgroundColor: theme.palette.primary.dark },
1074
+ "&:disabled": { opacity: 0.5 },
1075
+ "& .MuiTypography-root": { color: theme.palette.text.secondary },
1076
+ })} />
1145
1077
  ```
1146
1078
 
1147
- ## Generics
1079
+ <!-- Source: .ruler/frontend/testing.md -->
1148
1080
 
1149
- ```typescript
1150
- // Generic function
1151
- function getFirst<T>(array: T[]): T | undefined {
1152
- return array[0];
1153
- }
1081
+ # Testing
1154
1082
 
1155
- // Generic component
1156
- interface ListProps<T> {
1157
- items: T[];
1158
- renderItem: (item: T) => ReactNode;
1159
- }
1083
+ ## Philosophy
1160
1084
 
1161
- export function List<T>({ items, renderItem }: ListProps<T>) {
1162
- return <>{items.map((item) => renderItem(item))}</>;
1163
- }
1085
+ Focus on integration tests—test how components work together as users experience them.
1164
1086
 
1165
- // Constrained generics
1166
- function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
1167
- return items.find((item) => item.id === id);
1168
- }
1169
- ```
1087
+ **Priority:** Static (TS/ESLint) → Integration → Unit (pure utilities only) → E2E (critical flows only)
1170
1088
 
1171
- ## Discriminated Unions
1089
+ ## Test Structure
1172
1090
 
1173
1091
  ```typescript
1174
- // State machine
1175
- type QueryState<T> =
1176
- | { status: "idle" }
1177
- | { status: "loading" }
1178
- | { status: "success"; data: T }
1179
- | { status: "error"; error: Error };
1180
-
1181
- // Automatic type narrowing
1182
- if (state.status === "success") {
1183
- console.log(state.data); // TypeScript knows data exists
1184
- }
1092
+ describe("ComponentName", () => {
1093
+ it("should [behavior] when [condition]", async () => {
1094
+ const user = userEvent.setup();
1095
+ render(<Component />);
1185
1096
 
1186
- // Actions
1187
- type Action = { type: "SET_USER"; payload: User } | { type: "CLEAR_USER" };
1097
+ await user.click(screen.getByRole("button", { name: "Submit" }));
1188
1098
 
1189
- function reducer(state: State, action: Action) {
1190
- switch (action.type) {
1191
- case "SET_USER":
1192
- return { ...state, user: action.payload };
1193
- case "CLEAR_USER":
1194
- return { ...state, user: null };
1195
- }
1196
- }
1099
+ await waitFor(() => {
1100
+ expect(screen.getByText("Success")).toBeInTheDocument();
1101
+ });
1102
+ });
1103
+ });
1197
1104
  ```
1198
1105
 
1199
- ## Type Narrowing
1106
+ ## Query Priority
1200
1107
 
1201
- ```typescript
1202
- // typeof
1203
- function format(value: string | number) {
1204
- if (typeof value === "string") {
1205
- return value.toUpperCase();
1206
- }
1207
- return value.toFixed(2);
1208
- }
1108
+ 1. `getByRole` (best for accessibility)
1109
+ 2. `getByLabelText` (form fields)
1110
+ 3. `getByText` (non-interactive content)
1111
+ 4. `getByTestId` (last resort only)
1209
1112
 
1210
- // in operator
1211
- function move(animal: Fish | Bird) {
1212
- if ("swim" in animal) {
1213
- animal.swim();
1214
- } else {
1215
- animal.fly();
1216
- }
1217
- }
1113
+ ```typescript
1114
+ // Prefer
1115
+ screen.getByRole("button", { name: /submit/i });
1116
+ screen.getByLabelText("Email");
1218
1117
 
1219
- // instanceof
1220
- function handleError(error: unknown) {
1221
- if (error instanceof Error) {
1222
- console.log(error.message);
1223
- }
1224
- }
1118
+ // ❌ Avoid
1119
+ screen.getByTestId("submit-button");
1225
1120
  ```
1226
1121
 
1227
- ## Common Patterns
1228
-
1229
- ```typescript
1230
- // Optional chaining
1231
- const userName = user?.profile?.name;
1122
+ ## MSW Handlers
1232
1123
 
1233
- // Nullish coalescing
1234
- const displayName = userName ?? "Anonymous";
1124
+ Export factory functions, not static handlers:
1235
1125
 
1236
- // Non-null assertion (use sparingly)
1237
- const user = getUser()!;
1126
+ ```typescript
1127
+ // Good—flexible per test
1128
+ export const createUserHandler = (userData: User) =>
1129
+ rest.get("/api/user", (req, res, ctx) => res(ctx.json(userData)));
1238
1130
 
1239
- // Template literal types
1240
- type Route = `/users/${string}` | `/posts/${string}`;
1241
- type EventHandler = `on${Capitalize<string>}`;
1131
+ // Usage
1132
+ mockServer.use(createUserHandler(customData));
1242
1133
  ```
1243
1134
 
1244
- ## Best Practices
1135
+ ## What to Test
1245
1136
 
1246
- - **Never use `any`** - Use `unknown` or generics
1247
- - **Discriminated unions** for state machines
1248
- - **Zod** for runtime validation
1249
- - **Type guards** over type assertions
1250
- - **Const assertions** for readonly values
1251
- - **Utility types** to transform existing types
1137
+ Test: user interactions, all states (loading/success/error), integration between components
1138
+ Don't test: implementation details, third-party libraries, styles