2 Code guidelines
Claude Agent edited this page 2026-09-07 09:17:13 +01:00

Code guidelines

Four principles, the rules that make each of them a build failure rather than an intention, and the conventions this codebase has settled on. Written in English because contributors read it — see the language rule in AGENTS.md.

This file grew out of a repository-wide refactor, done in seven phases and now complete; the plan document that drove it is gone, per its own closing rule, and this file is what outlived it.


P1 — Single responsibility

One component, service or model serves one purpose. A file that does two things is split, whatever its size.

Rule Budget Applies to
max-lines 200 .ts
max-lines 250 .vue
max-lines-per-function 50 everywhere but tests
complexity 10 everywhere
max-depth 3 everywhere
max-params 4 everywhere

All count neither blank lines nor comments, so the budget measures code.

A budget is a symptom, not the rule. Splitting a 260-line view into a 130-line view and a 130-line half named Part2 satisfies the linter and nothing else. The question is what the second thing is called: if it has no name, the file was not doing two things and the fix is elsewhere.

Where the pieces go. A backend route file holds routing — authorisation goes to a service, serialisation to presenters/, validation to the route schema. A view holds layout and the wiring between stores and components; state logic with a name goes to a composable, and anything pure goes to services/.

P2 — Comments are rare, and say why

The code explains itself through naming and intermediate constants. What a comment used to carry now lives somewhere a reader can find it without opening the file:

What it says Where it belongs now
Paraphrases the code below it Nowhere. Rename something, or extract a named constant.
A Nextcloud / AppAPI / Circles / CalDAV / iCalendar constraint .claude/skills/nextcloud-exapp-dev/references/
A design decision with alternatives weighed Architecture, as a new D… or folded into the one it belongs to
Interface rationale UX
Why a test exists, or what it guards The test's own name
Why the code is the way it is, and no name can carry it It stays, rewritten to open with Why:

local/no-comments reports every comment that is not a directive (eslint-disable, @ts-expect-error, SPDX, licence headers) and does not open with Why: — in .vue templates as well as in scripts. no-inline-comments bans trailing ones outright.

It is an error everywhere the migration is done — which is everywhere. ex_app/src, ex_app/lib and e2e all report zero, so a regression in any of them fails the build.

A multi-line Why: is one block comment, not several // lines. The rule reads comment nodes, and consecutive // lines are separate nodes — only the first would carry the Why:. Use /* … */ (or /** … */) as soon as the reason needs a second line.

Why: is the narrow case, not an escape hatch. Reach for it only once a better name and an intermediate constant have both failed. It earns its place when the reason is a measurement, an upstream quirk with no home in the references, or a piece of history that explains why the obvious implementation is wrong:

// Why: unchecking clears the trace — otherwise the row still reads "checked by Alice".

A Why: that could be a name is still a comment to delete.

A JSDoc block is a comment. Type information belongs in the types; strict plus explicit signatures says more than a @param restating the parameter's name. The jsdoc/ preset rules are turned off for exactly this reason.

P3 — No inline literals

Every value is a named constant that says what it represents.

  • no-magic-numbers with ignore: [0, 1, -1] and enforceConst: true. Those three are the ones that mean what they are — an empty count, a single step, the previous element.
  • no-restricted-syntax refuses a bare string literal in a comparison or a switch case. A string being compared is a value with meaning, and the meaning belongs in its name.

The name is the point, not the indirection. const THIRTY = 30 is worse than the literal; const REFUSAL_TTL_MS = 60_000 is the rule working.

Conventions the constants follow:

  • A duration carries its unit: _MS, _DAYS, _HOUR. TTL_MS, POLL_INTERVAL_MS, KEEP_DAYS, EVENING_UTC_HOUR.
  • A bound says which side it bounds: MAX_PHOTO_BYTES, MAX_PER_FAMILY, NARROW_SCREEN_MAX_WIDTH.
  • A fallback says it is one: DEFAULT_WINDOW_DAYS, DEFAULT_FAMILY_COLOR.

Tests are exempt from both rules: an assertion is about a literal, and naming the expected value after the code that produces it makes the test agree with the implementation instead of checking it.

P3b — A sentence a member reads is a source, not a string

Every user-facing sentence is written in English inside t(DOMAIN, '…'), or n(DOMAIN, '…', '…', count) when it carries a count, and translated through the catalogue (D38). Four rules, each of which was a defect before it was a rule:

  • Never assemble a sentence. `${count} ${noun}` cannot be reordered by a translation and has nothing to agree with. A count takes n(…) with a complete sentence per form — French agrees at 2 where English agrees at 1 — and "(s)" is not something anyone reads. The remaining-items line carries both its numbers in one source for the same reason.
  • What a family typed is a parameter. Names of families, lists, items, recipes and calendars go in the parameters, never into a source: a catalogue holding one would translate their own words back at them.
  • Nothing user-facing is evaluated at import time. The catalogue registers after this bundle runs, so a label held in a module-level constant must be a getter or a function.
  • The backend describes, the boundary renders. A refusal carries a source and its parameters from where it is raised to the HTTP handler, which is the first place that knows who is asking. Two members can be mid-request in two languages at once, so there is no "current language" anywhere.

make l10n-check refuses a source with no French, an empty or fuzzy entry, a counted message missing a form, and a translation that lost a placeholder.

P4 — Utility services

Repeated logic becomes a named module, imported rather than re-implemented.

The trigger is the second site, not the third: an authorisation primitive, a date computation or a formatter existing twice is two things that will drift, and the copy that drifts is the one nobody looks at. services/access.ts and services/week.ts both exist because a second feature needed what a first one had inlined.

no-duplicate-imports keeps a module's imports in one statement. no-restricted-imports refuses parent-relative paths on the frontend, which the path aliases replace.


Conventions

Naming

Enumerations are frozen objects, not TypeScript enums. The value, the type and the list all come from one declaration:

export const ListKind = {
  SHOPPING: 'shopping',
  TASKS: 'tasks',
} as const
export type ListKind = (typeof ListKind)[keyof typeof ListKind]

Function prefixes carry the contract, and are used consistently:

Prefix Means Fails by
find… a database read returning null
create… / update… / delete… a database write throwing
…Context resolves a resource and checks access throwing NotFound
require… returns the thing or refuses throwing
present… turns an internal shape into the API's —
is… / has… / can… a boolean —

A boolean is named for the state, not the question: unreadable, hasOverrides, loaded, ready, isMe. A field named error holding a message and a field named failed holding a boolean are different fields.

Files are kebab-case (notification-prefs.ts, service-account.ts, appapi-auth.ts), except .vue components, which are PascalCase and match their exported name.

Language follows the audience, not the file. English identifiers, comments, tests and commit messages; French for anything reaching a screen — UI strings, Zod messages, API message fields and warnings. The trap is the backend, where a message looks like plumbing and is displayed verbatim. See AGENTS.md.

contracts/

ex_app/lib/src/contracts/ holds the types that cross the wire — Family, List, ListItem, Recipe, MealPlan, CalendarEvent, Recurrence and the snapshots a mutation answers with. It is the one directory in the backend that owns no behaviour: no imports of its own beyond its siblings, no database access, no Nextcloud call.

That is the point. db/ and services/ import from it rather than declaring these types themselves, so a module can be split or rewritten without moving the shapes its callers depend on. The frontend maps @contracts/* onto it and imports type-only, so nothing is emitted into the bundle and a field renamed on the server breaks the frontend build instead of breaking a screen at runtime.

The rule that keeps it honest: a type belongs here only if both sides need it. A …Input, a …Patch or an internal read shape stays with the module that consumes it.

The domain type and the wire type are not always the same, and the difference is not guessable. A presenter may add a field the database has no column for, nest one the domain keeps flat, or drop one nobody reads: present() in routes/families.ts adds memberCount and nests the calendar under resources, presentSnapshot flattens ListSnapshot and drops items[].createdAt, presentRecipe sends hasPhoto where the domain has photoPath. Typing a client against the domain type in those three cases produces code that compiles and is wrong.

So: every presenter declares its return type. Where the wire shape equals the domain type, annotate with that type and add nothing. Where it diverges, the wire shape gets its own named …Payload interface in the same file, beside the domain type, so the two are read together and a drift between them is a compile error rather than a screen that goes blank. The routes that return a service result raw — the two "send to a shopping list" ones — are the exception that proves it: no presenter runs, so the client type must mirror the service's shape, nesting included.

The frontend imports from here type-only, and the linter enforces it as an error, not a warning. ex_app/lib is not in the frontend's Docker build context — frontend-builder only copies ex_app/src — so a value import from @contracts/* builds green on a developer's machine and fails at deploy time. That asymmetry is why the rule cannot be a warning. When the frontend needs the value and not the type, it declares its own and types it against the contract: export const KIND_SHOPPING: ListKind = 'shopping' still breaks the build if the server renames it.

Dates

A day is a string, an instant is a string, and they are not the same type of thing. A due date, a meal date and an all-day event's start are YYYY-MM-DD: a day has no time, so there is no timezone to settle.

Never hand a bare YYYY-MM-DD to Date and read the day back off it. It becomes a local instant, and west of Greenwich the day moves — the grid then labels Monday's column Sunday. Either parse the string by hand, or force UTC explicitly (new Date(\${day}T00:00:00Z`)withgetUTC…`). Both are used here; the choice is per call site, the rule is not.

Tests that touch dates run outside UTC as well as in it. A suite running in UTC — which is what a CI runner defaults to — cannot see this class of bug at all.

On the frontend the choice is made once, in services/day.ts and services/format.ts. Bare days are parsed with utcDay and read back with toIsoDay; formatDay forces timeZone: 'UTC' so a day never renders as the day before. Anything asking when relative to the reader — today, tomorrow, overdue — belongs in services/relative-day.ts instead, which reads the local clock on purpose: "today" is a question about the reader's wall clock. The two are not interchangeable, and having them in separate modules is what keeps a call site from picking the wrong one by accident.

Errors and status codes

404 and never 403. A refusal that confirms the resource exists lets someone enumerate another family's content one identifier at a time. NotFound is the only access error the API raises.

An upstream status is not this app's status. A 429 stays a 429, an upstream 401/403 becomes a 403, an upstream 4xx becomes a 400 and an upstream 5xx becomes a 502 — flattening them all into 400 once turned "too many calendars created" into an apparent input error.

A message reaches the screen, in French. Anything a handler or a validator produces is read by a user, so it says what happened and what to do, not which layer failed.

Stores and the server

The server is authoritative on client state: every mutation returns the complete snapshot and the store replaces it. No store branches on an HTTP status code. The full reasoning, including the generation counters that stop a stale answer landing, is D26 in Architecture.

Vue components

vue/component-api-style allows <script setup> only, and vue/block-order fixes the order script, template, style. One API and one shape across the twenty SFCs, so that reading the twenty-first needs no adjustment.

CSS class names the end-to-end suite selects on are not renamed. .home-entry* belongs to the family home, .item* to the lists, .tabs__name to the list name: a shared prefix makes a broad selector match an adjacent feature. Splitting a component moves its styles; it does not rename them.

A component whose root carries a class styled by its parent works, and is used here. Vue puts the parent's scope id on a child component's root element, so <HomeCard class="home-card--events"> is styled from HomeEventsCard. Slot content keeps the parent's scope, though — a rule for something passed into a slot has to live in the component that passes it, or use :slotted().

Styles

Three files, and an SFC never restates what they hold:

  • styles/tokens.css resolves each Nextcloud variable once, with its fallback: --of-radius, --of-radius-large, --of-radius-pill, --of-radius-circle, the fluid gutters (--of-gutter*), the elevation shadows (--of-shadow-*), --of-view-padding and the 44px --of-touch-target. A stylesheet writing var(--border-radius, 8px) again is how the two --border-radius-pill fallbacks came to disagree.
  • styles/layout.css holds the page shell (.of-view, .of-view--scrolling) and the centred loader (.of-loading, .of-loading--roomy), which every view had written out identically.
  • The narrow-screen breakpoint is a Sass variable, $narrow-screen-max-width, injected into every <style lang="scss"> by vite.config.ts from services/breakpoints.ts. A @media rule cannot read a CSS custom property, so this is the only mechanism that keeps the JS half and the stylesheets on one number. An SFC that branches on the width needs lang="scss"; one that does not should stay plain CSS.

There is no spacing scale, deliberately: the px values in use do not form one, and putting them on a scale would move pixels.