From f290ab10f5e644467edf3a4876f06107c009b50a Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 5 May 2026 19:48:41 +0900 Subject: [PATCH 1/6] fix(kit): only toast product-sync result when triggered in this session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion toast was re-firing every time the operator opened the Products tab when a stale terminal job sat in `productSyncJobs`. Earlier passes guarded the in-memory `lastShownJobIdRef` against `progress.phase === "dismissed"`, but the underlying issue was using a "have I shown this jobId yet" set — a fresh mount started with an empty set, so the very first observation of an already-terminal job re-toasted it. Replace the set with a transition rule: track the previous status per platform, and only toast when the prior render saw the same jobId in `queued` / `running` and the new render sees it in `succeeded` / `failed`. Result: - Land on the page with a pre-existing terminal job → silent. The result banner still surfaces it; nothing pops up. - Trigger a sync from this mount → toast fires on completion as before. - Job ids change between mounts (a new sync ran on another tab) → no toast on the first observation; if it's still running we'll toast when it terminates here. - Dismissed jobs continue to be skipped explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/organization/project/products.tsx | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 0961633e..e31ee41e 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -54,11 +54,23 @@ export default function ProjectProducts() { // Sync state now lives in `productSyncJobs` and is read reactively // via `getActiveSyncJob` per platform — the worker writes progress - // back to the row, so the dashboard re-renders without polling. The - // local `lastShownJobId*` refs gate the completion toast so a - // succeeded job only toasts once even if the row updates again - // (e.g. on dismiss). - const lastShownJobIdRef = useRef>({ + // back to the row, so the dashboard re-renders without polling. + // + // Toast policy: only fire a completion toast for jobs the operator + // *actively triggered in this mounted session*. We track the + // previous status per platform; a toast fires only on the + // transition `running/queued → succeeded/failed`, never on the + // first observed status. That way revisiting the page (where the + // first observation is already terminal) shows the result banner + // but does NOT pop a stale toast for a sync the operator didn't + // just run. + type JobStatusSnapshot = { + jobId: string; + status: "queued" | "running" | "succeeded" | "failed"; + }; + const prevJobStatusRef = useRef< + Record<"IOS" | "Android", JobStatusSnapshot | null> + >({ IOS: null, Android: null, }); @@ -86,25 +98,35 @@ export default function ProjectProducts() { }; }, [products]); - // Show success/failure toast exactly once when a job transitions to - // a terminal state. The lastShownJobIdRef guard prevents the toast - // from re-firing on subsequent reactive updates of the same row. + // Toast on the running → terminal transition only. + // + // Earlier versions used a "shown jobIds" set, which fired on + // every fresh mount because the ref reset to empty — landing on + // the page with a pre-existing terminal job re-toasted it every + // time. The transition rule means: the very first observation + // of a job (no matter its status) just records state without + // toasting; subsequent observations fire only when the status + // crossed from non-terminal to terminal. useEffect(() => { for (const platform of ["IOS", "Android"] as const) { const job = platform === "IOS" ? iosJob : androidJob; if (!job) continue; + const prev = prevJobStatusRef.current[platform]; const terminal = job.status === "succeeded" || job.status === "failed"; + // Update the snapshot before deciding whether to toast — so + // even if we don't toast (initial observation, dismissed, or + // unchanged status) we still track the latest state. + prevJobStatusRef.current[platform] = { + jobId: job._id, + status: job.status, + }; if (!terminal) continue; - // Once the operator has dismissed a terminal job, the row's - // `progress.phase` flips to "dismissed". The result banner - // already gates on this; the toast effect needs the same - // gate or it re-fires the success/failure toast on every - // subsequent page reload (because `lastShownJobIdRef` is - // in-memory and resets) until the pruner deletes the row - // (CodeRabbit review on PR #127). if (job.progress.phase === "dismissed") continue; - if (lastShownJobIdRef.current[platform] === job._id) continue; - lastShownJobIdRef.current[platform] = job._id; + // Initial observation (no prev snapshot) OR a new jobId we've + // never seen → don't toast. We only toast for the same jobId + // when the previous render saw it in a non-terminal state. + if (!prev || prev.jobId !== job._id) continue; + if (prev.status !== "queued" && prev.status !== "running") continue; const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const result = job.result; if (job.status === "succeeded" && result) { From 4037afe61f73d20fbf98bddb5ae39e2d54f1bc58 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 5 May 2026 19:59:08 +0900 Subject: [PATCH 2/6] fix(kit): ASC credential resolver was sending Server API key id with ASC private key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveAscCredentials` required both `iosAscIssuerId` AND `iosAscKeyId` to be populated before using the ASC pair. The Settings UI deliberately exposes only ONE Issuer ID input (documented as "shared with Server API above") because Apple uses a single Issuer per team across both API gateways — so production projects always have `iosAscIssuerId = undefined` and `iosAscKeyId = ""`. Old gate (`iosAscIssuerId && iosAscKeyId`) returned false → fallback path used `iosAppStoreKeyId` (the In-App Purchase / Server API key id) but signed the JWT with the ASC private key content (loaded via `getAppleAscApiKey`). Apple rejected every request with 401 because `kid` claim didn't match the signing key. Affects every production tenant; reported by LukasB-DEV in the PR #127 thread, reproduced by hyochan on kit.openiap.dev ("localhost works, prod doesn't" — dev DBs happened to have matching values from earlier UI iterations). Fix: gate on `iosAscKeyId` alone, fall back issuer to `iosAppStoreIssuerId` when the dedicated ASC issuer slot is empty. Behavior: - ASC slot has key id → ASC pair (`issuerId`: ASC slot ?? legacy shared, `keyId`: ASC slot, `.p8`: ASC slot ?? legacy) - ASC slot empty → legacy Server API pair end-to-end (back-compat for projects that uploaded the ASC key into the legacy slot before the second slot existed) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/products/asc.ts | 37 +++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index a1f47782..d204a162 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -37,14 +37,37 @@ async function resolveAscCredentials( project: Awaited>, options: { detailedErrors?: boolean } = {}, ): Promise { - // Resolve as a *pair* — never mix the new ASC Issuer ID with the - // legacy Server API Key ID (or vice versa). If only one of the - // new fields is populated the operator is mid-migration; in that - // case fall back to the legacy pair entirely so we don't sign a - // request with mismatched identifiers Apple will reject as 401. - const useAsc = project.iosAscIssuerId && project.iosAscKeyId; + // Apple uses ONE Issuer ID per team across both API gateways + // (App Store Server API + App Store Connect API), so the + // Settings UI deliberately exposes a single shared Issuer ID + // input that writes to `iosAppStoreIssuerId` — `iosAscIssuerId` + // is never populated through the UI and only exists for + // backwards-compat with the brief window when both were + // separate inputs. + // + // The Key IDs are NOT shared: `iosAppStoreKeyId` is the In-App + // Purchase key (receipt verification) and `iosAscKeyId` is the + // App Store Connect API Team / Individual key (catalog + // management). They authenticate against different gateways and + // every Apple-issued key has a unique 10-char id. + // + // Pair-resolution rule: if `iosAscKeyId` is set, sign with the + // ASC pair (issuer falls back to the shared `iosAppStoreIssuerId` + // when `iosAscIssuerId` is missing). If `iosAscKeyId` is missing, + // fall back to the legacy single-slot Server API pair so projects + // mid-migration still work — `call()` surfaces a wrong-kind 401 + // hint when Apple rejects a Server-API key on an ASC endpoint. + // + // Earlier the gate required BOTH `iosAscIssuerId` AND + // `iosAscKeyId` to be set, which never happened in production + // (UI doesn't expose the Issuer field). The fallback then sent + // the JWT with `kid: iosAppStoreKeyId` (Server API key id) but + // signed with the ASC private key, and Apple rejected every + // request with a 401 across all production deployments + // (LukasB-DEV's report on PR #127). + const useAsc = !!project.iosAscKeyId; const issuerId = useAsc - ? project.iosAscIssuerId + ? (project.iosAscIssuerId ?? project.iosAppStoreIssuerId) : project.iosAppStoreIssuerId; const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId; if (!issuerId || !keyId) { From b6dc03c6aec02f3d382f04dbd7ec52bac8f3da06 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 5 May 2026 20:08:32 +0900 Subject: [PATCH 3/6] fix(kit): visually close subscription group section with explicit "Other products" header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Products table renders a "Subscription Group · {name}" header above clustered subscriptions, but Consumable / NonConsumable rows fell straight after the cluster with no closing delimiter — visually they looked like part of the subscription group even though `groupRowsByHierarchy` had correctly partitioned them as ungrouped. Add an explicit "Other products" header row before the ungrouped section, but only when at least one subscription group already rendered above (otherwise the table is just a flat list and the extra header would be noise). Same row structure as the existing group header, just a different label, so the visual treatment is consistent and the section break is unambiguous. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/organization/project/products.tsx | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index e31ee41e..6e23ac8c 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -644,15 +644,18 @@ function formatIsoDuration(iso: string): string { // Reorder a flat product list so subscriptions sharing the same ASC // `subscriptionGroupName` are visually clustered under a single -// "Subscription Group · {name}" header row. One-time products (no -// group) and rows missing group metadata pass through unchanged at -// the bottom — we don't want to invent a synthetic group label for -// non-subscriptions or for legacy rows synced before group capture -// landed. Original ordering is preserved within each cluster. +// "Subscription Group · {name}" header row. One-time products +// (Consumable / NonConsumable) and any row missing group metadata +// fall into an "Other products" cluster rendered after the +// subscription groups so the section breaks are explicit — without +// the second header, consumables visually inherited the previous +// "Subscription Group" header and looked like part of it. +// Original ordering is preserved within each cluster. function groupRowsByHierarchy( rows: Array, ): Array< | { kind: "groupHeader"; id: string; name: string } + | { kind: "otherHeader"; id: string } | { kind: "row"; row: ProductRow } > { const buckets = new Map>(); @@ -672,6 +675,7 @@ function groupRowsByHierarchy( } const out: Array< | { kind: "groupHeader"; id: string; name: string } + | { kind: "otherHeader"; id: string } | { kind: "row"; row: ProductRow } > = []; for (const name of groupOrder) { @@ -680,6 +684,14 @@ function groupRowsByHierarchy( out.push({ kind: "row", row }); } } + // Only emit the "Other products" delimiter when at least one + // subscription group is also rendering — when there are no + // groups, the table is just a flat list and the extra header + // is noise. With at least one group above, the explicit + // delimiter is what visually closes the group section. + if (groupOrder.length > 0 && ungrouped.length > 0) { + out.push({ kind: "otherHeader", id: "other-products" }); + } for (const row of ungrouped) { out.push({ kind: "row", row }); } @@ -1046,6 +1058,18 @@ function ProductGroup({ Subscription Group · {entry.name} + ) : entry.kind === "otherHeader" ? ( + + + Other products + + ) : ( Date: Tue, 5 May 2026 20:22:53 +0900 Subject: [PATCH 4/6] chore(kit): always rebuild dist in dev mode before booting Hono MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit dev launch config booted Vite (5173), Hono (3000), and Convex in parallel. Hono serves the SPA out of `./dist`, but `dist/` is whatever the previous build left behind — and `bun run build` is `vite build` which defaults to mode=production. In production mode Vite loads `.env.production` AFTER `.env.local`, so the prod Convex URL wins and gets baked into every `dist/assets/*.js` bundle. Visiting localhost:3000 in that state ran the prod GitHub OAuth App and bounced the operator out to kit.openiap.dev — even in incognito, because the redirect target lived in the bundle, not in cookies. Symptom triaged on Hyo Dev / martie. Wipe `dist/` + `node_modules/.vite`, then explicitly rebuild with `vite build --mode development` so Hono's static fallback at port 3000 ships a bundle pinned to `.env.local` (dev Convex URL). Vite at 5173 was already correct (its dev server runs in development mode by default). Single launch entry, always-fresh dev env, no manual cache management. Co-Authored-By: Claude Opus 4.7 (1M context) --- .vscode/launch.json | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 956d99b3..3bce4b40 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -96,6 +96,34 @@ "cwd": "${workspaceFolder}/libraries/flutter_inapp_purchase/example", "console": "integratedTerminal" }, + { + // Targets iPad (UDID 00008130-001929642642001C). To switch devices, + // run `xcrun xctrace list devices` and replace the UDID below. + // -tl:off + -v:n + MlaunchVerbosity=4 + MlaunchExtraArgs surface + // per-stage install progress (AFC byte transfer, codesign, launch). + // The leading `cd` re-asserts cwd because `bash -lc` reloads the + // login profile, which can move the shell out of cwd before + // dotnet runs. + "name": "🟣 MAUI IAP: iOS", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-ios -p:RuntimeIdentifier=ios-arm64 -p:_DeviceName=:v2:udid=00008130-001929642642001C -tl:off -v:n -p:MlaunchVerbosity=4 -p:MlaunchExtraArgs=\"-v -v -v\""], + "cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example", + "console": "integratedTerminal" + }, + { + // -tl:off + -v:n — same fix as iOS. The terminal logger collapses + // every MSBuild target into a rolling counter; turning it off makes + // _CompileToDalvik / _InstallApk / etc. stream line-by-line. + "name": "🟣 MAUI IAP: Android", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && (adb uninstall dev.hyo.openiap.maui.example || true) && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-android -tl:off -v:n"], + "cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example", + "console": "integratedTerminal" + }, { "name": "🎮 Godot IAP: Open in Editor", "type": "node", @@ -124,13 +152,23 @@ "console": "integratedTerminal" }, { + // Wipes `dist/` and `node_modules/.vite`, then ALWAYS + // rebuilds the SPA with `vite build --mode development` + // so Hono's static fallback at port 3000 serves a bundle + // baked against `.env.local` (dev Convex URL) — never + // `.env.production`. Without `--mode development`, + // `bun run build` defaults to production mode, loads + // `.env.production` AFTER `.env.local`, and the prod URL + // wins; that's why localhost:3000 sign-ins were bouncing + // through the prod GitHub OAuth App even after wiping + // dist (Hyo Dev / martie triage). "name": "🧰 Kit: Dev (Vite + Hono + Convex)", "type": "node", "request": "launch", "runtimeExecutable": "bash", "runtimeArgs": [ "-lc", - "npx --yes kill-port 3000 5173 || true && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" + "npx --yes kill-port 3000 5173 || true && rm -rf dist node_modules/.vite && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && bunx vite build --mode development && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" ], "cwd": "${workspaceFolder}/packages/kit", "console": "integratedTerminal" From c45d1a15763c44449ea78cea9e8608e725039acf Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 5 May 2026 20:35:24 +0900 Subject: [PATCH 5/6] fix(kit): kit launch top of list + colored group headers + session-only result banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small Products-page tweaks driven by yesterday's triage: - Move "🧰 Kit: Dev (Vite + Hono + Convex)" to the top of `.vscode/launch.json` since kit is the daily-driver task. - Group headers in the Products table now use colored backgrounds (blue tint for "Subscription Group · {name}", amber tint for "Other products") with a 4px left accent bar so the section break is unambiguous. Earlier `bg-muted/60` blended into the data rows on dark mode. - Result banner + completion toast now gate on `sessionTriggeredJobIdsRef` — a `Set` populated when the operator clicks Sync / Dry-run / Reset from THIS mount. Stale terminal jobs from a previous session (HMR reload, page revisit, sync triggered in another tab) no longer surface as if they had just run. The ref resets on every remount so the gate is automatic and never sticky. Co-Authored-By: Claude Opus 4.7 (1M context) --- .vscode/launch.json | 44 +++++++------- .../auth/organization/project/products.tsx | 58 ++++++++++++++++--- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3bce4b40..e4f7c079 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,28 @@ { "version": "0.2.0", "configurations": [ + { + // Wipes `dist/` and `node_modules/.vite`, then ALWAYS + // rebuilds the SPA with `vite build --mode development` + // so Hono's static fallback at port 3000 serves a bundle + // baked against `.env.local` (dev Convex URL) — never + // `.env.production`. Without `--mode development`, + // `bun run build` defaults to production mode, loads + // `.env.production` AFTER `.env.local`, and the prod URL + // wins; that's why localhost:3000 sign-ins were bouncing + // through the prod GitHub OAuth App even after wiping + // dist (Hyo Dev / martie triage). + "name": "🧰 Kit: Dev (Vite + Hono + Convex)", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-lc", + "npx --yes kill-port 3000 5173 || true && rm -rf dist node_modules/.vite && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && bunx vite build --mode development && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" + ], + "cwd": "${workspaceFolder}/packages/kit", + "console": "integratedTerminal" + }, { "type": "node-terminal", "request": "launch", @@ -150,28 +172,6 @@ "runtimeArgs": ["-lc", "adb uninstall dev.hyo.martie || true && ./gradlew :example:composeApp:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity"], "cwd": "${workspaceFolder}/libraries/kmp-iap", "console": "integratedTerminal" - }, - { - // Wipes `dist/` and `node_modules/.vite`, then ALWAYS - // rebuilds the SPA with `vite build --mode development` - // so Hono's static fallback at port 3000 serves a bundle - // baked against `.env.local` (dev Convex URL) — never - // `.env.production`. Without `--mode development`, - // `bun run build` defaults to production mode, loads - // `.env.production` AFTER `.env.local`, and the prod URL - // wins; that's why localhost:3000 sign-ins were bouncing - // through the prod GitHub OAuth App even after wiping - // dist (Hyo Dev / martie triage). - "name": "🧰 Kit: Dev (Vite + Hono + Convex)", - "type": "node", - "request": "launch", - "runtimeExecutable": "bash", - "runtimeArgs": [ - "-lc", - "npx --yes kill-port 3000 5173 || true && rm -rf dist node_modules/.vite && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && bunx vite build --mode development && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)" - ], - "cwd": "${workspaceFolder}/packages/kit", - "console": "integratedTerminal" } ] } diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 6e23ac8c..d2776dee 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -74,6 +74,13 @@ export default function ProjectProducts() { IOS: null, Android: null, }); + // Job ids the operator triggered FROM THIS MOUNT (Sync / Dry-run / + // Reset clicks). Result banner + completion toast both gate on + // this so a stale terminal job from a previous session — left + // over after a code edit / HMR reload / page revisit — doesn't + // re-surface as if a sync had just happened. Reset on remount so + // the gate is automatic and never sticky. + const sessionTriggeredJobIdsRef = useRef>(new Set()); // The draft form holds every field the push-sync flow consumes. // Optional fields are stored as empty strings here and converted to // `undefined` on submit so an unfilled price doesn't end up @@ -127,6 +134,12 @@ export default function ProjectProducts() { // when the previous render saw it in a non-terminal state. if (!prev || prev.jobId !== job._id) continue; if (prev.status !== "queued" && prev.status !== "running") continue; + // Belt-and-braces: only toast for jobs the operator triggered + // FROM THIS MOUNT. Without this gate a sync started in another + // tab that completes while this tab is open would also pop a + // toast here, which the operator would read as "did I just + // run that?". + if (!sessionTriggeredJobIdsRef.current.has(job._id)) continue; const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const result = job.result; if (job.status === "succeeded" && result) { @@ -228,12 +241,13 @@ export default function ProjectProducts() { const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const dryRun = options?.dryRun === true; try { - const { deduped } = await enqueueSync({ + const { jobId, deduped } = await enqueueSync({ apiKey: project.apiKey, platform, direction: "both", ...(dryRun ? { dryRun: true } : {}), }); + sessionTriggeredJobIdsRef.current.add(jobId); if (deduped) { toast.message(`${label} sync already running`, { duration: 4_000 }); } else { @@ -255,11 +269,12 @@ export default function ProjectProducts() { if (isActive) return; const label = platform === "IOS" ? "App Store Connect" : "Play Console"; try { - const { deduped } = await enqueueSync({ + const { jobId, deduped } = await enqueueSync({ apiKey: project.apiKey, platform, direction: "purge-local", }); + sessionTriggeredJobIdsRef.current.add(jobId); if (deduped) { toast.message(`${label} reset already running`, { duration: 4_000 }); } else { @@ -488,6 +503,9 @@ export default function ProjectProducts() { platform="IOS" rows={grouped.ios} job={iosJob ?? null} + triggeredInSession={ + !!iosJob?._id && sessionTriggeredJobIdsRef.current.has(iosJob._id) + } onSync={() => { void onSync("IOS"); }} @@ -508,6 +526,10 @@ export default function ProjectProducts() { platform="Android" rows={grouped.android} job={androidJob ?? null} + triggeredInSession={ + !!androidJob?._id && + sessionTriggeredJobIdsRef.current.has(androidJob._id) + } onSync={() => { void onSync("Android"); }} @@ -902,6 +924,7 @@ function ProductGroup({ platform, rows, job, + triggeredInSession, onSync, onDryRun, onPurge, @@ -911,6 +934,7 @@ function ProductGroup({ platform: "IOS" | "Android"; rows: Array; job: SyncJob | null; + triggeredInSession: boolean; onSync: () => void; onDryRun?: () => void; onPurge: () => void; @@ -921,7 +945,11 @@ function ProductGroup({ const isActive = job?.status === "queued" || job?.status === "running"; const isTerminal = job?.status === "succeeded" || job?.status === "failed"; const dismissed = job?.progress.phase === "dismissed"; - const showResult = isTerminal && !dismissed; + // Result banner only surfaces for jobs the operator triggered + // FROM THIS MOUNT — stale terminal jobs from prior sessions + // (HMR reload, page revisit) stay hidden so the operator can't + // mistake them for a sync that just ran. + const showResult = isTerminal && !dismissed && triggeredInSession; const [purgeOpen, setPurgeOpen] = useState(false); return (
@@ -1049,25 +1077,37 @@ function ProductGroup({ entry.kind === "groupHeader" ? ( - Subscription Group · {entry.name} + + + Subscription Group + + + · + + + {entry.name} + + ) : entry.kind === "otherHeader" ? ( - Other products + + Other products + ) : ( From 13d31549f75eaac89ee83434ca965dc74ce3e5be Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 5 May 2026 20:40:56 +0900 Subject: [PATCH 6/6] fix(kit): use SyncJob[_id]/[status] in JobStatusSnapshot for SSOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review on PR #128 — pull the field types straight off `SyncJob` (= `Doc<"productSyncJobs">`) so adding a new status literal in `convex/schema.ts` automatically widens the local snapshot type instead of silently drifting from the database shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kit/src/pages/auth/organization/project/products.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index d2776dee..cd66f582 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -64,9 +64,14 @@ export default function ProjectProducts() { // first observation is already terminal) shows the result banner // but does NOT pop a stale toast for a sync the operator didn't // just run. + // Pull the field types straight off `SyncJob` (= `Doc<"productSyncJobs">`) + // so the snapshot stays in lockstep with the schema — adding a new + // status literal in `convex/schema.ts` automatically widens the + // local type instead of silently drifting (Gemini SSOT review on + // PR #128). type JobStatusSnapshot = { - jobId: string; - status: "queued" | "running" | "succeeded" | "failed"; + jobId: SyncJob["_id"]; + status: SyncJob["status"]; }; const prevJobStatusRef = useRef< Record<"IOS" | "Android", JobStatusSnapshot | null>