← alpinetime

AlpineTime: frontend — Vue 3, composables, i18n from day one

Stack: Vue 3, TypeScript, Vite, PrimeVue 4, Pinia. Nothing exotic. The interesting decisions were about structure and error handling, not the framework.

i18n from the first component

de-CH primary, English secondary. Locale files structured by module: common.json, time.json, auth.json, admin.json. Every user-facing string goes through t('key') — no hardcoded text anywhere in the templates.

The reason this mattered early: retrofitting i18n into an existing app means hunting through every component for strings. Doing it from the start means it’s just how the app works. The upfront cost is real — you’re writing translation keys before you’ve finished the feature — but it’s cheaper than the alternative.

Error codes, not messages

The backend returns typed error codes (AUTH_FAILED, OVERLAP_DETECTED, NOT_FOUND), not human-readable strings. The frontend maps those codes to i18n keys in apiError.ts. This means the backend has no opinion about what language the error appears in, and the frontend has one place where error handling lives instead of thirteen.

Thirteen page-level catch blocks, all going through the same mapper. When I added a new error code, I changed one file.

Silent token refresh

Access tokens live in memory only — never localStorage, never sessionStorage. The refresh token lives in a httpOnly secure cookie; the browser handles it. When a request returns 401 with AUTH_EXPIRED, the axios interceptor silently requests a new access token and retries the original request. The user sees nothing. When a request returns 401 with AUTH_FAILED, the session is genuinely dead — clear the store, redirect to login.

The interceptor in client.ts also injects X-Request-ID on every request, which the backend logs. Useful when something goes wrong and I need to correlate a frontend error with a backend log line.

Composables keeping pages thin

Pages have a hard limit of 100 lines. They orchestrate — they call composables, wire up templates, handle routing. The logic lives elsewhere. useDashboard handles stat computation, timer controls, and employee selection. useClientHierarchy manages the client → project → task cascade. useEmployeeTimeReport owns report data loading and filtering.

This is enforced by architecture convention, not by a linter rule. But the limit is real and it shapes how things get built. When a page starts getting long, you extract. It’s a useful pressure.

Overlap modal

When the backend returns an OVERLAP_DETECTED error, it includes a conflicts array — each conflicting entry with its time range and description. The frontend opens a modal showing both sides and asks: merge or replace?

The modal is the frontend expression of the backend design decision. If the backend had returned a bare 409, the frontend couldn’t show this. Because the backend returns structured conflict data, the frontend can surface something useful. The two parts have to agree on what “overlap” means before either one is written.