storebound/apps/desktop/src/lib/model.ts

205 lines
12 KiB
TypeScript

// Pure view models for the screens: release timeline stages, health series against the store thresholds, review
// inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher.
// "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok".
import { plainError } from '../../../../cli/lib/google-errors';
import type { AppStatus, ProjectSummary } from '../api';
import { t } from '../i18n';
import type { Tone } from '../../../../cli/lib/train';
export type { Tone };
// ---- CLI JSON shapes the screens read (the commands' --json output) ----
export interface MetricRow { date: string; dims: Record<string, string>; values: Record<string, number> }
export interface PlayHealth {
window: { start: string; end: string; days: number };
crash: MetricRow[];
anr: MetricRow[];
devices?: { model: string; crash?: number; anr?: number; users?: number }[];
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: AppleSignature[];
betaCrashes: { build: string; count: number }[];
}
export type StoreReadError = import('../../../../cli/lib/health').StoreReadError;
export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError }
export interface Review { store: 'apple' | 'play'; id: string; rating: number; title?: string; body: string; where?: string; date: string; appVersion?: string; reply?: { text: string; date?: string; state?: string } }
export interface TfGroup { id: string; name: string; internal: boolean; testers: number; builds: number | 'all'; publicLink: boolean }
export interface TfBuild { version: string | null; build: string | null; processing: string | null; internal: string | null; external: string | null; uploaded: string | null }
export interface TestFlightStatus { groups: TfGroup[]; builds: TfBuild[]; missingGroups: string[] }
export interface CatalogRow { id: string; store: 'apple' | 'play'; level: 'error' | 'warn' | 'ok'; code: string; msg: string }
export interface ParityRow { id: string; region: string; currency: string; apple: number; play: number; diffPct: number }
export interface CatalogDiff { errors: number; rows: CatalogRow[]; parity: ParityRow[] }
/** A finding's machine-readable fix (cli/lib/fix-actions.ts): the write the Fix button runs (dry run, dialog, apply). */
export interface FixAction { method: string; args: Record<string, string> }
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; url?: string; action?: FixAction }
export interface ValidateResult { blockers: number; findings: Finding[] }
/** `storebound scan --json` (offline rejection-risk scan of the project files), the parts the Checks tab shows. */
export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low';
export interface ScanFinding {
rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string;
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; url?: string; accepted?: string; action?: FixAction; aiFix?: boolean;
}
export interface ScanResult {
stacks: string[];
sdks: { id: string; name: string; category: string; tracking: boolean }[];
findings: ScanFinding[];
counts: Record<ScanSeverity, number> & { accepted: number };
declarations: { changed: boolean; items: { area: string; what: string; value: string; status: 'detected' | 'ask'; evidence: { file: string; line: number }[] }[] };
}
export const isError = (x: unknown): x is StoreReadError => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string';
// ---- Release timeline ----
// The stages and lanes live in cli/lib/train.ts, shared with `storebound release status | plan`.
export { cmpVersion, type Lane, laneOf, type Stage, STAGES, storeNow, trainLanes, versionHistory } from '../../../../cli/lib/train';
// ---- Health ----
export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const;
export interface Series { dates: string[]; values: number[]; current: number | null; threshold: number; tone: Tone }
/** Daily user-perceived rates (fractions) and the current 28-day value against the bad behavior threshold. */
export function healthSeries(rows: MetricRow[], metric: 'userPerceivedCrashRate' | 'userPerceivedAnrRate', threshold: number): Series {
const pts = rows.filter(r => typeof r.values[metric] === 'number' && !r.dims.deviceModel).sort((a, b) => a.date.localeCompare(b.date));
const w28 = metric === 'userPerceivedCrashRate' ? 'userPerceivedCrashRate28dUserWeighted' : 'userPerceivedAnrRate28dUserWeighted';
let current: number | null = null;
for (let i = pts.length - 1; i >= 0 && current === null; i--) { const v = pts[i]!.values[w28]; if (typeof v === 'number') current = v; }
if (current === null && pts.length) current = pts.at(-1)!.values[metric]!;
return { dates: pts.map(p => p.date), values: pts.map(p => p.values[metric]!), current, threshold, tone: toneFor(current, threshold) };
}
/** Over the threshold: bad; from 80 % of it: warn; unknown: neutral. */
export function toneFor(v: number | null, threshold: number): Tone {
if (v === null) return 'neutral';
return v >= threshold ? 'bad' : v >= threshold * 0.8 ? 'warn' : 'ok';
}
// ---- Reviews ----
export interface StarStats { count: number; average: number | null; byStar: [number, number, number, number, number]; unanswered: number }
export function starStats(rs: Review[]): StarStats {
const byStar: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let sum = 0, n = 0;
for (const r of rs) if (r.rating >= 1 && r.rating <= 5) { byStar[r.rating - 1]!++; sum += r.rating; n++; }
return { count: rs.length, average: n ? sum / n : null, byStar, unanswered: rs.filter(r => !r.reply).length };
}
export interface ReviewFilter { store: 'all' | 'apple' | 'play'; stars: number[]; unanswered: boolean; query: string }
export function filterReviews(rs: Review[], f: ReviewFilter): Review[] {
const q = f.query.trim().toLowerCase();
return rs.filter(r => (f.store === 'all' || r.store === f.store) && (!f.stars.length || f.stars.includes(r.rating)) && (!f.unanswered || !r.reply)
&& (!q || `${r.title ?? ''} ${r.body}`.toLowerCase().includes(q))).sort((a, b) => b.date.localeCompare(a.date));
}
/** Draft file name of a review (cli/lib/reviews.ts draftName). */
export const draftName = (r: Pick<Review, 'store' | 'id'>): string => `${r.store}-${r.id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)}.md`;
// ---- Catalog parity matrix ----
export type CellState = 'ok' | 'warn' | 'error' | 'na' | 'unknown';
export interface MatrixRow { id: string; kind: 'product' | 'subscription' | 'extra'; config: boolean; ios: { state: CellState; notes: string[] }; android: { state: CellState; notes: string[] }; maxDiff: number | null }
export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null): MatrixRow[] {
const ids = new Map<string, MatrixRow['kind']>();
for (const id of s?.productIds ?? []) ids.set(id, 'product');
for (const id of s?.subscriptionIds ?? []) ids.set(id, 'subscription');
for (const r of diff?.rows ?? []) if (!ids.has(r.id)) ids.set(r.id, 'extra');
const cell = (id: string, store: 'apple' | 'play', sold: boolean): MatrixRow['ios'] => {
const rs = (diff?.rows ?? []).filter(r => r.id === id && r.store === store);
if (!diff) return { state: sold ? 'unknown' : 'na', notes: [] };
if (!rs.length) return { state: sold ? 'unknown' : 'na', notes: [] };
const state: CellState = rs.some(r => r.level === 'error') ? 'error' : rs.some(r => r.level === 'warn') ? 'warn' : 'ok';
return { state, notes: rs.filter(r => r.level !== 'ok').map(r => r.msg) };
};
return [...ids].map(([id, kind]) => {
const diffs = (diff?.parity ?? []).filter(p => p.id === id).map(p => p.diffPct);
return {
id, kind, config: kind !== 'extra',
ios: cell(id, 'apple', kind === 'extra' ? (diff?.rows ?? []).some(r => r.id === id && r.store === 'apple') : !!s?.catalog.ios.includes(id)),
android: cell(id, 'play', kind === 'extra' ? (diff?.rows ?? []).some(r => r.id === id && r.store === 'play') : !!s?.catalog.android.includes(id)),
maxDiff: diffs.length ? Math.max(...diffs) : null,
};
});
}
// ---- Portfolio: needs attention ----
export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' }
export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] {
const out: Attention[] = [];
if (!e) return out;
const base = { app: name, projectId };
for (const x of e.errors) out.push({ ...base, tone: 'bad', text: x, tab: 'checks' });
for (const p of e.check?.problems ?? []) out.push({ ...base, tone: 'bad', text: p, tab: 'checks' });
for (const w of (e.check?.warnings ?? []).slice(0, 3)) out.push({ ...base, tone: 'warn', text: w, tab: 'checks' });
const l = live ?? (e.release.source === 'live' ? e : undefined);
if (l) {
if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: plainError(l.storeErrors.ios) }), tab: 'releases' });
if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: plainError(l.storeErrors.android) }), tab: 'releases' });
for (const r of l.release.all ?? []) {
if (r.phase === 'rejected') out.push({ ...base, tone: 'bad', text: t('att.rejected', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });
if (r.phase === 'halted') out.push({ ...base, tone: 'bad', text: t('att.halted', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });
}
const c = l.catalog;
if (c && c.checked === 'live' && !c.ok) {
const n = c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length;
out.push({ ...base, tone: 'warn', text: t('att.catalog', { count: n }), tab: 'catalog' });
}
}
if (health && health.play && !isError(health.play)) {
const c = healthSeries(health.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
const a = healthSeries(health.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
if (c.tone === 'bad' || c.tone === 'warn') out.push({ ...base, tone: c.tone, text: t('att.crash'), tab: 'health' });
if (a.tone === 'bad' || a.tone === 'warn') out.push({ ...base, tone: a.tone, text: t('att.anr'), tab: 'health' });
}
return out;
}
// ---- Command palette ----
/** Subsequence match score (higher is better), or -1. Word starts and consecutive letters score more. */
export function fuzzy(query: string, text: string): number {
const q = query.trim().toLowerCase();
if (!q) return 0;
const s = text.toLowerCase();
let score = 0, at = -1, run = 0;
for (const ch of q) {
const i = s.indexOf(ch, at + 1);
if (i < 0) return -1;
run = i === at + 1 ? run + 1 : 0;
score += 1 + run * 2 + (i === 0 || /[\s/·.-]/.test(s[i - 1] ?? '') ? 3 : 0);
at = i;
}
return score - s.length * 0.01;
}
// ---- Identity ----
/** Two letters for a monogram ("Prism Puddle" -> "PP", "tidy" -> "TI"). */
export function monogram(name: string): string {
const words = name.trim().split(/[\s_-]+/).filter(Boolean);
const m = words.length >= 2 ? words[0]![0]! + words[1]![0]! : (words[0] ?? '?').slice(0, 2);
return m.toUpperCase();
}
/** Stable hue (0-359) from a name, for the monogram gradient. */
export function hueOf(name: string): number {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return h % 360;
}