Table of contents
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-numberswithignore: [0, 1, -1]andenforceConst: true. Those three are the ones that mean what they are — an empty count, a single step, the previous element.no-restricted-syntaxrefuses a bare string literal in a comparison or aswitchcase. 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 takesn(…)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.cssresolves 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-paddingand the 44px--of-touch-target. A stylesheet writingvar(--border-radius, 8px)again is how the two--border-radius-pillfallbacks came to disagree.styles/layout.cssholds 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">byvite.config.tsfromservices/breakpoints.ts. A@mediarule 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 needslang="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.