---
title: 'How-to Guides'
description: 'Step-by-step recipes for common contributor tasks: languages, themes, deployment, migrations.'
slug: 'how-to-guides'
weight: 2
date: 2026-06-16
---

## Add a New Language

> TrainUs already ships three languages today — English, French, and Danish (`da`) — not just the EN/FR pair the project website emphasizes. The steps below walk through adding one more, using Spanish (`es`) as the worked example.

Adding a language requires four touch points: a new locale file, wiring it into the i18n initializer, a selector option in Settings, and a decision-log entry.

1. `src/i18n/locales/<code>.json` — create the translation file
2. `src/i18n/index.js` — import and register the locale
3. `src/views/SettingsView.vue` — add the `<option>` in the language selector
4. `docs/decisions-log.md` — record the decision

### 1. Create the translation file

Copy `src/i18n/locales/en.json` to `src/i18n/locales/<code>.json`, where `<code>` is the two-letter BCP 47 language tag (e.g. `es`, `de`, `it`).

Translate every string value. Do **not** change any key name. Keep all interpolation placeholders (`{{date}}`, `{{title}}`, etc.) and HTML entities exactly as they appear in the source.

### 2. Register the locale in i18next

Open `src/i18n/index.js`. Add an import at the top alongside the existing ones:

```js
import es from './locales/es.json'
```

Then add the locale to the `resources` object inside `i18next.init`:

```js
resources: {
  en: { translation: en },
  fr: { translation: fr },
  da: { translation: da },
  es: { translation: es },   // ← new
},
```

### 3. Add the option in the Settings selector

Open `src/views/SettingsView.vue` and locate the `<select>` element with `id="settings-language"`. Add one `<option>` after the existing ones:

```html
<option value="es">Español</option>
```

Use the language's native name as the label (not the English name), so speakers can identify it without reading English.

### 4. Update the decision log

Add an entry to `docs/decisions-log.md` recording the addition and why.

### Checklist

- [ ] `src/i18n/locales/<code>.json` created and fully translated
- [ ] Import added in `src/i18n/index.js`
- [ ] `<option>` added in `src/views/SettingsView.vue`
- [ ] Language can be selected in Settings and the UI switches correctly
- [ ] `docs/decisions-log.md` updated

---

## Add a New Theme

A theme is a named set of CSS variable overrides scoped to a `[data-theme='<name>']` attribute. The app reads the active theme from the database and sets that attribute on `<html>` before the first render.

1. `src/styles/variables.css` — define the CSS variable block
2. `src/i18n/locales/{en,fr,da}.json` — add the display label, in every supported language
3. `src/views/SettingsView.vue` — add the `<option>` in the theme selector
4. Manual smoke-test in the browser
5. `docs/decisions-log.md` — record the decision

### 1. Define the CSS variables

Open `src/styles/variables.css` and append a new block at the end. Copy the `:root` block as a starting point and change only the values that differ from the default light theme.

```css
/* My theme */
[data-theme='<name>'] {
  --bg-color1: ...;
  --bg-color2: ...;
  --front-color1: ...;
  --front-color2: ...;
  --navbar-bg-color: ...;
  --navbar-front-color: ...;
  --navbar-hover-bg-color: ...;
  --navbar-hover-front-color: ...;
  --actionbar-bg-color: ...;
  --actionbar-font-color: ...;
  --border-color: ...;
  --input-bg: ...;
  --input-border: ...;
  --btn-primary-bg: ...;
  --btn-primary-color: ...;
  --btn-danger-bg: ...;
  --btn-danger-color: ...;
  --success-color: ...;
  --warning-color: ...;
}
```

The selector value (e.g. `trainus`) becomes the theme's key everywhere else. See the variable reference at the bottom of this recipe for what each one affects.

### 2. Add the display label in every supported language

TrainUs ships three languages today — English, French, and Danish (see [Add a New Language](#add-a-new-language) above) — so the new theme's label needs an entry in **all three** locale files, not just English and French. Add `"themeMyName"` to the `"settings"` object, after `"themeDark"`, in each:

`src/i18n/locales/en.json`:

```json
"themeMyName": "My Theme Label"
```

`src/i18n/locales/fr.json`:

```json
"themeMyName": "Mon thème"
```

`src/i18n/locales/da.json`:

```json
"themeMyName": "Mit tema"
```

If a language ends up missing the key, i18next falls back to `fallbackLng: 'en'` rather than crashing — but it's still worth catching during the smoke-test below, since a stray English label in another language's UI is easy to miss otherwise.

### 3. Add the option in the settings selector

In `src/views/SettingsView.vue`, locate the theme `<select>` block (search for `settings-theme`). Add one `<option>` after the existing ones:

```html
<option value="<name>">{{ $t('settings.themeMyName') }}</option>
```

The `value` must match the selector name from step 1.

### 4. Smoke-test

1. `npm run dev`
2. Open Settings → Theme → select your new theme.
3. Verify: navbar, background, buttons, and inputs all render with the new palette.
4. Refresh the page to confirm the theme persists (loaded from the database on startup).
5. Switch through each language (English, French, Danish) and confirm the theme's label is translated, not falling back to English.
6. Export settings and re-import to confirm the theme key round-trips correctly.

### 5. Update the decision log

Add an entry to `docs/decisions-log.md` recording the addition and why.

### Variable reference

| Variable                     | Affects                             |
| ---------------------------- | ----------------------------------- |
| `--bg-color1`                | Main page background                |
| `--bg-color2`                | Card / secondary surface background |
| `--front-color1`             | Primary text                        |
| `--front-color2`             | Secondary / muted text              |
| `--navbar-bg-color`          | Side navigation background          |
| `--navbar-front-color`       | Side navigation text & icons        |
| `--navbar-hover-bg-color`    | Nav item hover background           |
| `--navbar-hover-front-color` | Nav item hover text                 |
| `--actionbar-bg-color`       | Top action bar background           |
| `--actionbar-font-color`     | Top action bar text & icons         |
| `--border-color`             | Card and input borders              |
| `--input-bg`                 | Input field background              |
| `--input-border`             | Input field border                  |
| `--btn-primary-bg`           | Primary button background           |
| `--btn-primary-color`        | Primary button text                 |
| `--btn-danger-bg`            | Danger / delete button background   |
| `--btn-danger-color`         | Danger button text                  |
| `--success-color`            | Success messages and indicators     |
| `--warning-color`            | Warning messages and indicators     |

### Checklist

- [ ] CSS variable block added in `src/styles/variables.css`
- [ ] Display label added in every supported language (English, French, Danish)
- [ ] `<option>` added in `src/views/SettingsView.vue`
- [ ] Theme selectable, persists on reload, and round-trips through export/import
- [ ] `docs/decisions-log.md` updated

---

## Add a Database Migration

> **Ask first.** Per the project rules, `SCHEMA_VERSION` is only bumped once a schema has actually been deployed to users — and only after confirming with the project owner. Adding a column directly to `SCHEMA_SQL` (in `src/db/schema.js`) is enough for in-place changes before a release; you don't need any of the steps below for that case. Reach for a real migration only when existing users already have data under the old schema.

A `SCHEMA_VERSION` bump always ships together with exactly one entry in the `RELEASES` registry (`src/db/releases.js`), which is the single source of truth both the startup migration and the Restore Database compatibility check read from.

1. Bump `SCHEMA_VERSION` in `src/db/schema.js`.
2. Write the SQL that brings a live database from the previous version to the new one.
3. Add the corresponding entry to `RELEASES` in `src/db/releases.js`.
4. Test the upgrade path against a database still on the old version.
5. Update the reference docs.

### 1. Bump the version

```js
// src/db/schema.js
export const SCHEMA_VERSION = 2 // was 1
```

### 2. Write the migration script

A migration script is plain SQL — `ALTER TABLE`, data backfills, anything needed to bring the previous schema in line with the new one. It runs inside the same transaction as the `schema_version` stamp, so partial failures roll back cleanly.

### 3. Register the release

```js
// src/db/releases.js
export const RELEASES = [
  { schemaVersion: 1, appVersion: '1.0.0', dbBreak: false, migrationScript: null },
  {
    schemaVersion: 2,
    appVersion: '1.1.0',
    dbBreak: true,
    migrationScript: `ALTER TABLE exercise ADD COLUMN category TEXT;`,
  },
]
```

- `dbBreak: false` with `migrationScript: null` is a valid no-op entry (only the `schema_version` row is stamped) — use it for a version bump that doesn't actually need to touch existing data.
- `dbBreak: true` requires a non-null `migrationScript`; `getMigrationScripts()` throws if one is missing for a breaking release in range.

### 4. Test the upgrade path

Two code paths read `RELEASES`, and both need a check:

- **Startup migration**: open the app with an existing database stamped at the old `schema_version`. The app should show the blocking "download a backup first" screen, then apply your script and continue with data intact.
- **Restore Database**: restore a `.sqlite3` backup taken at the old schema version into a build running the new one — `checkCompatibility()` should detect the gap and run the same script during the restore's adaptation step.

See [Reference]({{< relref "technical.en.md" >}}) for the full migration state machine and flowchart, and [Understanding the Architecture]({{< relref "understanding-the-architecture.en.md" >}}) for why the system is built this way (forced backup, transactional migrations, fail-safe on a newer-than-app database).

### 5. Update the docs

- The schema table and ER diagram in [Reference]({{< relref "technical.en.md" >}})
- `docs/decisions-log.md`, with the date, decision, and rationale

### Checklist

- [ ] Confirmed with the project owner that a real version bump (not an in-place change) is warranted
- [ ] `SCHEMA_VERSION` bumped
- [ ] `RELEASES` entry added with a correct `migrationScript` (or `null` for a no-op)
- [ ] Startup migration tested against an old-version database
- [ ] Restore Database adaptation tested against an old-version backup
- [ ] Reference docs and `docs/decisions-log.md` updated

---

## Deploy TrainUs

TrainUs is a static site: `npm run build` outputs everything needed to `dist/`, ready to copy to any web server or CDN. There's a single deployment — each new build replaces the previous one at the site root, with no per-version folders. The service worker handles client-side code updates, and the startup migration system (see [Reference]({{< relref "technical.en.md" >}})) handles any database schema changes, so the same OPFS data simply carries over.

```bash
npm run build      # outputs static files to dist/
npm run preview    # preview the production build locally
```

TrainUs uses the `opfs-sahpool` VFS (see [Reference]({{< relref "technical.en.md" >}})), which doesn't strictly require COOP/COEP headers. Setting them anyway is recommended as defense-in-depth, and they may become required if the VFS implementation ever changes — the dev server already sets them as a precaution:

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

**Nginx**

```nginx
location / {
    add_header Cross-Origin-Opener-Policy "same-origin";
    add_header Cross-Origin-Embedder-Policy "require-corp";
}
```

**Apache**

```apache
<IfModule mod_headers.c>
    Header always set Cross-Origin-Opener-Policy "same-origin"
    Header always set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>
```

**Caddy**

```caddy
header {
    Cross-Origin-Opener-Policy "same-origin"
    Cross-Origin-Embedder-Policy "require-corp"
}
```

**Netlify** (`public/_headers`)

```
/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
```

**Vercel** (`vercel.json`)

```json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
      ]
    }
  ]
}
```

### Checklist

- [ ] `npm run build` completed without errors
- [ ] `dist/` deployed to the site root, replacing the previous build
- [ ] COOP/COEP headers configured on the server (recommended, not strictly required for `opfs-sahpool`)
- [ ] App loads and OPFS initializes correctly
