desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention

- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state
  already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the
  context menu list sections and sub views; old tab ids, section and section/sub links all resolve
- Players > Game services (games only): both stores, one row per leaderboard / achievement with its
  languages and each store's state, Game Center locale map, Play API-disabled state with the console plan
- games status --json: items, localeMap, playApiDisabled; inspect summary: games
- sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio
  filter, workspace as a footer icon row
- boot check opens places like a person (section, then sub view)
This commit is contained in:
CRTkafa 2026-10-24 13:01:50 +08:00
parent 98cd1d3c92
commit 0ef27cf2e4
22 changed files with 918 additions and 115 deletions

View file

@ -2,7 +2,7 @@
// 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 type { AppStatus, ProjectSummary, StoreRelease } from '../api';
import { t } from '../i18n';
import type { Tone } from '../../../../cli/lib/train';
@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | 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 interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' }
export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] {
const out: Attention[] = [];
@ -203,3 +203,46 @@ export function hueOf(name: string): number {
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return h % 360;
}
// ---- App kind and the sidebar ----
const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'];
/** A game: profile game, a games section in the config, or a Play game category. */
export function isGameSummary(s: ProjectSummary | null | undefined): boolean {
if (!s) return false;
return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? '');
}
export type SideGroup = 'attention' | 'progress' | 'live' | 'draft';
export interface SideStatus { group: SideGroup; text: string; tone: Tone }
/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */
export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus {
const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim();
if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' };
const rej = o.all.find(r => r.phase === 'rejected');
if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' };
const halted = o.all.find(r => r.phase === 'halted');
if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' };
const bad = o.att.filter(a => a.tone === 'bad').length;
if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' };
const crash = o.att.find(a => a.tab === 'health');
if (crash) return { group: 'attention', text: crash.text, tone: crash.tone };
const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1);
if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' };
const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent');
if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' };
const approved = o.all.find(r => r.phase === 'approved');
if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' };
const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout');
const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing');
if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' };
const warn = o.att.filter(a => a.tone === 'warn').length;
if (live.length) {
const vs = [...new Set(live.map(r => r.version).filter(Boolean))];
return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' };
}
if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' };
return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' };
}
export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft'];

View file

@ -0,0 +1,94 @@
// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is
// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts
// a read to fill it.
import type { CatalogParity, ListingsResult, StoreRelease } from '../api';
import { t } from '../i18n';
import type { AppSection, AppTab } from '../nav';
import type { IconName } from '../ui/icons';
import { type Attention, isGameSummary } from './model';
import { share } from './format';
import type { ProjectSummary } from '../api';
export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral';
export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName }
export interface GamesNext { changes: number; blockers: number; apiOff: boolean }
export interface SectionInput {
project: boolean;
att: Attention[];
all: StoreRelease[];
next: string | null;
summary: ProjectSummary | null;
catalog: CatalogParity | null;
/** Unsaved edits of the section's editors. */
unsaved: number;
/** Cached reads (null: not read yet). */
listings: ListingsResult | null;
reviews: { reply?: unknown }[] | null;
games: GamesNext | null;
/** Blockers of the last store readiness read (validate), null when never read. */
readiness?: number | null;
}
const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play');
/** Problems of a listings read: file problems plus required fields missing or over the limit. */
export function listingProblems(l: ListingsResult | null): number | null {
if (!l) return null;
let n = l.problems.length;
for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++;
return n;
}
export function sectionNext(section: AppSection, i: SectionInput): SectionNext {
const bad = i.att.filter(a => a.tone === 'bad');
if (section === 'release') {
const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted');
if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' };
const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0);
if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' };
const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1);
if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' };
const approved = i.all.find(r => r.phase === 'approved');
if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' };
const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing');
if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' };
const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent');
if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' };
if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' };
const live = i.all.find(r => r.phase === 'live' || r.phase === 'released');
return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' };
}
if (section === 'store') {
if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' };
const n = listingProblems(i.listings);
if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' };
if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' };
return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' };
}
if (section === 'revenue') {
const c = i.catalog;
if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' };
const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0;
if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' };
return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' };
}
if (section === 'players') {
const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null;
if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' };
if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' };
if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' };
if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' };
return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' };
}
const crash = i.att.find(a => a.tab === 'health');
if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' };
const problems = i.att.filter(a => a.tab === 'checks');
const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0);
if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' };
if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' };
return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' };
}
/** A game: profile game, a games section in the config, or a Play game category. */
export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s);