desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
import { devSwitch } from './dev/switches';
import { type Key, t } from './i18n';
import { ago } from './lib/format';
import { ago, num } from './lib/format';
import { groupByStudio, type UnifiedApp } from './lib/identity';
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
import { usePrefs } from './prefs-ctx';
@ -133,6 +133,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
const lastRead = d.live?.at ?? null;
// the app on screen is read first by the background reader
const { setFocus } = d;
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
@ -202,6 +205,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
<ReaderLine />
</div>
</nav>
<div className="main">
@ -217,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
<span className="spacer" />
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !visible.length} title={t('action.readStoresHint')}>
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
</button>
<ExportMenu />
</header>
@ -242,3 +246,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
</NavCtx.Provider>
);
}
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
function ReaderLine() {
const d = useData();
const r = d.reader;
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
const parts: string[] = [];
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
else if (!d.reader.autoRead) parts.push(t('reader.off'));
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
if (!parts.length) return null;
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
}

View file

@ -28,12 +28,13 @@ export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */
export interface Studio { id: string; name: string; color: StudioColor; logo: string | null; created: string }
export interface Settings {
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean };
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean };
channel: 'stable' | 'beta';
}
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }

View file

@ -216,8 +216,8 @@ const HEALTH: Record<string, HealthSnapshot> = {
anr: rows('Anr', series(28, 0.0024, 0.0004, 2), 0.0027),
devices: [{ model: 'Galaxy A12', crash: 0.061, users: 1900 }, { model: 'Redmi 9A', crash: 0.042, users: 1400 }, { model: 'Moto G Power (2021)', crash: 0.019, users: 900 }, { model: 'Pixel 6a', crash: 0.008, users: 2100 }, { model: 'Galaxy S23', crash: 0.004, users: 2600 }],
issues: [
{ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231' },
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231' },
{ type: 'CRASH', cause: 'java.lang.IllegalStateException: ComboMeter.update called after the level ended (com.example.prismpuddle.combo.ComboMeter)', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231', firstVersion: '224', firstOs: '26', lastOs: '35', lastSeen: iso(95), id: 'a1b2c3', uri: 'https://play.google.com/console/developers/app/vitals/crashes/a1b2c3/details' },
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231', firstVersion: '229', lastSeen: iso(300), id: 'd4e5f6', uri: 'https://play.google.com/console/developers/app/vitals/crashes/d4e5f6/details' },
{ type: 'CRASH', cause: 'java.lang.OutOfMemoryError', location: 'android.graphics.Bitmap.createBitmap', reports: 74, users: 61, lastVersion: '221' },
],
},
@ -230,7 +230,7 @@ const HEALTH: Record<string, HealthSnapshot> = {
{ category: 'Battery', metric: 'Foreground energy', unit: '%/h', version: '2.3.0', value: 9.4, goal: 'poor' },
],
signatures: [
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31 },
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31, id: 'sig-hang-1' },
{ build: '48', type: 'DISK_WRITES', signature: 'SaveStore.flush() + 88', weight: 0.12 },
{ build: '47', type: 'LAUNCHES', signature: 'AssetCache.warmUp() + 64', weight: 0.07 },
],
@ -411,7 +411,23 @@ const STORE_REVIEWS: Review[] = [
R('play', 's-p2', 2, 'Too many ads', 'An ad after every round is too much.', 'en', 5),
R('apple', 's-a3', 5, 'Harika', 'Her gün kullanıyorum, çok sade.', 'TUR', 7),
];
/** A sample report of a Health issue (`health --issue / --signature --json`). */
const ISSUE_SAMPLE = (args: Record<string, unknown>) => ({ sample: args.signature ? {
store: 'apple', version: '2.3.0 (48)', device: 'iPhone15,2', os: 'iOS 18.6', stack: ['PrismPuddle ComboMeter.update(delta:) + 212', 'PrismPuddle GameLoop.tick() + 88', 'QuartzCore CA::Display::DisplayLink::dispatch_items + 1012', 'UIKitCore -[UIApplication _run] + 888', 'PrismPuddle main + 64'],
} : {
store: 'play', type: 'CRASH', version: '231', device: 'Galaxy A12', os: 'API 31', time: iso(95),
stack: ['java.lang.IllegalStateException: ComboMeter.update called after the level ended', '\tat com.example.prismpuddle.combo.ComboMeter.update(ComboMeter.kt:88)', '\tat com.example.prismpuddle.level.Level.tick(Level.kt:214)', '\tat com.example.prismpuddle.GameLoop.run(GameLoop.kt:51)', '\tat android.os.Handler.handleCallback(Handler.java:958)', '\tat android.os.Looper.loop(Looper.java:294)'],
} });
/** One read as the sidecar would answer it (exported for the desktop boot check's auto-read case). */
export function fixtureRead(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
const r = read(method, id, args);
// App Store Connect reads carry the key's hourly quota (X-Rate-Limit), so the status line has something to show
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
}
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
if (id.startsWith('store:')) {
const play = /android:/.test(id);
switch (method) {
@ -475,7 +491,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
};
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
async function progressLines(job: string | null, lines: string[]) {
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
@ -549,7 +565,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
else await sleep(150);
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
return read(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
}
case 'job_cancel': cancelled.add(a.job as string); return true;
case 'write_with_confirm': {

View file

@ -753,6 +753,39 @@ export const en = {
'set.refreshSub': 'Reads use each account\'s API quota: they run when you ask, or on the schedule here',
'set.autoRefresh': 'Read the stores automatically',
'set.autoHint': 'While the app is open. Off by default.',
'health.likelyCause': 'Likely cause:',
'health.suggestedFix': 'Fix:',
'health.copied': "Copied for AI: paste it into your agent",
'health.copyFailed': "Could not copy to the clipboard",
'health.issueType': "Type",
'health.issueWhere': "Location",
'health.issueShare': "Share of reports",
'health.issueVersions': "versionCode",
'health.issueBuild': "Build",
'health.issueOs': "Android API",
'health.issueLastSeen': "Last report",
'health.sample': "Sample stack trace",
'health.noSample': "No sample report in the last 28 days.",
'health.copyForAi': "Copy for AI",
'health.openConsole': "Open in Play Console",
'health.issuesUnread': "Top issues not read: {error}",
'set.autoRead': "Read everything automatically",
'set.autoReadHint': "Status, releases, catalog, prices, listings, TestFlight, health, reviews and checks of every app, in the background, inside the stores' limits",
'set.everyMinutes': "While the app is open, read again every",
'set.onlyOnStart': "Only on start",
'set.everyHint': "Only reads past their age limit run (status 5 min, reviews 15 min, catalog and prices 30 min, health and analytics 6 h). Paused while the window is hidden.",
'action.refreshAll': "Refresh all",
'action.refreshAllHint': "Read everything of every app again now (read-only, inside the stores' limits)",
'read.ago': "read {when}",
'read.refreshing': "Refreshing…",
'read.couldNotRefresh': "couldn't refresh: {reason}",
'read.refresh': "Refresh",
'read.waiting': "Reading…",
'reader.progress': "Reading {done} of {total}",
'reader.upToDate': "Everything read",
'reader.off': "Auto-read is off",
'reader.appleQuota': "App Store limit {left}/{limit} left this hour",
'reader.backoff': "The store asked to slow down: waiting",
'set.off': 'Off',
'set.min': '{n} min',
'set.hour': '{n} h',

View file

@ -755,6 +755,39 @@ export const tr: Record<keyof typeof en, string> = {
'set.refreshSub': 'Okumalar her hesabın API kotasını kullanır: siz istediğinizde ya da buradaki zamanlamayla çalışır',
'set.autoRefresh': 'Mağazaları otomatik oku',
'set.autoHint': 'Uygulama açıkken. Varsayılan olarak kapalı.',
'health.likelyCause': 'Olası neden:',
'health.suggestedFix': 'Çözüm:',
'health.copied': "Yapay zekâ için kopyalandı: ajanınıza yapıştırın",
'health.copyFailed': "Panoya kopyalanamadı",
'health.issueType': "Tür",
'health.issueWhere': "Konum",
'health.issueShare': "Raporlardaki payı",
'health.issueVersions': "versionCode",
'health.issueBuild': "Derleme",
'health.issueOs': "Android API",
'health.issueLastSeen': "Son rapor",
'health.sample': "Örnek yığın izi",
'health.noSample': "Son 28 günde örnek rapor yok.",
'health.copyForAi': "Yapay zekâ için kopyala",
'health.openConsole': "Play Console'da aç",
'health.issuesUnread': "Öne çıkan hatalar okunamadı: {error}",
'set.autoRead': "Her şeyi otomatik oku",
'set.autoReadHint': "Her uygulamanın durumu, sürümleri, kataloğu, fiyatları, mağaza metinleri, TestFlight, sağlık, yorumlar ve kontrolleri arka planda, mağaza limitlerinin içinde okunur",
'set.everyMinutes': "Uygulama açıkken yeniden okuma aralığı",
'set.onlyOnStart': "Sadece açılışta",
'set.everyHint': "Yalnızca süresi dolan okumalar çalışır (durum 5 dk, yorumlar 15 dk, katalog ve fiyatlar 30 dk, sağlık ve analiz 6 sa). Pencere gizliyken durur.",
'action.refreshAll': "Hepsini yenile",
'action.refreshAllHint': "Her uygulamanın her şeyini şimdi yeniden oku (salt okunur, mağaza limitlerinin içinde)",
'read.ago': "{when} okundu",
'read.refreshing': "Yenileniyor…",
'read.couldNotRefresh': "yenilenemedi: {reason}",
'read.refresh': "Yenile",
'read.waiting': "Okunuyor…",
'reader.progress': "{total} okumadan {done} tamam",
'reader.upToDate': "Her şey okundu",
'reader.off': "Otomatik okuma kapalı",
'reader.appleQuota': "App Store limiti: bu saat {left}/{limit} kaldı",
'reader.backoff': "Mağaza yavaşlamamızı istedi: bekleniyor",
'set.off': 'Kapalı',
'set.min': '{n} dk',
'set.hour': '{n} sa',

View file

@ -0,0 +1,77 @@
// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for
// every app. Pure (tests/scheduler.test.ts).
import type { Lane } from './scheduler';
const MIN = 60_000;
/** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices /
* listings 30 min, health and analytics 6 h (the stores update them daily). */
export const TTL: Record<string, number> = {
'portfolio.status': 5 * MIN,
'apple.status': 5 * MIN,
'play.status': 5 * MIN,
'apple.testflight_status': 5 * MIN,
'reviews.list': 15 * MIN,
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
health: 6 * 60 * MIN,
'health.issue': 6 * 60 * MIN,
'analytics.compare': 6 * 60 * MIN,
'aso.audit': 6 * 60 * MIN,
};
export const DEFAULT_TTL = 30 * MIN;
export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL;
/** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */
export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean {
if (!at) return true;
const t = Date.parse(at);
return !Number.isFinite(t) || now - t >= ttlOf(method);
}
/** The store lanes a read takes a slot in. */
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');
return l.length ? l : ['local'];
}
export interface PlannedRead { method: string; args: Record<string, unknown> }
/** The reads the background reader does for one app (the ones its tabs show), by what the app has. */
export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] {
const r: PlannedRead[] = [];
const add = (method: string, args: Record<string, unknown> = {}) => r.push({ method, args });
if (o.ios) { add('apple.status'); add('apple.testflight_status'); }
if (o.android) add('play.status');
if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); }
if (o.folder) {
add('scan'); add('project.listings');
if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); }
add('analytics.compare'); add('aso.audit');
}
if (o.ios) add('apple.ppo_status');
return r;
}
/** Key of one read in the scheduler and the read bus. */
export const readKey = (target: string, method: string, args: Record<string, unknown> = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`;
/** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */
export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text);
/** The one-line reason of a failed read, for "couldn't refresh: <reason>". */
export function oneLine(text: string, max = 140): string {
const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? '';
const s = l.replace(/^\S+ \[[^\]]+\] /, '');
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
}

View file

@ -0,0 +1,60 @@
// One crash / ANR issue in detail (Health tab): the Play error issue or App Store diagnostic signature, its sample
// stack (`health.issue`), and the compact "Copy for AI" markdown. Pure (tests/model.test.ts).
import { type CrashHint, crashHint } from '../../../../cli/lib/crash-hints';
import type { IssueSample, PlayIssue, AppleSignature } from './model';
/** Likely cause of a known signature, from the issue text and its sample stack. */
export const hintFor = (v: IssueView, sample: IssueSample | null): CrashHint | null => crashHint(v.title, v.where, ...(sample?.stack.slice(0, 30) ?? []));
export interface IssueView {
store: 'play' | 'apple';
title: string;
/** Exception type / diagnostic type. */
kind: string;
where?: string;
reports?: number;
users?: number;
weight?: number;
versions?: { first?: string; last?: string };
os?: { first?: string; last?: string };
lastSeen?: string;
build?: string;
url?: string;
/** Args of the `health.issue` read for its sample stack; null when there is no id to ask for. */
sampleArgs: Record<string, string> | null;
}
export function playIssueView(i: PlayIssue): IssueView {
return {
store: 'play', title: i.cause || i.location || '?', kind: i.type === 'ANR' ? 'ANR' : i.type, where: i.location || undefined, reports: i.reports, users: i.users,
versions: { first: i.firstVersion, last: i.lastVersion }, os: i.firstOs || i.lastOs ? { first: i.firstOs, last: i.lastOs } : undefined, lastSeen: i.lastSeen, url: i.uri,
sampleArgs: i.id ? { issue: i.id } : null,
};
}
export function appleSignatureView(s: AppleSignature): IssueView {
return { store: 'apple', title: s.signature || s.type, kind: s.type, weight: s.weight, build: s.build, sampleArgs: s.id ? { signature: s.id } : null };
}
/** The top frames worth pasting (the full sample stays in the window). */
export const TOP_FRAMES = 15;
/** Compact markdown for an AI agent: title, type, where, counts, versions, top frames. */
export function issueMarkdown(app: string, v: IssueView, sample: IssueSample | null): string {
const out = [`## ${v.store === 'play' ? 'Google Play' : 'App Store'} ${v.kind}: ${v.title}`, '', `- App: ${app}`];
if (v.where && v.where !== v.title) out.push(`- Location: ${v.where}`);
if (v.reports !== undefined) out.push(`- Reports: ${v.reports}${v.users !== undefined ? `, users: ${v.users}` : ''}`);
if (v.weight !== undefined) out.push(`- Share of reports: ${Math.round(v.weight * 1000) / 10} %`);
if (v.versions?.first || v.versions?.last) out.push(`- versionCode: ${v.versions.first ?? '?'} to ${v.versions.last ?? '?'}`);
if (v.build) out.push(`- Build: ${v.build}`);
if (v.os) out.push(`- Android API: ${v.os.first ?? '?'} to ${v.os.last ?? '?'}`);
if (v.lastSeen) out.push(`- Last report: ${v.lastSeen}`);
const hint = hintFor(v, sample);
if (hint) out.push(`- Likely cause: ${hint.cause}`, `- Suggested fix: ${hint.fix}`);
if (sample) {
const meta = [sample.version && `version ${sample.version}`, sample.device, sample.os].filter(Boolean).join(', ');
out.push('', `Sample report${meta ? ` (${meta})` : ''}, top frames:`, '', '```', ...sample.stack.slice(0, TOP_FRAMES), ...(sample.stack.length > TOP_FRAMES ? [`... ${sample.stack.length - TOP_FRAMES} more frames`] : []), '```');
}
if (v.url) out.push('', `Console: ${v.url}`);
return out.join('\n') + '\n';
}

View file

@ -17,11 +17,17 @@ export interface PlayHealth {
crash: MetricRow[];
anr: MetricRow[];
devices?: { model: string; crash?: number; anr?: number; users?: number }[];
issues: { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string }[];
issues: PlayIssue[];
issuesError?: string;
}
/** One Play error issue (errorIssues:search; id / versions / API levels / last report when the API sent them). */
export interface PlayIssue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string; id?: string; firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string }
export interface AppleSignature { build: string; type: string; signature: string; weight: number; id?: string }
/** `health --issue / --signature --json`: one sample report's stack, top frame first. */
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
export interface AppleHealth {
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
signatures: { build: string; type: string; signature: string; weight: number }[];
signatures: AppleSignature[];
betaCrashes: { build: string; count: number }[];
}
export type StoreReadError = import('../../../../cli/lib/health').StoreReadError;

View file

@ -55,7 +55,8 @@ export function normalizeSettings(v: unknown): Settings {
if (!isRec(v)) return d;
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
return {
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool) },
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool),
autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) },
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
channel: v.channel === 'beta' ? 'beta' : 'stable',

View file

@ -0,0 +1,243 @@
// The background read scheduler: every store read of the window goes through one queue, so reading "everything for
// every app" stays far inside the stores' limits. Pure logic with an injectable clock (tests/scheduler.test.ts).
//
// - Lanes: 'apple' (App Store Connect), 'play' (Google Play Developer + Reporting API), 'local' (offline project
// reads). A task takes one slot in each of its lanes; each lane has its own concurrency limit (default 4).
// - Credentials: starts on one key are spaced (default 250 ms apart). App Store Connect sends X-Rate-Limit
// (user-hour-lim / user-hour-rem, about 3600 per key per hour): below 20 % left, the spacing spreads the rest over the
// hour; at the reserve (default 100, kept for the owner's own commands) the key pauses for 10 minutes.
// - 429 / RESOURCE_EXHAUSTED: the task waits and runs again with exponential backoff and jitter (and its key waits too),
// at most `maxAttempts` times.
// - Priority: 0 the visible app and tab, 1 portfolio status, 2 the rest; FIFO within a priority.
// - Dedupe: one in-flight (or queued) read per key; enqueueing it again returns the same promise (and may raise its
// priority).
export type Lane = 'apple' | 'play' | 'local';
export type Priority = 0 | 1 | 2;
export interface Clock {
now(): number;
setTimeout(fn: () => void, ms: number): unknown;
clearTimeout(h: unknown): void;
random(): number;
}
export const realClock: Clock = {
now: () => Date.now(),
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: h => clearTimeout(h as ReturnType<typeof setTimeout>),
random: () => Math.random(),
};
export interface QuotaSeen { limit?: number; remaining?: number; throttled?: boolean }
/** What one finished run says about the store's limits. */
export interface Outcome {
/** 429 / RESOURCE_EXHAUSTED: run it again later. */
throttled?: boolean;
/** Quota per credential (App Store Connect X-Rate-Limit). */
quota?: { credential: string; limit: number; remaining: number };
}
export interface ReadTask<R> {
key: string;
lanes: Lane[];
/** Keys of the store credentials it uses ("apple:<account>", "play:<account>"). */
credentials: string[];
priority: Priority;
run: () => Promise<R>;
}
export interface SchedulerOptions {
limits?: Partial<Record<Lane, number>>;
/** Minimum gap between two starts on one credential. */
spacingMs?: number;
/** Requests per hour kept free on an App Store Connect key for the owner's own commands. */
reserve?: number;
/** Below this share of the hourly limit left, starts are spread over the rest of the hour. */
slowBelow?: number;
/** How long a key pauses once it is at the reserve. */
pauseMs?: number;
backoffBaseMs?: number;
backoffMaxMs?: number;
maxAttempts?: number;
clock?: Clock;
/** Reads a result (or the error) for throttling and quota. */
classify?: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
}
export interface SchedulerStats {
queued: number;
running: number;
/** Finished in this round (since the queue was last empty). */
done: number;
/** done + running + queued of this round. */
total: number;
failed: number;
/** The lowest App Store Connect quota seen per credential this hour. */
quota: Record<string, { limit: number; remaining: number; at: number }>;
/** Credentials waiting on a backoff or quota pause, with the time they may start again. */
paused: Record<string, number>;
}
interface Item<R> {
task: ReadTask<R>;
seq: number;
attempt: number;
notBefore: number;
resolve: (r: R) => void;
reject: (e: unknown) => void;
promise: Promise<R>;
}
const HOUR = 3_600_000;
export class ReadScheduler {
private o: Required<Omit<SchedulerOptions, 'limits' | 'clock' | 'classify'>> & { limits: Record<Lane, number> };
private clock: Clock;
private classify: (result: unknown, error: unknown, task: ReadTask<unknown>) => Outcome;
private queue: Item<unknown>[] = [];
private running = new Map<string, Item<unknown>>();
private busy: Record<Lane, number> = { apple: 0, play: 0, local: 0 };
private nextAt = new Map<string, number>();
private quotas = new Map<string, { limit: number; remaining: number; at: number }>();
private seq = 0;
private done = 0;
private failed = 0;
private timer: unknown = null;
private timerAt = 0;
private listeners = new Set<() => void>();
constructor(o: SchedulerOptions = {}) {
this.o = {
limits: { apple: 4, play: 4, local: 2, ...o.limits },
spacingMs: o.spacingMs ?? 250,
reserve: o.reserve ?? 100,
slowBelow: o.slowBelow ?? 0.2,
pauseMs: o.pauseMs ?? 10 * 60_000,
backoffBaseMs: o.backoffBaseMs ?? 5_000,
backoffMaxMs: o.backoffMaxMs ?? 5 * 60_000,
maxAttempts: o.maxAttempts ?? 4,
};
this.clock = o.clock ?? realClock;
this.classify = o.classify ?? (() => ({}));
}
setLimits(limits: Partial<Record<Lane, number>>): void { this.o.limits = { ...this.o.limits, ...limits }; this.pump(); }
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); }
enqueue<R>(task: ReadTask<R>): Promise<R> {
const run = this.running.get(task.key);
if (run) return run.promise as Promise<R>;
const queued = this.queue.find(i => i.task.key === task.key);
if (queued) {
if (task.priority < queued.task.priority) { queued.task = { ...queued.task, priority: task.priority }; this.pump(); }
return queued.promise as Promise<R>;
}
let resolve!: (r: R) => void, reject!: (e: unknown) => void;
const promise = new Promise<R>((a, b) => { resolve = a; reject = b; });
this.queue.push({ task, seq: ++this.seq, attempt: 0, notBefore: 0, resolve, reject, promise } as unknown as Item<unknown>);
this.pump();
this.emit();
return promise;
}
/** Drops queued tasks (not the running ones); their promises reject with "cancelled". */
clear(filter: (key: string) => boolean = () => true): void {
const drop = this.queue.filter(i => filter(i.task.key));
this.queue = this.queue.filter(i => !filter(i.task.key));
for (const i of drop) i.reject(new Error('cancelled'));
this.pump();
this.emit();
}
stats(): SchedulerStats {
const now = this.clock.now();
const paused: Record<string, number> = {};
for (const [c, at] of this.nextAt) if (at - now > this.o.spacingMs) paused[c] = at;
const quota: SchedulerStats['quota'] = {};
for (const [c, q] of this.quotas) if (now - q.at < HOUR) quota[c] = q;
return { queued: this.queue.length, running: this.running.size, done: this.done, total: this.done + this.running.size + this.queue.length, failed: this.failed, quota, paused };
}
/** Earliest time a credential may start again. */
private gap(cred: string, now: number): number {
const q = this.quotas.get(cred);
if (!q || now - q.at >= HOUR) return this.o.spacingMs;
if (q.remaining <= this.o.reserve) return this.o.pauseMs;
if (q.remaining < q.limit * this.o.slowBelow) return Math.max(this.o.spacingMs, Math.ceil(HOUR / Math.max(1, q.remaining - this.o.reserve)));
return this.o.spacingMs;
}
private ready(i: Item<unknown>, now: number): number {
// 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot)
let at = i.notBefore;
for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0);
if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity;
return at <= now ? 0 : at;
}
private pump(): void {
const now = this.clock.now();
let wake = Infinity;
for (;;) {
const order = [...this.queue].sort((a, b) => a.task.priority - b.task.priority || a.seq - b.seq);
let started = false;
for (const i of order) {
const r = this.ready(i, now);
if (r === 0) { this.start(i, now); started = true; break; }
if (r !== Infinity) wake = Math.min(wake, r);
}
if (!started) break;
}
if (this.timer !== null && (wake === Infinity || wake !== this.timerAt)) { this.clock.clearTimeout(this.timer); this.timer = null; }
if (wake !== Infinity && this.timer === null) {
this.timerAt = wake;
this.timer = this.clock.setTimeout(() => { this.timer = null; this.pump(); }, Math.max(0, wake - now));
}
}
private start(i: Item<unknown>, now: number): void {
this.queue = this.queue.filter(x => x !== i);
this.running.set(i.task.key, i);
for (const l of i.task.lanes) this.busy[l]++;
for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now));
i.attempt++;
this.emit();
i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false));
}
private finish(i: Item<unknown>, result: unknown, error: unknown, ok: boolean): void {
const now = this.clock.now();
this.running.delete(i.task.key);
for (const l of i.task.lanes) this.busy[l]--;
let out: Outcome = {};
try { out = this.classify(result, error, i.task); } catch { /* a classifier bug never stalls the queue */ }
if (out.quota) {
const prev = this.quotas.get(out.quota.credential);
// the hour is the key's: keep the lowest count seen in it
const q = prev && now - prev.at < HOUR && prev.remaining < out.quota.remaining ? prev : { limit: out.quota.limit, remaining: out.quota.remaining, at: now };
this.quotas.set(out.quota.credential, q);
this.nextAt.set(out.quota.credential, Math.max(this.nextAt.get(out.quota.credential) ?? 0, now + this.gap(out.quota.credential, now)));
}
if (out.throttled && i.attempt < this.o.maxAttempts) {
const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1));
const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter"
i.notBefore = now + delay;
for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay));
this.queue.push(i);
} else {
this.done++;
if (!ok || out.throttled) this.failed++;
if (ok) i.resolve(result); else i.reject(error);
}
if (!this.queue.length && !this.running.size) { this.done = 0; this.failed = 0; }
this.pump();
this.emit();
}
private emit(): void { for (const f of this.listeners) { try { f(); } catch { /* listeners never break the queue */ } } }
}

View file

@ -225,6 +225,7 @@ export function Portfolio() {
);
};
const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead');
return (
<div className="page">
<div className="ph">
@ -240,13 +241,13 @@ export function Portfolio() {
{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 && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</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 }) : 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="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>

View file

@ -122,11 +122,11 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
<div className="form">
<div className="field"><span className="legend">{t('set.autoRefresh')}</span>
<Seg value={String(s.refresh.autoMinutes) as '0'} label={t('set.autoRefresh')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
<span className="hint">{t('set.autoHint')}</span></div>
<Toggle id="on-launch" label={t('set.onLaunch')} hint={t('set.onLaunchHint')} checked={s.refresh.onLaunch} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
<Toggle id="auto-read" label={t('set.autoRead')} hint={t('set.autoReadHint')} checked={s.refresh.autoRead} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
<div className="field"><span className="legend">{t('set.everyMinutes')}</span>
<Seg value={String(s.refresh.everyMinutes) as '0'} label={t('set.everyMinutes')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, everyMinutes: Number(v) as S['refresh']['everyMinutes'] } }))}
options={[['0', t('set.onlyOnStart')], ['5', t('set.min', { n: 5 })], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })]]} />
<span className="hint">{t('set.everyHint')}</span></div>
<Toggle id="icons" label={t('set.icons')} hint={t('set.iconsHint')} checked={s.refresh.icons} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
<div className="field"><label htmlFor="parallel">{t('set.parallel')}</label>
<select id="parallel" className="input" style={{ width: 120 }} value={s.refresh.parallel} onChange={e => set(x => ({ ...x, refresh: { ...x.refresh, parallel: Number(e.target.value) } }))}>
@ -134,7 +134,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
</select><span className="hint">{t('set.parallelHint')}</span></div>
<div className="actions">
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
</div>
</div>

View file

@ -8,7 +8,7 @@ import { useData, useRead, useWrite } from '../../store';
import { Icon, type IconName, StoreGlyph } from '../../ui/icons';
import { OwnerAction } from '../../ui/OwnerAction';
import { WriteButton } from '../../ui/WriteButton';
import { Card, Chip, Empty, ErrorNotice, Kpi, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit';
import { catalogMd, exportName } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { useNav } from '../../nav';
@ -87,13 +87,13 @@ export function CatalogTab({ project }: { project: Project }) {
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : t('kpi.notRead')} />
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : diff.loading ? t('read.waiting') : t('kpi.notRead')} />
</div>
{diff.error && <ErrorNotice error={diff.error} keep />}
<CatalogActions project={project} products={s?.products ?? 0} subs={s?.subscriptions ?? 0} refresh={() => { diff.refresh(); }} />
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
actions={<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{read ? t('action.refresh') : t('cat.compare')}</button>}>
actions={<ReadControl r={diff} label={t('cat.compare')} />}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>

View file

@ -6,7 +6,7 @@ import { ago } from '../../lib/format';
import type { Finding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import { useData, useRead } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
import { exportName, validateMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
@ -22,7 +22,7 @@ function ScanCard({ project }: { project: Project }) {
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
return (
<Card title={t('checks.scan')} icon="search" sub={s.data && sdks.length ? t('checks.scanSdks', { list: sdks.map(x => x.name).join(', ') }) : t('checks.scanSub')} pad={false}
actions={<button className="btn btn-sm" onClick={s.refresh} disabled={s.loading}><Icon name="refresh" className={s.loading ? 'spin' : undefined} />{t('checks.scanAgain')}</button>}>
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
@ -80,7 +80,7 @@ export function ChecksTab({ project }: { project: Project }) {
)}
</Card>
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
actions={<button className="btn btn-sm" onClick={v.refresh} disabled={v.loading}><Icon name="refresh" />{v.data ? t('action.refresh') : t('checks.run')}</button>}>
actions={<ReadControl r={v} label={t('checks.run')} />}>
{v.error && <div className="card-body"><ErrorNotice error={v.error} keep={!!v.data} /></div>}
{!v.data ? (
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>

View file

@ -14,7 +14,7 @@ import { useNav } from '../../nav';
import { useData, useRead, useWrite } from '../../store';
import { useExports } from '../../ui/Exports';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Seg, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
import { LineChart } from '../../ui/LineChart';
import { RankChart } from '../../ui/RankChart';
import { WriteButton } from '../../ui/WriteButton';
@ -107,7 +107,7 @@ function Aso({ projectId }: { projectId: string }) {
})) : [];
return (
<Card title={t('gr.aso')} icon="search" sub={a ? t('gr.asoSub', { date: date(a.generated) }) : t('gr.asoSubNone')} pad={false}
actions={<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{t('action.refresh')}</button>}>
actions={<ReadControl r={r} />}>
{r.error ? <div className="card-body"><ErrorNotice error={r.error} /></div> : !a ? <div className="card-body"><Skeleton lines={4} block /></div> : (
<div className="card-body stack" style={{ gap: 16 }}>
<div className="kpis" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
@ -167,7 +167,7 @@ function Experiments({ app }: { app: UnifiedApp }) {
<Card title={t('gr.ppo')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}</span>} pad={false}
actions={<>
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(asc).catch(() => {})}><Icon name="external" />{t('gr.results')}</button>
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('gr.ppoRead')}</button>
<ReadControl r={r} label={t('gr.ppoRead')} />
</>}>
{r.error && <div className="card-body"><ErrorNotice error={r.error} keep={!!r.data} /></div>}
{!r.data ? (r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('gr.ppoNotRead')}>{t('gr.ppoNotReadText')}</Empty>)

View file

@ -4,9 +4,13 @@ import type { UnifiedApp } from '../../lib/identity';
import { t } from '../../i18n';
import { ago, date, num, pct } from '../../lib/format';
import { type AppleHealth, healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type PlayHealth, type Series } from '../../lib/model';
import { useRead } from '../../store';
import { useState } from 'react';
import { api } from '../../api';
import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues';
import type { IssueSample } from '../../lib/model';
import { useData, useRead } from '../../store';
import { Icon } from '../../ui/icons';
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit';
import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit';
import { exportName, healthMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { StoreReadNotice } from '../../ui/StoreError';
@ -33,7 +37,47 @@ function Metric({ title, s }: { title: string; s: Series }) {
);
}
function Play({ h }: { h: PlayHealth }) {
/** One issue's detail: full title and type, counts, versions, last report, a sample stack (read and cached like the
* rest), "Open in Play Console" and "Copy for AI" (compact markdown for an agent). */
function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) {
const d = useData();
const s = useRead<{ sample: IssueSample | null }>(app.target, 'health.issue', { network: true, args: v.sampleArgs ?? {}, skip: !v.sampleArgs });
const sample = s.data?.sample ?? null;
const copy = () => {
navigator.clipboard?.writeText(issueMarkdown(app.name, v, sample)).then(() => d.toast({ kind: 'ok', title: t('health.copied') }), () => d.toast({ kind: 'bad', title: t('health.copyFailed') }));
};
const range = (a?: string, b?: string) => (a && b && a !== b ? `${a} – ${b}` : a ?? b ?? '–');
return (
<div className="issue-detail" data-testid="issue-detail">
<div className="strong issue-title">{v.title}</div>
<dl className="kv">
<dt>{t('health.issueType')}</dt><dd>{v.kind}</dd>
{v.where && v.where !== v.title && <><dt>{t('health.issueWhere')}</dt><dd className="mono-id">{v.where}</dd></>}
{v.reports !== undefined && <><dt>{t('col.reports')}</dt><dd className="num">{num(v.reports)}{v.users !== undefined ? ` · ${t('col.users')} ${num(v.users)}` : ''}</dd></>}
{v.weight !== undefined && <><dt>{t('health.issueShare')}</dt><dd className="num">{num(v.weight * 100, 1)}%</dd></>}
{(v.versions?.first || v.versions?.last) && <><dt>{t('health.issueVersions')}</dt><dd className="num">{range(v.versions.first, v.versions.last)}</dd></>}
{v.build && <><dt>{t('health.issueBuild')}</dt><dd className="num">{v.build}</dd></>}
{v.os && <><dt>{t('health.issueOs')}</dt><dd className="num">{range(v.os.first, v.os.last)}</dd></>}
{v.lastSeen && <><dt>{t('health.issueLastSeen')}</dt><dd>{ago(v.lastSeen)}</dd></>}
</dl>
{(() => { const h = hintFor(v, sample); return h ? <Notice tone="info" icon="wand"><div className="stack" style={{ gap: 4 }} data-testid="issue-hint"><span><b>{t('health.likelyCause')}</b> {h.cause}</span><span><b>{t('health.suggestedFix')}</b> {h.fix}</span></div></Notice> : null; })()}
{v.sampleArgs && (
<div className="stack" style={{ gap: 6 }}>
<div className="row between"><span className="label">{t('health.sample')}{sample ? ` · ${[sample.version, sample.device, sample.os].filter(Boolean).join(' · ')}` : ''}</span><ReadControl r={s} /></div>
{sample ? <pre className="stack-trace" data-testid="issue-stack">{sample.stack.join('\n')}</pre>
: s.loading ? <Skeleton lines={4} /> : !s.error ? <p className="small muted">{t('health.noSample')}</p> : null}
</div>
)}
<div className="actions">
<button className="btn btn-sm" onClick={copy}><Icon name="clipboard" />{t('health.copyForAi')}</button>
{v.url && <button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(v.url!).catch(() => {})}><Icon name="external" />{t('health.openConsole')}</button>}
</div>
</div>
);
}
function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) {
const [open, setOpen] = useState<number | null>(null);
const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
const devices = (h.devices ?? []).slice(0, 6);
@ -67,11 +111,11 @@ function Play({ h }: { h: PlayHealth }) {
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
<tbody>
{h.issues.slice(0, 6).map((i, n) => (
<tr key={n}>
<td><div className="row" style={{ gap: 8 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>{i.cause || '?'}</span></div>
<div className="mono-id muted" style={{ marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 360 }}>{i.location}</div></td>
<td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td>
</tr>
<IssueRows key={n} open={open === n} onToggle={() => setOpen(open === n ? null : n)} cols={3}
head={<><div className="row" style={{ gap: 8, minWidth: 0 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong ellipsis" title={i.cause || '?'}>{i.cause || '?'}</span></div>
<div className="mono-id muted ellipsis" style={{ marginTop: 4 }} title={i.location}>{i.location}</div></>}
cells={<><td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td></>}
detail={<IssueDetail app={app} v={playIssueView(i)} />} />
))}
</tbody>
</table>
@ -82,7 +126,21 @@ function Play({ h }: { h: PlayHealth }) {
);
}
function Apple({ h }: { h: AppleHealth }) {
/** A table row that opens its detail row below it (click or Enter / Space). */
function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) {
return (
<>
<tr className={`issue-row${open ? ' open' : ''}`} onClick={onToggle} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button">
<td style={{ maxWidth: 380 }}><div className="row" style={{ gap: 6, minWidth: 0 }}><Icon name="chevronRight" className={`ic13 chev${open ? ' rot' : ''}`} /><div style={{ minWidth: 0 }}>{head}</div></div></td>
{cells}
</tr>
{open && <tr className="edit-row"><td colSpan={cols}>{detail}</td></tr>}
</>
);
}
function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) {
const [open, setOpen] = useState<number | null>(null);
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
return (
<div className="grid g2">
@ -102,15 +160,16 @@ function Apple({ h }: { h: AppleHealth }) {
<div className="stack" style={{ gap: 16 }}>
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
{h.signatures.length ? (
<div className="list">
{h.signatures.slice(0, 5).map((s, i) => (
<div className="li" key={i}>
<span className="li-icon tone-bg-neutral"><Icon name="alertCircle" /></span>
<div className="li-main"><span className="li-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.signature || s.type}</span><span className="li-sub">{s.type} · {t('health.build', { build: s.build })}</span></div>
<span className="num small strong">{num(s.weight * 100, 1)}%</span>
</div>
))}
</div>
<table className="table">
<tbody>
{h.signatures.slice(0, 5).map((s, i) => (
<IssueRows key={i} open={open === i} onToggle={() => setOpen(open === i ? null : i)} cols={2}
head={<><div className="strong ellipsis" title={s.signature || s.type}>{s.signature || s.type}</div><div className="small muted">{s.type} · {t('health.build', { build: s.build })}</div></>}
cells={<td className="right num small strong">{num(s.weight * 100, 1)}%</td>}
detail={<IssueDetail app={app} v={appleSignatureView(s)} />} />
))}
</tbody>
</table>
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
</Card>
<Card title={t('health.betaCrashes')} icon="plane">
@ -128,7 +187,6 @@ function Apple({ h }: { h: AppleHealth }) {
export function HealthTab({ app }: { app: UnifiedApp }) {
const r = useRead<HealthSnapshot>(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name });
useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]);
const refresh = <button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading && !r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('health.read')}</button>;
if (!r.data) {
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
return (
@ -142,14 +200,13 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
{refresh}
<span />
<ReadControl r={r} />
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
<h2 className="label">Google Play</h2>
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <>{h.play.issuesError && <ErrorNotice error={t('health.issuesUnread', { error: h.play.issuesError })} />}<Play h={h.play} app={app} /></>}
<h2 className="label">App Store</h2>
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} app={app} />}
</>
);
}

View file

@ -10,7 +10,7 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region
import { type ReadState, useData, useRead, useWrite } from '../../store';
import { type ExportItem, useExports, useRunExport } from '../../ui/Exports';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
const GAP = 10;
@ -158,7 +158,7 @@ export function PricesTab({ project }: { project: Project }) {
<Card title={t('prices.overview')} icon="grid" sub={cur.fromCache ? t('common.cachedFrom', { when: ago(cur.at) }) : t('common.readWhen', { when: ago(cur.at) })} pad={false}
actions={<>
<label className="check"><input type="checkbox" checked={withAuto} onChange={ev => setWithAuto(ev.target.checked)} />{t('prices.withAuto')}</label>
<button className="btn btn-sm" onClick={cur.refresh} disabled={cur.loading}><Icon name="refresh" className={cur.loading ? 'spin' : undefined} />{t('action.refresh')}</button>
<ReadControl r={cur} />
</>}>
<div className="table-wrap">
<table className="table px-matrix">

View file

@ -13,7 +13,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -139,6 +139,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
</section>
) : null;
if (!l && d.portfolioBusy === 'live') return <><Card><Skeleton lines={5} block /></Card>{submit}</>;
if (!l) {
return (
<>

View file

@ -5,12 +5,12 @@ import { useEffect, useMemo, useState } from 'react';
import type { DraftsResult, ReplyDraft } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
import { t } from '../../i18n';
import { ago, date, num } from '../../lib/format';
import { date, num } from '../../lib/format';
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
import { useRead, useWrite } from '../../store';
import { WriteButton } from '../../ui/WriteButton';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Seg, Skeleton, Stars } from '../../ui/kit';
import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit';
import { exportName, reviewsCsv } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
@ -111,13 +111,11 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })}</span>
<ReadControl r={list} />
<div className="actions">
{project && <WriteButton run={w} icon="text" hint={t('reviews.makeDraftsHint')} w={{ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }}>{t('reviews.makeDrafts')}</WriteButton>}
<button className="btn btn-sm" onClick={list.refresh} disabled={list.loading}><Icon name="refresh" />{t('action.refresh')}</button>
</div>
</div>
{list.error && <Notice tone="warn">{t('common.readFailedKeep', { error: list.error })}</Notice>}
<div className="rv">
<div className="stack" style={{ gap: 16 }}>
<Card title={t('reviews.ratings')} icon="star">

View file

@ -15,7 +15,7 @@ import type { UnifiedApp } from '../../lib/identity';
import { useData, useRead } from '../../store';
import { ConsolePlanButton } from '../../ui/ConsolePlan';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Notice, Seg, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit';
import { OwnerAction } from '../../ui/OwnerAction';
import { ascUrl, PLAY_CONSOLE } from '../AppView';
@ -40,9 +40,9 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) };
const refresh = () => { r.refresh(); };
return (
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.appleSub')}</span>}
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={refresh} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<ReadControl r={{ ...r, refresh: async () => refresh() }} label={t('sub.read')} />
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(ascUrl(app.ios?.app.id ?? summary?.apple?.appId)).catch(() => {})}><Icon name="external" />App Store Connect</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
@ -142,9 +142,9 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project:
const picked = s?.codes.find(c => c.code === code) ?? null;
const args = { version_code: code, status, ...(status === 'inProgress' ? { percent: Number(share) } : {}) };
return (
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('sub.playSub')}</span>}
<Card title={t('sub.playTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')}</span>}
actions={<>
<button className="btn btn-sm" onClick={() => { r.refresh(); }} disabled={r.loading && !!r.data}><Icon name="refresh" className={r.loading && r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('sub.read')}</button>
<ReadControl r={r} label={t('sub.read')} />
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
</>}>
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}

View file

@ -4,11 +4,11 @@ import { Fragment, useState } from 'react';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { t } from '../../i18n';
import { ago, date, num } from '../../lib/format';
import { date, num } from '../../lib/format';
import type { TestFlightStatus, TfBuild, TfGroup, Tone } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
import { Card, Chip, Empty, Notice, ReadControl, Skeleton } from '../../ui/kit';
/** Beta state word -> tone and a short label. */
export function betaState(s: string | null): { tone: Tone; text: string } {
@ -96,10 +96,9 @@ export function TestFlightTab({ app }: { app: UnifiedApp }) {
return (
<>
<div className="row between">
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
<span />
<ReadControl r={r} />
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
{s.missingGroups.length > 0 && (
<Notice tone="warn" action={writable ? <WriteButton run={w} icon="plus" w={{ method: 'apple.testflight_groups', args: {}, title: t('tf.groupsSync'), done: t('tf.groupsSynced'), after: r.refresh }} hint={t('tf.groupsSyncHint')}>{t('tf.groupsSync')}</WriteButton> : undefined}>
{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}

View file

@ -13,6 +13,8 @@ import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
import { setCrashContext } from './lib/crash';
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
import { keepLines, progressOf } from './lib/progress';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
import { fixHint } from './lib/views';
import { failureText, usable } from './lib/results';
@ -32,6 +34,31 @@ export interface Toast {
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
/** What the read bus tells the cards showing one read (key: lib/freshness.ts readKey). */
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: ToolResult } | { kind: 'error'; error: string } | { kind: 'invalidate' };
/** The background reader's state for the status line. */
export interface ReaderState extends SchedulerStats { autoRead: boolean }
/** Throttling and App Store Connect quota of one finished read (the ToolResult's `quota`, else its text). */
export function classifyRead(result: unknown, error: unknown, task: ReadTask<unknown>): Outcome {
const r = result as ToolResult | undefined;
const q = r?.quota;
const out: Outcome = {};
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
return out;
}
/** Which stores an app has (for the reads it gets and the lanes they take). */
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
if (!a) return { ios: false, android: false };
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
}
export interface Data {
ready: boolean;
info: AppInfo | null;
@ -56,7 +83,20 @@ export interface Data {
portfolioError: string | null;
importBusy: boolean;
icons: Record<string, string | null>;
refreshPortfolio: (live?: boolean) => Promise<void>;
/** Background reader: progress and the App Store Connect quota seen this hour. */
reader: ReaderState;
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
/** Listens to one read's bus key. */
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
isReading: (key: string) => boolean;
/** Reads everything of every app that is past its TTL (force: everything). */
readAll: (force?: boolean) => Promise<void>;
/** After a write: the app's reads are read again. */
invalidate: (target: string) => void;
/** The app on screen (its reads go first). */
setFocus: (key: string | null) => void;
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<void>;
/** Reads the apps of one account (or all) from the stores. */
importApps: (account?: string) => Promise<void>;
checkAccount: (id: string) => Promise<void>;
@ -155,10 +195,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
const appsRef = useRef(apps);
appsRef.current = apps;
const refreshPortfolio = useCallback(async (liveRead = false) => {
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
const r = await api.portfolio(liveRead, job, stores);
@ -179,6 +219,78 @@ export function DataProvider({ children }: { children: ReactNode }) {
} finally { setBusy(null); }
}, [toast, updateToast]);
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
const schedRef = useRef<ReadScheduler | null>(null);
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
const sched = schedRef.current;
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
useEffect(() => {
let tm: ReturnType<typeof setTimeout> | null = null;
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
return () => { un(); if (tm) clearTimeout(tm); };
}, [sched]);
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
const set = bus.current.get(key) ?? new Set();
set.add(fn); bus.current.set(key, set);
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
}, []);
const isReading = useCallback((key: string) => sched.has(key), [sched]);
const focusRef = useRef<string | null>(null);
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
const c: string[] = [];
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
return c;
};
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
const key = readKey(target, method, args);
const app = appsRef.current.find(a => a.target === target);
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
const lanes = lanesOf(method, pf);
const p = sched.enqueue<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
emitRead(key, { kind: 'start' });
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
return p;
}, [sched, emitRead]);
const readAll = useCallback(async (force = false) => {
if (!force && !settingsRef.current.refresh.autoRead) return;
if (force || isStale('portfolio.status', liveRef.current?.at)) {
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
}
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
for (const a of list) {
const pf = platformsOf(a, statusRef.current(a));
for (const r of readsFor({ folder: !!a.project, ...pf })) {
const key = readKey(a.target, r.method, r.args);
if (sched.has(key)) continue;
if (!force) {
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
if (c && !isStale(r.method, c.at)) continue;
}
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
}
}
}, [sched, schedule, refreshPortfolio]);
const invalidate = useCallback((target: string) => {
const pre = `${target}\u001f`;
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
const a = appsRef.current.find(x => x.target === target);
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
const key = readKey(target, r.method, r.args);
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
}
}, [emitRead, schedule]);
const importApps = useCallback(async (account?: string) => {
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
if (!ids.length) return;
@ -236,21 +348,27 @@ export function DataProvider({ children }: { children: ReactNode }) {
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
// Settings > Refresh: on launch, then every N minutes (store reads use the accounts' API quota: off by default)
// Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after
// a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits).
const launched = useRef(false);
useEffect(() => {
if (!ready || launched.current) return;
if (!ready || launched.current || !apps.length) return;
launched.current = true;
if (settings.refresh.onLaunch && (projects.length || accounts.length)) importApps().finally(() => refreshPortfolio(true));
}, [ready, settings.refresh.onLaunch, projects.length, accounts.length, importApps, refreshPortfolio]);
const busyRef = useRef(portfolioBusy);
busyRef.current = portfolioBusy;
if (settings.refresh.autoRead) readAll(false).catch(() => {});
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
useEffect(() => {
const m = settings.refresh.autoMinutes;
if (!m) return;
const tm = setInterval(() => { if (!busyRef.current) refreshPortfolio(true); }, m * 60_000);
return () => clearInterval(tm);
}, [settings.refresh.autoMinutes, refreshPortfolio]);
if (!settings.refresh.autoRead) return;
let hiddenAt = 0;
const vis = () => {
if (document.hidden) { hiddenAt = Date.now(); return; }
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
hiddenAt = 0;
};
document.addEventListener('visibilitychange', vis);
const m = settings.refresh.everyMinutes;
const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null;
return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); };
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
// icons: cached first, then read (offline, cheap)
useEffect(() => {
@ -296,12 +414,15 @@ export function DataProvider({ children }: { children: ReactNode }) {
const liveFor = useCallback((a: UnifiedApp) => (a.project ? liveOf(a.project) : portfolio?.apps.find(x => x.project === a.target && x.release.source === 'live') ?? live?.value.apps.find(x => x.project === a.target)), [liveOf, portfolio, live]);
const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]);
const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]);
statusRef.current = statusFor;
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
const value = useMemo<Data>(() => ({
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob,
}), [ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
export interface ReadState<T> {
data: T | null;
/** Past its TTL (lib/freshness.ts): a refresh is due or running. */
stale?: boolean;
/** When `data` was read (from the store or the cache). */
at: string | null;
fromCache: boolean;
@ -339,7 +462,7 @@ export function useWrite(projectId: string | null) {
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
// Catalog tab's owner action applies it); everything else it could do was done
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
@ -369,52 +492,63 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, settings:
/**
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
* its time; offline reads (`network: false`) then refresh by themselves, store reads only on demand (they use the
* account's API quota). A failed read keeps the last good value and says so: never an empty "all fine".
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
*/
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
const d = useData();
const argsKey = JSON.stringify(o.args ?? {});
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const seq = useRef(0);
const dataRef = useRef<T | null>(null);
dataRef.current = s.data;
const dRef = useRef(d);
dRef.current = d;
const skipRef = useRef(o.skip);
skipRef.current = o.skip;
const refresh = useCallback(async () => {
const my = ++seq.current;
const job = newJob(method);
set(x => ({ ...x, loading: true, error: null }));
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
try {
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
if (my !== seq.current) return;
if (usable(method, r)) {
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
} else {
const msg = failureText(r);
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
if (skipRef.current) return;
set(x => ({ ...x, loading: true }));
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
}, [target, method, argsKey]);
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
useEffect(() => {
if (o.skip) return;
return d.onRead(key, e => {
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
else {
const r = e.result;
if (usable(method, r)) {
notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName);
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
}
} catch (e) {
if (my !== seq.current) return;
const msg = errorText(e);
set(x => ({ ...x, loading: false, error: msg }));
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
}
}, [target, method, argsKey, o.network, o.title, o.appName, d.settings]); // eslint-disable-line react-hooks/exhaustive-deps
});
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
let alive = true;
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
const auto = !o.network || dRef.current.settings.refresh.autoRead;
const running = dRef.current.isReading(key);
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const go = (at: string | null) => {
if (!alive) return;
const due = !o.network || isStale(method, at);
if (running) set(x => ({ ...x, loading: true }));
else if (auto && due) refresh();
else set(x => ({ ...x, loading: false }));
};
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
if (!alive) return;
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
else set(x => ({ ...x, loading: !o.network }));
if (!o.network) refresh();
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: true, stale: isStale(method, c.at), error: null, command: c.value.command });
go(c && c.value.data !== undefined ? c.at : null);
}, () => go(null));
return () => { alive = false; };
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps

View file

@ -169,6 +169,11 @@ svg { flex: none; }
.topbar .spacer { flex: 1; }
.stamp { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); white-space: nowrap; }
.stamp svg { width: 13px; height: 13px; }
.read-ctl { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.read-stamp { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 360px; }
.read-stamp.read-err { color: var(--warn-ink); }
.reader-line { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); min-width: 0; }
.reader-line > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-pill { font: 700 10px/1 var(--mono); letter-spacing: .06em; color: var(--warn-ink); background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn-solid) 45%, transparent); padding: 5px 7px; border-radius: 5px; }
.content { flex: 1; overflow: auto; min-height: 0; scroll-behavior: smooth; }
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; gap: 20px; }
@ -794,6 +799,14 @@ h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; }
.roll-ctl > .small:first-child { min-width: 150px; }
.table tbody tr.edit-row td { background: var(--surface-2); }
.table tbody tr.edit-row:hover td { background: var(--surface-2); }
.issue-row { cursor: pointer; }
.issue-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.issue-row .chev { flex: none; color: var(--muted); transition: transform var(--speed) var(--ease); }
.issue-row .chev.rot { transform: rotate(90deg); }
.ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.issue-detail { display: grid; gap: 12px; padding: 4px 0; min-width: 0; }
.issue-title { overflow-wrap: anywhere; }
.stack-trace { margin: 0; max-height: 280px; overflow: auto; padding: 10px 12px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line, var(--border)); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre; }
.build-edit { display: grid; gap: 10px; padding: 4px 0; max-width: 760px; }
textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: vertical; font-family: var(--font); }
.reply-edit { min-height: 110px; }

View file

@ -4,7 +4,7 @@ import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
import type { ReactNode } from 'react';
import type { ReleasePhase, StoreRelease } from '../api';
import { t } from '../i18n';
import { share } from '../lib/format';
import { ago, share } from '../lib/format';
import { hueOf, monogram, type Tone } from '../lib/model';
import { fixHint } from '../lib/views';
import { Icon, type IconName, StoreGlyph } from './icons';
@ -173,3 +173,17 @@ export function StudioMark({ studio, size = 'md' }: { studio: { name: string; co
export function StoreOnlyTag() {
return <span className="chip outline so-tag" title={t('apps.storeOnlyHint')}><Icon name="cloud" />{t('apps.storeOnly')}</span>;
}
/** 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: <reason>" 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; refresh: () => Promise<void> }; label?: string }) {
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
return (
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
</button>
</span>
);
}