Conventions for writing and reviewing DailyTM v2. Structural rules — layers, imports, authorization — live in ARCHITECTURE.md. This document covers everything below that line: naming, types, components, queries, tests, and commits.
The guiding principle: a buyer should be able to open any file in this repository and find it written the same way as the last one they opened. Consistency here is a product feature, not developer preference.
Load the skill before you write the code
Vendored skills live in .agents/skills/ (symlinked into .claude/skills/). Load the relevant one before starting — the rules in this document are the distillation, not the whole reference.
| Working on | Load |
|---|---|
| Organizations, members, roles, invitations | organization-best-practices |
| Auth config, sessions, cookies, rate limits, trusted origins | better-auth-security-best-practices |
| Any Better Auth setup or CLI question | better-auth-best-practices |
| Email/password, verification, reset flows | email-and-password-best-practices |
| 2FA / TOTP / backup codes | two-factor-authentication-best-practices |
| Schema, indexes, query plans, migration safety | postgres-best-practices (+ its references/) |
| tRPC + TanStack Query patterns | pnpm dlx @tanstack/intent@latest load "@trpc/tanstack-react-query#react-query-setup" |
| UI, layout, styling, design systems | ui-ux-pro-max |
pnpm dlx @tanstack/intent@latest list shows every intent skill available locally.
npxcannot run in this repo — npm's arborist crashes on pnpm's symlinkednode_modules. Usepnpm dlxfor every one of these commands.
Naming
| Thing | Convention | Example |
|---|---|---|
| Files | kebab-case | task-details-sheet.tsx |
| Service files | <resource>.service.ts |
project.service.ts |
| Router files | <resource>.router.ts |
project.router.ts |
| Test files | <subject>.test.ts / <subject>.authz.test.ts |
project.authz.test.ts |
| React components | PascalCase, one per file | TaskDetailsSheet |
| Hooks | use prefix, camelCase |
useActiveOrg |
| Booleans | is / has / can prefix |
isArchived, canDelete |
| Enums and their members | PascalCase type, SCREAMING_SNAKE members | StatusCategory.ACTIVE |
| Zod input schemas | <verb><Resource>Input |
createProjectInput |
| Database columns | camelCase in Prisma, mapped where Better Auth requires | organizationId |
Do not abbreviate. organizationId, not orgId, in schema and service signatures — ctx.orgId is the one deliberate exception because it appears in nearly every query.
Never name a file index.ts except for a barrel that re-exports a directory. Files named utils.ts, helpers.ts, or misc.ts accumulate unrelated code; name the file after what it does.
TypeScript
strict is on and stays on.
- No
any. Useunknownand narrow it. If a third-party type is wrong, write the narrowing in one place and export it. - No non-null assertions (
!). If a value can be null, handle it.ctx.orgIdis non-null because the procedure proved it, and its type says so. - No type assertions (
as) except at genuine boundaries — parsed JSON,process.env. Neverasyour way out of a type error. - Infer, do not restate. Derive types from Zod schemas (
z.infer<typeof createProjectInput>) and from Prisma, rather than hand-writing parallel interfaces that drift. v1 kept autils/types.tsof hand-written shapes mirroring Prisma models; they went stale. typefor object shapes,interfaceonly when declaration merging is needed.- Environment variables are read in exactly one module, validated once at startup, and imported from there.
process.env.FOOscattered through the codebase is how a missing variable becomes a runtime crash in production.
React and components
Visual direction lives in docs/references/ — annotated screenshots with what to take from each and what they show that is out of scope. Inspiration, never a checklist: our design tokens win every conflict, and a reference ignoring contrast or keyboard access does not excuse us from either.
- Server Components by default. Add
"use client"only when the file needs state, effects, or event handlers. Push the directive as far down the tree as it will go — a client boundary at the page level ships the whole page to the browser. - One component per file, named the same as the file.
- Props are a named type declared directly above the component. No inline object types longer than two properties.
- No prop drilling past two levels. Use composition (
children, slots) or a context. - Derive, do not sync. No
useEffectwhose only job is to copy one piece of state into another. If it can be computed during render, compute it. useEffectneeds a comment saying which external system it synchronizes with. Effects without an external system are usually a bug.- shadcn primitives in
components/ui/are generated. Do not hand-edit them; if a variant is needed, add it through the component'scvaconfig or wrap it in a feature component.
Every interactive surface ships five states
Empty, loading, error, success, and permission-denied. A component that only handles the happy path is not finished. Analytics and counters additionally handle zero — v1 divided by zero totals and rendered NaN charts.
Accessibility is part of done
Keyboard reachable, visible focus ring, labelled controls, contrast met, motion respecting prefers-reduced-motion, and a working layout at 320px wide. Not a later audit — part of the same change.
Data fetching
- Server Components prefetch; Client Components subscribe. The RSC calls
prefetch(trpc.x.queryOptions(input))and wraps its children in<HydrateClient>; the client callsuseQuery(trpc.x.queryOptions(input))with the same input. Mismatched input means a different query key, and the page silently refetches what it already had. See ARCHITECTURE.md. - Never await
prefetch. It is fire-and-forget; awaiting serializes prefetches. - Always use the options factory.
trpc.x.queryOptions(input), never a hand-built{ queryKey, queryFn }— that loses type-safe keys and breaks hydration matching. - Invalidate with tRPC filters:
queryClient.invalidateQueries(trpc.task.pathFilter())for a whole router,trpc.task.list.queryFilter(input)for one query. Never a hand-written key array. - Data that came from an RSC
prefetchis refreshed withrouter.refresh(). Invalidating auseSuspenseQuerydeadlocks — see ARCHITECTURE.md. - Never format a date with the runtime's locale.
toLocaleDateString(undefined, …)is a hydration bug. UseformatDatefromsrc/lib/format.ts. - Conditional queries use
skipToken, not aenabled: falsedance. - Mutations that change a visible list are optimistic: snapshot with
getQueryData(trpc.x.queryKey(input)), write withsetQueryData, restore the snapshot inonError,invalidateQueriesinonSettled, and show a toast that says what failed. A board that silently snaps back destroys trust. - Pagination over unbounded lists. Any query that can return more than ~100 rows takes a cursor and is consumed through
infiniteQueryOptions+getNextPageParam. Tasks, comments, activity, and notifications all qualify. superjsonis the tRPC transformer, soDatecrosses the wire asDate. Do not serialize dates to strings by hand.
Prisma and Postgres
Column and type conventions are decided in ARCHITECTURE.md — the short version, because these are the ones people forget:
model Task {
id String @id @default(uuid(7))
organizationId String @map("organization_id")
dueDate DateTime? @map("due_date") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@index([organizationId, projectId, rank])
@@map("task")
}
@db.Timestamptz(3)on everyDateTime. Prisma's Postgres default drops the timezone, and this app is full of due dates and timers.@default(uuid(7))on DailyTM ids. Not v4 — it fragments the index. Better Auth models keep their own generator.@map/@@mapsnake_case on every model and column, soselect * from taskworks without quoting.- Every foreign key column gets an index; Postgres does not add one.
- Enums for small stable sets (status, priority). A lookup table only when the set needs metadata.
Then:
- Every tenant-owned model carries
organizationIdand an index that leads with it. - Every query in a service filters by
ctx.orgId. See ARCHITECTURE.md —findUniqueis banned in services. selectoverinclude. Return the fields the caller needs.includegrows silently as the schema grows and leaks columns into API responses.- No N+1. If you are looping over rows and querying inside the loop, restructure into one query. The seed script and the subtask/label rollups on a board query are the usual offenders.
- Multi-write operations use
$transaction. Creating a task and its activity event is one transaction, not two writes and a hope. - Migrations are committed.
db pushis for local iteration only; every schema change that reachesmainhas a migration file, because buyers run migrations against real data. Partial indexes and CHECK constraints cannot be expressed in the Prisma schema — hand-write them into the migration SQL, and never rely ondb pushto produce them. - Deletes that own external objects (attachments) go through the service that also deletes the object. Never delete such a row directly.
tRPC
- Procedures are named for what they do:
list,byId,create,update,archive,delete,reorder. NotgetProjectsinsideprojectRouter— the router already says "project". - Every input has a Zod schema, exported from the router file so tests and forms reuse it.
- Every mutation that changes shared state writes an
ActivityEventin the same transaction. - Throw
TRPCErrorwith the codes in ARCHITECTURE.md. Never return{ error: "..." }.
Errors and logging
- Fail loudly at boundaries, gracefully in the UI. A user sees "Couldn't save that task", not a stack trace, and never a silent no-op.
- No
console.login committed code. Deliberate diagnostics useconsole.errororconsole.warnwith context. - Never log secrets, tokens, presigned URLs, or full request bodies.
- An empty
catch {}is a bug. Handle it or let it propagate.
Testing
- Every service gets an
.authz.test.tsproving cross-organization denial for read and for write. This suite is the release gate; a service without one is not merged. - Unit tests target services, not routers. Services are pure functions of
(ctx, input), which is the entire reason for that rule. - Test behaviour, not implementation. Assert on what a caller observes.
- Name tests as sentences:
it("returns NOT_FOUND for a project in another organization"). - No shared mutable fixtures between tests. Each test creates what it needs.
- Playwright covers one full happy path end to end. It is not where edge cases are tested.
Comments
Comment why, never what. Code says what it does; a comment explains the decision that is not visible.
// wrong
// loop through the tasks
for (const task of tasks) { … }
// right
// Ranks are recomputed client-side before the request so the board does not
// wait a round trip; the server re-derives them and wins on conflict.
Every deliberate simplification with a known ceiling gets a comment naming the ceiling and the upgrade path. Delete commented-out code — git remembers it.
Formatting
Prettier and ESLint decide. Do not argue with them in review, and do not commit a formatting-only diff alongside a behaviour change.
pnpm lint # eslint
pnpm lint:fix # eslint --fix
pnpm format # prettier --write
pnpm typecheck # tsc --noEmit
pnpm test # vitest
Commits and branches
- Branches:
feat/<slug>,fix/<slug>,chore/<slug>, short-lived, merged intov2-build. - Conventional commits:
feat(task): add board reordering. The scope is the resource. - One logical change per commit. A commit that touches the schema, the service, and the UI for one feature is one change. A commit that also reformats three unrelated files is two.
- Never commit
.env,.env.local, real credentials, or the contents ofsrc/generated/.
Definition of done
A change is done when every line is true:
-
pnpm lint && pnpm typecheck && pnpm test && pnpm buildall pass - The service has an authz test proving cross-organization denial
- Every query is scoped by
ctx.orgId - Empty, loading, error, and permission-denied states exist
- Keyboard navigable, focus visible, works at 320px
- No
any, no!, noconsole.log, no commented-out code -
.env.exampleupdated if a variable was added, and the variable is optional or the app still boots without it - Any decision worth remembering is written down, not left in a commit message