trainUs/website/content/developers/how-to-guides.en.md
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

13 KiB

title description slug weight date
How-to Guides Step-by-step recipes for common contributor tasks: languages, themes, deployment, migrations. how-to-guides 2 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:

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

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

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:

<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.

/* 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 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:

"themeMyName": "My Theme Label"

src/i18n/locales/fr.json:

"themeMyName": "Mon thème"

src/i18n/locales/da.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:

<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

// 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

// 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.

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

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

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

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)

{
  "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