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.
507 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|