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

139 lines
9 KiB
TypeScript

// 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<string, RegionRow>();
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<string, RegionRow> }[] } {
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<PriceDiffRow, 'from' | 'to'>): 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<string>();
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<string, { errors: number; warnings: number; tiles: number }> {
const v = r.variants.find(x => x.id === variant) ?? r.variants[0];
const out: Record<string, { errors: number; warnings: number; tiles: number }> = {};
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;
}