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:
parent
98cd1d3c92
commit
0ef27cf2e4
22 changed files with 918 additions and 115 deletions
|
|
@ -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'];
|
||||
|
|
|
|||
94
apps/desktop/src/lib/sections.ts
Normal file
94
apps/desktop/src/lib/sections.ts
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue