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.
src/i18n/locales/<code>.json— create the translation filesrc/i18n/index.js— import and register the localesrc/views/SettingsView.vue— add the<option>in the language selectordocs/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>.jsoncreated and fully translated- Import added in
src/i18n/index.js <option>added insrc/views/SettingsView.vue- Language can be selected in Settings and the UI switches correctly
docs/decisions-log.mdupdated
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.
src/styles/variables.css— define the CSS variable blocksrc/i18n/locales/{en,fr,da}.json— add the display label, in every supported languagesrc/views/SettingsView.vue— add the<option>in the theme selector- Manual smoke-test in the browser
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
npm run dev- Open Settings → Theme → select your new theme.
- Verify: navbar, background, buttons, and inputs all render with the new palette.
- Refresh the page to confirm the theme persists (loaded from the database on startup).
- Switch through each language (English, French, Danish) and confirm the theme's label is translated, not falling back to English.
- 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 insrc/views/SettingsView.vue- Theme selectable, persists on reload, and round-trips through export/import
docs/decisions-log.mdupdated
Add a Database Migration
Ask first. Per the project rules,
SCHEMA_VERSIONis only bumped once a schema has actually been deployed to users — and only after confirming with the project owner. Adding a column directly toSCHEMA_SQL(insrc/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.
- Bump
SCHEMA_VERSIONinsrc/db/schema.js. - Write the SQL that brings a live database from the previous version to the new one.
- Add the corresponding entry to
RELEASESinsrc/db/releases.js. - Test the upgrade path against a database still on the old version.
- 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: falsewithmigrationScript: nullis a valid no-op entry (only theschema_versionrow is stamped) — use it for a version bump that doesn't actually need to touch existing data.dbBreak: truerequires a non-nullmigrationScript;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
.sqlite3backup 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_VERSIONbumpedRELEASESentry added with a correctmigrationScript(ornullfor 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.mdupdated
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 buildcompleted without errorsdist/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