// Portfolio: every app of every studio on both stores. A greeting that says what is moving, KPIs, a toolbar (search, // studio chips with counts, sort, grouping, cards / table), one store-listing card per app (rendered screenshots, the // release track per store, crash rate, one next step) or the dense table (live version and state per store, next // version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first. import { plainError } from '../../../../cli/lib/google-errors'; import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { useShowIgnored } from '../lib/show-ignored'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity'; import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; import { type AppTab, useNav } from '../nav'; import { useContextMenu } from '../ui/ContextMenu'; import { appMenu } from '../ui/app-menu'; import type { IconName } from '../ui/icons'; import { useData } from '../store'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds'; export { nextStep } from '../ui/ds'; import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; /** Tag of one app's offline config check. */ export function checkState(e: Pick | undefined): { text: string; tone: Tone } { if (!e) return { text: t('check.notChecked'), tone: 'neutral' }; if (e.errors.length || !e.check) return { text: t('check.notLoaded'), tone: 'bad' }; if (e.check.problems.length) return { text: t('check.problems', { count: e.check.problems.length }), tone: 'bad' }; if (e.check.warnings.length) return { text: t('check.warnings', { count: e.check.warnings.length }), tone: 'warn' }; return { text: t('check.ok'), tone: 'ok' }; } export function Pips({ r, store }: { r: StoreRelease | null; store: 'ios' | 'android' }) { const lane = laneOf(store, r); return ( ); } function StoreCell({ e, store, on }: { e: AppStatus | undefined; store: 'ios' | 'android'; on: boolean }) { if (!on) return {t('app.notOnStore')}; if (!e) return {t('common.unknown')}; const err = e.storeErrors[store]; if (err) return {t('portfolio.unreadable')}; const all = e.release.all ?? [e.release.current?.ios, e.release.current?.android, e.release.next?.ios, e.release.next?.android].filter((x): x is StoreRelease => !!x); const { now, next } = storeNow(all, store); const r = now ?? next; if (!r) return {t('portfolio.noVersion')}; return (
{r.version}
{now && next && {t('portfolio.thenNext', { version: next.version ?? '', phase: t(`phase.${next.phase}` as Key) })}}
); } function NextCell({ e }: { e: AppStatus | undefined }) { const n = e?.release.next; if (!e) return {t('common.unknown')}; if (!n) return {t('portfolio.noNext')}; return (
{n.version}
{n.ios && {t(`phase.${n.ios.phase}`)}} {n.android && {t(`phase.${n.android.phase}`)}}
); } /** Last cached health read of every app (the Health tab reads it; the portfolio only shows what is saved). */ function useCachedHealth(targets: string[], round = 0): Record { const [m, set] = useState>({}); const key = targets.join() + '#' + round; useEffect(() => { for (const id of targets) { api.cached('health', id).then(c => set(x => ({ ...x, [id]: c?.value.data ? { h: c.value.data as HealthSnapshot, at: c.at } : null })), () => {}); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps return m; } function HealthCell({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { if (!android) return {t('portfolio.healthNoPlay')}; if (!c) return {t('portfolio.healthNotRead')}; const play = c.h.play; if (!play) return {t('portfolio.healthNoPlay')}; if (isError(play)) return {t('portfolio.unreadable')}; const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); return (
{pct(s.current)}
); } /** Newest store activity of an app (release record date), else when its folder was added. */ function updatedAt(a: UnifiedApp, l: AppStatus | undefined): string | null { const dates = (l?.release.all ?? []).map(r => r.date).filter((x): x is string => !!x); return dates.sort().at(-1) ?? a.project?.added ?? null; } // ---- App cards ---- /** Every release record of an app (live read first). */ function releasesOf(l: AppStatus | undefined): StoreRelease[] { if (!l) return []; return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x); } function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { const play = c?.h.play; if (!android || !c || !play || isError(play)) { return {!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}; } const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); return ( {t('pf.crash')}{pct(s.current)} ); } interface CardRow { app: UnifiedApp; e: AppStatus | undefined; l: AppStatus | undefined; att: Attention[] } function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; health: { h: HealthSnapshot; at: string } | null | undefined; studio?: ReactNode }) { const nav = useNav(); const { app, e, l, att } = r; const projectId = app.project && !app.project.missing ? app.project.id : null; const shots = useShots(projectId); const hue = useTint(icon, app.name); const all = releasesOf(l); const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; const next = l?.release.next?.version ?? null; const cta = nextStep({ project: !!app.project, att, all, next }); const name = e?.app?.name && app.project ? e.app.name : app.name; const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); const style = tintStyle(hue); const d = useData(); const menu = useContextMenu(); const [showIgnored] = useShowIgnored(); return (
appMenu(app, d, nav, { showIgnored }))}>
{sub}{!app.project && {t('apps.storeOnly')}}
{t('pf.next')} {next ?? '–'}
{studio}
); } // ---- What changed ---- interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone } function events(rows: CardRow[], health: Record): Ev[] { const out: Ev[] = []; for (const r of rows) { for (const rel of releasesOf(r.l)) { if (!rel.date) continue; const store = rel.store === 'ios' ? 'App Store' : 'Google Play', version = rel.version ?? ''; const ev = (key: Key, icon: IconName, tone: Tone, vars: Record = {}) => out.push({ key: `${r.app.key}|${rel.store}|${version}|${rel.phase}`, app: r.app, text: t(key, { version, store, ...vars }), at: rel.date!, icon, tone }); switch (rel.phase) { case 'live': ev('pf.evLive', 'check', 'ok'); break; case 'rollout': ev('pf.evRollout', 'rocket', 'accent', { share: share(rel.fraction ?? 1) }); break; case 'review': case 'sent': ev('pf.evReview', 'clock', 'warn'); break; case 'approved': ev('pf.evApproved', 'check', 'ok'); break; case 'rejected': ev('pf.evRejected', 'alertCircle', 'bad'); break; case 'halted': ev('pf.evHalted', 'pause', 'bad'); break; case 'testing': ev('pf.evTesting', 'flask', 'info'); break; case 'draft': ev('pf.evDraft', 'edit', 'neutral'); break; default: break; } } const c = health[r.app.target]; const play = c?.h.play; if (c && play && !isError(play)) { const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); if (s.current !== null) out.push({ key: `${r.app.key}|health`, app: r.app, text: t('pf.evCrash', { rate: pct(s.current) }), at: c.at, icon: 'activity', tone: s.tone }); } if (r.app.project?.added) out.push({ key: `${r.app.key}|added`, app: r.app, text: t('pf.evAdded'), at: r.app.project.added, icon: 'folderPlus', tone: 'neutral' }); } return out.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); } function Activity({ items, iconFor }: { items: Ev[]; iconFor: (a: UnifiedApp) => string | null }) { const nav = useNav(); const [more, setMore] = useState(false); if (!items.length) return {t('pf.feedEmptyText')}; const shown = more ? items : items.slice(0, 8); return ( <>
    {shown.map(ev => (
  1. ))}
{items.length > 8 && } ); } function FixFirst({ items, appOf, iconFor }: { items: Attention[]; appOf: (key: string) => UnifiedApp | undefined; iconFor: (a: UnifiedApp) => string | null }) { const nav = useNav(); const [more, setMore] = useState(false); if (!items.length) return {t('portfolio.allClearText')}; const shown = more ? items : items.slice(0, 5); return (
{shown.map((a, i) => { const app = appOf(a.projectId), tab = a.tab ?? 'checks'; return (
{app ? : } {a.app} {a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')}

{a.text}

{a.hint &&

{a.hint}

}
); })} {items.length > 5 && }
); } function greeting(now = new Date()): string { const h = now.getHours(); return h < 5 ? t('pf.evening') : h < 12 ? t('pf.morning') : h < 18 ? t('pf.afternoon') : t('pf.evening'); } const VIEW_KEY = 'storebound.portfolio.v1'; interface View { studio: string; sort: SortBy; group: boolean; layout: 'cards' | 'table' } function loadView(): View { try { const v = JSON.parse(localStorage.getItem(VIEW_KEY) ?? '{}') as Partial; return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false, layout: v.layout === 'table' ? 'table' : 'cards' }; } catch { return { studio: 'all', sort: 'attention', group: true, layout: 'cards' }; } } export function Portfolio() { const d = useData(); const nav = useNav(); const [view, setViewState] = useState(loadView); const [query, setQuery] = useState(''); const setView = (v: Partial) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; }); const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]); const [showIgnored, setShowIgnored] = useShowIgnored(); const menu = useContextMenu(); const [round, setRound] = useState(0); const [rechecking, setRechecking] = useState(false); const health = useCachedHealth(visible.map(a => a.target), round); const blocked = useMemo(() => blockedProjects(visible.map(a => { const h = health[a.target]?.h, l = d.liveFor(a); return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] }; })), [visible, health, d]); // "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache const recheck = async () => { setRechecking(true); const names = new Set(blocked.flatMap(b => b.apps)); for (const a of visible.filter(x => names.has(x.name))) await api.call('health', a.target, { args: {} }, newJob('health')).catch(() => null); setRechecking(false); setRound(n => n + 1); }; const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' }); const all = useMemo(() => visible.map(app => { const e = d.statusFor(app), l = d.liveFor(app); const att = attention(app.key, app.name, e, l, health[app.target]?.h); return { app, e, l, att, attention: att.length, blockers: att.filter(x => x.tone === 'bad').length, updated: updatedAt(app, l) }; }), [visible, d, health]); const counts = useMemo(() => studioCounts(visible), [visible]); const studioOk = view.studio === 'all' || view.studio === 'none' || d.studios.some(s => s.id === view.studio); const rows = filterSort(all as (ListRow & typeof all[number])[], { studio: studioOk ? view.studio : 'all', query, sort: view.sort }) as typeof all; const grouped = view.group && d.studios.length ? groupByStudio(rows, d.studios) : [{ studio: undefined as string | null | undefined, rows }]; useExports('portfolio', [{ id: 'portfolio-csv', label: t('export.portfolio'), icon: 'portfolio', kind: 'csv', file: exportName('storebound', 'portfolio'), disabled: visible.length ? false : t('export.noApps'), build: () => portfolioCsv(rows.map(r => ({ name: r.app.name, studio: d.studios.find(s => s.id === r.app.studio)?.name ?? '', s: r.l ?? r.e }))) }]); if (d.ready && !visible.length) { return (

{t('nav.portfolio')}

{t('portfolio.lead')}

}>{t('portfolio.emptyText')}
); } const shown = rows.map(r => r.l).filter((x): x is AppStatus => !!x); const releases = shown.flatMap(l => l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x)); const stores = rows.reduce((n, r) => n + (r.app.ios || r.e?.app?.platforms.ios ? 1 : 0) + (r.app.android || r.e?.app?.platforms.android ? 1 : 0), 0); const liveCount = shown.reduce((n, l) => n + (l.release.current?.ios ? 1 : 0) + (l.release.current?.android ? 1 : 0), 0); const rolling = releases.filter(r => r.phase === 'rollout'); const review = releases.filter(r => r.phase === 'review'); const att = rows.flatMap(r => r.att); const blockers = att.filter(a => a.tone === 'bad').length; const known = shown.length > 0; const iosApps = rows.filter(r => r.app.ios || r.e?.app?.platforms.ios).length, androidApps = rows.filter(r => r.app.android || r.e?.app?.platforms.android).length; const storeOnly = rows.filter(r => !r.app.project).length; const neverImported = d.accounts.filter(a => !d.storeApps[a.id]).length; const row = (r: typeof all[number]) => { const { app, e, l } = r; const st = app.project?.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : app.project ? checkState(e) : null; const cat = l?.catalog ?? e?.catalog; const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; return ( open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
{sub}{!app.project && {t('apps.storeOnly')}}
{e || !app.project || app.project.missing ? : } {e || !app.project || app.project.missing ? : } {!app.project ? {t('portfolio.noConfig')} : cat ? (
{t('portfolio.products', { count: cat.products })} · {t('portfolio.subs', { count: cat.subscriptions })} {cat.checked === 'live' ? (cat.ok ? {t('portfolio.inParity')} : {t('portfolio.differs', { count: cat.missingIos.length + cat.missingAndroid.length + cat.onlyIos.length + cat.onlyAndroid.length })}) : {t('portfolio.configOnly')}}
) : –} {st ? {st.text} : –} ); }; const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead'); const moving = rolling.length + review.length; const headline = !known ? t('pf.notMovingUnknown') : moving ? t('pf.moving', { count: moving }) : t('pf.movingNone'); const sortedAtt = [...att].sort((a, b) => (a.tone === 'bad' ? 0 : a.tone === 'warn' ? 1 : 2) - (b.tone === 'bad' ? 0 : b.tone === 'warn' ? 1 : 2)); const feed = events(rows, health); const appOf = (key: string) => visible.find(a => a.key === key); const cards = (list: typeof all) => (
{list.map(r => )}
); return (

{greeting()}

{headline}

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}

{d.portfolioError && {d.portfolioError}} {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} {!known && d.ready && d.portfolioBusy !== 'live' && d.refreshPortfolio(true)}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} /> `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
setQuery(e.target.value)} placeholder={t('portfolio.search')} aria-label={t('portfolio.search')} />
{d.studios.map(s => ( ))} {d.studios.length > 0 && (counts[''] ?? 0) > 0 && }
setView({ sort })} label={t('portfolio.sort')} options={[['attention', t('portfolio.sortAttention'), 'alert'], ['recent', t('portfolio.sortRecent'), 'clock'], ['name', t('portfolio.sortName')]]} /> {d.studios.length > 0 && } setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
{ignoredApps.length > 0 && (
{showIgnored && ignoredApps.map(a => ( ))}
)} {view.layout === 'cards' && (rows.length ? grouped.map(g => { const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; return (
{s !== undefined && (

{s?.name ?? t('studio.none')}{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}

)} {cards(g.rows)}
); }) : {t('portfolio.noMatchText')})} {view.layout === 'table' && {t('legend.done')}{t('legend.stages')}
}> {rows.length ? (
{grouped.map(g => { const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; return ( {s !== undefined && ( )} {g.rows.map(row)} ); })}
{t('col.app')}App StoreGoogle Play{t('col.next')}{t('col.crash')}{t('col.catalog')}{t('col.config')}
{s?.name ?? t('studio.none')}{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}
) : {t('portfolio.noMatchText')}} }
{t('nav.studios')}
{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : }
{t('nav.accounts')}
{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : }
{t('portfolio.folders')}
{t('portfolio.foldersN', { count: d.projects.length, store: storeOnly })}
{t('nav.ignored')}
{d.apps.filter(a => a.ignored).length ? : t('kpi.none')}
{t('portfolio.storesRead')}
{d.live ? ago(d.live.at) : t('time.never')}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}
); }