Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)

This commit is contained in:
CRTkafa 2026-10-14 00:14:32 +08:00
parent e59f439101
commit 714352ec18
24 changed files with 570 additions and 103 deletions

View file

@ -71,7 +71,7 @@ const STORE_APPS: Record<string, StoreApp[]> = {
droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood),
],
[A1]: [
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.1.0', 'READY_FOR_SALE']),
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.0.4', 'READY_FOR_SALE']),
ios('6400000012', 'com.example.blockgarden.ios', 'Block Garden', G.block, ['1.2.0', 'IN_REVIEW']), ios('6400000013', 'com.example.wordlantern', 'Word Lantern', G.lantern, ['2.0.1', 'READY_FOR_SALE']),
ios('6400000014', 'com.example.oldracer', 'Old Racer', G.racer, ['1.9.3', 'REMOVED_FROM_SALE']),
],
@ -166,7 +166,8 @@ function status(id: string, live: boolean): AppStatus {
}
/** Store-only app releases (fixture), by bundle id / package name. */
const STORE_RELEASES: Record<string, StoreRelease[]> = {
'com.example.orbithop': [rel('ios', '3.1.0', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
// the stores do not always move together: Play is on 3.1.0, the App Store still on 3.0.4
'com.example.orbithop': [rel('ios', '3.0.4', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
'com.example.blockgarden.ios': [rel('ios', '1.2.0', 'review', { build: '17', state: 'IN_REVIEW', date: iso(60 * 5) }), rel('ios', '1.1.2', 'live', { build: '15', state: 'READY_FOR_SALE', date: iso(60 * 24 * 40) })],
'com.example.blockgarden': [rel('android', '1.2.0', 'live', { build: '120', state: 'completed' })],
'com.example.wordlantern': [rel('ios', '2.0.1', 'live', { build: '41', state: 'READY_FOR_SALE', date: iso(60 * 24 * 18) })],

View file

@ -106,7 +106,7 @@ export const PLAY_STATUS = {
// A first release in the shapes the stores really answered (pitfall: App Store "1.0" in review while Google Play's
// production release of "1.0.0" reads "completed" but the app is not public yet). `play status --json` adds the public
// store page check (publicPage): 404 = not public yet, 200 = published.
// store page check (publicPage): 404 = not public yet, 200 = published with the page's live version.
export const APPLE_STATUS_FIRST_REVIEW = {
app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } },
versions: [version('ver-10', '1.0', 'WAITING_FOR_REVIEW', 'b-3')],
@ -119,7 +119,7 @@ const playFirst = (state: 'public' | 'notPublic') => ({
{ track: 'production', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed', userFraction: 1 }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
],
publicPage: { state, http: state === 'public' ? 200 : 404, versionName: null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' },
publicPage: { state, http: state === 'public' ? 200 : 404, versionName: state === 'public' ? '1.0.0' : null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' },
});
export const PLAY_STATUS_FIRST_404 = playFirst('notPublic');
export const PLAY_STATUS_FIRST_200 = playFirst('public');

View file

@ -118,9 +118,13 @@ export const en = {
'phase.paused': 'Paused',
'phase.approved': 'Approved',
'phase.review': 'In review',
'phase.sent': 'Sent to Google (in review or not yet published)',
'phase.released': 'Released to production (Google may still be reviewing)',
'phase.releasedUnknown': 'Released to production (live state unknown: the store page could not be read)',
'phase.sent': 'In review',
'phase.released': "Couldn't check the store page",
'phase.releasedUnknown': "Couldn't check the store page",
'phase.pageUnknown': "Couldn't check the store page",
'phase.pageStale': "couldn't check the store page, as of {when}",
'phase.reviewLive': 'In review (live: {version})',
'phase.reviewManaged': 'In review / ready to publish (managed publishing)',
'phase.rejected': 'Rejected',
'phase.testing': 'Testing',
'phase.draft': 'Draft',
@ -236,6 +240,12 @@ export const en = {
'tl.store': 'Store',
'tl.created': 'Created {date}',
'tl.notOnStore': 'Not on this store',
'tl.unknown': 'Unknown',
'tl.unreadable': "Couldn't read {store}: state unknown",
'tl.otherVersion': '{now} · {phase} ({version} not submitted)',
'tl.notThisVersion': 'Not this version',
'tl.current': 'Current',
'tl.bothVersions': 'Play {play} · App Store {ios}',
'tl.noVersion': '{version} is not on {store} yet',
'tl.everyone': 'Everyone',
'tl.day': 'day {day} of 7',
@ -245,6 +255,7 @@ export const en = {
'releases.notReadText': 'The timeline shows each version on both stores: build, TestFlight or internal testing, review, live and the rollout share. Read the stores once to fill it.',
'releases.noAccounts': 'Tip: map store accounts to this app in App folders first.',
'releases.storeUnreadable': '{store} could not be read, so its lane shows as unknown: {error}',
'releases.storeStale': "Couldn't refresh {store}: showing the read from {when}. {error}",
'releases.noVersions': 'No versions on the stores yet',
'releases.noVersionsText': 'When the first build is uploaded it shows here.',
'releases.history': 'Versions on the stores',

View file

@ -120,9 +120,13 @@ export const tr: Record<keyof typeof en, string> = {
'phase.paused': 'Duraklatıldı',
'phase.approved': 'Onaylandı',
'phase.review': 'İncelemede',
'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)',
'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)',
'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)',
'phase.sent': 'İncelemede',
'phase.released': 'Mağaza sayfası kontrol edilemedi',
'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla',
'phase.reviewLive': 'İncelemede (yayında: {version})',
'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)',
'phase.rejected': 'Reddedildi',
'phase.testing': 'Testte',
'phase.draft': 'Taslak',
@ -238,6 +242,12 @@ export const tr: Record<keyof typeof en, string> = {
'tl.store': 'Mağaza',
'tl.created': 'Oluşturma: {date}',
'tl.notOnStore': 'Bu mağazada yok',
'tl.unknown': 'Bilinmiyor',
'tl.unreadable': '{store} okunamadı: durum bilinmiyor',
'tl.otherVersion': '{now} · {phase} ({version} gönderilmedi)',
'tl.notThisVersion': 'Bu sürüm değil',
'tl.current': 'Güncel',
'tl.bothVersions': 'Play {play} · App Store {ios}',
'tl.noVersion': '{version} henüz {store} mağazasında yok',
'tl.everyone': 'Herkes',
'tl.day': '7 günün {day}. günü',
@ -247,6 +257,7 @@ export const tr: Record<keyof typeof en, string> = {
'releases.notReadText': 'Zaman çizelgesi her sürümü iki mağazada birlikte gösterir: derleme, TestFlight ya da dahili test, inceleme, yayın ve dağıtım payı. Doldurmak için mağazaları bir kez okuyun.',
'releases.noAccounts': 'İpucu: önce Uygulama klasörleri ekranında bu uygulamaya mağaza hesaplarını bağlayın.',
'releases.storeUnreadable': '{store} okunamadı, bu yüzden şeridi bilinmiyor olarak gösteriliyor: {error}',
'releases.storeStale': '{store} yenilenemedi: {when} okunan durum gösteriliyor. {error}',
'releases.noVersions': 'Mağazalarda henüz sürüm yok',
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
'releases.history': 'Mağazalardaki sürümler',

View file

@ -4,7 +4,8 @@
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { useState } from 'react';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
@ -13,7 +14,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -22,23 +23,35 @@ function stageCaption(l: Lane, i: number): string {
if (i !== l.at || !l.release) return '';
const r = l.release;
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
if (i === 2) return t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return t(r.publicPage === 'unknown' ? 'phase.releasedUnknown' : 'phase.released');
if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
return '';
}
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet,
* or (only without any record of the app on that store) "Not on this store". */
function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } {
const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]);
const store = storeName(lane.store);
if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) };
if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) };
if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) };
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
}
function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) {
const r = lane.release;
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
return (
<div className="tl-lane">
<div className="tl-who">
<StoreGlyph store={lane.store} />
<div>
<div className="w1">{storeName(lane.store)}</div>
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
</div>
</div>
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
{gap ? <div className="tl-none" data-gap={gap.kind}>{gap.line}</div> : STAGES.map((s, i) => {
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
const last = s === 'rollout';
return (
@ -102,16 +115,19 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
return null;
}
function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) {
function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) {
const lanes = trainLanes(train, tf);
// "Live" only when a store has it live or rolling out; a Play release Google may still review is "Released"
const label = tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
// the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null;
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
return (
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
<Card pad={false} title={<span className="train-head"><span className="ver">{ver}</span><Chip tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
<div className="tl" style={{ paddingTop: 12 }}>
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} />)}
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} />)}
</div>
{lanes.some(l => l.release) && app.project && (
<div className="card-foot roll-foot">
@ -157,9 +173,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const errs = Object.entries(l.storeErrors);
return (
<>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
{errs.map(([s, msg]) => {
const when = l.staleStores?.[s as 'ios'];
return <Notice key={s} tone="warn">{when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>;
})}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>

View file

@ -16,6 +16,7 @@ import { keepLines, progressOf } from './lib/progress';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
import { fixHint } from './lib/views';
import { withLastGood } from '../../../cli/lib/train';
import { failureText, usable } from './lib/results';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
@ -201,7 +202,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
const r = await api.portfolio(liveRead, job, stores);
let r = await api.portfolio(liveRead, job, stores);
// a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty
const prevLive = liveRef.current;
if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) };
setPortfolio(r);
if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r);

View file

@ -22,7 +22,11 @@ export function phaseLabel(r: StoreRelease | null | undefined): { text: string;
const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : '';
if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' };
if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' };
if (r.phase === 'released' && r.publicPage === 'unknown') return { text: t('phase.releasedUnknown'), tone: 'neutral' };
const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : '';
if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' };
if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent'))
return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' };
if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' };
if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' };
return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] };
}