= { live: 'ok', rollout: 'accent', halted: 'bad', released: 'info', approved: 'ok', review: 'warn', sent: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' };
/** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */
export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } {
if (!r) return { text: t('phase.none'), tone: 'neutral' };
const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : '';
if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' };
if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' };
const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : '';
if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' };
if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent'))
return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.playPending')) + stale, tone: 'warn' };
if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' };
if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' };
return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] };
}
export function PhaseChip({ r }: { r: StoreRelease | null | undefined }) {
const p = phaseLabel(r);
return {p.text} ;
}
export const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
/** "App Store · com.example.app" badge, dashed when the app is not on that store. */
export function StoreBadge({ store, id, off, children }: { store: 'ios' | 'android'; id?: string | null; off?: boolean; children?: ReactNode }) {
return (
{storeName(store)}
{id && <>{id} >}
{off && · {t('app.notSetUp')} }
{children && <> {children}>}
);
}
/** The app's own icon when the project has one, else a monogram on a gradient picked from the name. */
export function AppIcon({ name, src, className = '' }: { name: string; src?: string | null; className?: string }) {
if (src) return ;
const h = hueOf(name);
return (
{monogram(name)}
);
}
export function Kpi({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub?: ReactNode; icon?: IconName; tone?: Tone }) {
return (
{icon && }{label}
{value}
{sub !== undefined &&
{sub}
}
);
}
export function Bar({ value, tone, thin, label }: { value: number; tone?: Tone; thin?: boolean; label?: string }) {
const v = Math.max(0, Math.min(1, value));
return (
);
}
/** Crash / ANR sparkline (visx): see charts.tsx. */
export { Sparkline } from './charts';
export function Stars({ n, label }: { n: number; label?: string }) {
return (
{[1, 2, 3, 4, 5].map(i => )}
);
}
export function Notice({ tone, children, icon, action }: { tone: 'bad' | 'warn' | 'ok' | 'info'; children: ReactNode; icon?: IconName; action?: ReactNode }) {
const ic: IconName = icon ?? (tone === 'bad' ? 'alertCircle' : tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info');
return ;
}
/** A failed read or write in words, with how to fix it when the error is a known one ("errors that say how to fix"). */
export function ErrorNotice({ error, tone = 'warn', keep, action }: { error: string; tone?: 'bad' | 'warn'; keep?: boolean; action?: ReactNode }) {
const hint = fixHint(error);
return (
{keep ? t('common.readFailedKeep', { error }) : error}
{hint && {t(hint)} }
);
}
/** A card footer with a failed read and, when known, how to fix it. */
export function ErrorFoot({ error }: { error: string }) {
const hint = fixHint(error);
return {error}{hint && {t(hint)} }
;
}
export function Skeleton({ lines = 3, block }: { lines?: number; block?: boolean }) {
return (
{block && }
{Array.from({ length: lines }, (_, i) => )}
);
}
export function Empty({ illo, title, children, actions, compact }: { illo: IlloName; title: string; children?: ReactNode; actions?: ReactNode; compact?: boolean }) {
return (
{title}
{children &&
{children}
}
{actions &&
{actions}
}
);
}
export function Seg({ value, options, onChange, label }: { value: T; options: [T, string, IconName?][]; onChange: (v: T) => void; label: string }) {
return (
{options.map(([v, text, icon]) => onChange(v)}>{icon && }{text} )}
);
}
export function Card({ title, icon, sub, actions, children, foot, className = '', pad = true, id }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; foot?: ReactNode; className?: string; pad?: boolean; id?: string }) {
return (
{title !== undefined && (
{icon && }{title} {sub &&
{sub}
}
{actions &&
{actions}
}
)}
{pad ? {children}
: children}
{foot && {foot}
}
);
}
export function Kbd({ keys }: { keys: string[] }) {
return <>{keys.map(k => {k} )}>;
}
/** Platform modifier label (⌘ on macOS, Ctrl elsewhere). */
export const MOD = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
/** A studio's logo, or its monogram on the studio colour. `null` studio: "No studio" (dashed). */
export function StudioMark({ studio, size = 'md' }: { studio: { name: string; color: string; logo?: string | null } | null; size?: 'sm' | 'md' | 'lg' }) {
if (!studio) return ;
if (studio.logo) return ;
return {monogram(studio.name)} ;
}
/** Small "store only" marker (an app without an app folder). */
export function StoreOnlyTag() {
return {t('apps.storeOnly')} ;
}
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
* "couldn't refresh: " over the last good value, and the per-card refresh icon (no "Read" button first). */
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; justNow?: boolean; pausedUntil?: number | null; refresh: () => Promise }; label?: string }) {
const paused = r.pausedUntil ? t('read.paused', { time: new Date(r.pausedUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '';
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : paused && r.loading ? paused : r.justNow ? t('read.justNow') : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
return (
{text && {text} }
{ r.refresh(); }} disabled={r.loading} aria-busy={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
);
}