The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign `constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle stopped before React drew anything. The saved state was not the cause. - build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines the property when the frozen prototype would refuse it - error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a backup); public/boot.js shows the same panel when the bundle fails or the window stays empty - logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated, home as ~, keys, tokens and e-mail addresses replaced - state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry tolerant parsing, partial settings keep their fields, unreadable files kept aside - folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them - tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci, the Forgejo CI workflow and the macOS release
291 lines
20 KiB
TypeScript
291 lines
20 KiB
TypeScript
// 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<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={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[]): Record<string, { h: HealthSnapshot; at: string } | null> {
|
|
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
|
|
const key = targets.join();
|
|
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>
|
|
);
|
|
}
|
|
|
|
function AttentionList({ items }: { items: Attention[] }) {
|
|
const nav = useNav();
|
|
if (!items.length) return <Empty compact illo="shield" title={t('portfolio.allClear')}>{t('portfolio.allClearText')}</Empty>;
|
|
return (
|
|
<div className="list">
|
|
{items.slice(0, 8).map((a, i) => (
|
|
<button key={i} className="li" onClick={() => nav.go({ screen: 'app', appId: a.projectId, tab: a.tab ?? 'checks' })}>
|
|
<span className={`li-icon tone-bg-${a.tone}`}><Icon name={a.tone === 'bad' ? 'alertCircle' : a.tone === 'warn' ? 'alert' : 'info'} /></span>
|
|
<span className="li-main"><span className="li-title">{a.text}</span><span className="li-sub">{a.app}{a.tab ? ` · ${t(`tab.${a.tab}`)}` : ''}</span></span>
|
|
<Icon name="chevronRight" className="ic14 muted" />
|
|
</button>
|
|
))}
|
|
</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;
|
|
}
|
|
|
|
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<View>;
|
|
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<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 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 (
|
|
<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)}>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="ph">
|
|
<div><h1>{t('nav.portfolio')}</h1><p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}</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>}
|
|
{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 && <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 }) : t('kpi.notRead')} />
|
|
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? t('kpi.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 ? t('kpi.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>}
|
|
</div>
|
|
|
|
<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="grid g-main-side">
|
|
<Card title={t('portfolio.attention')} icon="alert" sub={t('portfolio.attentionSub')} pad={false}><AttentionList items={[...att].sort((a, b) => (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} /></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>
|
|
);
|
|
}
|