storebound/apps/desktop/src/screens/Portfolio.tsx
CRT dece83c3e8 Desktop: custom context menus everywhere, never the browser's menu
One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
2026-10-23 08:59:14 +08:00

507 lines
34 KiB
TypeScript

// 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<AppStatus, 'errors' | 'check'> | 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 (
<span className="pips" aria-hidden="true">
{STAGES.map((s, i) => <b key={s} className={i < lane.at ? 'on' : i === lane.at ? (lane.tone === 'neutral' ? 'on' : lane.tone === 'accent' ? 'on' : lane.tone) : ''} />)}
{lane.share !== null && lane.share < 1 && <span className="pct">{share(lane.share)}</span>}
</span>
);
}
function StoreCell({ e, store, on }: { e: AppStatus | undefined; store: 'ios' | 'android'; on: boolean }) {
if (!on) return <span className="muted small">{t('app.notOnStore')}</span>;
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
const err = e.storeErrors[store];
if (err) return <span title={plainError(err)}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
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 <span className="muted small">{t('portfolio.noVersion')}</span>;
return (
<div className="mini">
<div className="row" style={{ gap: 8 }}><span className="strong num">{r.version}</span><PhaseChip r={r} /></div>
<Pips r={r} store={store} />
{now && next && <span className="small muted">{t('portfolio.thenNext', { version: next.version ?? '', phase: t(`phase.${next.phase}` as Key) })}</span>}
</div>
);
}
function NextCell({ e }: { e: AppStatus | undefined }) {
const n = e?.release.next;
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
if (!n) return <span className="muted small">{t('portfolio.noNext')}</span>;
return (
<div className="mini">
<span className="strong num">{n.version}</span>
<div className="row small muted" style={{ gap: 10 }}>
{n.ios && <span className="row" style={{ gap: 4 }}><StoreGlyph store="ios" className="ic13" />{t(`phase.${n.ios.phase}`)}</span>}
{n.android && <span className="row" style={{ gap: 4 }}><StoreGlyph store="android" className="ic13" />{t(`phase.${n.android.phase}`)}</span>}
</div>
</div>
);
}
/** 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<string, { h: HealthSnapshot; at: string } | null> {
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
const key = targets.join() + '#' + round;
useEffect(() => {
for (const id of targets) {
api.cached<ToolResult>('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 <span className="muted small">{t('portfolio.healthNoPlay')}</span>;
if (!c) return <span className="muted small">{t('portfolio.healthNotRead')}</span>;
const play = c.h.play;
if (!play) return <span className="muted small">{t('portfolio.healthNoPlay')}</span>;
if (isError(play)) return <Chip tone="warn">{t('portfolio.unreadable')}</Chip>;
const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
return (
<div className="mini-health" title={t('portfolio.healthTitle', { when: ago(c.at) })}>
<Sparkline compact values={s.values.slice(-14)} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: t('health.crash'), value: pct(s.current) })} />
<span className={`num small strong ${s.tone === 'bad' ? 'tbad' : s.tone === 'warn' ? 'twarn' : ''}`}>{pct(s.current)}</span>
</div>
);
}
/** 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 <span className="pf-health muted small">{!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}</span>;
}
const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
return (
<span className="pf-health" title={t('portfolio.healthTitle', { when: ago(c.at) })}>
<span className="pf-spark"><Sparkline compact values={s.values.slice(-14)} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: t('health.crash'), value: pct(s.current) })} /></span>
<span className="pf-health-v"><span className="small muted">{t('pf.crash')}</span><span className={`num strong ${s.tone === 'bad' ? 'tbad' : s.tone === 'warn' ? 'twarn' : ''}`}>{pct(s.current)}</span></span>
</span>
);
}
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 (
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <AppIcon name={app.name} src={icon} className="pf-hero-icon" />}
</div>
<div className="pf-body">
<div className="pf-head">
<AppIcon name={app.name} src={icon} className="pf-icon" />
<div className="pf-title">
<button className="pf-name" onClick={() => go('releases')}>{name}</button>
<span className="pf-id mono-id">{sub}{!app.project && <span className="so-inline" title={t('apps.storeOnlyHint')}><Icon name="cloud" /><span className="sr">{t('apps.storeOnly')}</span></span>}</span>
</div>
<div className="pf-next">
<span className="pf-next-l">{t('pf.next')}</span>
<span className="pf-next-v num">{next ?? '–'}</span>
</div>
</div>
{studio}
<Pipeline all={all} ios={ios} android={android} />
<div className="pf-foot">
<CardHealth c={health} android={android} />
<button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => go(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>
</div>
</div>
</article>
);
}
// ---- What changed ----
interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone }
function events(rows: CardRow[], health: Record<string, { h: HealthSnapshot; at: string } | null>): 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<string, string> = {}) =>
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 <Empty compact illo="unknown" title={t('pf.feedEmpty')}>{t('pf.feedEmptyText')}</Empty>;
const shown = more ? items : items.slice(0, 8);
return (
<>
<ol className="pf-feed">
{shown.map(ev => (
<li key={ev.key}>
<button className="pf-ev" onClick={() => nav.go({ screen: 'app', appId: ev.app.key, tab: ev.icon === 'activity' ? 'health' : 'releases' })}>
<span className={`pf-ev-ic tone-bg-${ev.tone === 'accent' ? 'info' : ev.tone}`}><Icon name={ev.icon} /></span>
<AppIcon name={ev.app.name} src={iconFor(ev.app)} className="pf-ev-app" />
<span className="pf-ev-t"><b>{ev.app.name}</b> {ev.text}</span>
<time className="pf-ev-at small muted" dateTime={ev.at}>{ago(ev.at)}</time>
</button>
</li>
))}
</ol>
{items.length > 8 && <button className="btn btn-ghost btn-sm pf-more" aria-expanded={more} onClick={() => setMore(x => !x)}>{more ? t('pf.less') : t('pf.more', { count: items.length - 8 })}</button>}
</>
);
}
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 <Empty compact illo="shield" title={t('portfolio.allClear')}>{t('portfolio.allClearText')}</Empty>;
const shown = more ? items : items.slice(0, 5);
return (
<div className="pf-fixes">
{shown.map((a, i) => {
const app = appOf(a.projectId), tab = a.tab ?? 'checks';
return (
<div key={i} className={`pf-fix tone-${a.tone}`}>
<div className="pf-fix-head">
{app ? <AppIcon name={app.name} src={iconFor(app)} className="pf-fix-icon" /> : <Icon name="alert" />}
<span className="pf-fix-app">{a.app}</span>
<Chip tone={a.tone}>{a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')}</Chip>
</div>
<p className="pf-fix-t">{a.text}</p>
{a.hint && <p className="pf-fix-h small muted">{a.hint}</p>}
<div className="pf-fix-a">
<button className={`btn btn-sm ${a.tone === 'bad' ? 'btn-primary' : ''}`} onClick={() => nav.go({ screen: 'app', appId: a.projectId, tab })}>
<Icon name={tab === 'checks' ? 'wand' : 'arrowRight'} />{tab === 'checks' ? t('pf.fixIn', { tab: t('tab.checks') }) : t('pf.openTab', { tab: t(`tab.${tab}`) })}
</button>
</div>
</div>
);
})}
{items.length > 5 && <button className="btn btn-ghost btn-sm pf-more" aria-expanded={more} onClick={() => setMore(x => !x)}>{more ? t('pf.less') : t('pf.more', { count: items.length - 5 })}</button>}
</div>
);
}
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<View>;
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<View>(loadView);
const [query, setQuery] = useState('');
const setView = (v: Partial<View>) => 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<ToolResult>('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 (
<div className="page">
<div className="ph"><div><h1>{t('nav.portfolio')}</h1><p>{t('portfolio.lead')}</p></div></div>
<Card pad={false}>
<Empty illo="apps" title={t('portfolio.emptyTitle')} actions={<>
<button className="btn btn-primary" onClick={() => nav.go({ screen: 'accounts' })}><Icon name="building" />{t('portfolio.emptyAccounts')}</button>
<button className="btn" onClick={() => nav.go({ screen: 'apps' })}><Icon name="folderPlus" />{t('action.addFolder')}</button>
<button className="btn btn-ghost" onClick={nav.openWelcome}><Icon name="sparkle" />{t('action.welcome')}</button>
</>}>{t('portfolio.emptyText')}</Empty>
</Card>
</div>
);
}
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 (
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<td>
<div className="cell-app">
<AppIcon name={app.name} src={d.iconFor(app)} />
<div className="t">
<button className="link n" onClick={ev => { ev.stopPropagation(); open(app); }}>{e?.app?.name && app.project ? e.app.name : app.name}</button>
<div className="s"><span className="mono-id">{sub}</span>{!app.project && <span className="so-inline" title={t('apps.storeOnlyHint')}><Icon name="cloud" /><span className="sr">{t('apps.storeOnly')}</span></span>}</div>
</div>
</div>
</td>
<td>{e || !app.project || app.project.missing ? <StoreCell e={l} store="ios" on={ios} /> : <Skeleton lines={2} />}</td>
<td>{e || !app.project || app.project.missing ? <StoreCell e={l} store="android" on={android} /> : <Skeleton lines={2} />}</td>
<td><NextCell e={l} /></td>
<td><HealthCell c={health[app.target]} android={android} /></td>
<td>
{!app.project ? <span className="small muted">{t('portfolio.noConfig')}</span> : cat ? (
<div className="mini">
<span className="num small strong">{t('portfolio.products', { count: cat.products })} · {t('portfolio.subs', { count: cat.subscriptions })}</span>
{cat.checked === 'live' ? (cat.ok ? <Chip tone="ok">{t('portfolio.inParity')}</Chip> : <Chip tone="warn">{t('portfolio.differs', { count: cat.missingIos.length + cat.missingAndroid.length + cat.onlyIos.length + cat.onlyAndroid.length })}</Chip>)
: <span className="small muted">{t('portfolio.configOnly')}</span>}
</div>
) : <span className="muted small">–</span>}
</td>
<td className="right">{st ? <Chip tone={st.tone}>{st.text}</Chip> : <span className="muted small">–</span>}</td>
</tr>
);
};
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) => (
<div className="pf-grid">{list.map(r => <AppCard key={r.app.key} r={r} icon={d.iconFor(r.app)} health={health[r.app.target]} />)}</div>
);
return (
<div className="page pf2">
<div className="ph pf-hero">
<div>
<p className="pf-greet">{greeting()}</p>
<h1 className="pf-headline">{headline}</h1>
<p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}</p>
</div>
<div className="actions">
<button className="btn" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length} title={t('action.importAppsHint')}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy} title={t('action.readStoresHint')}>
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
</button>
</div>
</div>
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
<div className="kpis">
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : notRead} />
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? [...new Set(rolling.map(r => 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')} />
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? notRead : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
</div>
<div className="toolbar" role="toolbar" aria-label={t('portfolio.toolbar')}>
<div className="search-input grow-0"><Icon name="search" /><input className="input" value={query} onChange={e => setQuery(e.target.value)} placeholder={t('portfolio.search')} aria-label={t('portfolio.search')} /></div>
<div className="fchips" role="group" aria-label={t('portfolio.studioFilter')}>
<button className="fchip" aria-pressed={view.studio === 'all' || !studioOk} onClick={() => setView({ studio: 'all' })}>{t('portfolio.allStudios')}<span className="n">{visible.length}</span></button>
{d.studios.map(s => (
<button key={s.id} className="fchip" aria-pressed={view.studio === s.id} onClick={() => setView({ studio: s.id })}><StudioMark studio={s} size="sm" />{s.name}<span className="n">{counts[s.id] ?? 0}</span></button>
))}
{d.studios.length > 0 && (counts[''] ?? 0) > 0 && <button className="fchip" aria-pressed={view.studio === 'none'} onClick={() => setView({ studio: 'none' })}><StudioMark studio={null} size="sm" />{t('studio.none')}<span className="n">{counts['']}</span></button>}
</div>
<span className="grow" />
<Seg value={view.sort} onChange={sort => 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 && <label className="check"><input type="checkbox" checked={view.group} onChange={e => setView({ group: e.target.checked })} />{t('portfolio.group')}</label>}
<Seg value={view.layout} onChange={layout => setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
</div>
{ignoredApps.length > 0 && (
<div className="row small" style={{ gap: 8, flexWrap: 'wrap' }} data-testid="ignored-strip">
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
{showIgnored && ignoredApps.map(a => (
<span key={a.key} className="row muted" style={{ gap: 4 }}>
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }))}>{a.name}</button>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
</span>
))}
</div>
)}
{view.layout === 'cards' && (rows.length ? grouped.map(g => {
const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null;
return (
<section key={g.studio ?? (g.studio === null ? 'none' : 'all')} className="pf-group" aria-label={s === undefined ? t('portfolio.apps') : s?.name ?? t('studio.none')}>
{s !== undefined && (
<h2 className="pf-group-h"><StudioMark studio={s} size="sm" /><span>{s?.name ?? t('studio.none')}</span><span className="muted small">{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}</span></h2>
)}
{cards(g.rows)}
</section>
);
}) : <Card pad={false}><Empty compact illo="unknown" title={t('portfolio.noMatch')}>{t('portfolio.noMatchText')}</Empty></Card>)}
{view.layout === 'table' && <Card title={t('portfolio.apps')} icon="portfolio" sub={known ? t('portfolio.appsSub', { when: ago(d.live?.at) }) : t('portfolio.appsSubOffline')} pad={false}
actions={<div className="legend-row"><span><span className="pips"><b className="on" /></span>{t('legend.done')}</span><span>{t('legend.stages')}</span></div>}>
{rows.length ? (
<div className="table-wrap">
<table className="table pf">
<thead><tr>
<th>{t('col.app')}</th><th>App Store</th><th>Google Play</th><th>{t('col.next')}</th><th>{t('col.crash')}</th><th>{t('col.catalog')}</th><th className="right">{t('col.config')}</th>
</tr></thead>
{grouped.map(g => {
const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null;
return (
<tbody key={g.studio ?? (g.studio === null ? 'none' : 'all')}>
{s !== undefined && (
<tr className="group-row"><td colSpan={7}>
<span className="row" style={{ gap: 10 }}><StudioMark studio={s} size="sm" /><b>{s?.name ?? t('studio.none')}</b><span className="muted small">{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}</span></span>
</td></tr>
)}
{g.rows.map(row)}
</tbody>
);
})}
</table>
</div>
) : <Empty compact illo="unknown" title={t('portfolio.noMatch')}>{t('portfolio.noMatchText')}</Empty>}
</Card>}
<div className="pf-cols">
<Card title={t('pf.feed')} icon="activity" sub={t('pf.feedSub')} pad={false}><Activity items={feed} iconFor={d.iconFor} /></Card>
<div className="pf-side">
<Card title={t('pf.fixFirst')} icon="alert" sub={sortedAtt.length ? t('pf.fixFirstSub', { count: sortedAtt.length }) : t('portfolio.attentionSub')} pad={false}><FixFirst items={sortedAtt} appOf={appOf} iconFor={d.iconFor} /></Card>
<Card title={t('portfolio.workspace')} icon="settings">
<dl className="kv">
<dt>{t('nav.studios')}</dt><dd>{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : <button className="link" onClick={() => nav.go({ screen: 'accounts' })}>{t('studio.add')}</button>}</dd>
<dt>{t('nav.accounts')}</dt><dd>{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : <button className="link" onClick={() => nav.go({ screen: 'accounts' })}>{t('action.addAccount')}</button>}</dd>
<dt>{t('portfolio.folders')}</dt><dd>{t('portfolio.foldersN', { count: d.projects.length, store: storeOnly })}</dd>
<dt>{t('nav.ignored')}</dt><dd>{d.apps.filter(a => a.ignored).length ? <button className="link" onClick={() => nav.go({ screen: 'ignored' })}>{t('portfolio.ignoredN', { count: d.apps.filter(a => a.ignored).length })}</button> : t('kpi.none')}</dd>
<dt>{t('portfolio.storesRead')}</dt><dd>{d.live ? ago(d.live.at) : t('time.never')}</dd>
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}</dd>
</dl>
</Card>
</div>
</div>
</div>
);
}