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:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue