diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx
index 4129d7e37..f40cfc6c9 100644
--- a/app/features/scanner/components/ScreenshotPage.tsx
+++ b/app/features/scanner/components/ScreenshotPage.tsx
@@ -280,9 +280,16 @@ export function ScreenshotPage() {
useEffect(() => {
if (!inspectKey) return;
setInspectKey(null);
- void claimInspectFrame(inspectKey).then((frame) => {
- if (frame) void analyze(frame);
- });
+ claimInspectFrame(inspectKey).then(
+ (frame) => {
+ if (frame) void analyze(frame);
+ else
+ setError(
+ "Inspected frame did not arrive — go back to the other tab and press Inspect again",
+ );
+ },
+ (e) => setError(String(e)),
+ );
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent | undefined;
@@ -291,6 +298,7 @@ export function ScreenshotPage() {
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
+ const isMinimap = activeDetector === "minimap";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows();
@@ -535,7 +543,52 @@ export function ScreenshotPage() {
)}
- {frame && event && !isDeath && !isMapStart && !isOwn && (
+ {frame && event && isMinimap && (
+
+ {(() => {
+ const data = event.data as unknown as MinimapData;
+ return (
+ <>
+ stage {stageLabel(data.stage) ?? "?"}
+ {" · "}
+ {data.spectator ? "spectator map" : "POV overlay"}
+ {" · "}team{" "}
+
+ {data.teammates
+ .map(
+ (p) =>
+ `${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`,
+ )
+ .join(", ") || "—"}
+
+ {data.enemies.length > 0 && (
+ <>
+ {" · "}enemies{" "}
+
+ {data.enemies
+ .map(
+ (p) =>
+ `${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`,
+ )
+ .join(", ")}
+
+ >
+ )}
+ {!data.spectator && (
+ <>
+
+ {minimap.CARD_LAYOUTS.map((card) => (
+
+ ))}
+ >
+ )}
+ >
+ );
+ })()}
+
+ )}
+
+ {frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap && (
diff --git a/app/features/scanner/store/db.ts b/app/features/scanner/store/db.ts
index e3a459cef..1896eb489 100644
--- a/app/features/scanner/store/db.ts
+++ b/app/features/scanner/store/db.ts
@@ -39,67 +39,118 @@ function extractFrames(source: IDBObjectStore, frames: IDBObjectStore): void {
};
}
+const ALL_STORES = [
+ EVENTS_STORE,
+ FRAMES_STORE,
+ VODS_STORE,
+ VOD_EVENTS_STORE,
+ VOD_FRAMES_STORE,
+ INSPECT_FRAMES_STORE,
+];
+
// xxx: get rid of migrate before we go live with this
/**
- * Versioned migrations: each `oldVersion < N` block upgrades a database from
- * below version N and runs exactly once per database. Any schema change —
- * including one to an EXISTING store (new index, moved field) — must be a new
- * block plus a DB_VERSION bump, never an edit to an old block: databases that
- * already ran the old block would silently skip the change otherwise.
+ * Brings any database — fresh, older-versioned or drifted (a dev database
+ * whose version was bumped past DB_VERSION without these stores) — to the
+ * current schema. Store creation is existence-guarded rather than
+ * version-gated so a reopen at version+1 (see openDb) can heal drift; only
+ * data moves stay keyed on the version they shipped in.
*/
function migrate(
database: IDBDatabase,
transaction: IDBTransaction,
oldVersion: number,
): void {
- if (oldVersion < 2) {
- // v1/v2 era stores; contains() guards absorb the pre-versioned scheme,
- // where every creation was unconditionally contains()-gated
- if (!database.objectStoreNames.contains(EVENTS_STORE)) {
- const store = database.createObjectStore(EVENTS_STORE, {
- keyPath: "id",
- autoIncrement: true,
- });
- store.createIndex("t", "t");
- store.createIndex("detectedAt", "detectedAt");
- }
- if (!database.objectStoreNames.contains(VODS_STORE)) {
- database.createObjectStore(VODS_STORE, { keyPath: "name" });
- }
- if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) {
- const store = database.createObjectStore(VOD_EVENTS_STORE, {
- keyPath: "id",
- autoIncrement: true,
- });
- store.createIndex("vod", "vod");
- }
+ if (!database.objectStoreNames.contains(EVENTS_STORE)) {
+ const store = database.createObjectStore(EVENTS_STORE, {
+ keyPath: "id",
+ autoIncrement: true,
+ });
+ store.createIndex("t", "t");
+ store.createIndex("detectedAt", "detectedAt");
+ }
+ if (!database.objectStoreNames.contains(VODS_STORE)) {
+ database.createObjectStore(VODS_STORE, { keyPath: "name" });
+ }
+ if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) {
+ const store = database.createObjectStore(VOD_EVENTS_STORE, {
+ keyPath: "id",
+ autoIncrement: true,
+ });
+ store.createIndex("vod", "vod");
+ }
+ if (!database.objectStoreNames.contains(FRAMES_STORE)) {
+ database.createObjectStore(FRAMES_STORE);
+ }
+ if (!database.objectStoreNames.contains(VOD_FRAMES_STORE)) {
+ database.createObjectStore(VOD_FRAMES_STORE);
}
if (oldVersion < 3) {
- // frame blobs move out of the event records into keyed frame stores
- const frames = database.createObjectStore(FRAMES_STORE);
- const vodFrames = database.createObjectStore(VOD_FRAMES_STORE);
- extractFrames(transaction.objectStore(EVENTS_STORE), frames);
- extractFrames(transaction.objectStore(VOD_EVENTS_STORE), vodFrames);
+ // v1/v2 era kept the frame blobs embedded in the event records
+ extractFrames(
+ transaction.objectStore(EVENTS_STORE),
+ transaction.objectStore(FRAMES_STORE),
+ );
+ extractFrames(
+ transaction.objectStore(VOD_EVENTS_STORE),
+ transaction.objectStore(VOD_FRAMES_STORE),
+ );
}
- if (oldVersion < 4) {
+ if (!database.objectStoreNames.contains(INSPECT_FRAMES_STORE)) {
database.createObjectStore(INSPECT_FRAMES_STORE);
}
}
-function openDb(): Promise {
+function openAt(version?: number): Promise {
return new Promise((resolve, reject) => {
- const req = indexedDB.open(DB_NAME, DB_VERSION);
+ const req =
+ version === undefined
+ ? indexedDB.open(DB_NAME)
+ : indexedDB.open(DB_NAME, version);
req.onupgradeneeded = (event) => {
migrate(req.result, req.transaction!, event.oldVersion);
};
+ req.onblocked = () => {
+ // biome-ignore lint/suspicious/noConsole: the only diagnostic channel for a hang caused by other tabs
+ console.warn(
+ "scanner database upgrade is blocked — close or reload other sendou.ink tabs",
+ );
+ };
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
+/**
+ * Opens at whatever version exists, then upgrades when the schema is behind —
+ * either an honest old version or a drifted database sitting at/above
+ * DB_VERSION without all stores, which a plain open(DB_VERSION) would
+ * silently accept (or reject with VersionError).
+ */
+async function openDb(): Promise {
+ let database = await openAt();
+ const missingStore = ALL_STORES.some(
+ (name) => !database.objectStoreNames.contains(name),
+ );
+ if (database.version < DB_VERSION || missingStore) {
+ const nextVersion = Math.max(DB_VERSION, database.version + 1);
+ database.close();
+ database = await openAt(nextVersion);
+ }
+ return database;
+}
+
let dbPromise: Promise | null = null;
export function db(): Promise {
- dbPromise ??= openDb();
+ dbPromise ??= openDb().then((database) => {
+ // when another tab needs to upgrade, release the connection instead of
+ // blocking that tab forever; the next call here reconnects fresh
+ database.onversionchange = () => {
+ database.close();
+ dbPromise = null;
+ };
+ return database;
+ });
return dbPromise;
}