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