Add installation modal dialog
This commit is contained in:
parent
8a96f28246
commit
b69fae1edc
|
|
@ -1 +1 @@
|
|||
{"sessionId":"5ade3667-a4e8-4863-b973-cb39073bdd6a","pid":5817,"procStart":"18931","acquiredAt":1781606447105}
|
||||
{"sessionId":"493ec87f-3940-4bbe-b1fb-f7d93a184f03","pid":4723,"procStart":"26079","acquiredAt":1783170307973}
|
||||
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
Record of architectural and technical decisions made during development.
|
||||
|
||||
## 2026-07-04 — First-launch warning modal (single tab / no private browsing)
|
||||
|
||||
**Decision:** Added `FirstLoadWarning.vue`, a one-time modal shown on first app launch warning that TrainUs (1) can only be open in one browser tab at a time and (2) does not work in private/incognito browsing. Modeled on `BackupReminder.vue`'s conditional-modal pattern: checks a new `first_load_warning_seen` settings key on mount, shows the modal if unset, and persists the flag permanently on dismiss (no session-only snooze — this is a one-time notice, not a recurring reminder). Both warnings share a single modal and a single "Got it" dismiss button per the original request, rather than two separate dialogs.
|
||||
|
||||
Because every Playwright test runs in its own fresh browser context (a fresh OPFS database), the "first launch" condition the modal keys off is true for every test, not just a real user's actual first run — left unhandled, this would have popped a blocking modal in front of roughly 30 existing test files. Rather than adding test-only branches to the production component, `tests/conftest.py`'s `page` fixture now injects a `MutationObserver`-based init script that auto-dismisses the modal the instant it appears, for every test except the dedicated `tests/test_step23_first_load_warning.py`, which opts out via a new `keep_first_load_warning` pytest marker to exercise the real modal. Verified against the full existing suite plus the two trickiest interactions (the Step 3c backup startup modal, which can appear stacked with this one, and the Step 16 migration flow, where DB-readiness — and so this modal — can appear mid-test after the migration completes).
|
||||
|
||||
**Rationale:** The DB layer's `opfs-sahpool` VFS only supports a single active connection per origin, and OPFS may be unavailable in private browsing; both previously only surfaced as the generic `error.browserNotCompatible` message after the failure already happened. A proactive one-time warning is cheaper for users than a confusing post-hoc error screen. The `MutationObserver` approach (over a fixed-timeout wait) was chosen for the test-infra fix because it reacts the instant the modal enters the DOM regardless of when — the initial page load, or later during Step 16's mid-test dbReady transition — so it doesn't add fixed wait time to any of the ~30 unrelated test files.
|
||||
|
||||
## 2026-06-18 — Clear (×) button added to list search fields
|
||||
|
||||
**Decision:** Added a clear button inside the search input on the four list views with a search bar (`ExerciseListView`, `ComboListView`, `SessionListView`, `CollectionsView`). The button (Bootstrap `bi-x-lg` icon) only renders when the field has a value, sits absolutely positioned inside the input (`padding-right` added to make room), and on click empties `searchQuery` and refocuses the input via a new `clearSearch()` function in each view. The click handler uses `@mousedown.prevent` instead of `@click` so the button doesn't steal focus from the input first (which would otherwise fire `onSearchBlur` while the field still holds its old, non-empty value). Added a shared i18n key `common.clearSearch` (EN/FR/DA) for its `title`/`aria-label` rather than per-entity keys, since the action is identical across all four views. Each view gets its own `data-testid="<entity>-search-clear"` to match the existing per-view `data-testid` convention for the search bar/input/toggle. The pattern is duplicated across the four `.vue` files rather than extracted into a shared component, consistent with how the rest of the search bar (debounce, toggle, blur-to-close) is already duplicated there.
|
||||
|
|
|
|||
22
docs/plan.md
22
docs/plan.md
|
|
@ -765,6 +765,28 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
|||
|
||||
---
|
||||
|
||||
### Backlog — First-launch warning modal (single tab / no private browsing)
|
||||
|
||||
**Status:** ✅ Done (2026-07-04)
|
||||
|
||||
**Requested:** 2026-07-03, user request — on first app startup, show a warning modal telling the user (1) the app can only be open in one browser tab at a time, and (2) the app does not work in private/incognito browsing mode. Both messages in a single modal, one "J'ai compris" (Got it) button to dismiss.
|
||||
|
||||
**Why (found during exploration):** the DB layer (`src/db/worker.js`) uses `sqlite3.installOpfsSAHPoolVfs(...)`, the OPFS SyncAccessHandle Pool VFS, which only supports a single active connection per origin — a 2nd tab, or private/incognito mode where OPFS may be unavailable, currently only surfaces as a generic post-failure error screen (`error.browserNotCompatible` in `App.vue`'s `dbErrorBody`). This feature adds a proactive warning instead of only a reactive error.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- New `settings` table key `first_load_warning_seen` (absent until dismissed, then `"true"`), read/written via `useSettings()`.
|
||||
- New component `src/components/FirstLoadWarning.vue`, modeled on `src/components/BackupReminder.vue`: on `onMounted`, reads the flag and, if unset, shows a `ModalDialog` with both warnings (single-tab-only + no-private-browsing) and one dismiss button that persists the flag and closes for good (no session-only snooze, unlike the backup reminder).
|
||||
- Mounted once in `src/App.vue`'s `v-else` app-shell block, alongside `<BackupReminder>` / `<InstallPrompt>`.
|
||||
- New top-level `firstLoadWarning: { title, singleTab, privateMode, dismiss }` i18n block in `en.json`, `fr.json`, `da.json`.
|
||||
- New `tests/test_step23_first_load_warning.py`: modal appears on a fresh DB, both messages present, dismiss persists the flag, modal does not reappear on reload. Firefox + Chromium.
|
||||
|
||||
**Test-infra fix found along the way:** every Playwright test runs in a fresh browser context — a fresh OPFS database — which is exactly the "first launch" condition, so the modal would have appeared (and blocked interaction) in every one of the ~30 existing test files, not just the new one. Fixed by overriding the `page` fixture in `tests/conftest.py` to inject a `MutationObserver`-based init script that auto-clicks the dismiss button as soon as it appears; the new test file opts out via a `keep_first_load_warning` pytest marker (registered in `tests/pytest.ini`) so it can exercise the real modal. Verified no regressions across the full existing suite plus the trickiest cases (backup startup modal, Step 16 migration flow) on Firefox.
|
||||
|
||||
**Key files changed:** `src/components/FirstLoadWarning.vue` (new), `src/App.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step23_first_load_warning.py` (new), `tests/conftest.py`, `tests/pytest.ini`, `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'i18next-vue'
|
|||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackupReminder from './components/BackupReminder.vue'
|
||||
import FirstLoadWarning from './components/FirstLoadWarning.vue'
|
||||
import InstallPrompt from './components/InstallPrompt.vue'
|
||||
import SaveFileDialog from './components/SaveFileDialog.vue'
|
||||
import { db } from './db/database.js'
|
||||
|
|
@ -163,6 +164,7 @@ const pageTitle = computed(() => {
|
|||
|
||||
<template v-else>
|
||||
<BackupReminder :key="route.fullPath" />
|
||||
<FirstLoadWarning />
|
||||
<InstallPrompt />
|
||||
<nav class="navbar" :aria-label="$t('nav.mainNav')" @keydown="onNavKeydown">
|
||||
<div class="navbar-brand">{{ $t('app.name') }}</div>
|
||||
|
|
|
|||
53
src/components/FirstLoadWarning.vue
Normal file
53
src/components/FirstLoadWarning.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
const { get, set } = useSettings()
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const seen = await get('first_load_warning_seen')
|
||||
if (!seen) {
|
||||
showModal.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function dismiss() {
|
||||
showModal.value = false
|
||||
await set('first_load_warning_seen', 'true')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog v-if="showModal" :title="$t('firstLoadWarning.title')" @close="dismiss">
|
||||
<div data-testid="first-load-warning-modal">
|
||||
<p class="modal-message" data-testid="first-load-warning-single-tab">
|
||||
{{ $t('firstLoadWarning.singleTab') }}
|
||||
</p>
|
||||
<p class="modal-message" data-testid="first-load-warning-private-mode">
|
||||
{{ $t('firstLoadWarning.privateMode') }}
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" data-testid="first-load-warning-dismiss" @click="dismiss">
|
||||
{{ $t('firstLoadWarning.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-message {
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -430,6 +430,12 @@
|
|||
"goToSettings": "Gå til indstillinger"
|
||||
}
|
||||
},
|
||||
"firstLoadWarning": {
|
||||
"title": "Før du starter",
|
||||
"singleTab": "TrainUs kan kun være åben i én browserfane ad gangen. Hvis den åbnes i endnu en fane, opstår der fejl.",
|
||||
"privateMode": "TrainUs fungerer ikke i privat/inkognitotilstand — dine data vil ikke blive gemt.",
|
||||
"dismiss": "Forstået"
|
||||
},
|
||||
"install": {
|
||||
"button": "Installer app",
|
||||
"dismiss": "Afvis installationsprompt"
|
||||
|
|
|
|||
|
|
@ -430,6 +430,12 @@
|
|||
"goToSettings": "Go to Settings"
|
||||
}
|
||||
},
|
||||
"firstLoadWarning": {
|
||||
"title": "Before you start",
|
||||
"singleTab": "TrainUs can only be open in one browser tab at a time. Opening it in a second tab will cause errors.",
|
||||
"privateMode": "TrainUs does not work in private/incognito browsing mode — your data would not be saved.",
|
||||
"dismiss": "Got it"
|
||||
},
|
||||
"install": {
|
||||
"button": "Install app",
|
||||
"dismiss": "Dismiss install prompt"
|
||||
|
|
|
|||
|
|
@ -430,6 +430,12 @@
|
|||
"goToSettings": "Aller aux paramètres"
|
||||
}
|
||||
},
|
||||
"firstLoadWarning": {
|
||||
"title": "Avant de commencer",
|
||||
"singleTab": "TrainUs ne peut être ouvert que dans un seul onglet à la fois. L'ouvrir dans un second onglet provoquera des erreurs.",
|
||||
"privateMode": "TrainUs ne fonctionne pas en navigation privée — vos données ne seraient pas sauvegardées.",
|
||||
"dismiss": "J'ai compris"
|
||||
},
|
||||
"install": {
|
||||
"button": "Installer l'application",
|
||||
"dismiss": "Ignorer l'invitation à installer"
|
||||
|
|
|
|||
|
|
@ -154,3 +154,36 @@ def browser_context_args():
|
|||
return {
|
||||
"viewport": {"width": 1280, "height": 720},
|
||||
}
|
||||
|
||||
|
||||
# Every test runs in a fresh browser context, i.e. a fresh (OPFS) database, which
|
||||
# is exactly the "first launch" condition that shows the first-load warning modal
|
||||
# (see src/components/FirstLoadWarning.vue). Auto-dismiss it for every test except
|
||||
# the ones that specifically exercise it, so it doesn't block interaction with the
|
||||
# rest of the app. A MutationObserver is used (rather than a one-off wait) so it
|
||||
# reliably clicks the button whenever the modal appears, including after a
|
||||
# mid-test transition such as the Step 16 migration flow.
|
||||
_AUTO_DISMISS_FIRST_LOAD_WARNING = """
|
||||
(() => {
|
||||
function tryDismiss() {
|
||||
const btn = document.querySelector('[data-testid="first-load-warning-dismiss"]');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
const observer = new MutationObserver(tryDismiss);
|
||||
const start = () => {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
tryDismiss();
|
||||
};
|
||||
if (document.body) start();
|
||||
else document.addEventListener('DOMContentLoaded', start);
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(page, request):
|
||||
"""Wrap pytest-playwright's `page` fixture to auto-dismiss the first-load
|
||||
warning modal, unless the test is marked `keep_first_load_warning`."""
|
||||
if not request.node.get_closest_marker("keep_first_load_warning"):
|
||||
page.add_init_script(_AUTO_DISMISS_FIRST_LOAD_WARNING)
|
||||
yield page
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
[pytest]
|
||||
base_url = http://localhost:5199
|
||||
markers =
|
||||
keep_first_load_warning: do not auto-dismiss the first-load warning modal (used by tests that exercise it directly)
|
||||
|
|
|
|||
69
tests/test_step23_first_load_warning.py
Normal file
69
tests/test_step23_first_load_warning.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Backlog — First-launch warning modal (single tab / no private browsing)
|
||||
- test_modal_appears_on_fresh_db: Modal appears on first load of a fresh database
|
||||
- test_modal_shows_both_messages: Both the single-tab and private-browsing messages are shown
|
||||
- test_dismiss_persists_flag: Dismissing sets the settings flag and hides the modal
|
||||
- test_modal_does_not_reappear_after_reload: Modal does not reappear on reload once dismissed
|
||||
"""
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
# The conftest `page` fixture auto-dismisses this modal for every other test file
|
||||
# (since a fresh browser context = a fresh DB = "first launch" every time); opt out
|
||||
# here since these tests exercise the modal directly.
|
||||
pytestmark = pytest.mark.keep_first_load_warning
|
||||
|
||||
|
||||
def _wait_for_db(page: Page, app_url: str):
|
||||
"""Navigate and wait for the database to be ready."""
|
||||
page.goto(app_url)
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def test_modal_appears_on_fresh_db(page: Page, app_url: str):
|
||||
"""Modal appears on first load of a fresh database."""
|
||||
_wait_for_db(page, app_url)
|
||||
|
||||
expect(page.locator('[data-testid="first-load-warning-modal"]')).to_be_visible()
|
||||
|
||||
|
||||
def test_modal_shows_both_messages(page: Page, app_url: str):
|
||||
"""Both the single-tab and private-browsing messages are shown together."""
|
||||
_wait_for_db(page, app_url)
|
||||
|
||||
expect(page.locator('[data-testid="first-load-warning-single-tab"]')).to_be_visible()
|
||||
expect(page.locator('[data-testid="first-load-warning-private-mode"]')).to_be_visible()
|
||||
|
||||
|
||||
def test_dismiss_persists_flag(page: Page, app_url: str):
|
||||
"""Dismissing the modal sets the settings flag and hides the modal."""
|
||||
_wait_for_db(page, app_url)
|
||||
|
||||
modal = page.locator('[data-testid="first-load-warning-modal"]')
|
||||
expect(modal).to_be_visible()
|
||||
|
||||
page.locator('[data-testid="first-load-warning-dismiss"]').click()
|
||||
expect(modal).not_to_be_visible()
|
||||
|
||||
flag = page.evaluate(
|
||||
"async () => await window.__repos.settings.get('first_load_warning_seen')"
|
||||
)
|
||||
assert flag == 'true', f"first_load_warning_seen should be 'true', got '{flag}'"
|
||||
|
||||
|
||||
def test_modal_does_not_reappear_after_reload(page: Page, app_url: str):
|
||||
"""Once dismissed, the modal does not reappear on reload."""
|
||||
_wait_for_db(page, app_url)
|
||||
|
||||
page.locator('[data-testid="first-load-warning-dismiss"]').click()
|
||||
|
||||
page.reload()
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
expect(page.locator('[data-testid="first-load-warning-modal"]')).not_to_be_visible()
|
||||
836
tokscale-export-20260703-144634.json
Normal file
836
tokscale-export-20260703-144634.json
Normal file
|
|
@ -0,0 +1,836 @@
|
|||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent": "Build",
|
||||
"clients": "opencode",
|
||||
"cost": 80.35733614300014,
|
||||
"messageCount": 1943,
|
||||
"tokens": {
|
||||
"cacheRead": 131494231,
|
||||
"cacheWrite": 5975709,
|
||||
"input": 18961214,
|
||||
"output": 810636,
|
||||
"total": 157265517
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "Explore",
|
||||
"clients": "claude, opencode",
|
||||
"cost": 15.377813074999988,
|
||||
"messageCount": 306,
|
||||
"tokens": {
|
||||
"cacheRead": 7340290,
|
||||
"cacheWrite": 937849,
|
||||
"input": 2021878,
|
||||
"output": 176576,
|
||||
"total": 10478887
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "Plan",
|
||||
"clients": "opencode",
|
||||
"cost": 6.213292899999999,
|
||||
"messageCount": 98,
|
||||
"tokens": {
|
||||
"cacheRead": 2122656,
|
||||
"cacheWrite": 187913,
|
||||
"input": 1022055,
|
||||
"output": 50446,
|
||||
"total": 3383959
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "Compaction",
|
||||
"clients": "opencode",
|
||||
"cost": 4.1193574,
|
||||
"messageCount": 9,
|
||||
"tokens": {
|
||||
"cacheRead": 163,
|
||||
"cacheWrite": 130330,
|
||||
"input": 699367,
|
||||
"output": 23311,
|
||||
"total": 853171
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "Test Runner",
|
||||
"clients": "opencode",
|
||||
"cost": 2.8917479999999993,
|
||||
"messageCount": 282,
|
||||
"tokens": {
|
||||
"cacheRead": 1793541,
|
||||
"cacheWrite": 108046,
|
||||
"input": 98653,
|
||||
"output": 33057,
|
||||
"total": 2033297
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "Docs Writer",
|
||||
"clients": "opencode",
|
||||
"cost": 2.5088409999999994,
|
||||
"messageCount": 36,
|
||||
"tokens": {
|
||||
"cacheRead": 1546392,
|
||||
"cacheWrite": 0,
|
||||
"input": 157439,
|
||||
"output": 37938,
|
||||
"total": 1741769
|
||||
}
|
||||
},
|
||||
{
|
||||
"agent": "General",
|
||||
"clients": "opencode",
|
||||
"cost": 0.539935,
|
||||
"messageCount": 12,
|
||||
"tokens": {
|
||||
"cacheRead": 290780,
|
||||
"cacheWrite": 29032,
|
||||
"input": 9249,
|
||||
"output": 6674,
|
||||
"total": 335735
|
||||
}
|
||||
}
|
||||
],
|
||||
"daily": [
|
||||
{
|
||||
"cost": 0.41224720000000004,
|
||||
"date": "2026-07-03",
|
||||
"messageCount": 22,
|
||||
"tokens": {
|
||||
"cacheRead": 940096,
|
||||
"cacheWrite": 38328,
|
||||
"input": 10969,
|
||||
"output": 10647,
|
||||
"total": 1000040
|
||||
},
|
||||
"turnCount": 2
|
||||
},
|
||||
{
|
||||
"cost": 0.256831,
|
||||
"date": "2026-07-01",
|
||||
"messageCount": 15,
|
||||
"tokens": {
|
||||
"cacheRead": 462553,
|
||||
"cacheWrite": 31923,
|
||||
"input": 11163,
|
||||
"output": 5206,
|
||||
"total": 510845
|
||||
},
|
||||
"turnCount": 4
|
||||
},
|
||||
{
|
||||
"cost": 0.9797120999999999,
|
||||
"date": "2026-06-30",
|
||||
"messageCount": 10,
|
||||
"tokens": {
|
||||
"cacheRead": 1180532,
|
||||
"cacheWrite": 146982,
|
||||
"input": 7345,
|
||||
"output": 3489,
|
||||
"total": 1338348
|
||||
},
|
||||
"turnCount": 6
|
||||
},
|
||||
{
|
||||
"cost": 3.964411649999996,
|
||||
"date": "2026-06-29",
|
||||
"messageCount": 120,
|
||||
"tokens": {
|
||||
"cacheRead": 8018313,
|
||||
"cacheWrite": 166797,
|
||||
"input": 21158,
|
||||
"output": 57997,
|
||||
"total": 8264265
|
||||
},
|
||||
"turnCount": 16
|
||||
},
|
||||
{
|
||||
"cost": 14.114278349999996,
|
||||
"date": "2026-06-18",
|
||||
"messageCount": 444,
|
||||
"tokens": {
|
||||
"cacheRead": 26520977,
|
||||
"cacheWrite": 772275,
|
||||
"input": 176558,
|
||||
"output": 182152,
|
||||
"total": 27651962
|
||||
},
|
||||
"turnCount": 55
|
||||
},
|
||||
{
|
||||
"cost": 0.3690055499999999,
|
||||
"date": "2026-06-17",
|
||||
"messageCount": 14,
|
||||
"tokens": {
|
||||
"cacheRead": 389926,
|
||||
"cacheWrite": 27109,
|
||||
"input": 6833,
|
||||
"output": 8658,
|
||||
"total": 432526
|
||||
},
|
||||
"turnCount": 3
|
||||
},
|
||||
{
|
||||
"cost": 64.53090050000012,
|
||||
"date": "2026-06-16",
|
||||
"messageCount": 917,
|
||||
"tokens": {
|
||||
"cacheRead": 151918831,
|
||||
"cacheWrite": 1987112,
|
||||
"input": 392384,
|
||||
"output": 701638,
|
||||
"total": 154999965
|
||||
},
|
||||
"turnCount": 41
|
||||
},
|
||||
{
|
||||
"cost": 9.405165150000006,
|
||||
"date": "2026-06-14",
|
||||
"messageCount": 232,
|
||||
"tokens": {
|
||||
"cacheRead": 14530218,
|
||||
"cacheWrite": 434357,
|
||||
"input": 71732,
|
||||
"output": 213471,
|
||||
"total": 15249778
|
||||
},
|
||||
"turnCount": 29
|
||||
},
|
||||
{
|
||||
"cost": 0.19272855000000003,
|
||||
"date": "2026-06-13",
|
||||
"messageCount": 7,
|
||||
"tokens": {
|
||||
"cacheRead": 156186,
|
||||
"cacheWrite": 15437,
|
||||
"input": 1413,
|
||||
"output": 5583,
|
||||
"total": 178619
|
||||
},
|
||||
"turnCount": 4
|
||||
},
|
||||
{
|
||||
"cost": 28.31693669999997,
|
||||
"date": "2026-06-12",
|
||||
"messageCount": 390,
|
||||
"tokens": {
|
||||
"cacheRead": 39048117,
|
||||
"cacheWrite": 692539,
|
||||
"input": 174952,
|
||||
"output": 184656,
|
||||
"total": 40100264
|
||||
},
|
||||
"turnCount": 11
|
||||
},
|
||||
{
|
||||
"cost": 11.713803999999998,
|
||||
"date": "2026-06-11",
|
||||
"messageCount": 92,
|
||||
"tokens": {
|
||||
"cacheRead": 3835309,
|
||||
"cacheWrite": 212878,
|
||||
"input": 74617,
|
||||
"output": 89427,
|
||||
"total": 4212231
|
||||
},
|
||||
"turnCount": 12
|
||||
},
|
||||
{
|
||||
"cost": 4.840531199999996,
|
||||
"date": "2026-06-09",
|
||||
"messageCount": 119,
|
||||
"tokens": {
|
||||
"cacheRead": 9479214,
|
||||
"cacheWrite": 259180,
|
||||
"input": 49729,
|
||||
"output": 58377,
|
||||
"total": 9846500
|
||||
},
|
||||
"turnCount": 15
|
||||
},
|
||||
{
|
||||
"cost": 4.235904949999998,
|
||||
"date": "2026-06-08",
|
||||
"messageCount": 155,
|
||||
"tokens": {
|
||||
"cacheRead": 8223499,
|
||||
"cacheWrite": 244635,
|
||||
"input": 132257,
|
||||
"output": 50623,
|
||||
"total": 8651014
|
||||
},
|
||||
"turnCount": 14
|
||||
},
|
||||
{
|
||||
"cost": 2.9825825999999993,
|
||||
"date": "2026-06-07",
|
||||
"messageCount": 61,
|
||||
"tokens": {
|
||||
"cacheRead": 4897682,
|
||||
"cacheWrite": 173280,
|
||||
"input": 86796,
|
||||
"output": 40206,
|
||||
"total": 5197964
|
||||
},
|
||||
"turnCount": 4
|
||||
},
|
||||
{
|
||||
"cost": 0.9010546500000001,
|
||||
"date": "2026-06-06",
|
||||
"messageCount": 19,
|
||||
"tokens": {
|
||||
"cacheRead": 920393,
|
||||
"cacheWrite": 51349,
|
||||
"input": 5716,
|
||||
"output": 27682,
|
||||
"total": 1005140
|
||||
},
|
||||
"turnCount": 5
|
||||
},
|
||||
{
|
||||
"cost": 1.0923621000000003,
|
||||
"date": "2026-06-05",
|
||||
"messageCount": 19,
|
||||
"tokens": {
|
||||
"cacheRead": 1795697,
|
||||
"cacheWrite": 101788,
|
||||
"input": 7911,
|
||||
"output": 9881,
|
||||
"total": 1915277
|
||||
},
|
||||
"turnCount": 6
|
||||
},
|
||||
{
|
||||
"cost": 1.78596485,
|
||||
"date": "2026-06-04",
|
||||
"messageCount": 62,
|
||||
"tokens": {
|
||||
"cacheRead": 2786625,
|
||||
"cacheWrite": 157699,
|
||||
"input": 76411,
|
||||
"output": 40869,
|
||||
"total": 3061604
|
||||
},
|
||||
"turnCount": 5
|
||||
},
|
||||
{
|
||||
"cost": 13.23304699999999,
|
||||
"date": "2026-06-02",
|
||||
"messageCount": 395,
|
||||
"tokens": {
|
||||
"cacheRead": 20408906,
|
||||
"cacheWrite": 718488,
|
||||
"input": 198263,
|
||||
"output": 268343,
|
||||
"total": 21594000
|
||||
},
|
||||
"turnCount": 51
|
||||
},
|
||||
{
|
||||
"cost": 3.121295999999995,
|
||||
"date": "2026-06-01",
|
||||
"messageCount": 118,
|
||||
"tokens": {
|
||||
"cacheRead": 5457665,
|
||||
"cacheWrite": 170098,
|
||||
"input": 49743,
|
||||
"output": 46460,
|
||||
"total": 5723966
|
||||
},
|
||||
"turnCount": 11
|
||||
},
|
||||
{
|
||||
"cost": 9.753698750000005,
|
||||
"date": "2026-05-31",
|
||||
"messageCount": 308,
|
||||
"tokens": {
|
||||
"cacheRead": 17093630,
|
||||
"cacheWrite": 665323,
|
||||
"input": 179179,
|
||||
"output": 154587,
|
||||
"total": 18092719
|
||||
},
|
||||
"turnCount": 31
|
||||
},
|
||||
{
|
||||
"cost": 6.757524749999998,
|
||||
"date": "2026-05-19",
|
||||
"messageCount": 107,
|
||||
"tokens": {
|
||||
"cacheRead": 6203435,
|
||||
"cacheWrite": 1161211,
|
||||
"input": 121,
|
||||
"output": 36106,
|
||||
"total": 7400873
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 3.7578223499999983,
|
||||
"date": "2026-05-18",
|
||||
"messageCount": 139,
|
||||
"tokens": {
|
||||
"cacheRead": 5767382,
|
||||
"cacheWrite": 358113,
|
||||
"input": 153,
|
||||
"output": 45615,
|
||||
"total": 6171263
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 11.007388399999998,
|
||||
"date": "2026-05-14",
|
||||
"messageCount": 105,
|
||||
"tokens": {
|
||||
"cacheRead": 7369852,
|
||||
"cacheWrite": 952046,
|
||||
"input": 170,
|
||||
"output": 82628,
|
||||
"total": 8404696
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 4.351449700000002,
|
||||
"date": "2026-05-11",
|
||||
"messageCount": 489,
|
||||
"tokens": {
|
||||
"cacheRead": 44887569,
|
||||
"cacheWrite": 2216290,
|
||||
"input": 2916,
|
||||
"output": 240144,
|
||||
"total": 47346919
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 0.073390968,
|
||||
"date": "2026-05-05",
|
||||
"messageCount": 69,
|
||||
"tokens": {
|
||||
"cacheRead": 4126080,
|
||||
"cacheWrite": 0,
|
||||
"input": 95594,
|
||||
"output": 16161,
|
||||
"total": 4241161
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 0.21754622500000007,
|
||||
"date": "2026-04-28",
|
||||
"messageCount": 17,
|
||||
"tokens": {
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"input": 640531,
|
||||
"output": 3839,
|
||||
"total": 645338
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 12.0778915,
|
||||
"date": "2026-04-22",
|
||||
"messageCount": 263,
|
||||
"tokens": {
|
||||
"cacheRead": 9078065,
|
||||
"cacheWrite": 271228,
|
||||
"input": 15916614,
|
||||
"output": 82088,
|
||||
"total": 25355387
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 0.7519993749999998,
|
||||
"date": "2026-04-21",
|
||||
"messageCount": 105,
|
||||
"tokens": {
|
||||
"cacheRead": 6883685,
|
||||
"cacheWrite": 466509,
|
||||
"input": 630,
|
||||
"output": 32472,
|
||||
"total": 7389468
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 9.739795749999997,
|
||||
"date": "2026-04-20",
|
||||
"messageCount": 251,
|
||||
"tokens": {
|
||||
"cacheRead": 22020930,
|
||||
"cacheWrite": 1315006,
|
||||
"input": 940,
|
||||
"output": 99941,
|
||||
"total": 23445869
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 4.481086,
|
||||
"date": "2026-04-18",
|
||||
"messageCount": 61,
|
||||
"tokens": {
|
||||
"cacheRead": 3282992,
|
||||
"cacheWrite": 309752,
|
||||
"input": 93,
|
||||
"output": 36127,
|
||||
"total": 3628964
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 0.479889,
|
||||
"date": "2026-04-03",
|
||||
"messageCount": 12,
|
||||
"tokens": {
|
||||
"cacheRead": 207648,
|
||||
"cacheWrite": 0,
|
||||
"input": 59338,
|
||||
"output": 3175,
|
||||
"total": 270161
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 13.705962000000003,
|
||||
"date": "2026-03-31",
|
||||
"messageCount": 141,
|
||||
"tokens": {
|
||||
"cacheRead": 5836294,
|
||||
"cacheWrite": 0,
|
||||
"input": 1644138,
|
||||
"output": 102685,
|
||||
"total": 7583117
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 11.707100999999986,
|
||||
"date": "2026-03-30",
|
||||
"messageCount": 190,
|
||||
"tokens": {
|
||||
"cacheRead": 9793672,
|
||||
"cacheWrite": 0,
|
||||
"input": 784418,
|
||||
"output": 115527,
|
||||
"total": 10693617
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 9.132739,
|
||||
"date": "2026-03-28",
|
||||
"messageCount": 162,
|
||||
"tokens": {
|
||||
"cacheRead": 9189318,
|
||||
"cacheWrite": 0,
|
||||
"input": 452331,
|
||||
"output": 91057,
|
||||
"total": 9732706
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 18.31148299999999,
|
||||
"date": "2026-03-27",
|
||||
"messageCount": 420,
|
||||
"tokens": {
|
||||
"cacheRead": 6586206,
|
||||
"cacheWrite": 0,
|
||||
"input": 2521761,
|
||||
"output": 96383,
|
||||
"total": 9204350
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 1.6884175,
|
||||
"date": "2026-03-26",
|
||||
"messageCount": 13,
|
||||
"tokens": {
|
||||
"cacheRead": 283625,
|
||||
"cacheWrite": 0,
|
||||
"input": 264001,
|
||||
"output": 9064,
|
||||
"total": 556690
|
||||
},
|
||||
"turnCount": 0
|
||||
},
|
||||
{
|
||||
"cost": 2.8088324999999994,
|
||||
"date": "2026-03-25",
|
||||
"messageCount": 29,
|
||||
"tokens": {
|
||||
"cacheRead": 515025,
|
||||
"cacheWrite": 0,
|
||||
"input": 426574,
|
||||
"output": 16738,
|
||||
"total": 958337
|
||||
},
|
||||
"turnCount": 0
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"client": "opencode, claude",
|
||||
"cost": 154.14335354999952,
|
||||
"model": "claude-sonnet-4-6",
|
||||
"performance": {
|
||||
"msPer1KTokens": 131.27891702972073,
|
||||
"sampleCount": 3346,
|
||||
"timedTokens": 319584172,
|
||||
"tokenCoverage": 0.9957107665667683,
|
||||
"totalDurationMs": 41954664
|
||||
},
|
||||
"provider": "opencode, anthropic",
|
||||
"sessionCount": 82,
|
||||
"tokens": {
|
||||
"cacheRead": 310413071,
|
||||
"cacheWrite": 7134279,
|
||||
"input": 1411382,
|
||||
"output": 2002116,
|
||||
"total": 320960848
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 68.75964850000005,
|
||||
"model": "claude-opus-4-6",
|
||||
"performance": {
|
||||
"msPer1KTokens": 678.703267189516,
|
||||
"sampleCount": 915,
|
||||
"timedTokens": 47011751,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 31907029
|
||||
},
|
||||
"provider": "github_copilot, opencode",
|
||||
"sessionCount": 166,
|
||||
"tokens": {
|
||||
"cacheRead": 39461287,
|
||||
"cacheWrite": 889556,
|
||||
"input": 6152671,
|
||||
"output": 508237,
|
||||
"total": 47011751
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "claude",
|
||||
"cost": 27.014924499999996,
|
||||
"model": "claude-fable-5",
|
||||
"performance": {
|
||||
"msPer1KTokens": 260.22168923268407,
|
||||
"sampleCount": 200,
|
||||
"timedTokens": 11958542,
|
||||
"tokenCoverage": 0.9921450858895039,
|
||||
"totalDurationMs": 3111872
|
||||
},
|
||||
"provider": "anthropic",
|
||||
"sessionCount": 5,
|
||||
"tokens": {
|
||||
"cacheRead": 11279302,
|
||||
"cacheWrite": 460453,
|
||||
"input": 142331,
|
||||
"output": 171133,
|
||||
"total": 12053219
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 12.828726000000001,
|
||||
"model": "claude-opus-4-7",
|
||||
"performance": {
|
||||
"msPer1KTokens": 440.04060995500197,
|
||||
"sampleCount": 178,
|
||||
"timedTokens": 12511218,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 5505444
|
||||
},
|
||||
"provider": "opencode",
|
||||
"sessionCount": 5,
|
||||
"tokens": {
|
||||
"cacheRead": 11638787,
|
||||
"cacheWrite": 789166,
|
||||
"input": 229,
|
||||
"output": 83036,
|
||||
"total": 12511218
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 6.772487500000001,
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"performance": {
|
||||
"msPer1KTokens": 188.6804191325956,
|
||||
"sampleCount": 58,
|
||||
"timedTokens": 9370877,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 1768101
|
||||
},
|
||||
"provider": "openrouter",
|
||||
"sessionCount": 3,
|
||||
"tokens": {
|
||||
"cacheRead": 9078065,
|
||||
"cacheWrite": 271228,
|
||||
"input": 66,
|
||||
"output": 21518,
|
||||
"total": 9370877
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 6.577768725,
|
||||
"model": "qwen3.6-plus",
|
||||
"performance": {
|
||||
"msPer1KTokens": 278.94389486299883,
|
||||
"sampleCount": 735,
|
||||
"timedTokens": 69691925,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 19440137
|
||||
},
|
||||
"provider": "opencode",
|
||||
"sessionCount": 12,
|
||||
"tokens": {
|
||||
"cacheRead": 65756742,
|
||||
"cacheWrite": 3580881,
|
||||
"input": 4410,
|
||||
"output": 334668,
|
||||
"total": 69691925
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 5.522950225000005,
|
||||
"model": "qwen/qwen3.6-plus",
|
||||
"performance": {
|
||||
"msPer1KTokens": 338.02756345097083,
|
||||
"sampleCount": 217,
|
||||
"timedTokens": 16629848,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 5621347
|
||||
},
|
||||
"provider": "openrouter",
|
||||
"sessionCount": 5,
|
||||
"tokens": {
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"input": 16557079,
|
||||
"output": 64409,
|
||||
"total": 16629848
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 3.962829899999999,
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"performance": {
|
||||
"msPer1KTokens": 101.17965789503508,
|
||||
"sampleCount": 52,
|
||||
"timedTokens": 5029509,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 508884
|
||||
},
|
||||
"provider": "openrouter",
|
||||
"sessionCount": 1,
|
||||
"tokens": {
|
||||
"cacheRead": 4395908,
|
||||
"cacheWrite": 609710,
|
||||
"input": 60,
|
||||
"output": 23831,
|
||||
"total": 5029509
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "claude",
|
||||
"cost": 0.9580045000000003,
|
||||
"model": "claude-haiku-4-5",
|
||||
"performance": {
|
||||
"msPer1KTokens": 130.6431946244881,
|
||||
"sampleCount": 113,
|
||||
"timedTokens": 2909788,
|
||||
"tokenCoverage": 0.949849824656699,
|
||||
"totalDurationMs": 380144
|
||||
},
|
||||
"provider": "anthropic",
|
||||
"sessionCount": 7,
|
||||
"tokens": {
|
||||
"cacheRead": 2556275,
|
||||
"cacheWrite": 318724,
|
||||
"input": 159532,
|
||||
"output": 28888,
|
||||
"total": 3063419
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "claude",
|
||||
"cost": 0.6386975000000001,
|
||||
"model": "claude-sonnet-5",
|
||||
"performance": {
|
||||
"msPer1KTokens": 171.06539394692618,
|
||||
"sampleCount": 36,
|
||||
"timedTokens": 1480504,
|
||||
"tokenCoverage": 0.9921871753871229,
|
||||
"totalDurationMs": 253263
|
||||
},
|
||||
"provider": "anthropic",
|
||||
"sessionCount": 3,
|
||||
"tokens": {
|
||||
"cacheRead": 1390630,
|
||||
"cacheWrite": 63735,
|
||||
"input": 22092,
|
||||
"output": 15705,
|
||||
"total": 1492162
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "opencode",
|
||||
"cost": 0.073390968,
|
||||
"model": "xiaomi/mimo-v2.5-pro",
|
||||
"performance": {
|
||||
"msPer1KTokens": 631.3860756523981,
|
||||
"sampleCount": 68,
|
||||
"timedTokens": 4241161,
|
||||
"tokenCoverage": 1.0,
|
||||
"totalDurationMs": 2677810
|
||||
},
|
||||
"provider": "openrouter",
|
||||
"sessionCount": 1,
|
||||
"tokens": {
|
||||
"cacheRead": 4126080,
|
||||
"cacheWrite": 0,
|
||||
"input": 95594,
|
||||
"output": 16161,
|
||||
"total": 4241161
|
||||
}
|
||||
},
|
||||
{
|
||||
"client": "claude",
|
||||
"cost": 0.0,
|
||||
"model": "<synthetic>",
|
||||
"performance": {
|
||||
"msPer1KTokens": null,
|
||||
"sampleCount": 0,
|
||||
"timedTokens": 0,
|
||||
"tokenCoverage": 0.0,
|
||||
"totalDurationMs": 0
|
||||
},
|
||||
"provider": "unknown",
|
||||
"sessionCount": 7,
|
||||
"tokens": {
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"input": 6,
|
||||
"output": 0,
|
||||
"total": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"cost": 287.25278186799954,
|
||||
"tokens": 502055943
|
||||
}
|
||||
}
|
||||
|
|
@ -1004,6 +1004,20 @@ The three low-level download helpers (`downloadJson` in `jsonEnvelope.js`, `down
|
|||
|
||||
---
|
||||
|
||||
### 3.13 First-Load Warning
|
||||
|
||||
The `opfs-sahpool` VFS used by the DB worker (`src/db/worker.js`, see §2) only supports one active connection per origin, and OPFS may be unavailable entirely in private/incognito browsing. Both conditions used to only surface reactively, as the generic `error.browserNotCompatible` message once a second tab or a private window failed to open the database (see `App.vue`'s `dbErrorBody`). This feature adds a proactive, one-time warning instead.
|
||||
|
||||
**Settings key:** `first_load_warning_seen` — absent until dismissed, then `"true"`, never reset.
|
||||
|
||||
**Component:** `FirstLoadWarning.vue`, mounted once in `App.vue`'s DB-ready branch, modeled on `BackupReminder`'s conditional-modal pattern. On mount it reads the settings flag; if absent, it shows a `ModalDialog` with both warnings (single tab only, no private browsing) and a single dismiss button (`$t('firstLoadWarning.dismiss')`) that persists the flag and closes. Unlike `BackupReminder`, it has no snooze/session-dismiss state — once dismissed it is gone for good.
|
||||
|
||||
**Test infrastructure note:** since every Playwright test runs in a fresh browser context (a fresh OPFS database), the modal's "first launch" condition is true for every single test, not just a real user's actual first run. `tests/conftest.py`'s `page` fixture injects a `MutationObserver`-based init script that auto-clicks the dismiss button as soon as it appears, for every test except the ones in `tests/test_step23_first_load_warning.py` that exercise the modal directly (opted out via the `keep_first_load_warning` pytest marker).
|
||||
|
||||
**Key files:** `src/components/FirstLoadWarning.vue`, `src/App.vue`, `tests/conftest.py`
|
||||
|
||||
---
|
||||
|
||||
## 4. Routing
|
||||
|
||||
All routes use hash mode (`createWebHashHistory`). The root path `/` redirects to `/home`.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ date: 2026-06-16
|
|||
|
||||
> This page describes every TrainUs screen in detail: available actions, form fields, keyboard shortcuts. For a general introduction, see [Getting Started]({{< relref "demarrage-rapide.en.md" >}}); for the "why" behind the choices, see [Understanding TrainUs]({{< relref "comprendre.en.md" >}}).
|
||||
|
||||
## First Launch
|
||||
|
||||
The very first time you open TrainUs, a one-time warning modal explains two important constraints of the app:
|
||||
|
||||
- **One tab at a time** — TrainUs can only be open in a single browser tab at once. Opening it in a second tab causes errors.
|
||||
- **No private/incognito browsing** — TrainUs does not work in private or incognito windows; your data would not be saved.
|
||||
|
||||
Click **Got it** to dismiss the modal — it will not appear again.
|
||||
|
||||
## Home
|
||||
|
||||
The Home page (`#/home`) is the default landing page. It provides an overview of your recent training activity.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ date: 2026-06-14
|
|||
|
||||
> Cette page décrit chaque écran de TrainUs en détail : actions disponibles, champs des formulaires, raccourcis clavier. Pour une présentation générale, voir [Démarrage rapide]({{< relref "demarrage-rapide.fr.md" >}}) ; pour le « pourquoi » des choix, voir [Comprendre TrainUs]({{< relref "comprendre.fr.md" >}}).
|
||||
|
||||
## Premier lancement
|
||||
|
||||
La toute première fois que vous ouvrez TrainUs, une fenêtre d'avertissement unique explique deux contraintes importantes de l'application :
|
||||
|
||||
- **Un seul onglet à la fois** — TrainUs ne peut être ouvert que dans un seul onglet de navigateur. L'ouvrir dans un second onglet provoque des erreurs.
|
||||
- **Pas de navigation privée** — TrainUs ne fonctionne pas en navigation privée/incognito ; vos données ne seraient pas sauvegardées.
|
||||
|
||||
Cliquez sur **J'ai compris** pour fermer la fenêtre — elle ne réapparaîtra plus.
|
||||
|
||||
## Accueil
|
||||
|
||||
La page d'accueil (`#/home`) est la page par défaut. Elle donne un aperçu de votre activité récente.
|
||||
|
|
|
|||
Loading…
Reference in a new issue