// What deserves a desktop notification after a read (Settings > Notifications). Pure: compares the previous read with // the new one and never fires on a first read (nothing to compare with = no flood). import type { PortfolioResult } from '../api'; import { sameVersion } from '../../../../cli/lib/portfolio'; import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model'; export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string } /** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */ export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] { if (!prev) return []; const WATCH = new Set(['review', 'sent', 'released', 'approved', 'rejected', 'live', 'halted']); const out: ReleaseChange[] = []; for (const a of next.apps) { const before = prev.apps.find(p => p.project === a.project); if (!before || before.release.source !== 'live' || a.release.source !== 'live') continue; for (const r of a.release.all ?? []) { if (!r.version) continue; const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version))); if (!old || old.phase === r.phase) continue; if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase }); } } return out; } /** New 1-2 star reviews since the previous read. */ export function newLowReviews(prev: Review[] | null | undefined, next: Review[]): Review[] { if (!prev) return []; const seen = new Set(prev.map(r => `${r.store}:${r.id}`)); return next.filter(r => r.rating <= 2 && !seen.has(`${r.store}:${r.id}`)); } /** Crash or ANR rate that went over the Play bad behavior threshold since the previous read. */ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: HealthSnapshot): ('crash' | 'anr')[] { if (!prev?.play || isError(prev.play) || !next.play || isError(next.play)) return []; const out: ('crash' | 'anr')[] = []; const was = { crash: healthSeries(prev.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash), anr: healthSeries(prev.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr) }; const now = { crash: healthSeries(next.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash), anr: healthSeries(next.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr) }; for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k); return out; }