// Pure view models of the screens added for full CLI coverage: the price matrix (product x territory, both stores), // the price sheet diff summary, the analytics chart series, render grids, and the "how to fix it" hint of an error. import type { AnalyticsCompare, PriceDiffRow, PriceRowData, PricesCurrent, RendersResult } from '../api'; import type { Key } from '../i18n'; /** Territories shown first in the price overview (big stores, several currencies). */ export const KEY_REGIONS = ['US', 'GB', 'DE', 'FR', 'JP', 'KR', 'BR', 'IN', 'TR', 'MX'] as const; export interface PriceCell { price: number; currency: string; kind: 'manual' | 'auto'; startDate?: string } export interface RegionRow { region: string; apple: PriceCell | null; play: PriceCell | null; scheduled: PriceCell | null; gapPct: number | null } const cellOf = (r: PriceRowData): PriceCell => ({ price: r.price, currency: r.currency, kind: r.kind ?? 'manual', ...(r.startDate ? { startDate: r.startDate } : {}) }); /** One product across every region either store sells it in. Current prices only (a future App Store start date is * `scheduled`); the gap is |apple - play| / max, only when both use the same currency. */ export function regionRows(p: PricesCurrent, product: string): RegionRow[] { const by = new Map(); const row = (region: string) => { let r = by.get(region); if (!r) by.set(region, (r = { region, apple: null, play: null, scheduled: null, gapPct: null })); return r; }; for (const x of p.rows.filter(r => r.product === product)) { const r = row(x.region); if (x.store === 'apple' && x.startDate) { r.scheduled = cellOf(x); continue; } // a manual Apple price wins over the equalized one of the same territory if (x.store === 'apple' && (!r.apple || (r.apple.kind === 'auto' && x.kind !== 'auto'))) r.apple = cellOf(x); if (x.store === 'play') r.play = cellOf(x); } for (const r of by.values()) { if (r.apple && r.play && r.apple.currency === r.play.currency && Math.max(r.apple.price, r.play.price) > 0) r.gapPct = (Math.abs(r.apple.price - r.play.price) / Math.max(r.apple.price, r.play.price)) * 100; } const key = (x: string) => { const i = (KEY_REGIONS as readonly string[]).indexOf(x); return i < 0 ? 100 : i; }; return [...by.values()].sort((a, b) => key(a.region) - key(b.region) || a.region.localeCompare(b.region)); } /** The overview matrix: products x the key regions (and only regions somebody sells in). */ export function priceMatrix(p: PricesCurrent, regions: readonly string[] = KEY_REGIONS): { regions: string[]; rows: { product: string; cells: Record }[] } { const rows = p.products.map(product => ({ product, cells: Object.fromEntries(regionRows(p, product).map(r => [r.region, r] as const)) })); const used = regions.filter(r => rows.some(x => x.cells[r])); return { regions: used, rows }; } export interface PriceStats { products: number; regions: number; rows: number; manual: number; auto: number; gaps: number; missing: number } export function priceStats(p: PricesCurrent, threshold = 10): PriceStats { const regions = new Set(p.rows.map(r => r.region)); const gaps = p.products.flatMap(x => regionRows(p, x)).filter(r => r.gapPct !== null && r.gapPct >= threshold).length; return { products: p.products.length, regions: regions.size, rows: p.rows.length, manual: p.rows.filter(r => r.kind !== 'auto').length, auto: p.rows.filter(r => r.kind === 'auto').length, gaps, missing: p.missing.length }; } export interface DiffSummary { changed: number; added: number; same: number; up: number; down: number; products: string[]; pairs: number } export function diffSummary(rows: PriceDiffRow[], changedPairs: string[]): DiffSummary { const live = rows.filter(r => r.change !== 'same'); return { changed: live.filter(r => r.change === 'change').length, added: live.filter(r => r.change === 'new').length, same: rows.length - live.length, up: live.filter(r => r.from !== null && r.to > r.from).length, down: live.filter(r => r.from !== null && r.to < r.from).length, products: [...new Set(live.map(r => r.product))].sort(), pairs: changedPairs.length, }; } export const changePct = (r: Pick): number | null => (r.from ? ((r.to - r.from) / r.from) * 100 : null); // ---- analytics chart ---- export interface ChartSeries { dates: string[]; reach: number[]; installs: number[]; conversion: (number | null)[]; events: { date: string; subject: string; commit: string }[] } /** Daily reach (App Store impressions / Play store listing visitors), installs (downloads / acquisitions) and * conversion, with the store changes from git as markers. */ export function chartSeries(a: AnalyticsCompare, store: 'apple' | 'play'): ChartSeries { const pts = store === 'apple' ? a.series.apple.map(d => ({ date: d.date, reach: d.impressions, installs: d.downloads })) : a.series.play.map(d => ({ date: d.date, reach: d.visitors, installs: d.acquisitions })); const first = pts[0]?.date ?? '', last = pts[pts.length - 1]?.date ?? ''; const seen = new Set(); const events = a.effects.filter(e => e.store === store && e.event.date >= first && e.event.date <= last) .filter(e => (seen.has(e.event.commit) ? false : (seen.add(e.event.commit), true))).map(e => ({ date: e.event.date, subject: e.event.subject, commit: e.event.commit })); return { dates: pts.map(p => p.date), reach: pts.map(p => p.reach), installs: pts.map(p => p.installs), conversion: pts.map(p => (p.reach ? p.installs / p.reach : null)), events }; } /** * A daily series as an index: 100 = the average of its own non-empty days, so reach, installs and conversion share one * axis although they differ by orders of magnitude (installs on a count axis next to impressions lie flat). Empty days * stay empty; a series whose average is 0 has no index (all null). */ export function indexSeries(values: (number | null)[]): (number | null)[] { const xs = values.filter((v): v is number => v !== null && Number.isFinite(v)); const mean = xs.length ? xs.reduce((n, v) => n + v, 0) / xs.length : 0; return values.map(v => (v === null || !Number.isFinite(v) || !mean ? null : (v / mean) * 100)); } /** Conversion before vs after one change (null when a side has no data). */ export const effectDelta = (before?: number | null, after?: number | null): number | null => (before && after !== undefined && after !== null ? ((after - before) / before) * 100 : null); // ---- store art ---- export interface ArtFilter { variant: string; lang: string; onlyIssues: boolean } /** The rows of one variant and language, optionally only those with issues. */ export function artRows(r: RendersResult, f: ArtFilter) { const v = r.variants.find(x => x.id === f.variant) ?? r.variants[0]; const l = v?.langs.find(x => x.lang === f.lang) ?? v?.langs[0]; if (!l) return []; return l.rows.map(row => ({ ...row, tiles: f.onlyIssues ? row.tiles.filter(t => t.issues.length || row.issues.length) : row.tiles })) .filter(row => !f.onlyIssues || row.tiles.length || row.issues.length); } /** Issue counts per language of a variant (for the language picker). */ export function langIssues(r: RendersResult, variant: string): Record { const v = r.variants.find(x => x.id === variant) ?? r.variants[0]; const out: Record = {}; for (const l of v?.langs ?? []) { const all = l.rows.flatMap(row => [...row.issues, ...row.tiles.flatMap(t => t.issues)]); out[l.lang] = { errors: all.filter(i => i.level === 'error').length, warnings: all.filter(i => i.level === 'warn').length, tiles: l.rows.reduce((n, row) => n + row.tiles.length, 0) }; } return out; } // ---- errors that say how to fix them ---- const HINTS: [RegExp, Key][] = [ [/service[ _]account|ASC_KEY_ID|ASC_ISSUER_ID|App Store Connect key not found/i, 'hint.keys'], [/401|403|unauthori[sz]ed|forbidden|permission|caller does not have/i, 'hint.permission'], [/not set in storebound\.config|storebound\.config\.ts: .* is not set|is not set in storebound/i, 'hint.config'], [/no renders|shots render|nothing in .*renders/i, 'hint.renders'], [/ffmpeg|ffprobe/i, 'hint.ffmpeg'], [/playwright|chrom(e|ium)/i, 'hint.chrome'], [/no snapshots|analytics snapshot/i, 'hint.snapshot'], [/reportsBucket/i, 'hint.bucket'], [/analytics report request|analytics setup/i, 'hint.analyticsSetup'], [/no editable (App Store )?version|apple listing --version/i, 'hint.version'], [/no fastlane folder/i, 'hint.fastlane'], [/no prices CSV|prices export writes one/i, 'hint.priceSheet'], [/price point/i, 'hint.pricePoint'], [/ENOTFOUND|ECONNRESET|ETIMEDOUT|network|fetch failed|timed out/i, 'hint.network'], [/confirm token|confirm-required|terminal only/i, 'hint.confirm'], [/git log|not a git repository/i, 'hint.git'], ]; /** The fix of a known error in words (an i18n key), or null. */ export function fixHint(msg: string | null | undefined): Key | null { if (!msg) return null; return HINTS.find(([re]) => re.test(msg))?.[1] ?? null; }