diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b1b01fe..4cfa717 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ -// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), +// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line +// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { DryRunPreview } from './ui/DryRunPreview'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; -import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import type { UnifiedApp } from './lib/identity'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { usePrefs } from './prefs-ctx'; import { Accounts } from './screens/Accounts'; import { AppFolders } from './screens/AppFolders'; @@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts'; import { useContextMenu } from './ui/ContextMenu'; import { appMenu } from './ui/app-menu'; import { isEditable, isItem, type MenuInput } from './ui/menu-logic'; -import { worstTone } from './ui/ds'; -import { attention } from './lib/model'; +import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model'; const WS_ICON: Record = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; const SIDE_KEY = 'storebound.side.v1'; +const STUDIO_KEY = 'storebound.side.studio.v1'; function loadCollapsed(): string[] { try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; } } +function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } } +const GROUP_KEY: Record = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' }; export function App({ onTheme }: { onTheme: () => void }) { const d = useData(); @@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) { const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(loadCollapsed); + const [studioFilter, setStudioFilterState] = useState(loadStudio); + const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } }; const exports = useExportItems(); const runExport = useRunExport(); @@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) { const allReads = useReadState({ tags: ['all'] }); const [showIgnored, setShowIgnored] = useShowIgnored(); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); - const listed = showIgnored ? [...visible, ...ignoredApps] : visible; - const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; - // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it) - const sideOrder = groups.flatMap(x => x.rows.map(r => r.app)); + const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]); + const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]); + // the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each + const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null; + const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]); + const sideRows = useMemo(() => visible.filter(inStudio).map(a => { + const e = d.statusFor(a), l = d.liveFor(a); + return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) }; + }), [visible, d, inStudio]); + const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [ + ...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length), + ...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []), + ]; + // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it) + const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app))); const stepApp = useCallback((dir: 1 | -1) => { if (!sideOrder.length) return; const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1; const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!; - go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' }); - }, [sideOrder, route, go]); + go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' }); + }, [sideOrder, route, go, tabsOf]); const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) { }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; - const tabs = app ? tabsFor(!!app.project) : []; + const game = app ? gameOf(app) : false; + const tabs = app ? tabsOf(app) : []; + const sections = app ? sectionsFor(!!app.project, game) : []; - // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs + // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections, + // Cmd/Ctrl+Shift+[ ] the previous / next section useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; @@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) { go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! }); return; } - if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { + if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) { + const sec = sections[Number(e.key) - 1]; + if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); } + return; + } + if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) { e.preventDefault(); - const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); - go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); + const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1); + go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs, stepApp]); + }, [palette, welcome, route, go, app, sections, game, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) { const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') }; const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, - run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) }); c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) }); c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); - if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + // every section of the open app (1-5) and every sub view in it + if (app) sections.forEach((sec, i) => { + const subs = subsOf(sec, !!app.project, game); + c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) }); + if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + }); // the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is // also one Ctrl+K away; opening a tab is already above if (app) { @@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) { c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); return c; - }, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]); + }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]); const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] - : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] + : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])] : [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }]; const lastRead = d.live?.at ?? null; // the app on screen is read first by the background reader @@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) { const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const menu = useContextMenu(); - const appItem = (a: UnifiedApp) => { - const e = d.statusFor(a); - const missing = !!a.project?.missing; - const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null)); - const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); - return ( -
- -
- ); - }; + const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => ( +
+ +
+ ); + const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE]; return ( @@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
- -
{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')} +
{t('nav.appsLabel')} {visible.length}
+ {d.studios.length > 1 && ( +
+ + {d.studios.map(s => )} +
+ )} {groups.map(g => { - const s = d.studios.find(x => x.id === g.studio) ?? null; - const gid = g.studio ?? 'none'; + const gid = `g:${g.id}`; const open = !collapsed.includes(gid); - if (!d.studios.length) return
{g.rows.map(r => appItem(r.app))}
; return ( -
- - {open &&
{g.rows.map(r => appItem(r.app))}
} + {open &&
{g.rows.map(r => appItem(r.app, r.st))}
}
); })} {!visible.length && !showIgnored &&

{t('nav.noApps')}

} {ignoredApps.length > 0 && } -
{t('nav.workspace')}
- {WORKSPACE.map((s, i) => ( - - ))} +
+
+ {WS_ROW.map((s, i) => { + const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]); + const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0; + return ( + + ); + })}
diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index fc782ca..adfc21f 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -245,3 +245,45 @@ .table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); } /* Settings > Appearance > Tables: compact */ :root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; } + +/* ---------- App sections (docs IA): section header, next step, sub views ---------- */ +.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; } +.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; } +.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; } +.sec-head-t { grid-area: t; min-width: 0; } +.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; } +.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); } +.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); } +.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; } +.sec-next > svg { width: 15px; height: 15px; flex: none; } +.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-next:not(:has(.btn)) { padding-right: 12px; } +.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; } +.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.sec-next.tone-ok > svg { color: var(--ok-solid); } +.sec-next.tone-info > svg { color: var(--accent-solid); } +.sec-next .btn { flex: none; } +.sec-subs { grid-area: subs; justify-self: start; } +.sec-subs .ed-dot { margin-left: 2px; } +@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } } + +/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */ +.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; } +.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; } +.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); } +:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); } +.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; } +.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); } +.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); } +.side-studios button:focus-visible { outline: 2px solid var(--focus); } +.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); } +.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); } +.side-ws button:hover { background: var(--hover); color: var(--ink); } +.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); } +.side-ws button:focus-visible { outline: 2px solid var(--focus); } +.side-ws svg { width: 17px; height: 17px; } +.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 4ba99d4..7e7be9e 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -430,6 +430,16 @@ const GAMES_PRISM = { play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] }, findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }], links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }], + items: [ + { kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } }, + { kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true, + apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } }, + ...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })), + ], + localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] }, + playApiDisabled: null, }; const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3e3d6ba..c4f37ed 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2116,4 +2116,106 @@ export const en = { 'cm.copyPrincipal': "Service account / key", 'cm.copyProfile': "Profile name", 'cm.openAccounts': "Open Studios & accounts", + "sec.release": "Release", + "sec.store": "Store page", + "sec.revenue": "Revenue", + "sec.players": "Players", + "sec.health": "Health", + "sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.", + "sec.store.for": "What people see on the store: texts per language, screenshots and app details.", + "sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.", + "sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.", + "sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.", + "sec.views": "{section} views", + "tab.games": "Game services", + "next.open": "Open {where}", + "next.rejected": "{version} was rejected on {store}", + "next.halted": "{version} is halted on {store}", + "next.blockers": "{count} blockers before submit", + "next.blockers_one": "1 blocker before submit", + "next.rollout": "{version} is rolling out at {share}", + "next.approved": "{version} is approved: release it", + "next.submit": "Submit {version} for review", + "next.review": "{version} is in review", + "next.managed": "{version} waits in Publishing overview (managed publishing)", + "next.upload": "Upload a first build", + "next.live": "Live: {version}", + "next.notRead": "Stores not read yet", + "next.storeLive": "The live store page, read from the stores", + "next.listingProblems": "{count} listing problems", + "next.listingProblems_one": "1 listing problem", + "next.unsaved": "{count} unsaved edits", + "next.unsaved_one": "1 unsaved edit", + "next.listingsUnread": "Listings not checked yet", + "next.storeOk": "Store page in step with the config", + "next.catalogDiff": "{count} products differ between the stores", + "next.catalogDiff_one": "1 product differs between the stores", + "next.noProducts": "No products yet: add one", + "next.products": "{products} products · {subs} subscriptions", + "next.reviews": "{count} reviews without a reply", + "next.reviews_one": "1 review without a reply", + "next.reviewsUnread": "Reviews not read yet", + "next.reviewsOk": "All reviews answered", + "next.gamesApiOff": "Play Games Services API is off", + "next.gamesBlockers": "{count} game services blockers", + "next.gamesBlockers_one": "1 game services blocker", + "next.gamesSync": "Game services: {count} changes to sync", + "next.gamesSync_one": "Game services: 1 change to sync", + "next.fixFirst": "{count} problems to fix first", + "next.fixFirst_one": "1 problem to fix first", + "next.warnings": "{count} warnings to look at", + "next.warnings_one": "1 warning to look at", + "next.healthOk": "No problems found", + "side.gAttention": "Needs attention", + "side.gProgress": "In progress", + "side.gLive": "Live", + "side.gDraft": "Not on a store yet", + "side.missing": "Folder missing", + "side.rejected": "Rejected · {what}", + "side.halted": "Halted · {what}", + "side.blockers": "{count} blockers", + "side.blockers_one": "1 blocker", + "side.rollout": "Rollout {share} · {what}", + "side.review": "In review · {what}", + "side.approved": "Approved · {what}", + "side.draft": "Draft · {what}", + "side.live": "Live · {version}", + "side.liveWarn": "Live · {version} · {count} warnings", + "side.liveWarn_one": "Live · {version} · 1 warning", + "side.storeOnly": "Store only · not read yet", + "side.notRead": "Not read yet", + "side.warnings": "{count} warnings", + "side.warnings_one": "1 warning", + "side.notOnStore": "No store version yet", + "side.ignored": "Ignored", + "side.studioFilter": "Show apps of a studio", + "side.allStudios": "All", + "games.keep": "In step", + "games.update": "Differs", + "games.create": "Missing", + "games.notOnStore": "Not on this store", + "games.notListed": "Not planned", + "games.notRead": "Not read", + "games.item": "Item", + "games.name": "Name", + "games.points": "Points", + "games.languages": "Languages", + "games.leaderboard": "Leaderboard", + "games.achievement": "Achievement", + "games.hidden": "hidden until earned", + "games.localeChanges": "Game Center languages: {list}", + "games.checkAgain": "Check again", + "games.apiOffTitle": "Play Games Services API is off.", + "games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.", + "games.itemsTitle": "Leaderboards and achievements", + "games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.", + "games.refresh": "Read again", + "games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).", + "games.notReadYet": "Reading game services…", + "games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.", + "games.localeMap": "Game Center locale map", + "games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).", + "games.mapSet": "Set", + "games.mapSuggested": "Suggested", + "games.mapHint": "Add to games.localeMap: {entries}", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 8901d14..e19a2e7 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2118,4 +2118,106 @@ export const tr: Record = { 'cm.copyPrincipal': "Hizmet hesabı / anahtar", 'cm.copyProfile': "Profil adı", 'cm.openAccounts': "Stüdyolar ve hesapları aç", + "sec.release": "Yayın", + "sec.store": "Mağaza sayfası", + "sec.revenue": "Gelir", + "sec.players": "Oyuncular", + "sec.health": "Sağlık", + "sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.", + "sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.", + "sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.", + "sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.", + "sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.", + "sec.views": "{section} görünümleri", + "tab.games": "Oyun servisleri", + "next.open": "{where} aç", + "next.rejected": "{version}, {store} tarafından reddedildi", + "next.halted": "{version}, {store} üzerinde durduruldu", + "next.blockers": "Göndermeden önce {count} engel", + "next.blockers_one": "Göndermeden önce 1 engel", + "next.rollout": "{version} dağıtımı {share} seviyesinde", + "next.approved": "{version} onaylandı: yayınla", + "next.submit": "{version} sürümünü incelemeye gönder", + "next.review": "{version} incelemede", + "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", + "next.upload": "İlk build'i yükle", + "next.live": "Yayında: {version}", + "next.notRead": "Mağazalar henüz okunmadı", + "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", + "next.listingProblems": "{count} mağaza metni sorunu", + "next.listingProblems_one": "1 mağaza metni sorunu", + "next.unsaved": "{count} kaydedilmemiş düzenleme", + "next.unsaved_one": "1 kaydedilmemiş düzenleme", + "next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi", + "next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu", + "next.catalogDiff": "{count} ürün mağazalar arasında farklı", + "next.catalogDiff_one": "1 ürün mağazalar arasında farklı", + "next.noProducts": "Henüz ürün yok: bir tane ekle", + "next.products": "{products} ürün · {subs} abonelik", + "next.reviews": "{count} yorum yanıtsız", + "next.reviews_one": "1 yorum yanıtsız", + "next.reviewsUnread": "Yorumlar henüz okunmadı", + "next.reviewsOk": "Tüm yorumlar yanıtlandı", + "next.gamesApiOff": "Play Games Services API kapalı", + "next.gamesBlockers": "{count} oyun servisi engeli", + "next.gamesBlockers_one": "1 oyun servisi engeli", + "next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik", + "next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik", + "next.fixFirst": "Önce düzeltilecek {count} sorun", + "next.fixFirst_one": "Önce düzeltilecek 1 sorun", + "next.warnings": "Bakılacak {count} uyarı", + "next.warnings_one": "Bakılacak 1 uyarı", + "next.healthOk": "Sorun bulunmadı", + "side.gAttention": "İlgi bekliyor", + "side.gProgress": "Sürüyor", + "side.gLive": "Yayında", + "side.gDraft": "Henüz mağazada değil", + "side.missing": "Klasör bulunamadı", + "side.rejected": "Reddedildi · {what}", + "side.halted": "Durduruldu · {what}", + "side.blockers": "{count} engel", + "side.blockers_one": "1 engel", + "side.rollout": "Dağıtım {share} · {what}", + "side.review": "İncelemede · {what}", + "side.approved": "Onaylandı · {what}", + "side.draft": "Taslak · {what}", + "side.live": "Yayında · {version}", + "side.liveWarn": "Yayında · {version} · {count} uyarı", + "side.liveWarn_one": "Yayında · {version} · 1 uyarı", + "side.storeOnly": "Yalnız mağaza · henüz okunmadı", + "side.notRead": "Henüz okunmadı", + "side.warnings": "{count} uyarı", + "side.warnings_one": "1 uyarı", + "side.notOnStore": "Henüz mağaza sürümü yok", + "side.ignored": "Yok sayıldı", + "side.studioFilter": "Bir stüdyonun uygulamalarını göster", + "side.allStudios": "Tümü", + "games.keep": "Uyumlu", + "games.update": "Farklı", + "games.create": "Eksik", + "games.notOnStore": "Bu mağazada değil", + "games.notListed": "Planlanmadı", + "games.notRead": "Okunmadı", + "games.item": "Öğe", + "games.name": "Ad", + "games.points": "Puan", + "games.languages": "Diller", + "games.leaderboard": "Skor tablosu", + "games.achievement": "Başarım", + "games.hidden": "kazanılana kadar gizli", + "games.localeChanges": "Game Center dilleri: {list}", + "games.checkAgain": "Yeniden kontrol et", + "games.apiOffTitle": "Play Games Services API kapalı.", + "games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.", + "games.itemsTitle": "Skor tabloları ve başarımlar", + "games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.", + "games.refresh": "Yeniden oku", + "games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).", + "games.notReadYet": "Oyun servisleri okunuyor…", + "games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.", + "games.localeMap": "Game Center dil eşlemesi", + "games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).", + "games.mapSet": "Ayarlı", + "games.mapSuggested": "Önerilen", + "games.mapHint": "games.localeMap'e ekleyin: {entries}", }; diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index e1979c0..a104d02 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -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']; diff --git a/apps/desktop/src/lib/sections.ts b/apps/desktop/src/lib/sections.ts new file mode 100644 index 0000000..5cad08e --- /dev/null +++ b/apps/desktop/src/lib/sections.ts @@ -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); diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index a6429e1..b442265 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,31 +4,58 @@ import { createContext, useContext } from 'react'; import type { Key } from './i18n'; import type { IconName } from './ui/icons'; -export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const; +export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and - * store art are the live ones; Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; -export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); + +/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */ +export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const; +export type AppSection = (typeof SECTIONS)[number]; +export const SECTION_TABS: Record = { + release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'], +}; +export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release'; +export const SECTION_KEY: Record = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' }; +export const SECTION_ICON: Record = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' }; + +/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder; + * listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth']; +/** The sub views an app has: the folder decides most; Game services only for games. */ +export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder)); +/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */ +export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); }; +export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); }; export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', - testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games', }; export const TAB_ICON: Record = { releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart', - appinfo: 'clipboard', checks: 'shield', tools: 'sliders', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle', }; + +/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or + * "section/sub" ("store/art"). Unknown: Release. */ +export function placeOf(v: string | undefined | null): AppTab { + if (!v) return 'releases'; + const [a, b] = v.split('/'); + if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab; + if (APP_TABS.includes(a as AppTab)) return a as AppTab; + if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!; + return 'releases'; +} + export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string }; -/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health tab; - * "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ +/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health section; + * "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ export function parseStart(v: string, apps: string[]): Route | null { const [screen, tab, n] = v.split(':'); const app = apps[Math.max(0, (Number(n) || 1) - 1)]; - if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' }; + if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) }; if (screen === 'portfolio') return { screen: 'portfolio' }; if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null; return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; @@ -41,7 +68,7 @@ export function parseRouteJson(v: string): Route | null { if (!o || typeof o !== 'object') return null; const r = o as Record; if (r.screen === 'portfolio') return { screen: 'portfolio' }; - if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null; if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; return null; } diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 1736d8d..1a8424d 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -1,16 +1,20 @@ -// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no +// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release, +// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; import { appMenu, playUrlFor } from '../ui/app-menu'; import { useShowIgnored } from '../lib/show-ignored'; import { useState } from 'react'; +import { GamesTab } from './tabs/Games'; +import { SectionHead } from './SectionHead'; import { api, errorText } from '../api'; import { type Key, t } from '../i18n'; import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity'; import { attention, storeNow } from '../lib/model'; import { Hero, nextStep, Tabs } from '../ui/ds'; -import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; +import { isGameSummary } from '../lib/model'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav'; import { useData } from '../store'; import { Icon } from '../ui/icons'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -107,11 +111,15 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null); const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null); const name = (app.project && e?.app?.name) || app.name; - const tabs = tabsFor(!!app.project); + const game = isGameSummary(e?.summary); + const sections = sectionsFor(!!app.project, game); + const section = sectionOf(tab); const ed = useEdits(); useUnsavedGuard(); const studio = d.studios.find(s => s.id === app.studio) ?? null; const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x }); + const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!); + const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0); const all = l?.release.all ?? []; const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null; const att = attention(app.key, app.name, e, l); @@ -137,19 +145,20 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} - compact={tab !== 'releases'} + compact={section !== 'release' || tab !== 'releases'} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <>
- { - const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0; - return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + { + const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0); + return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; })} />
-
+ a + editCount(s2), 0)} editCount={editCount} onTab={setTab} /> +
{tab === 'releases' && } {tab === 'catalog' && app.project && } @@ -160,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } + {tab === 'games' && app.project && } {tab === 'testflight' && } {tab === 'growth' && } {tab === 'appinfo' && app.project && } diff --git a/apps/desktop/src/screens/SectionHead.tsx b/apps/desktop/src/screens/SectionHead.tsx new file mode 100644 index 0000000..75f04c1 --- /dev/null +++ b/apps/desktop/src/screens/SectionHead.tsx @@ -0,0 +1,60 @@ +// The header of one app section (docs IA): the section's name, one line on what it is for, the single next step +// derived from state that is already read (lib/sections.ts), and the sub views as a segmented control (only when the +// section has more than one). +import type { ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { UnifiedApp } from '../lib/identity'; +import type { Attention } from '../lib/model'; +import { sectionNext } from '../lib/sections'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, TAB_ICON, TAB_KEY } from '../nav'; +import { useCachedRead, useData } from '../store'; +import type { GameServicesStatus } from '../ui/GameServicesCard'; +import { Icon } from '../ui/icons'; + +const PURPOSE: Record = { + release: 'sec.release.for', store: 'sec.store.for', revenue: 'sec.revenue.for', players: 'sec.players.for', health: 'sec.health.for', +}; + +export function SectionHead({ app, section, tab, subs, att, all, next, unsaved, editCount, onTab }: { + app: UnifiedApp; section: AppSection; tab: AppTab; subs: AppTab[]; att: Attention[]; all: StoreRelease[]; next: string | null; unsaved: number; + editCount: (t: AppTab) => number; onTab: (t: AppTab) => void; +}) { + const d = useData(); + const e = d.statusFor(app); + const pid = app.project && !app.project.missing ? app.project.id : null; + // cached reads only: the header never starts a store call + const listings = useCachedRead(section === 'store' ? pid : null, 'project.listings'); + const reviews = useCachedRead<{ reply?: unknown }[]>(section === 'players' ? app.target : null, 'reviews.list', { days: 30 }); + const games = useCachedRead(section === 'players' && subs.includes('games') ? pid : null, 'games.status'); + const validate = useCachedRead<{ findings?: { level: string }[] }>(section === 'health' || section === 'release' ? pid : null, 'validate'); + const n = sectionNext(section, { + project: !!app.project, att, all, next, summary: e?.summary ?? null, catalog: (d.liveFor(app) ?? e)?.catalog ?? null, unsaved, listings, + reviews: Array.isArray(reviews) ? reviews : null, readiness: validate?.findings ? validate.findings.filter(f => f.level === 'blocker').length : null, + games: games ? { changes: (games.apple?.changes.length ?? 0) + (games.play?.changes.length ?? 0), blockers: games.findings.filter(f => f.level === 'error').length, apiOff: !!games.playApiDisabled } : null, + }); + return ( +
+
+

{t(SECTION_KEY[section])}

+

{t(PURPOSE[section])}

+
+
+ + {n.text} + {n.tab && n.tab !== tab && } +
+ {subs.length > 1 && ( +
+ {subs.map(x => { + const c = editCount(x); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7e6b1c3..8666d46 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -19,7 +19,6 @@ import { Bar, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; import { EmptyState, Section } from '../../ui/ds'; import '../../styles/appinfo.css'; import { WriteButton } from '../../ui/WriteButton'; -import { GameServicesCard } from '../../ui/GameServicesCard'; import { ascUrl } from '../AppView'; const AREA: Record = { dataSafety: 'ai.aDataSafety', appPrivacy: 'ai.aAppPrivacy', appInfo: 'ai.aAppInfo', encryption: 'ai.aEncryption', review: 'ai.aReview' }; @@ -258,7 +257,6 @@ export function AppInfoTab({ project }: { project: Project }) {
)} -

{t('ai.foot', { when: ago(r.at) })}

diff --git a/apps/desktop/src/screens/tabs/Games.tsx b/apps/desktop/src/screens/tabs/Games.tsx new file mode 100644 index 0000000..26950fd --- /dev/null +++ b/apps/desktop/src/screens/tabs/Games.tsx @@ -0,0 +1,90 @@ +// Players > Game services: Game Center (App Store) and Play Games Services (Google Play) for a game, from +// `games status --json` (cli/games-status.ts). The two store panels (switches, ids, counts, sync writes with dry run + +// confirm) are the GameServicesCard; below it one row per leaderboard / achievement with its languages and each +// store's state against the config, the Game Center locale map with suggestions, and the Play API-disabled state with +// the enable link and the console plan. +import { Fragment } from 'react'; +import type { Project } from '../../api'; +import { t } from '../../i18n'; +import { useRead } from '../../store'; +import { ConsolePlanButton } from '../../ui/ConsolePlan'; +import { EmptyState, Section } from '../../ui/ds'; +import { type GameAction, type GameItemRow, GameServicesCard, type GameServicesStatus } from '../../ui/GameServicesCard'; +import { Icon, StoreGlyph } from '../../ui/icons'; +import { Chip, Notice } from '../../ui/kit'; +import { OpenLink } from '../../ui/OpenLink'; + +const ACTION_TONE: Record = { keep: 'ok', update: 'warn', create: 'info' }; +const ACTION_KEY = { keep: 'games.keep', update: 'games.update', create: 'games.create' } as const; + +function StoreState({ on, plan, read }: { on: boolean; plan: { action: GameAction; changed: string[] } | null; read: boolean }) { + if (!on) return {t('games.notOnStore')}; + if (!plan) return {read ? t('games.notListed') : t('games.notRead')}; + return {t(ACTION_KEY[plan.action])}; +} + +function ItemsTable({ rows, appleRead, playRead }: { rows: GameItemRow[]; appleRead: boolean; playRead: boolean }) { + return ( +
+ + + + + + + + {rows.map(r => { + const locs = r.apple?.locales.filter(l => l.action !== 'keep') ?? []; + return ( + + + + + + + + + ); + })} + +
{t('games.item')}{t('games.name')}{t('games.points')}{t('games.languages')}{t('gs.gameCenter')}{t('gs.playGames')}
{r.id} +
{t(r.kind === 'leaderboard' ? 'games.leaderboard' : 'games.achievement')}{r.hidden ? ` · ${t('games.hidden')}` : ''}
{r.name}{r.points ?? '–'}{r.locales.join(', ')}{locs.length > 0 &&
{t('games.localeChanges', { list: locs.map(l => `${l.locale} ${t(ACTION_KEY[l.action])}`).join(', ') })}
}
+
+ ); +} + +export function GamesTab({ project }: { project: Project }) { + const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); + const s = r.data; + const rows = s?.items ?? []; + const appleRead = !!s?.apple && !s.apple.error, playRead = !!s?.play && !s.play.error; + const map = s?.localeMap; + return ( + <> + {s?.playApiDisabled && ( + + + +
}> + {t('games.apiOffTitle')} {t('games.apiOffText', { api: s.playApiDisabled.title })} + + )} + +
r.refresh()} disabled={r.loading} aria-busy={r.loading}>{t('games.refresh')}}> + {rows.length ? + : {s ? t('games.noItems') : t('games.notReadYet')}} +

{t('games.images')}

+
+ {map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && ( +
+
+ {Object.entries(map.configured).map(([k, v]) =>
{k}
{v} {t('games.mapSet')}
)} + {map.suggested.map(x =>
{x.id}
{x.from} → {x.to} {t('games.mapSuggested')}
)} +
+ {map.suggested.length > 0 &&

{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}

} +
+ )} + + ); +} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 877b9ca..cfe6af2 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -750,3 +750,21 @@ export function useRead(target: string, method: string, o: { network: boolean return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh }; } + +/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a + * section's "what's next" line that must not cost a store call. Null until the read ran once. */ +export function useCachedRead(target: string | null, method: string, args: Record = {}): T | null { + const d = useData(); + const argsKey = JSON.stringify(args); + const [v, set] = useState(null); + useEffect(() => { + set(null); + if (!target) return; + let alive = true; + const a = JSON.parse(argsKey) as Record; + api.cached(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {}); + const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); }); + return () => { alive = false; un(); }; + }, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps + return v; +} diff --git a/apps/desktop/src/ui/GameServicesCard.tsx b/apps/desktop/src/ui/GameServicesCard.tsx index 3aa77cd..50ae13f 100644 --- a/apps/desktop/src/ui/GameServicesCard.tsx +++ b/apps/desktop/src/ui/GameServicesCard.tsx @@ -4,7 +4,7 @@ // (dry run -> native dialog -> apply) of `apple game-center` and `play games`; the PGS setup is a console plan. import type { Project } from '../api'; import { t } from '../i18n'; -import { useRead, useWrite } from '../store'; +import { type ReadState, useRead, useWrite } from '../store'; import { ConsolePlanButton } from './ConsolePlan'; import { StoreGlyph } from './icons'; import { Card, Chip, ErrorNotice, Skeleton } from './kit'; @@ -18,13 +18,23 @@ export interface GameServicesStatus { play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts; findings: Finding[]; links: { label: string; url: string }[]; + items?: GameItemRow[]; + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + playApiDisabled?: { service: string; title: string; url: string } | null; +} +export type GameAction = 'create' | 'update' | 'keep'; +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; ios: boolean; android: boolean; + apple: { action: GameAction; changed: string[]; locales: { locale: string; action: GameAction }[] } | null; + play: { action: GameAction; changed: string[] } | null; } const tone = (l: Finding['level']) => (l === 'error' ? 'bad' : l === 'warn' ? 'warn' : 'ok'); const yesNo = (v: boolean | null | undefined) => (v === null || v === undefined ? {t('gs.unknown')} : {v ? t('gs.yes') : t('gs.no')}); -export function GameServicesCard({ project }: { project: Project }) { - const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); +export function GameServicesCard({ project, read }: { project: Project; read?: ReadState }) { + const own = useRead(project.id, 'games.status', { network: true, title: t('gs.title'), skip: !!read }); + const r = read ?? own; const w = useWrite(project.id); if (!r.data) return r.error ? : ; const s = r.data; diff --git a/apps/desktop/src/ui/app-menu.ts b/apps/desktop/src/ui/app-menu.ts index d82ba47..144e0aa 100644 --- a/apps/desktop/src/ui/app-menu.ts +++ b/apps/desktop/src/ui/app-menu.ts @@ -5,7 +5,8 @@ import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity'; import { setShowIgnored } from '../lib/show-ignored'; -import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; +import { type AppTab, type Nav, SECTION_ICON, SECTION_KEY, sectionsFor, subsOf, TAB_ICON, TAB_KEY } from '../nav'; +import { isGameSummary } from '../lib/model'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links'; @@ -36,7 +37,12 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play'); return [ { label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') }, - { label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }, + { label: t('cm.openTab'), icon: 'grid', submenu: sectionsFor(!!p, isGameSummary(d.statusFor(app)?.summary)).map((sec, i) => { + const subs = subsOf(sec, !!p, isGameSummary(d.statusFor(app)?.summary)); + return subs.length > 1 + ? { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), submenu: subs.map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) } + : { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), onSelect: () => go(subs[0]!) }; + }) }, 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index b38aa8b..6460ba2 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -275,7 +275,7 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod return
{children}
; } -export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string; /** A keyboard hint shown in the tab ("1"). */ hint?: string } /** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that * do not fit go into a "More" menu at the end, the active tab always stays in the row. */ @@ -323,11 +323,12 @@ export function Tabs({ items, value, onChange, label, panelId return (
{items.map((x, i) => ( - ))} {hidden.length > 0 && ( diff --git a/apps/desktop/tests/sections.test.ts b/apps/desktop/tests/sections.test.ts new file mode 100644 index 0000000..e5a6494 --- /dev/null +++ b/apps/desktop/tests/sections.test.ts @@ -0,0 +1,79 @@ +// App sections (docs IA): old tab ids and section links resolve to one place, the sections an app has, the "what's +// next" line of each section and the sidebar's status line. +import { describe, expect, test } from 'bun:test'; +import type { StoreRelease } from '../src/api'; +import type { Attention } from '../src/lib/model'; +import { isGameSummary, sideStatus } from '../src/lib/model'; +import { listingProblems, type SectionInput, sectionNext } from '../src/lib/sections'; +import { APP_TABS, parseRouteJson, parseStart, placeOf, SECTION_TABS, SECTIONS, sectionOf, sectionsFor, subsOf } from '../src/nav'; + +const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial = {}): StoreRelease => + ({ store, version, phase, fraction: null, track: null, ...o }) as StoreRelease; +const base: SectionInput = { project: true, att: [], all: [], next: null, summary: null, catalog: null, unsaved: 0, listings: null, reviews: null, games: null }; +const att = (tone: Attention['tone'], tab: Attention['tab'], text = 'x'): Attention => ({ app: 'A', projectId: 'a', tone, text, tab }); + +describe('places', () => { + test('every old tab id is in exactly one section', () => { + for (const tab of APP_TABS) expect(SECTIONS.filter(s => SECTION_TABS[s].includes(tab))).toHaveLength(1); + }); + test('old tab ids, sections and section/sub links', () => { + expect(placeOf('prices')).toBe('prices'); + expect(placeOf('revenue')).toBe('catalog'); + expect(placeOf('store/art')).toBe('art'); + expect(placeOf('store/prices')).toBe('listings'); + expect(placeOf('health')).toBe('health'); + expect(placeOf('nope')).toBe('releases'); + expect(parseStart('app:players/games:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'games' }); + expect(parseStart('app:release', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"store/appinfo"}')).toEqual({ screen: 'app', appId: 'k', tab: 'appinfo' }); + expect(sectionOf('testflight')).toBe('release'); + }); + test('store-only apps have no Revenue; Game services only for games with a folder', () => { + expect(sectionsFor(false)).toEqual(['release', 'store', 'players', 'health']); + expect(subsOf('players', true, false)).toEqual(['reviews']); + expect(subsOf('players', true, true)).toEqual(['reviews', 'games']); + expect(subsOf('players', false, true)).toEqual(['reviews']); + expect(subsOf('store', false)).toEqual(['listings', 'art']); + }); + test('a game: profile, games config or a Play game category', () => { + expect(isGameSummary({ profile: 'app' } as never)).toBe(false); + expect(isGameSummary({ profile: 'game' } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', games: { gameCenter: true, playGames: false, leaderboards: 0, achievements: 0 } } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' } } as never)).toBe(true); + }); +}); + +describe('next step per section', () => { + test('release: rejected first, then blockers, rollout, review (managed), upload, live', () => { + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'rejected')], att: [att('bad', 'checks')] })).toMatchObject({ tone: 'bad', tab: 'releases' }); + expect(sectionNext('release', { ...base, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ text: '2 blockers before submit', tab: 'checks' }); + expect(sectionNext('release', { ...base, all: [rel('android', '3.0', 'rollout', { fraction: 0.2 })] }).text).toContain('20'); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'review', { managed: true })] }).text).toContain('Publishing overview'); + expect(sectionNext('release', base)).toMatchObject({ tab: 'releases', text: 'Upload a first build' }); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'live')] })).toMatchObject({ tone: 'ok', tab: null }); + }); + test('store, revenue, players, health', () => { + const listings = { files: { play: null, apple: null }, problems: [], locales: [{ lang: 'en', rtl: false, screenshots: null, complete: 0.5, apple: null, play: { code: 'en-US', fields: [{ field: 'title', length: 0, limit: 30, required: true, state: 'missing' }] } }] } as never; + expect(listingProblems(listings)).toBe(1); + expect(sectionNext('store', { ...base, listings })).toMatchObject({ text: '1 listing problem', tab: 'listings' }); + expect(sectionNext('revenue', base).tab).toBe('catalog'); + expect(sectionNext('revenue', { ...base, catalog: { checked: 'live', ok: false, products: 2, subscriptions: 0, onlyIos: ['a'], onlyAndroid: [], missingIos: [], missingAndroid: ['b'] } })).toMatchObject({ tone: 'warn', text: '2 products differ between the stores' }); + expect(sectionNext('players', { ...base, reviews: [{}, { reply: { text: 'hi' } }, {}] })).toMatchObject({ text: '2 reviews without a reply', tab: 'reviews' }); + expect(sectionNext('players', { ...base, reviews: [], games: { changes: 0, blockers: 0, apiOff: true } })).toMatchObject({ tone: 'bad', tab: 'games' }); + expect(sectionNext('players', { ...base, reviews: [] }).tone).toBe('ok'); + expect(sectionNext('health', { ...base, att: [att('bad', 'health', 'Crash rate over the line')] })).toMatchObject({ text: 'Crash rate over the line', tab: 'health' }); + expect(sectionNext('health', { ...base, att: [att('bad', 'checks')] })).toMatchObject({ text: '1 problem to fix first', tab: 'checks' }); + }); +}); + +describe('sidebar status line', () => { + const o = { att: [] as Attention[], all: [] as StoreRelease[], read: true, missing: false, storeOnly: false }; + test('groups and lines', () => { + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'rejected')] })).toEqual({ group: 'attention', text: 'Rejected · iOS 2.1', tone: 'bad' }); + expect(sideStatus({ ...o, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ group: 'attention', text: '2 blockers' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'review'), rel('android', '2.0', 'live')] })).toMatchObject({ group: 'progress', text: 'In review · iOS 2.1' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'live'), rel('android', '2.1', 'live')] })).toEqual({ group: 'live', text: 'Live · 2.1', tone: 'ok' }); + expect(sideStatus({ ...o, read: false })).toMatchObject({ group: 'draft', text: 'Not read yet' }); + expect(sideStatus({ ...o, missing: true })).toMatchObject({ group: 'attention', text: 'Folder missing' }); + }); +}); diff --git a/cli/games-status.ts b/cli/games-status.ts index 5a06489..814732c 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -7,7 +7,8 @@ import { ascClient } from './apple/asc'; import { readProjectEntitlements } from './apple/signing'; import type { Loaded } from './lib/config'; import { consoleUrl } from './lib/console-links'; -import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services'; +import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services'; +import { parseGoogleError } from './lib/google-errors'; import type { Finding as VFinding } from './lib/validate-rules'; import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games'; @@ -18,6 +19,33 @@ export interface GameServicesStatus { config: { leaderboards: number; achievements: number }; changes: string[]; error?: string }; findings: (Finding & { store: 'apple' | 'play' })[]; links: { label: string; url: string }[]; + /** One row per configured leaderboard / achievement: its texts and where each store stands against the config + * (create / update / keep; null = not on that store or that store was not read). */ + items?: GameItemRow[]; + /** Game Center locale map: the configured entries and the suggestions from the live locales. */ + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + /** Play: the Games Configuration API is off in the service account's Cloud project. */ + playApiDisabled?: { service: string; title: string; url: string } | null; +} + +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; + ios: boolean; android: boolean; + apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null; + play: { action: Action; changed: string[] } | null; +} + +/** The per-item rows of the status (pure; plans are null when that store was not read). */ +export function gameItemRows(g: NonNullable, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] { + const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null; + const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record; platforms?: ('ios' | 'android')[] }, extra: Partial): GameItemRow => { + const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id); + return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra, + ios: onStore(x, 'apple'), android: onStore(x, 'play'), + apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null, + play: p ? { action: p.action, changed: p.changed } : null }; + }; + return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))]; } /** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */ @@ -37,7 +65,8 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p const { config, root } = loaded; const g = config.games ?? {}; const findings = localGameFindings(loaded); - const out: GameServicesStatus = { apple: null, play: null, findings, links: [] }; + const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null }; + let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null; const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length, achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length }); const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null }; @@ -46,8 +75,10 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p try { const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const s = await readGameCenter(c, config.apple.bundleId); + applePlan = planGameCenter(g, config.languages, s, g.localeMap); + out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {}); out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null, - leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined }; + leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined }; } catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; } } if (config.play && usesPlayGames(config) && o.play !== false) { @@ -58,9 +89,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' }; else try { const have = await readPlayGames(gamesConfigClient(root), appId); - out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) }; - } catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } + playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have); + out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) }; + } catch (e) { + const f = parseGoogleError((e as Error).message); + if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl }; + out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } } + out.items = gameItemRows(g, applePlan, playPlan); return out; } diff --git a/cli/inspect.ts b/cli/inspect.ts index 4c2e7d5..e3313d9 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -44,6 +44,7 @@ export function summarize(ctx: Ctx): ProjectSummary { variants: Object.keys(config.shots?.variants ?? {}), playReports: !!config.play?.reportsBucket, playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null, + games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null, }; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 58a2582..1a174d5 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -148,6 +148,8 @@ export interface ProjectSummary { /** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number * of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */ playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null; + /** The games section of the config (Game Center / Play Games Services and the item counts), null without one. */ + games?: { gameCenter: boolean; playGames: boolean; leaderboards: number; achievements: number } | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index 636bcd8..8554834 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -8,7 +8,7 @@ import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; -import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; +import { gameItemRows, gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; import { run as gameCenter } from '../cli/apple/game-center'; import { run as playGames } from '../cli/play/games'; import { run as appleSubmit } from '../cli/apple/submit'; @@ -298,3 +298,15 @@ describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDI expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1); }); }); + +describe('games status rows (desktop Players section)', () => { + test('one row per item, store states from the plans, null when a store was not read', () => { + const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] }; + const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }]; + const rows = gameItemRows(g, plan, null); + expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]); + expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] }); + expect(rows[0]!.play).toBeNull(); + expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null }); + }); +}); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 6f7ced0..1943097 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise }): return page; } -/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs - * that do not fit the window go there). */ +/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its + * sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */ +const SECTION_OF: Record = { + releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue', + reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health', +}; + +/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it + * does not fit), then the sub view in the section's segmented control when the section has more than one. */ async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise { - const tab = page.locator(`[role="tab"]#${id}`); + const sub = id.replace(/^tab-/, ''); + const sec = SECTION_OF[sub]; + if (!sec) throw new Error(`unknown place ${id}`); + const tab = page.locator(`[role="tab"]#tab-${sec}`); await tab.waitFor({ state: 'attached', timeout: o.timeout }); - if (await tab.isVisible()) { await tab.click(o); return; } - const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); - try { - await page.locator('.ds-tab-more').click(o); - await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); - } catch (e) { - const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); - throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + if (await tab.isVisible()) await tab.click(o); + else { + const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); + try { + await page.locator('.ds-tab-more').click(o); + await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); + } catch (e) { + const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); + throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + } } + const btn = page.locator(`.sec-subs #sub-${sub}`); + if (await btn.count()) { await btn.click(o); return; } + // a section with one sub view has no segmented control: the section itself is the place + if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`); +} + +/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */ +async function places(page: Page, o: { timeout?: number } = {}): Promise { + const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, ''))); + const out: string[] = []; + for (const sec of secs) { + await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {}); + await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {}); + const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, ''))); + const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0]; + // a one-view section: its only view (Players without games is Reviews) + out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`)); + } + return out; } async function state(page: Page) { @@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br if (await wizard.count()) problems.push('the welcome guide does not close with Escape'); } // every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen - const count = await page.locator('nav.side button.nav').count(); + // (apps are .nav rows; the workspace is the footer's icon row, named by aria-label) + const SIDE = 'nav.side button.nav, nav.side button.nav-ws'; + const count = await page.locator(SIDE).count(); for (let i = 0; i < count; i++) { - const b = page.locator('nav.side button.nav').nth(i); - const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); + const b = page.locator(SIDE).nth(i); + const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`)); await settle(page); await check(label); @@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br // shared with the CLI: each account names its credential profile (or that its old keychain key has not moved) const accts = fx.accounts_list as { label?: string; profile?: string | null }[]; if (accts.length) { - const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first(); + const nav = page.locator('nav.side [data-screen="accounts"]').first(); if (await nav.count()) { await nav.click({ timeout: WAIT_MS }); await settle(page); @@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' await gone(page, '.cp-panel'); await crashCheck('Releases'); visited++; - // every tab of the app, then App info's console steps and the Dependencies screen - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + // every place of the app (each section and its sub views), then App info's console steps and Dependencies + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`)); await settle(page); @@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty'); } notes.push(`tabs: ${tabs.join(', ')}`); - const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first(); + const deps = page.locator('nav.side [data-screen="dependencies"]').first(); await deps.click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Android SDK'); await settle(page); @@ -710,7 +743,7 @@ async function runAutoReadCase(browser: Browser): Promise { await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`)); await settle(page); const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim(); - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`)); await settle(page);