// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { type CSSProperties, type ReactNode, useState } from 'react';
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';
import { ago, date, share } from '../../lib/format';
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
import { useContextMenu } from '../../ui/ContextMenu';
import { releaseMenu } from '../../ui/app-menu';
import '../../styles/releases.css';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
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 r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
return '';
}
/** 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') };
}
/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages
* as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */
function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) {
const r = lane.release;
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
const ph = r ? phaseLabel(r) : null;
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
const d = useData();
const menu = useContextMenu();
return (
releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
);
}
return null;
}
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);
// 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;
const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null;
return (