Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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` | | |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions docs/data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 9 additions & 2 deletions docs/features/cx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.
5 changes: 4 additions & 1 deletion docs/features/empire.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/features/planning_data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions docs/features/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
43 changes: 43 additions & 0 deletions docs/features/save_conflict.md
Original file line number Diff line number Diff line change
@@ -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 ("<name> (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`.
6 changes: 6 additions & 0 deletions docs/features/wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Suspense>`, so children can
`await` data in `<script setup>`.
- **Missing shared plan.** A 404 on the `shared-plan-uuid` step
Expand Down
39 changes: 37 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, computed } from "vue";
import { defineAsyncComponent, onMounted, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
import { useRoute } from "vue-router";
const routeData = useRoute();

Expand Down Expand Up @@ -32,7 +34,38 @@
import { useUserStore } from "@/stores/userStore";
const userStore = useUserStore();
import { userActivity } from "@/features/user_activity/userActivityStore";
import { identifyUser } from "@/lib/analytics/useAnalytics";
import { identifyUser, trackEvent } from "@/lib/analytics/useAnalytics";
import { useQuery } from "@/lib/query_cache/useQuery";
import { dbBlocked } from "@/database/composables/useIndexedDBStore";
import { sessionReplaced } from "@/lib/crossTab";
import { useToast } from "@/ui";
import type { MessageReactive } from "naive-ui";
const toast = useToast();

// an older PRUNplanner tab holds the database, game data waits on it
let blockedToast: MessageReactive | undefined;
watch(dbBlocked, (blocked) => {
if (blocked) trackEvent("app:db_blocked");
blockedToast?.destroy();
blockedToast = blocked
? toast(t("save_conflict.notice.db_blocked"), {
type: "error",
duration: 0,
})
: undefined;
});

// another tab logged in while the leave-page prompt kept this one open
watch(sessionReplaced, () =>
toast(t("save_conflict.notice.session_replaced"), {
type: "error",
duration: 0,
action: {
label: t("save_conflict.notice.reload"),
onClick: () => location.reload(),
},
})
);

const isLoggedIn = computed(() => userStore.isLoggedIn);
const showUpdateNotification = computed(
Expand All @@ -48,6 +81,8 @@
if (userStore.isLoggedIn) {
// start user activity monitor if logged in
const _activity = userActivity;
// changed in another browser or device since this one stored them
useQuery("GetPreferences").execute().catch(console.error);
}
});
</script>
Expand Down
40 changes: 36 additions & 4 deletions src/database/composables/useIndexedDBStore.ts
Original file line number Diff line number Diff line change
@@ -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 keyof T> = T[K] extends IDBValidKey ? T[K] : never;
export interface IStoreStatistic {
Expand All @@ -10,6 +12,12 @@ export interface IStoreStatistic {
}

let dbPromise: Promise<IDBPDatabase> | 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<boolean> = ref(false);

export async function requestPersistence() {
if (navigator && navigator.storage && navigator.storage.persist) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/features/api/cxData.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CXData>} Updated CX Preference Data
*/
export async function callPatchCX(
cxName: string,
cxUuid: string,
data: CXData
data: CXData,
baseModifiedAt?: string
): Promise<CX> {
return apiService.put(
`/planning/cx/${cxUuid}/`,
{
cx_name: cxName,
cx_data: data,
base_modified_at: baseModifiedAt,
},
CXPutSchema,
CXSchema
Expand Down
Loading
Loading