// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort, // grouping), one row per app (live version and state per store with its place on the release track, next version, // crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state. import { useEffect, useMemo, useState } from 'react'; import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; import { useNav } from '../nav'; import { useData } from '../store'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; 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[]): Record { const [m, set] = useState>({}); const key = targets.join(); 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)}
); } function AttentionList({ items }: { items: Attention[] }) { const nav = useNav(); if (!items.length) return {t('portfolio.allClearText')}; return (
{items.slice(0, 8).map((a, i) => ( ))}
); } /** 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; } const VIEW_KEY = 'storebound.portfolio.v1'; interface View { studio: string; sort: SortBy; group: boolean } 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 }; } catch { return { studio: 'all', sort: 'attention', group: true }; } } 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 health = useCachedHealth(visible.map(a => a.target)); 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)}>
{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} : –} ); }; return (

{t('nav.portfolio')}

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}

{d.portfolioError && {d.portfolioError}} {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} {!known && d.ready && 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 && }
{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')}}
(a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} />
{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 ?? '–'}
); }