This commit is contained in:
Kelorastus
2026-09-16 11:42:24 +02:00
parent b7f3061be3
commit 3fdfde7043
18 changed files with 1485 additions and 21 deletions
+5
View File
@@ -13,6 +13,7 @@ import type {
SignInResult,
StackEvent,
Status,
SyncConflict,
Unlisten,
} from "./types";
@@ -52,6 +53,10 @@ function createTauriBackend(): Backend {
getSettings: () => invoke<Settings>("get_settings"),
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
pickFolder: () => invoke<string | null>("pick_folder"),
syncNow: () => invoke<void>("sync_now"),
getSyncConflicts: () => invoke<SyncConflict[]>("get_sync_conflicts"),
resolveSyncConflict: (tbl, key, choice, value) =>
invoke<SyncConflict[]>("resolve_sync_conflict", { tbl, key, choice, value: value === undefined ? null : value }),
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
+38
View File
@@ -4,6 +4,7 @@ import { backend } from "./backend";
import { errorText, hostOf } from "./format";
import { SIGN_IN_CANCELLED } from "./types";
import type {
ConflictChoice,
InstallProgress,
LogEvent,
LogLevel,
@@ -12,6 +13,7 @@ import type {
StackComponent,
StackEvent,
Status,
SyncConflict,
Unlisten,
} from "./types";
@@ -24,6 +26,7 @@ export type ActionKind =
| "signed_out_offline"
| "installing"
| "restoring"
| "syncing"
| "update"
| "launch"
| "stack"
@@ -45,6 +48,8 @@ export function deriveAction(s: Status | null): ActionKind {
return "installing";
case "restoring":
return "restoring";
case "syncing":
return "syncing";
case "launching":
case "running":
return "stack";
@@ -75,6 +80,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
"signing_in",
"installing",
"restoring",
"syncing",
"launching",
"running",
]);
@@ -104,6 +110,10 @@ class Launcher {
busy = $state<string | null>(null);
refreshing = $state(false);
stopping = $state(false);
/** The sync review panel. */
syncOpen = $state(false);
/** Decisions waiting in this PC's database; null until the panel loads them. */
conflicts = $state<SyncConflict[] | null>(null);
notes = $state<NotesState>({
key: null,
loading: false,
@@ -117,6 +127,8 @@ class Launcher {
/** Settings can't change while something is installing or running. */
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : "");
/** Conflicts plus refused edits from the last sync: what needs the operator. */
syncAttention = $derived(this.status?.sync ? this.status.sync.conflicts + this.status.sync.rejected : 0);
notesVersion = $derived(
this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null,
);
@@ -339,6 +351,32 @@ class Launcher {
});
}
syncNow() {
return this.run("sync", async () => {
await backend.syncNow();
// The panel lists what the sync just found (or settled).
if (this.syncOpen) this.conflicts = await backend.getSyncConflicts();
});
}
/** Opens the review panel and loads what waits for a decision. */
async openSync() {
this.syncOpen = true;
await this.run("conflicts", async () => {
this.conflicts = await backend.getSyncConflicts();
});
}
closeSync() {
this.syncOpen = false;
}
resolveConflict(conflict: SyncConflict, choice: ConflictChoice, value?: unknown) {
return this.run("resolve", async () => {
this.conflicts = await backend.resolveSyncConflict(conflict.tbl, conflict.key, choice, value);
});
}
async pickFolder(): Promise<string | null> {
try {
return await backend.pickFolder();
+106 -2
View File
@@ -2,7 +2,7 @@
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
//
// ?scenario=first-run | update-required | ready | offline-installed |
// offline-not-installed | installing | running | error
// offline-not-installed | installing | running | sync-conflicts | error
// &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap
@@ -14,6 +14,7 @@ import {
SIGN_IN_CANCELLED,
type Backend,
type InstallProgress,
type ConflictChoice,
type InstallStage,
type LogEvent,
type LogLevel,
@@ -24,6 +25,8 @@ import {
type StackComponent,
type StackEvent,
type Status,
type SyncConflict,
type SyncStatus,
type Unlisten,
} from "./types";
@@ -131,6 +134,42 @@ const STAGE_DONE: Record<InstallStage, string> = {
finalize: "Install finalized",
};
const POI = "poi_0192f3a1c4e87b5d9a2e6f10b3c4d5e6";
const TARGET = "tar_0192f3a2d91c7e4fa6b80c1d2e3f4a5b";
const AREA = "are_0192f3a3e0b1c2d3e4f5a6b7c8d9e0f1";
const MOCK_CONFLICTS: SyncConflict[] = [
{
tbl: "db_ground_poi",
key: { poi_id: POI },
kind: "conflict",
fields: ["name", "color"],
base: { poi_id: POI, name: "Relay hill", color: "red", lat: 48.61, lon: 2.35 },
mine: { poi_id: POI, name: "Relay hill (north slope)", color: "orange", lat: 48.61, lon: 2.35 },
theirs: { poi_id: POI, name: "Relay hill, confirmed", color: "yellow", lat: 48.61, lon: 2.35 },
reason: null,
},
{
tbl: "db_ground_target",
key: { target_id: TARGET },
kind: "conflict",
fields: ["*exists"],
base: { target_id: TARGET, name: "Bridge 14", status_id: null },
mine: null,
theirs: { target_id: TARGET, name: "Bridge 14", status_id: "st_damaged" },
reason: null,
},
{
tbl: "db_ground_area",
key: { area_id: AREA },
kind: "rejected",
fields: [],
base: null,
mine: { area_id: AREA, name: "NFA Delta (wider)" },
theirs: { area_id: AREA, name: "NFA Delta" },
reason: "only the owner can change or delete this",
},
];
type Payloads = {
status: Status;
"install-progress": InstallProgress;
@@ -173,6 +212,8 @@ function initialStatus(scenario: MockScenario): Status {
phase: "idle",
error: null,
install_dir: DEFAULT_DIR,
sync: null,
live: null,
};
switch (scenario) {
case "first-run":
@@ -193,7 +234,15 @@ function initialStatus(scenario: MockScenario): Status {
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
break;
case "running":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" });
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running", live: true });
break;
case "sync-conflicts":
Object.assign(s, {
installed: CURRENT,
operator: OPERATOR,
signed_in: true,
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
});
break;
case "error":
Object.assign(s, {
@@ -225,6 +274,8 @@ class MockBackend implements Backend {
private job = 0;
private booted = false;
private signInCancelled = false;
/** What waits for a decision in the mock "local database". */
private conflicts: SyncConflict[];
/** Latest event per component, as `get_stack` reports it. */
private readonly stackStates = new Map<StackComponent, StackEvent>();
private readonly listeners: Listeners = {
@@ -241,6 +292,7 @@ class MockBackend implements Backend {
private readonly notesVariant: NotesVariant,
) {
this.status = initialStatus(scenario);
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
@@ -475,6 +527,58 @@ class MockBackend implements Backend {
return this.snapshot();
}
private syncCounts(pending: number): SyncStatus {
return {
pending,
conflicts: this.conflicts.filter((c) => c.kind === "conflict").length,
rejected: this.conflicts.filter((c) => c.kind === "rejected").length,
waiting: 0,
};
}
async syncNow(): Promise<void> {
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
const sent = this.status.sync?.pending ?? 0;
this.commit({ phase: "syncing", error: null });
await this.wait(1400);
const counts = this.syncCounts(0);
this.commit({ phase: "idle", sync: counts });
this.log(
"info",
`Synced with the DHP: ${sent} change(s) sent, 4 received. 0 waiting to send, ${counts.conflicts} conflict(s), ${counts.rejected} refused.`,
);
}
async getSyncConflicts(): Promise<SyncConflict[]> {
await this.wait(300);
return structuredClone(this.conflicts);
}
async resolveSyncConflict(
tbl: string,
key: Record<string, unknown>,
choice: ConflictChoice,
): Promise<SyncConflict[]> {
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: review sync once it has stopped.";
const index = this.conflicts.findIndex((c) => c.tbl === tbl && JSON.stringify(c.key) === JSON.stringify(key));
if (index < 0) throw `Nothing to resolve for ${tbl}.`;
const conflict = this.conflicts[index];
if (choice === "dismiss" && conflict.kind !== "rejected") throw `"dismiss" is not a valid decision for a conflict`;
await this.wait(250);
this.conflicts.splice(index, 1);
const pending = (this.status.sync?.pending ?? 0) + (choice === "mine" || choice === "value" ? 1 : 0);
this.commit({ sync: this.syncCounts(pending) });
const outcome =
choice === "theirs"
? "took the DHP's version"
: choice === "dismiss"
? "kept the refused change on this PC only"
: "kept this PC's version; the next sync sends it";
this.log("info", `${tbl}: ${outcome}`);
return structuredClone(this.conflicts);
}
async pickFolder(): Promise<string | null> {
await new Promise((resolve) => setTimeout(resolve, 450));
return "E:\\ODIAN";
+51
View File
@@ -7,6 +7,7 @@ export type Phase =
| "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
| "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open
| "error";
@@ -37,6 +38,44 @@ export interface Status {
error: string | null;
/** e.g. "D:\\ODIAN". */
install_dir: string;
/** Operator-data sync counts from the last sync this session; null before one. */
sync: SyncStatus | null;
/**
* While ODIAN runs: true when its data comes from the DHP (live), false when from
* this PC. null when ODIAN is not running, or the DHP offers no Engine API.
*/
live: boolean | null;
}
export interface SyncStatus {
/** Local edits the DHP has not taken yet. */
pending: number;
/** Records both sides changed: the operator decides. */
conflicts: number;
/** Edits the DHP refused (another operator's record, ...). */
rejected: number;
/** DHP changes this PC could not apply yet. */
waiting: number;
}
/**
* theirs: take the DHP's version. mine: keep this PC's (the next sync sends it).
* value: keep a field-by-field pick. dismiss: keep a refused edit on this PC only.
*/
export type ConflictChoice = "theirs" | "mine" | "value" | "dismiss";
export interface SyncConflict {
/** Database table, e.g. "db_ground_poi". */
tbl: string;
key: Record<string, unknown>;
kind: "conflict" | "rejected";
/** Clashing fields; "*exists": one side deleted it; "*value": changed differently as a whole. */
fields: string[];
base: unknown;
mine: unknown;
theirs: unknown;
/** Why the DHP refused it (rejected only). */
reason: string | null;
}
export interface Settings {
@@ -115,6 +154,7 @@ export const MOCK_SCENARIOS = [
"offline-not-installed",
"installing",
"running",
"sync-conflicts",
"error",
] as const;
@@ -152,6 +192,17 @@ export interface Backend {
getSettings(): Promise<Settings>;
setSettings(settings: Settings): Promise<Status>;
pickFolder(): Promise<string | null>;
/** Push and pull operator data now; only while ODIAN is stopped. */
syncNow(): Promise<void>;
/** What the operator has to decide, oldest first. */
getSyncConflicts(): Promise<SyncConflict[]>;
/** Records one decision; resolves with what is left to decide. */
resolveSyncConflict(
tbl: string,
key: Record<string, unknown>,
choice: ConflictChoice,
value?: unknown,
): Promise<SyncConflict[]>;
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;