From c48a89de115b99e953b06baeea3140b70cb9f077 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 3 Oct 2026 14:54:59 +0200 Subject: [PATCH] feat(planning): safe side-by-side planning in several tabs (save conflicts, cross-tab sync, logout, db upgrades) Lost edits, lost preferences, logout that doesn't stick, stale lists and the IndexedDB upgrade hang when several tabs are open. - Plans, empires and CXs send the version they were loaded with (base_modified_at); a save over another tab's newer one opens a dialog listing both sides' changes (save as new plan, overwrite, reload), a deleted one offers save as new or a notice - Planning mutations broadcast on a BroadcastChannel; other tabs refetch lists and reload open editors, or keep unsaved edits with a notice - One login state across tabs through the storage event; an account switch stops the old tab's persisted stores from writing - Preference PATCH sends only changed keys and per-uuid overrides; preferences are fetched on every start, the frontend override cleanup is gone - IndexedDB blocking/blocked handlers: old tabs release the DB and show the update notification - Analytics: save_conflict, app:remote_change, app:session_change, app:db_blocked Spec: specs/2026-10-03-multitab-02-frontend.md Co-Authored-By: Claude Opus 5.5 --- docs/analytics.md | 6 + docs/data-layer.md | 42 +++ docs/features/README.md | 1 + docs/features/cx.md | 11 +- docs/features/empire.md | 5 +- docs/features/planning_data.md | 7 +- docs/features/preferences.md | 12 +- docs/features/save_conflict.md | 43 +++ docs/features/wrapper.md | 6 + src/App.vue | 39 ++- src/database/composables/useIndexedDBStore.ts | 40 ++- src/features/api/cxData.api.ts | 5 +- src/features/api/empireData.api.ts | 8 +- src/features/api/schemas/cxData.schemas.ts | 9 +- .../api/schemas/empireData.schemas.ts | 10 + .../api/schemas/planningData.schemas.ts | 10 +- src/features/api/schemas/user.schemas.ts | 10 + src/features/api/userData.api.ts | 7 +- src/features/cx/cxDiff.ts | 103 +++++++ src/features/cx/useCXSave.ts | 129 +++++++++ .../empire/components/EmpireConfiguration.vue | 32 ++- .../components/EmpireConfigurationForm.vue | 11 +- .../empire/components/EmpireOnboarding.vue | 14 +- src/features/empire/empire.constants.ts | 11 + src/features/empire/empireDiff.ts | 41 +++ src/features/empire/useEmpireForm.ts | 146 +++++++++- .../planning/components/tools/PlanCOGM.vue | 49 +++- src/features/planning/usePlanHistory.ts | 2 + src/features/planning_data/planDiff.ts | 215 +++++++++++++++ src/features/planning_data/usePlan.ts | 37 +-- src/features/planning_data/usePlan.types.ts | 13 + src/features/preferences/preferenceSync.ts | 116 ++++++++ src/features/preferences/usePreferences.ts | 62 ++--- .../profile/components/UserPreferences.vue | 4 - .../components/SaveConflictDialog.vue | 194 ++++++++++++++ .../components/SaveConflictNotice.vue | 37 +++ .../save_conflict/saveConflict.types.ts | 28 ++ .../save_conflict/saveConflict.util.ts | 98 +++++++ src/features/save_conflict/useSaveConflict.ts | 71 +++++ src/features/wrapper/dataLoader.types.ts | 7 +- .../wrapper/planningDataLoader.types.ts | 2 + src/features/wrapper/usePlanningDataLoader.ts | 46 +++- src/lib/analytics/useAnalytics.types.ts | 26 +- src/lib/crossTab.ts | 189 +++++++++++++ src/lib/persistStorage.ts | 23 ++ .../query_cache/queries/planning.queries.ts | 53 ++-- src/lib/query_cache/queries/user.queries.ts | 15 +- src/lib/query_cache/useQuery.ts | 8 +- src/lib/useVersionCheck.ts | 9 + src/locales/en_US/profile.json | 2 +- src/locales/en_US/save_conflict.json | 61 +++++ src/main.ts | 4 + src/stores/planningStore.ts | 17 +- src/stores/userStore.ts | 32 ++- .../useIndexedDBStore.upgrade.test.ts | 53 ++++ .../features/api/backendContract.test.ts | 7 +- src/tests/features/api/empireData.api.test.ts | 1 + src/tests/features/cx/cxDiff.test.ts | 99 +++++++ src/tests/features/cx/useCXSave.test.ts | 153 +++++++++++ .../components/EmpireConfiguration.test.ts | 37 ++- .../empire/components/EmpireEmpty.test.ts | 11 +- .../components/EmpireOnboarding.test.ts | 17 +- src/tests/features/empire/empireDiff.test.ts | 44 +++ .../features/empire/useEmpireForm.test.ts | 131 ++++++++- .../planning/components/PlanCOGM.test.ts | 20 +- .../features/planning_data/planDiff.test.ts | 162 +++++++++++ .../features/planning_data/usePlan.test.ts | 50 +++- .../preferences/preferenceSync.test.ts | 91 +++++++ .../preferences/usePreferences.sync.test.ts | 34 +++ .../preferences/usePreferences.test.ts | 20 -- .../components/UserPreferences.test.ts | 5 +- .../save_conflict/saveConflict.util.test.ts | 76 ++++++ .../save_conflict/useSaveConflict.test.ts | 59 ++++ .../WrapperPlanningDataLoader.test.ts | 92 +++++++ src/tests/lib/crossTab.test.ts | 252 ++++++++++++++++++ .../queries/planning.queries.test.ts | 126 +++++++++ src/tests/stores/planningStore.test.ts | 5 +- src/tests/stores/userStore.test.ts | 27 +- .../test_data/api_data_cx_definition.json | 1 + src/tests/test_data/api_data_cx_list.json | 5 + src/tests/test_data/api_data_empire_list.json | 7 + .../test_data/api_data_exchange_patch.json | 1 + src/tests/views/EmpireView.test.ts | 136 +++++++--- src/tests/views/ExchangesView.test.ts | 43 ++- src/tests/views/PlanView.save.test.ts | 246 +++++++++++++++++ src/views/EmpireView.vue | 76 +++++- src/views/ExchangesView.vue | 70 ++++- src/views/PlanView.vue | 223 ++++++++++++++-- 88 files changed, 4251 insertions(+), 307 deletions(-) create mode 100644 docs/features/save_conflict.md create mode 100644 src/features/cx/cxDiff.ts create mode 100644 src/features/cx/useCXSave.ts create mode 100644 src/features/empire/empire.constants.ts create mode 100644 src/features/empire/empireDiff.ts create mode 100644 src/features/planning_data/planDiff.ts create mode 100644 src/features/preferences/preferenceSync.ts create mode 100644 src/features/save_conflict/components/SaveConflictDialog.vue create mode 100644 src/features/save_conflict/components/SaveConflictNotice.vue create mode 100644 src/features/save_conflict/saveConflict.types.ts create mode 100644 src/features/save_conflict/saveConflict.util.ts create mode 100644 src/features/save_conflict/useSaveConflict.ts create mode 100644 src/lib/crossTab.ts create mode 100644 src/lib/persistStorage.ts create mode 100644 src/locales/en_US/save_conflict.json create mode 100644 src/tests/database/composables/useIndexedDBStore.upgrade.test.ts create mode 100644 src/tests/features/cx/cxDiff.test.ts create mode 100644 src/tests/features/cx/useCXSave.test.ts create mode 100644 src/tests/features/empire/empireDiff.test.ts create mode 100644 src/tests/features/planning_data/planDiff.test.ts create mode 100644 src/tests/features/preferences/preferenceSync.test.ts create mode 100644 src/tests/features/save_conflict/saveConflict.util.test.ts create mode 100644 src/tests/features/save_conflict/useSaveConflict.test.ts create mode 100644 src/tests/lib/crossTab.test.ts create mode 100644 src/tests/lib/query_cache/queries/planning.queries.test.ts create mode 100644 src/tests/views/PlanView.save.test.ts diff --git a/docs/analytics.md b/docs/analytics.md index 2ffc7a216..22397d21c 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -82,6 +82,7 @@ the tables here list the same events; a test | `plan:edit` | `planet_natural_id`, `field`, and the relevant of `building_ticker`, `recipe_id`, `amount`, `infrastructure_type`, `expert_type`, `workforce_type`, `lux_type`, `value`, `is_from_popular`, `is_most_planned` | see below | | `plan:undo` | `trigger` (`button`, `shortcut`) | | | `plan:redo` | `trigger` (`button`, `shortcut`) | | +| `plan:save_conflict` | `planet_natural_id`, `is_deleted`, `choice` (`save_as_new`, `overwrite`, `reload`, `close`) | the save conflict dialog was answered: the plan was saved (`is_deleted`: deleted) in another tab or device since it was loaded | `plan:edit` replaces one event per click. `field` is one of `building_add`, `building_amount`, `recipe_add`, `recipe_mix_add`, `recipe_change`, @@ -109,6 +110,7 @@ The typical setup is not also sent as `plan:edit` events; undoing it is a | `empire:update` | `is_success` | the empire configuration was saved | | `empire:reload` | | | | `empire:material_io_expand` | | a Material I/O row was opened | +| `empire:save_conflict` | `choice` (`overwrite`, `reload`, `close`) | the save conflict dialog of the empire configuration was answered | | `manage:cx_create` | | | | `manage:cx_delete` | `cx_uuid` | | | `manage:empire_create` | | | @@ -120,6 +122,7 @@ The typical setup is not also sent as `plan:edit` events; undoing it is a | `manage:plan_delete` | `plan_uuid` | | | `exchange:update` | `location`, `cx_uuid` | | | `exchange:reload` | `location` | | +| `exchange:save_conflict` | `location` (`exchanges_view`, `cogm`), `choice` (`overwrite`, `reload`, `close`) | the save conflict dialog of a CX was answered | | `material:market_drawer_open` | `material_ticker` | | ### Tools @@ -159,6 +162,9 @@ so for them `tool:use` is close to a pageview that got a result: | `xit:transfer_copy` | | | | `app:navigation_toggle` | `navigation_style` | | | `app:version_reload` | | | +| `app:remote_change` | `object_type` (`plan`, `empire`, `cx`), `has_unsaved_edits`, `is_deleted` | another tab saved or deleted what an open editor shows: it reloaded, or (with unsaved edits, or deleted) showed a notice. A deleted empire is sent by the empire page, always with `has_unsaved_edits: false` | +| `app:session_change` | `reason` (`logout`, `login`, `other_user`) | another tab logged out, logged in, or logged in as another user; sent before this tab resets or reloads | +| `app:db_blocked` | | the local game data database waits on an older PRUNplanner tab after a deploy | | `onboarding:step_click` | `step` (`empire_save`, `planet_search`, `exchanges`) | a step of the first-run card on the Empire page | ## Pageviews diff --git a/docs/data-layer.md b/docs/data-layer.md index 9d945de23..110a648ef 100644 --- a/docs/data-layer.md +++ b/docs/data-layer.md @@ -183,6 +183,13 @@ component / composable the connection; on an `InvalidStateError` or `UnknownError` (a connection lost without a `close` event) `storeAndPreload` calls `dropDB` so the next `getDB` reopens it. +- **Upgrades with several tabs open.** Every release bumps the DB version. + When a newer tab opens it, the older tab's `blocking` handler closes its + connection, marks the app outdated (`useVersionCheck().markOutdated()` + shows the update notification) and never reopens the old version: + `getDB` throws, and the tab keeps working from its in-memory layer. A tab + of a release without that handler keeps the upgrade waiting; the new tab + sets `dbBlocked` and `App.vue` asks to close or reload the other tabs. - **`services/`** is the API consumers should use: | Service | Provides | @@ -230,6 +237,41 @@ The stores are setup-style. Persistence uses `pinia-plugin-persistedstate` - `userAlertsStore` is not synced to the backend. Alert rules stay per-browser. +## 6. Several tabs (`src/lib/crossTab.ts`) + +Each tab hydrates the persisted stores once and then keeps its own copy, so +the tabs of one browser are kept in step explicitly (registered in +`main.ts`): + +- **Planning changes.** Every planning mutation in `planning.queries.ts` + invalidates through `changed(prefixes, uuid)`, which also posts + `{userId, keys, uuid}` on the `"prunplanner"` `BroadcastChannel`. The + user's other tabs run the same `invalidate`, then set `remoteChange`. + `usePlanningDataLoader` reloads its loaded steps under those keys + (`refreshKey`) without clearing them and emits `refreshed`; open editors + (plan, empire configuration, CX) reload when they have no unsaved edits, + otherwise they show a "Saved in another tab" notice. +- **Save versions.** Plans, empires and CXs carry `modified_at`. A save + sends the version its edit started from as `base_modified_at` and takes + the new one from the response. A 409 (saved elsewhere) or 404 (deleted) + opens the save conflict dialog, see + [features/save_conflict.md](features/save_conflict.md). +- **Login state.** A `storage` listener on `prunplanner_user`: no refresh + token means another tab logged out (`userStore.resetSession()`, leave + pages that need a login); another user, or a login while this tab had + none, ends this tab's session and reloads. It first stops this tab's + persisted stores from writing (`lib/persistStorage.ts`, the `storage` of + the user and planning stores), so a tab kept open by the leave-page + prompt can never write the old login or plans back; it shows a notice to + reload. The same user adopts the tokens and only the preference + keys the other tab changed. Values are assigned only when they differ, + so the persist plugin can't bounce writes between tabs. +- **Preferences** (`features/preferences/preferenceSync.ts`) keep the last + state known to match the backend. The debounced PATCH sends only what + changed since: top-level keys, and `planOverrides` per uuid with `null` + for a removed one (the backend merges per uuid). `GetPreferences` runs + on every app start when logged in. + ## Config (`src/lib/config.ts`) `config.ts` reads the `VITE_*` env vars (see the README table) and applies diff --git a/docs/features/README.md b/docs/features/README.md index 086549bce..253e6c998 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -39,6 +39,7 @@ One page per `src/features/*` folder. Each page follows the same outline: | --- | --- | | [api](api.md) | Backend `call*()` functions and Zod schemas | | [wrapper](wrapper.md) | Data-loading gates used by every view | +| [save_conflict](save_conflict.md) | Dialog when a plan, empire or CX was saved or deleted in another tab | | [material_tile](material_tile.md) | The standard material ticker chip | | [plan_analytics](plan_analytics.md) | Planet insights box on the plan page | | [help](help.md) | Markdown help drawer and tutorial | diff --git a/docs/features/cx.md b/docs/features/cx.md index f4e423270..60fbb588c 100644 --- a/docs/features/cx.md +++ b/docs/features/cx.md @@ -37,6 +37,13 @@ and small CX helpers. Every price in the app is resolved by a `PriceBook` - `findEmpireCXUuid(empireUuid)` returns the CX assigned to an empire; - `getPreferenceOptions(includeNone)` returns select options, where "None" means Universe 30D. +- **`useCXSave.ts`**: saves a CX edited on the Exchanges page or in a + plan's COGM tool with the version the edit started from; saved or + deleted in another tab opens the save conflict dialog or sets + `remoteNotice` (see [save_conflict](save_conflict.md)). `isEdited` + compares through `cxDiff`. +- **`cxDiff.ts`**: `diffCX(from, to)`, what changed in a CX's name, + exchanges and ticker prices, for the empire and per planet. - **`components/MaterialCXOverviewTable.vue`**: per-exchange price and volume table for one material, including market share. @@ -57,5 +64,5 @@ and small CX helpers. Every price in the app is resolved by a `PriceBook` ## Tests -`src/tests/features/cx/priceBook.test.ts`, `usePrice.test.ts` and -`useCXData.test.ts`. +`src/tests/features/cx/priceBook.test.ts`, `usePrice.test.ts`, +`useCXData.test.ts`, `useCXSave.test.ts` and `cxDiff.test.ts`. diff --git a/docs/features/empire.md b/docs/features/empire.md index 45530b1da..30f273e5f 100644 --- a/docs/features/empire.md +++ b/docs/features/empire.md @@ -29,7 +29,10 @@ itself. See [../planning-engine.md](../planning-engine.md#empires). **Composables and types:** - `useEmpireForm(data)` keeps an editable copy of an empire's configuration - and saves it with `PatchEmpire`. + and saves it with `PatchEmpire`, sending the version it started from. A + save over another tab's newer one opens the save conflict dialog + (`conflict`, see [save_conflict](save_conflict.md)); a newer empire from + another tab replaces a clean form, or sets `remoteNotice` if it has edits. - `useProductionOpportunities(empireIO, cxUuid)` works out which recipes could use the empire's surplus materials. It prices them with `usePrice(...).getPrice(…, "SELL")` and loads its data on mount. diff --git a/docs/features/planning_data.md b/docs/features/planning_data.md index 5e996ea2c..80797ed5e 100644 --- a/docs/features/planning_data.md +++ b/docs/features/planning_data.md @@ -16,7 +16,12 @@ mapping. plan's `PlanCOGCProgram`. - `createNewPlan`, `saveExistingPlan`, `reloadExistingPlan` and `cloneSharedPlan` go through `useQuery` (`CreatePlan`, `PatchPlan`, - `GetPlan`, `PostCloneSharedPlan`). + `GetPlan`, `PostCloneSharedPlan`). `createNewPlan` returns the uuid + and save version (`IPlanSaved`); `saveExistingPlan(uuid, data, base)` + returns that or `{ error: "conflict" | "deleted" | "failed" }`. +- **`planDiff.ts`**: `diffPlan(from, to)` lists what changed between two + plan versions for the save conflict dialog, matched by building, hab, + expert and workforce type. - `getPlanNamePlanet(uuid)` looks a plan's name and planet up from the store. - `isEditDisabled(routeParams)` returns true for shared plans. diff --git a/docs/features/preferences.md b/docs/features/preferences.md index 6d1d7d692..20853d4b3 100644 --- a/docs/features/preferences.md +++ b/docs/features/preferences.md @@ -17,7 +17,8 @@ localStorage and synced to the backend. | --- | --- | | `userPreferences.types.ts` | `IPreferenceDefault` and `IPlanPreferenceOverview` (frontend-only). `UserPreference` and `PreferencePerPlan` are derived from `UserPreferenceSchema` in `src/features/api/schemas/user.schemas.ts` | | `userDefaults.ts` | `preferenceDefaults`: every default, including `planDefaults` for per-plan keys | -| `usePreferences.ts` | Writable computeds for each global preference, `cleanPlanPreferences()`, `getBurnDisplayClass()`. It watches the store and **debounces a `PatchPreferences` call by 5s** | +| `usePreferences.ts` | Writable computeds for each global preference and `getBurnDisplayClass()`. It watches the store and **debounces a `PatchPreferences` call by 5s** that sends only what changed since the last sync | +| `preferenceSync.ts` | The last state known to match the backend, `diffPreferences` (changed keys, `planOverrides` per uuid, `null` for removed) and `applyPreferencePatch`. Also used when another tab changed preferences, see [data-layer.md §6](../data-layer.md#6-several-tabs-srclibcrosstabts) | | `usePlanPreferences.ts` | `usePlanPreferences(planUuid)`: writable computeds for one plan's overrides, merged over `planDefaults`. `planUuid` is a ref, getter or string; while it is `undefined` it reads `planDefaults` and writes are no-ops | ## Adding a preference @@ -35,11 +36,16 @@ localStorage and synced to the backend. - **Never mutate `preferenceDefaults`.** `userStore` uses it to seed and reset state, so clone it before changing anything. Tests that leaked changes here have caused order-dependent failures. -- **Backend sync is debounced and fire-and-forget.** Errors are only logged. +- **Backend sync is debounced and fire-and-forget.** Errors are only logged, + and a failed PATCH is retried once. +- **A deleted plan's overrides are removed by the backend.** The frontend + never cleans them up itself: a stale tab would delete overrides of plans + another tab just created. - **`userStore.initLocale` loads the locale before mount.** Changing it goes through `userStore.setLocale`, which lazy-loads the messages. ## Tests -`src/tests/features/preferences/usePreferences.test.ts` and +`src/tests/features/preferences/usePreferences.test.ts`, +`usePreferences.sync.test.ts`, `preferenceSync.test.ts` and `usePlanPreferences.test.ts`. diff --git a/docs/features/save_conflict.md b/docs/features/save_conflict.md new file mode 100644 index 000000000..eb1e61795 --- /dev/null +++ b/docs/features/save_conflict.md @@ -0,0 +1,43 @@ +# save_conflict + +**Purpose.** A plan, empire configuration or CX saved in another tab (or on +another device) after this tab loaded it makes the save fail with 409, a +deleted one with 404. This folder shows what happened and lets the user +choose, so nothing is overwritten silently. + +**Used by.** `PlanView`, `useEmpireForm` (`EmpireConfiguration`, +`EmpireOnboarding`), `useCXSave` (`ExchangesView`, `PlanCOGM`). + +## Key files + +| File | Role | +| --- | --- | +| `saveConflict.util.ts` | `getSaveError(err)` (409 `conflict` / 404 `deleted`), `diffByKey`, `threeWay(loaded, saved, mine, diff)` and `collapse(lines, 8)` | +| `saveConflict.types.ts` | `IChangeLine` (`area`, i18n `key`, `params`), `IChanges`, `SaveConflictOption`, `ISaveConflictRequest` | +| `useSaveConflict.ts` | Dialog state; `ask({deleted, options, loadChanges})` resolves with the chosen option, or `null` when closed | +| `components/SaveConflictDialog.vue` | "Changed in the other tab" and "Your changes", areas changed on both sides highlighted, long lists collapsed | +| `components/SaveConflictNotice.vue` | Inline "Saved / Deleted in another tab" notice with Reload | + +The diffs live with their type: `planning_data/planDiff.ts`, +`empire/empireDiff.ts`, `cx/cxDiff.ts`. They are pure and match list items +by key, never by position, so a reordered list is no change. + +## Options + +- **Plan:** Save as new plan (" (copy)" in the original's empire), + Overwrite (save without a base version), Reload. Deleted: Save as new plan. +- **Empire, CX:** Overwrite, Reload. Deleted: a notice. +- Closing the dialog keeps the edits unsaved. + +## Gotchas + +- Every save must send the version its edit started from and take the new + one from the response, or a tab conflicts with itself. +- If loading the saved version fails, the dialog still offers its options, + without the lists. + +## Tests + +`src/tests/features/save_conflict/`, `planning_data/planDiff.test.ts`, +`empire/empireDiff.test.ts`, `empire/useEmpireForm.test.ts`, +`cx/cxDiff.test.ts`, `cx/useCXSave.test.ts`. diff --git a/docs/features/wrapper.md b/docs/features/wrapper.md index c7f42fd52..c6fe13e83 100644 --- a/docs/features/wrapper.md +++ b/docs/features/wrapper.md @@ -24,6 +24,12 @@ when every step has finished. `data:empire:plans`), and the loader emits `complete` at the end. `WrapperPlanningDataLoader` also emits `update:empireUuid` and `update:cxUuid`, which resolve the default empire and CX. +- **Changes from other tabs.** Planning steps with a `refreshKey` reload + when another tab changes data under it (`remoteChange`, see + [data-layer.md §6](../data-layer.md#6-several-tabs-srclibcrosstabts)). + The old data stays until the new arrives, so the page never falls back to + the loading screen; the step's `data:*` event fires again and the loader + emits `refreshed`. A refreshed CX list keeps the selected CX. - **Rendering.** The slot renders inside ``, so children can `await` data in ` diff --git a/src/database/composables/useIndexedDBStore.ts b/src/database/composables/useIndexedDBStore.ts index 3f596ddb4..5702160dd 100644 --- a/src/database/composables/useIndexedDBStore.ts +++ b/src/database/composables/useIndexedDBStore.ts @@ -1,7 +1,9 @@ import { type IDBPDatabase, openDB } from "idb"; +import { ref, type Ref } from "vue"; import config from "@/lib/config"; import { DB_SCHEMA } from "@/database/schema"; +import { useVersionCheck } from "@/lib/useVersionCheck"; type KeyOfStore = T[K] extends IDBValidKey ? T[K] : never; export interface IStoreStatistic { @@ -10,6 +12,12 @@ export interface IStoreStatistic { } let dbPromise: Promise | null = null; +// a newer version of the app opened the database in another tab: this tab +// works from the data it has in memory and never reopens the old version +let outdated: boolean = false; + +/** Opening waits on other tabs that still have an older version open */ +export const dbBlocked: Ref = ref(false); export async function requestPersistence() { if (navigator && navigator.storage && navigator.storage.persist) { @@ -18,6 +26,9 @@ export async function requestPersistence() { } export async function getDB() { + if (outdated) + throw new Error("IndexedDB is used by a newer PRUNplanner version."); + if (!dbPromise) { dbPromise = openDB( config.INDEXEDDB_DBNAME, @@ -44,11 +55,32 @@ export async function getDB() { terminated() { dbPromise = null; }, + // another tab opens a newer version: let it upgrade, and show + // this one's update notification + blocking(_currentVersion, blockedVersion, event) { + // null: another tab deletes the database, that waits + if (blockedVersion === null) return; + (event.target as IDBDatabase).close(); + dbPromise = null; + outdated = true; + useVersionCheck().markOutdated(); + }, + // tabs of an older release without `blocking` keep the + // database open, the upgrade waits until they close + blocked() { + dbBlocked.value = true; + }, } - ).catch((err: unknown) => { - dbPromise = null; - throw err; - }); + ) + .then((db) => { + dbBlocked.value = false; + return db; + }) + .catch((err: unknown) => { + dbPromise = null; + dbBlocked.value = false; + throw err; + }); } // Request persistence after DB is ready try { diff --git a/src/features/api/cxData.api.ts b/src/features/api/cxData.api.ts index 8ff33f3ed..67aa45e95 100644 --- a/src/features/api/cxData.api.ts +++ b/src/features/api/cxData.api.ts @@ -95,18 +95,21 @@ export async function callUpdateCXJunctions( * @async * @param {string} cxUuid CX Uuid * @param {CXData} data CX Preference Data + * @param {string} [baseModifiedAt] Version the edit started from * @returns {Promise} Updated CX Preference Data */ export async function callPatchCX( cxName: string, cxUuid: string, - data: CXData + data: CXData, + baseModifiedAt?: string ): Promise { return apiService.put( `/planning/cx/${cxUuid}/`, { cx_name: cxName, cx_data: data, + base_modified_at: baseModifiedAt, }, CXPutSchema, CXSchema diff --git a/src/features/api/empireData.api.ts b/src/features/api/empireData.api.ts index 047ae2d7a..e21bb2c8c 100644 --- a/src/features/api/empireData.api.ts +++ b/src/features/api/empireData.api.ts @@ -9,6 +9,7 @@ import { import { EmpireMaterialIOStateSchema, EmpirePayloadSchema, + EmpireSaveResponseSchema, PlanEmpireElementListSchema, PlanEmpireJunctionListSchema, } from "@/features/api/schemas/empireData.schemas"; @@ -21,6 +22,7 @@ import type { import type { EmpireMaterialIOState, EmpirePayload, + EmpireSaveResponse, PlanEmpireElement, PlanEmpireJunction, } from "@/features/api/schemas/empireData.schemas"; @@ -61,17 +63,17 @@ export async function callGetEmpirePlans(empireUuid: string): Promise { * @async * @param {string} empireUuid Empire Uuid * @param {EmpirePayload} data Empire Patch data - * @returns {Promise} Updated empire data + * @returns {Promise} Updated empire data */ export async function callPatchEmpire( empireUuid: string, data: EmpirePayload -): Promise { +): Promise { return apiService.put( `planning/empire/${empireUuid}/`, data, EmpirePayloadSchema, - PlanEmpireSchema + EmpireSaveResponseSchema ); } diff --git a/src/features/api/schemas/cxData.schemas.ts b/src/features/api/schemas/cxData.schemas.ts index 99501e669..260173939 100644 --- a/src/features/api/schemas/cxData.schemas.ts +++ b/src/features/api/schemas/cxData.schemas.ts @@ -69,10 +69,17 @@ export const CXSchema = z.object({ ), cx_data: CXDataSchema, cx_name: z.string().nonempty(), + modified_at: z.string(), }); export type CX = z.infer; -export const CXPutSchema = CXSchema.pick({ cx_data: true, cx_name: true }); +export const CXPutSchema = CXSchema.pick({ + cx_data: true, + cx_name: true, +}).extend({ + // save version, ignored on create + base_modified_at: z.string().nullish(), +}); export const CXListSchema = z.array(CXSchema); diff --git a/src/features/api/schemas/empireData.schemas.ts b/src/features/api/schemas/empireData.schemas.ts index 73ab9de17..f8abbce16 100644 --- a/src/features/api/schemas/empireData.schemas.ts +++ b/src/features/api/schemas/empireData.schemas.ts @@ -15,6 +15,8 @@ export const PlanEmpireElementSchema = PlanEmpireSchema.extend({ planet_natural_id: z.string(), }) ), + // version of the configuration (name, faction, permits) + modified_at: z.string(), }); export type PlanEmpireElement = z.infer; @@ -26,6 +28,8 @@ export const EmpirePayloadSchema = z.object({ empire_faction: PlanEmpireFactionSchema, empire_permits_used: z.number().int().min(1), empire_permits_total: z.number().int().min(2), + // save version, ignored on create + base_modified_at: z.string().nullish(), }); export type EmpirePayload = z.input; @@ -35,6 +39,12 @@ const PlanEmpireJunctionSchema = z.object({ }); export type PlanEmpireJunction = z.input; +// embedded plan empires carry no version, the saved one does +export const EmpireSaveResponseSchema = PlanEmpireSchema.extend({ + modified_at: z.string(), +}); +export type EmpireSaveResponse = z.infer; + export const PlanEmpireJunctionListSchema = z.array(PlanEmpireJunctionSchema); const MaterialValueSchema = z.object({ diff --git a/src/features/api/schemas/planningData.schemas.ts b/src/features/api/schemas/planningData.schemas.ts index 16cfc445b..1e460d7c6 100644 --- a/src/features/api/schemas/planningData.schemas.ts +++ b/src/features/api/schemas/planningData.schemas.ts @@ -151,6 +151,8 @@ export const PlanSchema = z.object({ plan_cogc: PlanCOGCProgramSchema, plan_data: PlanDataSchema, empires: z.array(PlanEmpireSchema).optional(), + // save version, the shared payload has none + modified_at: z.string().optional(), }); export type Plan = z.infer; @@ -179,12 +181,18 @@ export const PlanCreateDataSchema = PlanSchema.pick({ }); export type PlanCreateData = z.input; +// base_modified_at: the version the edit started from, a newer stored one +// makes the save fail with 409; absent or null overwrites export const PlanSaveDataSchema = PlanCreateDataSchema.extend({ uuid: z.uuid(), + base_modified_at: z.string().nullish(), }); export type PlanSaveData = z.input; -export const PlanSaveCreateResponseSchema = PlanSchema.pick({ uuid: true }); +export const PlanSaveCreateResponseSchema = PlanSchema.pick({ + uuid: true, + modified_at: true, +}); export type PlanSaveCreateResponse = z.infer< typeof PlanSaveCreateResponseSchema >; diff --git a/src/features/api/schemas/user.schemas.ts b/src/features/api/schemas/user.schemas.ts index e89727eb3..f77467205 100644 --- a/src/features/api/schemas/user.schemas.ts +++ b/src/features/api/schemas/user.schemas.ts @@ -171,3 +171,13 @@ export const UserPreferencePayloadSchema = UserPreferenceSchema.extend({ .nullish() .transform((v) => v ?? null), }); + +// PATCH sends only the changed keys; the backend merges planOverrides per +// uuid and deletes the ones sent as null +export const UserPreferencePatchSchema = + UserPreferencePayloadSchema.partial().extend({ + planOverrides: z + .record(z.string(), PreferencePerPlanSchema.nullable()) + .optional(), + }); +export type UserPreferencePatch = z.input; diff --git a/src/features/api/userData.api.ts b/src/features/api/userData.api.ts index 0d7aa50b8..c9cc7da44 100644 --- a/src/features/api/userData.api.ts +++ b/src/features/api/userData.api.ts @@ -15,7 +15,7 @@ import { UserRequestPasswordResetPayloadSchema, UserPasswordResetPayloadSchema, RefreshTokenResponseSchema, - UserPreferencePayloadSchema, + UserPreferencePatchSchema, UserPreferenceSchema, UserRegistrationResponseSchema, } from "@/features/api/schemas/user.schemas"; @@ -26,6 +26,7 @@ import type { TokenResponse, UserChangePasswordPayload, UserPreference, + UserPreferencePatch, UserProfile, UserProfilePatch, UserRegistrationPayload, @@ -217,12 +218,12 @@ export async function callPasswordReset( } export async function callPatchUserPreferences( - preferences: UserPreference + preferences: UserPreferencePatch ): Promise { return apiService.patch( "/user/preferences/", preferences, - UserPreferencePayloadSchema, + UserPreferencePatchSchema, UserPreferenceSchema ); } diff --git a/src/features/cx/cxDiff.ts b/src/features/cx/cxDiff.ts new file mode 100644 index 000000000..43f7c72dd --- /dev/null +++ b/src/features/cx/cxDiff.ts @@ -0,0 +1,103 @@ +// Types & Interfaces +import type { CX } from "@/features/api/schemas/cxData.schemas"; +import type { IChangeLine } from "@/features/save_conflict/saveConflict.types"; + +/** The saved fields of a CX */ +export type ICXDiffable = Pick; + +// shown for a preference that isn't set on one side +const NONE = "—"; + +/** + * Preferences as "scope:key" → value, the empire's scope is empty + */ +function flatten( + empire: T[], + planets: { planet: string; preferences: T[] }[], + keyOf: (item: T) => string, + valueOf: (item: T) => string | number +): Map { + const map = new Map(); + empire.forEach((p) => map.set(`:${keyOf(p)}`, valueOf(p))); + planets.forEach(({ planet, preferences }) => + preferences.forEach((p) => map.set(`${planet}:${keyOf(p)}`, valueOf(p))) + ); + return map; +} + +function changes( + area: string, + from: Map, + to: Map, + params: (key: string) => Record +): IChangeLine[] { + const lines: IChangeLine[] = []; + for (const key of new Set([...from.keys(), ...to.keys()])) { + const before = from.get(key) ?? NONE; + const after = to.get(key) ?? NONE; + if (before === after) continue; + + const planet = key.split(":")[0]; + lines.push({ + area: `${area}:${key}`, + key: planet ? `${area}_planet` : area, + params: { + ...params(key), + ...(planet ? { planet } : {}), + from: before, + to: after, + }, + }); + } + return lines; +} + +/** + * What changed in a CX: its name, the exchange per type (BUY, SELL, BOTH) + * and the ticker prices, for the empire and per planet + * + * @author jplacht + * + * @param {ICXDiffable} from Old version + * @param {ICXDiffable} to New version + * @returns {IChangeLine[]} Change lines + */ +export function diffCX(from: ICXDiffable, to: ICXDiffable): IChangeLine[] { + const lines: IChangeLine[] = []; + + if (from.cx_name !== to.cx_name) + lines.push({ + area: "name", + key: "name", + params: { from: from.cx_name, to: to.cx_name }, + }); + + const exchanges = (cx: ICXDiffable) => + flatten( + cx.cx_data.cx_empire, + cx.cx_data.cx_planets, + (p) => p.type, + (p) => p.exchange + ); + lines.push( + ...changes("exchange", exchanges(from), exchanges(to), (key) => ({ + type: key.split(":")[1], + })) + ); + + const tickers = (cx: ICXDiffable) => + flatten( + cx.cx_data.ticker_empire, + cx.cx_data.ticker_planets, + (p) => `${p.ticker}:${p.type}`, + (p) => p.value + ); + lines.push( + ...changes("ticker", tickers(from), tickers(to), (key) => ({ + ticker: key.split(":")[1], + type: key.split(":")[2], + })) + ); + + return lines; +} diff --git a/src/features/cx/useCXSave.ts b/src/features/cx/useCXSave.ts new file mode 100644 index 000000000..0d26e410a --- /dev/null +++ b/src/features/cx/useCXSave.ts @@ -0,0 +1,129 @@ +import { ref, type Ref } from "vue"; + +// Composables +import { useQuery } from "@/lib/query_cache/useQuery"; +import { trackEvent } from "@/lib/analytics/useAnalytics"; +import { useSaveConflict } from "@/features/save_conflict/useSaveConflict"; + +// Util +import { + getSaveError, + threeWay, +} from "@/features/save_conflict/saveConflict.util"; +import { diffCX, type ICXDiffable } from "@/features/cx/cxDiff"; + +// Types & Interfaces +import type { CX } from "@/features/api/schemas/cxData.schemas"; + +/** + * Saves a CX edited on the Exchanges page or in a plan's COGM tool. The + * save sends the version the edit started from: saved in another tab + * meanwhile opens the conflict dialog (`conflict`), deleted there sets + * `remoteNotice`. + * + * @author jplacht + * + * @param {"exchanges_view" | "cogm"} location Where the CX is edited + */ +export function useCXSave(location: "exchanges_view" | "cogm") { + const conflict = useSaveConflict(); + const remoteNotice: Ref<"saved" | "deleted" | null> = ref(null); + + async function fetchSaved(cxUuid: string): Promise { + const cxs = await useQuery("GetAllCX").execute({ forceRefetch: true }); + return cxs.find((c) => c.uuid === cxUuid); + } + + /** + * Saves the edits + * + * @param {CX} loaded CX the edits started from + * @param {ICXDiffable} mine Edited name and data + * @param {boolean} [overwrite=false] Save over a newer version + * @returns {Promise} True if the store now holds the saved or + * reloaded CX, so the editor reads it again + */ + async function save( + loaded: CX, + mine: ICXDiffable, + overwrite: boolean = false + ): Promise { + try { + await useQuery("PatchCX", { + cxName: mine.cx_name, + cxUuid: loaded.uuid, + data: mine.cx_data, + baseModifiedAt: overwrite ? undefined : loaded.modified_at, + }).execute(); + remoteNotice.value = null; + return true; + } catch (err) { + const kind = getSaveError(err); + if (kind === "deleted") { + remoteNotice.value = "deleted"; + return false; + } + if (kind !== "conflict") throw err; + } + + const choice = await conflict.ask({ + deleted: false, + options: ["overwrite", "reload"], + loadChanges: async () => { + const saved = await fetchSaved(loaded.uuid); + if (!saved) throw new Error("CX deleted"); + return threeWay(loaded, saved, mine, diffCX); + }, + }); + trackEvent("exchange:save_conflict", { + location, + choice: choice ?? "close", + }); + + if (choice === "overwrite") return save(loaded, mine, true); + if (choice === "reload") { + const saved = await fetchSaved(loaded.uuid); + remoteNotice.value = saved ? null : "deleted"; + return saved !== undefined; + } + return false; + } + + /** + * Unsaved edits, ignoring the order of the preferences + * + * @param {CX} loaded CX the edits started from + * @param {ICXDiffable} mine Edited name and data + * @returns {boolean} Edited + */ + function isEdited(loaded: CX, mine: ICXDiffable): boolean { + return diffCX(loaded, mine).length > 0; + } + + /** + * Another tab saved or deleted the CX the editor shows: says so when + * it has unsaved edits or is gone, else the editor reloads + * + * @param {CX | undefined} saved The CX as stored now, undefined if deleted + * @param {CX} loaded CX the edits started from + * @param {ICXDiffable} mine Edited name and data + * @returns {boolean} True if the editor should reload + */ + function onRemoteChange( + saved: CX | undefined, + loaded: CX, + mine: ICXDiffable + ): boolean { + const edited = isEdited(loaded, mine); + trackEvent("app:remote_change", { + object_type: "cx", + has_unsaved_edits: edited, + is_deleted: !saved, + }); + if (!saved) remoteNotice.value = "deleted"; + else if (edited) remoteNotice.value = "saved"; + return !!saved && !edited; + } + + return { conflict, remoteNotice, save, isEdited, onRemoteChange }; +} diff --git a/src/features/empire/components/EmpireConfiguration.vue b/src/features/empire/components/EmpireConfiguration.vue index 1b86ce4d2..1c13e7316 100644 --- a/src/features/empire/components/EmpireConfiguration.vue +++ b/src/features/empire/components/EmpireConfiguration.vue @@ -1,5 +1,5 @@