Always apply these standards to all code you write.
Run all source processes from the repository root. docker compose is reserved for self-hosted packaging only.
| What | Command |
|---|---|
| Frontend | vp run website#dev |
| Landing | vp run landing#dev |
| Backend | air |
| All E2E tests | vp run test:e2e:website |
| Real-runtime E2E | vp run test:e2e:website:real-runtime |
| Single E2E file | vp run test:e2e:website -- e2e/<file>.spec.ts |
Rules:
- All JS/TS toolchain commands go through
vp. Never invokenode,vitest,vite,playwright,pnpm,npm, oryarndirectly. - Env vars live at repo root:
.env.local(runtime) and.env.example(committed template). - Backend must require explicit datasource config from env; fail immediately if missing. No silent fallback to in-memory stores.
- PostgreSQL schema via
gooseversioned migrations only. Migrations live inapps/backend/internal/platform/migrate/migrations/. Current schema snapshot inapps/backend/db/schema/latest.sql. Seedocs/schema-migrate/PLAN-1.mdfor rules. - Backend hot reload config lives in root
.air.toml. - New entrypoints go in root
package.json,vp, or a Go CLI — not inscripts/*.sh,apps/**/scripts/*.mjs, or wrapper CLIs. apps/landingis part of the monorepo workspace. It uses"catalog:"references like other workspace packages.
Worktrees are isolated branches that do not auto-merge into main.
- Review and explicitly merge/cherry-pick worktree commits before deleting. Verify with
git statusandgit rev-list --left-right --countfirst. - Delete stale worktrees (only behind
main, no unique work). Keep.claude/worktrees/ephemeral. - Each worktree needs its own
.env.localwith uniquePORT,OPENTOGGL_WEB_PROXY_TARGET, and frontend port (vp run website#dev -- --port <N>).
This repo is implemented from docs/ and openapi/.
- Implementation must match
docs/andopenapi/definitions exactly — not "close enough". - Read relevant docs before making changes. If docs define the behavior, implement that, not a local interpretation.
- If implementation and docs differ, fix the implementation. Never edit docs to match drifted code.
- Doc clarifications must be additive. Changing a documented rule is an explicit change request, not an implementation convenience.
- Code, dependencies, module boundaries, naming, and API contracts that differ from docs must be fixed in code.
- Structure fixes take priority over feature expansion.
Files in openapi/ fall into two categories:
- Upstream (read-only):
toggl-track-api-v9.swagger.json,toggl-reports-v3.swagger.json,toggl-webhooks-v1.swagger.json. These are external Toggl API specs we must stay compatible with. Never modify these files. Work around their quirks in code (e.g. skip validator, adapter layer). - Ours (editable):
opentoggl-*.openapi.json. These are our own API definitions. Edit these when adding or changing OpenTickly-specific endpoints.
- Prefer OpenAPI-generated structs for HTTP request/response bodies. If no generated type exists, update the OpenAPI doc first, regenerate, then use it.
- No handwritten
map[string]any,map[any]any, ormap[string]interface{}in production Go code (domain,application,infra,bootstrap,transport). Exceptions: generated code and inherently dictionary-shaped external boundaries. - Keep maps at the narrowest adapter boundary; convert to strong types immediately.
- Tests also prefer explicit structs over maps.
- One canonical X: one name, one implementation, one entrypoint per concept. No aliases, no parallel paths, no duplicates.
- One responsibility: a unit does one job. Split if it does multiple unrelated jobs.
- Best-practice only: keep the strongest pattern, remove weaker transitional variants.
- No placeholder normalization: track transitional paths as debt with exit conditions.
- Use
lo.ToPtr/lo.FromPtr/lo.FromPtrOrfromgithub.qkg1.top/samber/lofor pointer helpers.
- Search for similar functionality before implementing.
- Extend if 80% match exists.
- Extract to shared module instead of copy-pasting.
A keystroke must re-render exactly the field being typed into. If typing in a dialog input re-renders a sibling chart, list, or the app shell, that is a bug — fix it structurally, do not paper it over with React.memo / useMemo / useCallback (React Compiler owns memoization in this repo).
A dialog or popover that owns its own ephemeral inputs (text fields, selected color, toggle, step index) must keep that state inside itself. Do not make the parent page hold [name, setName], [email, setEmail], [selectedColor, setColor] etc. just to hand them back through controlled props. Every keystroke setting state on the parent re-renders every sibling it paints — charts (Recharts), tables, sidebars, the shell.
// ❌ Wrong — page owns transient dialog state; every keystroke re-renders the page
function Page() {
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
return (
<>
<ExpensiveChart ... />
{open && (
<InviteDialog
email={email} role={role}
onEmailChange={setEmail} onRoleChange={setRole}
onSubmit={() => mutate({ email, role })}
/>
)}
</>
);
}
// ✅ Correct — dialog owns its fields; parent only holds `open` and receives final values
function Page() {
const [open, setOpen] = useState(false);
return (
<>
<ExpensiveChart ... />
{open && (
<InviteDialog
onClose={() => setOpen(false)}
onSubmit={(values) => mutate(values)}
/>
)}
</>
);
}Rule of thumb: a prop is "transient form state" if it only matters while the dialog is open and is thrown away on close. Those live inside the dialog. Props the parent actually needs to react to (e.g. selected item id that drives a detail pane) stay lifted.
If the parent genuinely needs to observe a field mid-edit (e.g. live preview), lift only that one field — not the whole form — and isolate the preview in its own component so the rest of the page is unaffected.
For components whose re-render cost is visible (Recharts, large virtualized lists, the app shell), add a render-count regression test using a simple ref counter (see shared/test/useRenderCount.ts). Do not use <Profiler> for equality assertions — its subtree/phase semantics make counts flaky. Reserve Profiler for performance investigation, not regression tests.
Keep files under 300 lines. Split by responsibility, extract sub-components, separate logic from presentation, group by feature.
- Clear code over comments. Block comments at method top when needed.
- Conventional Commit format.
TDD required for: new product functionality, bug fixes, behavior changes. TDD not required for: docs-only, config/infra cleanup, mechanical renames, generated-code refreshes, structural refactors without behavior change.
Non-TDD work still requires proportional verification:
- Run unit tests, E2E tests, type checks, lint checks.
- Smoke test with Playwright; take screenshots to verify UI.
- Infra/config changes: verify with startup checks and runtime evidence.
- Schema changes: add a new goose migration + update
latest.sql, verify withairstartup and tests. - Docs-only changes need consistency checks, not
vp check/vp test.
A task is complete only when it passes tests and matches docs//openapi/ definitions.
Tests must only wait for state signals, never for time. The single rule: if a default 5s assertion timeout isn't enough, the test is waiting for the wrong thing.
Bumping a timeout to make a flaky test pass hides a real bug. When an assertion needs more than the default 5s:
- Diagnose: the app is making the user wait for something that should already be settled (e.g. language flash, stale cache, unblocked render).
- Fix the app: block rendering until the prerequisite state is ready (e.g.
LanguageSyncblocks children until i18n is applied, not fire-and-forget in auseEffect). - Then the test passes at default timeout with no hack.
{ timeout: 10000 } on a locator assertion is a code smell, not a solution.
page.waitForTimeout() is banned in E2E tests. It is both slow (wastes CI time) and flaky (too short on slow runners).
- Only exception: DnD tests that simulate mouse step timing (
waitForTimeout(50)). - Replace with: auto-retry assertions (
expect(locator).toBeVisible(),.toHaveText(),.toHaveURL()).
After page.goto() or page.reload(), always wait for the page-level container testid before any interaction or assertion:
// ✅ Correct — wait for page content to be ready
await page.goto("/overview");
await expect(page.getByTestId("workspace-overview-page")).toBeVisible();
await page.getByRole("link", { name: "Timer" }).click();
// ❌ Wrong — visible ≠ clickable when Suspense/loading overlays exist
await page.goto("/overview");
await expect(page.getByRole("link", { name: "Timer" })).toBeVisible();
await page.getByRole("link", { name: "Timer" }).click();After creating data via page.evaluate(fetch(...)), wait for the UI to reflect it before proceeding:
await createTimeEntryForWorkspace(page, {...});
await page.goto("/timer");
await expect(page.getByText("My Entry")).toBeVisible(); // wait for dataSet actionTimeout: 5_000 so a stuck .click() fails in 5s with a precise locator error, instead of burning the full 30s test timeout with an unhelpful "test timeout exceeded" message. This makes tests faster to fail, not slower to pass.
When user feedback or a code review points to a recurring code-quality issue, do not fix only the current instance first. Create or update a harness-lint rule that captures the issue, run the lint so it reports the problem, and then change the code until the lint passes.
For code-related fixes, use this workflow:
- Convert the feedback into a rule with
harness-lint rule suggest "<feedback>", or update an existing rule underRules/. - If the rule should trigger a specific Codex skill, add
skill: <skill-name>to the rule frontmatter. - Run
harness-lint check --changedand confirm the lint identifies the issue. - Fix the code.
- Run
harness-lint check --changedagain before finishing.