src/lib/entry-units.ts
Rama main · réplica de lectura del repositorio original.
// Which units the diver *types* each measurement in, remembered per device.
//
// A separate question from the account's `units` preference, which stays the
// display authority for every saved dive, chart and stat. This one only governs
// the boxes in the dive form and the gear-set dialog: a diver who thinks in
// metres and rents an SPG reading psi flips pressure to psi and leaves the rest
// alone. Form state is metric either way - `UnitNumberInput` converts at the
// edge and nothing downstream of it learns which system was on.
//
// `localStorage`, like the remembered chart views next to it: a rented psi SPG
// lasts a whole trip, so the choice has to survive the tab closing. Not the user
// row, because that would be 5-6 new NOT NULL columns on the hottest record, an
// API change and export coverage, all for an entry convenience.
//
// **Two things here depart from `chart-series-view.ts`, which this otherwise
// copies.**
//
// It is genuinely subscribed rather than reading `subscribeToNothing`. Those
// keys are each read by one component, so nothing can disagree with anything;
// this one has concurrent readers that share a dimension - the gear-set dialog
// opens from *inside* the dive form and both render a weight box - and two
// unsubscribed readers would show different units for the same dimension and
// last-writer-wins each other's flips.
//
// And the raw snapshot is cached in module state rather than read from storage
// per call, because `getSnapshot` runs on every render of every consumer and two
// of those sit on the form's hottest path (`MixtureSetWarning` re-renders per
// keystroke). The cache is keyed on the identity of the `Storage` it was primed
// from: in production that object never changes, and under Vitest swapping it is
// exactly what `useStorage(memoryStorage())` does per test - so a fresh store
// re-primes and one test's write cannot serve as the next one's stale read.
//
// The key carries no version suffix, and for a different reason than the chart
// keys' `-vN`. There, a filtered key set makes a newly-added channel read as
// deliberately switched off. Here absence already means the right thing - no
// override, follow the account - so a dimension added later starts correct under
// the old key.
import {
ENTRY_DIMENSIONS,
UNIT_SYSTEMS,
type EntryDimension,
type UnitSystem,
} from "@/lib/units";
export const ENTRY_UNITS_KEY = "opendiving:entry-units";
/** The dimensions the diver has moved off their account preference, and where to. */
export type EntryUnitOverrides = Partial<Record<EntryDimension, UnitSystem>>;
// The raw entry, and the `Storage` it was read out of. `primed` is separate from
// `primedFrom` because `undefined` is a real answer - a browser that hands out no
// storage at all - and must not read as "never looked".
let snapshot: string | null = null;
let primed = false;
let primedFrom: Storage | undefined;
const listeners = new Set<() => void>();
// Storage can be unavailable (Safari private mode, storage disabled) - and where
// it is missing entirely, reaching through it is a `TypeError` rather than a
// storage error. Entering in account units is a fine outcome, not an error case.
function currentStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}
function cache(raw: string | null): void {
snapshot = raw;
primedFrom = currentStorage();
primed = true;
}
function notify(): void {
for (const listener of listeners) listener();
}
/**
* The stored entry, raw and unparsed.
*
* Deliberately returns the string rather than a parsed record: this is the
* snapshot handed to `useSyncExternalStore`, which compares with `Object.is` and
* would loop forever on a freshly built object each call. A string compares by
* value, so parsing happens once, downstream.
*/
export function readStoredEntryUnits(): string | null {
const storage = currentStorage();
if (primed && storage === primedFrom) return snapshot;
primedFrom = storage;
primed = true;
try {
snapshot = storage?.getItem(ENTRY_UNITS_KEY) ?? null;
} catch {
snapshot = null;
}
return snapshot;
}
/**
* A stored entry as a set of overrides, filtered rather than rejected.
*
* Anything unrecognized is dropped and the rest kept: an unknown dimension is a
* stale build's, not corruption, and a dimension with no usable value simply has
* no override - which is the same state a diver who never touched a toggle is in.
* A non-object entry yields no overrides at all.
*/
export function parseEntryUnits(raw: string | null): EntryUnitOverrides {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const record = parsed as Record<string, unknown>;
const overrides: EntryUnitOverrides = {};
for (const dimension of ENTRY_DIMENSIONS) {
const value = record[dimension];
if (UNIT_SYSTEMS.includes(value as UnitSystem)) {
overrides[dimension] = value as UnitSystem;
}
}
return overrides;
} catch {
return {};
}
}
/**
* Stores the overrides, refreshes the cache and tells every consumer.
*
* An empty record removes the key rather than writing `{}`: no overrides is the
* natural state, and leaving an entry behind for it would be a stored value that
* means the same as no stored value.
*
* The cache takes what was *asked for* rather than re-reading, so a browser that
* refused the write still flips the boxes for this session - the choice is not
* remembered, which is the honest outcome, but the toggle is not dead either.
*/
export function writeEntryUnits(overrides: EntryUnitOverrides): void {
const raw =
Object.keys(overrides).length === 0 ? null : JSON.stringify(overrides);
try {
if (raw === null) {
window.localStorage.removeItem(ENTRY_UNITS_KEY);
} else {
window.localStorage.setItem(ENTRY_UNITS_KEY, raw);
}
} catch {
// As above - losing the remembered choice costs a click, not data.
}
cache(raw);
notify();
}
/**
* Forgets every override. Called from `signOut`.
*
* The entry override is view state like the remembered chart selections, and
* unlike them it is cleared on the way out, because what it changes is what a box
* *parses* rather than what it shows. A second diver at a shared browser who
* never touched a toggle would meet a psi-labelled pressure field, type 200
* meaning bar, and commit 13.79 bar - inside the API's range CHECK and
* indistinguishable from real data afterwards.
*/
export function clearEntryUnits(): void {
try {
window.localStorage.removeItem(ENTRY_UNITS_KEY);
} catch {
// As above.
}
cache(null);
notify();
}
/**
* The subscribe half of `useSyncExternalStore`, and a real one.
*
* Cross-tab `storage` events stay unhandled, like every sibling key here: what
* this exists for is the readers in *this* document that share a dimension.
*/
export function subscribeToEntryUnits(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* The overrides with `dimension` flipped to the other system.
*
* Flipping *to* the account's own system removes the key rather than storing a
* value equal to it: no-override is the natural state, so an account-level change
* in settings then carries every unoverridden dimension with it. What an override
* records is the absolute system ("my SPG reads psi"), not "flipped" - that fact
* does not change when the diver re-themes their account.
*/
export function toggleDimension(
overrides: EntryUnitOverrides,
dimension: EntryDimension,
accountUnits: UnitSystem,
): EntryUnitOverrides {
const current = overrides[dimension] ?? accountUnits;
const next: UnitSystem = current === "metric" ? "imperial" : "metric";
const result = { ...overrides };
if (next === accountUnits) {
delete result[dimension];
} else {
result[dimension] = next;
}
return result;
}