// 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; values: Record } 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 } 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 & { 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): 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(); 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; }