Desktop: tray mini card on left click (store status at a glance next to the tray icon; Refresh runs the main window's Refresh all)

This commit is contained in:
CRTkafa 2026-10-23 11:15:35 +08:00
parent c03938f9dc
commit 60a72e6947
23 changed files with 1052 additions and 11 deletions

View file

@ -0,0 +1,156 @@
// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get /
// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all.
import { ArrowsClockwise, ArrowSquareOut, BellSlash, Bell, DotsThree, Power, Star, WarningOctagon, Pulse, AppWindow } from '@phosphor-icons/react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model';
import { ago, cardT, type CardKey, type CardLang } from './text';
export interface CardHost {
get: () => Promise<CardSnapshot | null>;
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
onShown: (fn: () => void) => () => void;
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void;
}
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
const T = useCallback((k: CardKey, v?: Record<string, string | number>) => cardT(lang, k, v), [lang]);
const [s, setS] = useState<CardSnapshot | null>(null);
const [menu, setMenu] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [shown, setShown] = useState(0);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
host.get().then(x => { if (x) setS(x); }).catch(() => {});
const a = host.onSnapshot(setS);
const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); });
host.action('ready');
const tm = setInterval(() => setNow(Date.now()), 30_000);
return () => { a(); b(); clearInterval(tm); };
}, [host]);
// every show: fade in, focus the first control (keyboard users start at Refresh)
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const all = [...(root.current?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [])];
const i = all.indexOf(document.activeElement as HTMLElement);
const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length];
if (n) { e.preventDefault(); n.focus(); }
}
};
const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore');
const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase));
const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key);
const busy = !!s?.busy;
const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length;
return (
<div className="tc" ref={root} onKeyDown={onKey} data-shown={shown} tabIndex={-1} role="dialog" aria-label={T('title')}>
<header className="tc-head">
<div className="tc-title">
<strong>{T('title')}</strong>
<span className="tc-meta">{s?.muted && <span className="tc-muted"><BellSlash size={12} weight="bold" />{T('muted')}</span>}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })}</span>
</div>
<button className="tc-btn tc-primary" data-first onClick={() => host.action('refresh')} disabled={busy} aria-busy={busy} data-testid="card-refresh">
<ArrowsClockwise size={14} weight="bold" className={busy ? 'spin' : undefined} />{busy ? T('refreshing') : T('refresh')}
</button>
<button className="tc-icon" onClick={() => setMenu(m => !m)} aria-haspopup="menu" aria-expanded={menu} aria-label={T('more')} title={T('more')}><DotsThree size={18} weight="bold" /></button>
{menu && (
<div className="tc-menu" role="menu">
<button role="menuitem" onClick={() => host.action('open')}><AppWindow size={15} />{T('openMain')}</button>
<button role="menuitem" onClick={() => { host.action('mute'); setMenu(false); }}>{s?.muted ? <Bell size={15} /> : <BellSlash size={15} />}{s?.muted ? T('unmute') : T('mute')}</button>
<hr />
<button role="menuitem" onClick={() => host.action('quit')}><Power size={15} />{T('quit')}</button>
</div>
)}
</header>
<div className="tc-body">
{!s && <p className="tc-empty">{T('noData')}</p>}
{s && calm && <p className="tc-empty">{s.at ? T('calm') : T('noData')}</p>}
{!!s?.rejections.length && (
<Section title={T('rejections')} tone="bad">
{s.rejections.map(r => (
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone="bad" onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
line={<><WarningOctagon size={13} weight="fill" />{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>} sub={r.reason}
consoles={consoles(link(r.app), r.store, host, T)} />
))}
</Section>
)}
{!!s?.releases.length && (
<Section title={T('moving')}>
{s.releases.map(r => (
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone={r.phase === 'halted' ? 'bad' : r.phase === 'approved' ? 'ok' : r.phase === 'review' ? 'info' : 'accent'}
onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>}
bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null}
consoles={consoles(link(r.app), r.store, host, T)} />
))}
</Section>
)}
{!!s?.health.length && (
<Section title={T('health')} tone="warn">
{s.health.map(h => (
<Row key={h.app} hue={h.hue} name={h.name} tone="warn" onOpen={() => host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })}
line={<><Pulse size={13} weight="bold" />{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}</>} consoles={consoles(link(h.app), 'android', host, T)} />
))}
</Section>
)}
{s && s.at && (
<Section title={T('reviews')} aside={s.reviews.recent ? T('recent', { n: s.reviews.recent }) : T('recentNone')}>
{s.reviews.low.map(r => (
<button key={`${r.app}:${r.date}:${r.text}`} className="tc-review" onClick={() => host.action('route', route(r.app, 'reviews'))}>
<span className="tc-stars" aria-label={T('stars', { n: r.rating })}>{[1, 2, 3, 4, 5].map(i => <Star key={i} size={11} weight={i <= r.rating ? 'fill' : 'regular'} />)}</span>
<span className="tc-rname">{r.name}</span>
<span className="tc-rtext">{r.text}</span>
</button>
))}
</Section>
)}
</div>
</div>
);
}
function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode {
const url = st === 'ios' ? l?.asc : l?.play;
if (!url) return null;
return <button className="tc-ext" onClick={e => { e.stopPropagation(); host.action('external', url); }} title={T(st === 'ios' ? 'asc' : 'play')} aria-label={T(st === 'ios' ? 'asc' : 'play')}><ArrowSquareOut size={14} /></button>;
}
function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) {
return (
<section className={`tc-sec${tone ? ` tc-${tone}` : ''}`}>
<h2><span>{title}</span>{aside && <em>{aside}</em>}</h2>
{children}
</section>
);
}
function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) {
return (
<div className={`tc-row tone-${tone}`}>
<button className="tc-open" onClick={onOpen} aria-label={openLabel}>
<span className="tc-ic" style={{ '--tint-h': hue } as React.CSSProperties} aria-hidden>{initials(name)}</span>
<span className="tc-txt">
<span className="tc-name">{name}</span>
<span className="tc-line">{line}</span>
{sub && <span className="tc-sub">{sub}</span>}
{bar != null && <span className="tc-bar" aria-hidden><i style={{ width: `${Math.max(2, Math.round(bar * 100))}%` }} /></span>}
</span>
</button>
{consoles}
</div>
);
}
const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?';

View file

@ -0,0 +1,66 @@
// Main window side of the tray card: turns what this window already knows (live releases, the last cached reviews and
// health per app, the shared read state of Refresh all, mute) into a CardSnapshot and hands it to the host
// (card_snapshot), which keeps it for the card. No reads of its own: reviews and health come from the cache that the
// window's reads fill, and the card's Refresh arrives as the tray's `tray-action` read (store.tsx), i.e. Refresh all.
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api';
import type { HealthSnapshot, Review } from '../lib/model';
import { hueOf } from '../lib/model';
import { useData, useReadState } from '../store';
import { playUrlFor } from '../ui/app-menu';
import { cardApi, inTauri as hasHost } from './card-api';
import { cardSnapshot, type CardInput } from './model';
const inTauri = hasHost();
const ascUrl = (id: string | null | undefined) => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps');
export function TrayCardBridge() {
const d = useData();
const all = useReadState({ tags: ['all'] });
const [extra, setExtra] = useState<Record<string, { reviews: Review[] | null; health: HealthSnapshot | null }>>({});
const [ask, setAsk] = useState(0);
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
// the card opened (or a read pass ended): the last cached reviews and health of every app
useEffect(() => {
if (!inTauri) return;
return cardApi.onRequest(() => setAsk(n => n + 1));
}, []);
const wasBusy = useRef(all.busy);
useEffect(() => { if (wasBusy.current && !all.busy) setAsk(n => n + 1); wasBusy.current = all.busy; }, [all.busy]);
useEffect(() => {
if (!inTauri || !d.ready) return;
let live = true;
Promise.all(visible.map(async a => {
const [r, h] = await Promise.all([
api.cached<{ data?: Review[] }>('reviews.list', a.target, { days: 30 }).catch(() => null),
api.cached<{ data?: HealthSnapshot }>('health', a.target, {}).catch(() => null),
]);
return [a.key, { reviews: Array.isArray(r?.value.data) ? r.value.data : null, health: (h?.value.data as HealthSnapshot | undefined) ?? null }] as const;
})).then(rows => { if (live) setExtra(Object.fromEntries(rows)); }).catch(() => {});
return () => { live = false; };
}, [ask, d.ready, visible]);
const snap = useMemo(() => {
const inputs: CardInput[] = visible.map(a => {
const st = d.liveFor(a) ?? d.statusFor(a);
const ios = st?.app?.platforms.ios ?? (a.ios ? { ascAppId: a.ios.app.id } : null);
return {
key: a.key, name: a.name, hue: hueOf(a.name), status: st,
reviews: extra[a.key]?.reviews ?? null, health: extra[a.key]?.health ?? null,
asc: ios ? ascUrl(ios.ascAppId) : null, play: st?.app?.platforms.android || a.android ? playUrlFor(d, a) : null,
};
});
return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now() });
}, [visible, d, extra, all.busy]);
const last = useRef('');
useEffect(() => {
if (!inTauri || !d.ready) return;
const json = JSON.stringify(snap);
if (json === last.current) return;
last.current = json;
cardApi.push(snap).catch(() => {});
}, [snap, d.ready]);
return null;
}

View file

@ -0,0 +1,25 @@
// The tray card's host calls (the card page's counterpart of src/api.ts, kept apart so the card bundle stays small).
// Main window: card_snapshot + the `card-request` event. Card window: card_get, card_action and its two events.
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { CardSnapshot } from './model';
export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready';
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
function sub<T>(ev: string, fn: (p: T) => void): () => void {
const un = listen<T>(ev, e => fn(e.payload));
return () => { un.then(f => f()).catch(() => {}); };
}
export const cardApi = {
/** Main window: hands the host the card's snapshot (the host forwards it as `card-snapshot`). */
push: (snapshot: CardSnapshot) => invoke<void>('card_snapshot', { snapshot }),
/** Main window: the card opened; refresh what is kept per app. */
onRequest: (fn: () => void) => sub<null>('card-request', () => fn()),
get: () => invoke<CardSnapshot | null>('card_get'),
onSnapshot: (fn: (s: CardSnapshot) => void) => sub<CardSnapshot>('card-snapshot', fn),
onShown: (fn: () => void) => sub<null>('card-shown', () => fn()),
action: (action: CardAction, arg?: string) => { invoke('card_action', { action, arg: arg ?? null }).catch(() => {}); },
};

View file

@ -0,0 +1,58 @@
/* The tray card (card.html): v2 language at 360 x 480, rounded, its own surface; fades in over 120 ms on every show. */
html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; }
body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
body.tc-page #card { height: 100%; }
body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
:root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; }
@keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.tc { box-sizing: border-box; height: 100%; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg);
border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; }
.tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); }
.tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tc-title strong { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); letter-spacing: -.01em; }
.tc-meta { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--ds-t-micro); }
.tc-muted { display: inline-flex; align-items: center; gap: 3px; color: var(--warn-ink); background: var(--warn-bg); border-radius: var(--ds-r-pill); padding: 0 6px; }
.tc button { font: inherit; color: inherit; cursor: pointer; }
.tc button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.tc-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: var(--ds-r-pill); border: 0; font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); }
.tc .tc-primary { background: var(--ds-grad); color: #fff; box-shadow: var(--ds-e1); }
.tc-primary[disabled] { opacity: .7; cursor: default; }
.tc-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: var(--ds-r-pill); border: 1px solid var(--ds-stroke); background: var(--ds-surface); }
.tc-icon:hover, .tc-icon[aria-expanded="true"] { background: var(--hover); }
.tc-menu { position: absolute; z-index: 3; right: 12px; top: 50px; min-width: 230px; padding: 5px; border-radius: var(--ds-r); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); }
.tc-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; background: none; border-radius: var(--ds-r-xs); text-align: left; font-size: var(--ds-t-meta); }
.tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; }
.tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; }
.tc-body { flex: 1; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; }
.tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); }
.tc-sec { margin-top: 8px; }
.tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
.tc-sec h2 em { font-style: normal; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--faint); }
.tc-sec.tc-bad { background: var(--bad-bg); border: 1px solid color-mix(in srgb, var(--bad-solid) 30%, transparent); border-radius: var(--ds-r); padding: 2px 4px 4px; }
.tc-sec.tc-bad h2 { color: var(--bad-ink); }
.tc-row { display: flex; align-items: center; gap: 2px; border-radius: var(--ds-r-sm); }
.tc-row:hover { background: color-mix(in srgb, var(--hover) 80%, transparent); }
.tc-open { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: flex-start; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
.tc-ic { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; font-size: 10px; font-weight: 700; color: #fff; background: linear-gradient(135deg, var(--tint), hsl(calc(var(--tint-h) + 30) var(--ds-tint-s) 45%)); }
.tc-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; flex: 1; }
.tc-name { font-weight: var(--ds-w-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-line { display: inline-flex; align-items: center; gap: 4px; font-size: var(--ds-t-meta); color: var(--ink-2); }
.tone-bad .tc-line { color: var(--bad-ink); font-weight: var(--ds-w-strong); }
.tone-ok .tc-line { color: var(--ok-ink); }
.tone-info .tc-line { color: var(--info-ink); }
.tone-warn .tc-line { color: var(--warn-ink); }
.tone-accent .tc-line { color: var(--accent); }
.tc-sub { font-size: var(--ds-t-micro); color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tc-bar { margin-top: 4px; height: 4px; border-radius: 4px; background: var(--ds-inset); overflow: hidden; }
.tc-bar i { display: block; height: 100%; background: var(--ds-grad); border-radius: 4px; }
.tc-ext { flex: none; display: grid; place-items: center; width: 28px; height: 28px; margin-right: 4px; border: 0; background: none; color: var(--muted); border-radius: var(--ds-r-xs); }
.tc-ext:hover { background: var(--hover); color: var(--ink); }
.tc-review { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; width: 100%; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
.tc-review:hover { background: var(--hover); }
.tc-stars { display: inline-flex; gap: 1px; align-items: center; color: var(--warn-solid); }
.tc-rname { font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-rtext { grid-column: 1 / -1; font-size: var(--ds-t-meta); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

View file

@ -0,0 +1,25 @@
// Dev only: a card snapshot for the plain browser (no Tauri host), so the card can be looked at in `vite` alone.
import type { CardSnapshot } from './model';
const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
export const CARD_FIXTURE: CardSnapshot = {
at: ago(4), busy: false, muted: false, apps: 6,
rejections: [{ app: 'a1', name: 'Prism Puddle', hue: 280, store: 'ios', version: '2.4.0', reason: 'Guideline 2.1 - Performance - App Completeness: the app crashed on launch on iPad Air (5th generation).' }],
releases: [
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'ios', version: '1.8.2', phase: 'review', fraction: null },
{ app: 'a3', name: 'Moss Runner', hue: 30, store: 'ios', version: '3.0.0', phase: 'approved', fraction: null },
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'android', version: '1.8.1', phase: 'rollout', fraction: 0.2 },
],
reviews: { recent: 14, low: [
{ app: 'a2', name: 'Tidy Tiles', store: 'play', rating: 1, text: 'Lost my progress · After the update every level is locked again.', date: ago(90) },
{ app: 'a3', name: 'Moss Runner', store: 'apple', rating: 2, text: 'Too many ads between runs', date: ago(600) },
] },
health: [{ app: 'a4', name: 'Sample Blocks', hue: 210, kinds: ['crash'] }],
links: [
{ key: 'a1', name: 'Prism Puddle', hue: 280, asc: 'https://appstoreconnect.apple.com/apps', play: null },
{ key: 'a2', name: 'Tidy Tiles', hue: 160, asc: 'https://appstoreconnect.apple.com/apps', play: 'https://play.google.com/console' },
{ key: 'a3', name: 'Moss Runner', hue: 30, asc: 'https://appstoreconnect.apple.com/apps', play: null },
{ key: 'a4', name: 'Sample Blocks', hue: 210, asc: null, play: 'https://play.google.com/console' },
],
};

View file

@ -0,0 +1,53 @@
// The tray card window's entry (card.html): theme and language from the same prefs as the main window, the host
// through Tauri (or a fixture in a plain dev browser).
import { createRoot } from 'react-dom/client';
import { applyPrefs, loadPrefs, resolveLang } from '../prefs';
import { Card, type CardHost } from './Card';
import { cardApi, inTauri } from './card-api';
import type { CardSnapshot } from './model';
import '../styles.css';
import '../design.css';
import './card.css';
const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en');
function prefs() {
const p = loadPrefs();
if (import.meta.env.DEV) {
const q = new URLSearchParams(location.search);
const th = q.get('theme'), lg = q.get('lang');
if (th === 'light' || th === 'dark') p.theme = th;
if (lg === 'en' || lg === 'tr') p.lang = lg;
}
applyPrefs(p, document.documentElement, sys());
return resolveLang(p.lang, sys());
}
async function devHost(): Promise<CardHost> {
const { CARD_FIXTURE } = await import('./fixture');
const q = new URLSearchParams(location.search);
const snap: CardSnapshot = q.get('empty')
? { ...CARD_FIXTURE, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] } }
: { ...CARD_FIXTURE, busy: !!q.get('busy') };
return { get: async () => snap, onSnapshot: () => () => {}, onShown: () => () => {}, action: (a, arg) => console.info('card action', a, arg ?? '') };
}
const tauriHost: CardHost = cardApi;
void (async () => {
let lang = prefs();
const host = inTauri() ? tauriHost : await devHost();
const root = createRoot(document.getElementById('card')!);
const render = () => root.render(<Card key={lang} host={host} lang={lang} />);
render();
const relang = () => { const l = prefs(); if (l !== lang) { lang = l; render(); } };
// theme or language changed in the main window (same origin: shared localStorage)
window.addEventListener('storage', relang);
host.onShown(() => {
relang();
document.body.classList.remove('tc-in');
void document.body.offsetWidth;
document.body.classList.add('tc-in');
});
document.body.classList.add('tc-in');
})();

View file

@ -0,0 +1,86 @@
// The tray card's data: what the main window already knows, boiled down to the few rows the card shows. Pure (the
// main window's bridge calls it, tests/card-model.test.ts pins it); the card itself only draws a CardSnapshot.
import type { AppStatus } from '../api';
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from '../lib/model';
export type CardStore = 'ios' | 'android';
/** A release that moves or waits: in review, rolling out (with its share), approved and waiting for release, halted. */
export interface CardRelease { app: string; name: string; hue: number; store: CardStore; version: string | null; phase: 'review' | 'rollout' | 'approved' | 'halted'; fraction: number | null }
export interface CardRejection { app: string; name: string; hue: number; store: CardStore; version: string | null; reason: string | null }
export interface CardLowReview { app: string; name: string; store: 'apple' | 'play'; rating: number; text: string; date: string }
export interface CardHealth { app: string; name: string; hue: number; kinds: ('crash' | 'anr')[] }
export interface CardApp { key: string; name: string; hue: number; asc: string | null; play: string | null }
export interface CardSnapshot {
/** Last store read of the main window (ISO), null before any. */
at: string | null;
/** The main window's Refresh all is running (its shared read state). */
busy: boolean;
muted: boolean;
apps: number;
rejections: CardRejection[];
releases: CardRelease[];
reviews: { recent: number; low: CardLowReview[] };
health: CardHealth[];
/** Apps with something on the card, for the quick actions (console links). */
links: CardApp[];
}
export interface CardInput {
key: string; name: string; hue: number; status: AppStatus | undefined;
reviews?: Review[] | null; health?: HealthSnapshot | null;
asc?: string | null; play?: string | null;
}
export const RECENT_DAYS = 7;
export const LOW_DAYS = 14;
export const MAX_ROWS = 6;
export const MAX_LOW = 3;
const DAY = 86_400_000;
const one = (s: string, max: number): string => { const x = s.replace(/\s+/g, ' ').trim(); return x.length > max ? `${x.slice(0, max - 1)}…` : x; };
/** Crash / ANR rates over the Play bad behavior threshold right now. */
export function healthAlerts(h: HealthSnapshot | null | undefined): ('crash' | 'anr')[] {
if (!h?.play || isError(h.play)) return [];
const out: ('crash' | 'anr')[] = [];
if (healthSeries(h.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone === 'bad') out.push('crash');
if (healthSeries(h.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr).tone === 'bad') out.push('anr');
return out;
}
export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number }): CardSnapshot {
const now = o.now ?? Date.now();
const rejections: CardRejection[] = [], releases: CardRelease[] = [], low: CardLowReview[] = [], health: CardHealth[] = [];
const touched = new Set<string>();
let recent = 0;
for (const a of apps) {
for (const r of a.status?.release.all ?? []) {
if (r.phase === 'rejected') { rejections.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, reason: r.reason ? one(r.reason, 140) : null }); touched.add(a.key); }
else if (r.phase === 'review' || r.phase === 'sent' || r.phase === 'rollout' || r.phase === 'approved' || r.phase === 'halted') {
releases.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, phase: r.phase === 'sent' ? 'review' : r.phase, fraction: r.phase === 'rollout' ? r.fraction : null });
touched.add(a.key);
}
}
for (const rv of a.reviews ?? []) {
const age = now - Date.parse(rv.date);
if (!(age > -DAY)) continue; // unreadable date, or far in the future
if (age <= RECENT_DAYS * DAY) recent++;
if (rv.rating <= 2 && age <= LOW_DAYS * DAY) {
low.push({ app: a.key, name: a.name, store: rv.store, rating: rv.rating, text: one([rv.title, rv.body].filter(Boolean).join(' · '), 110), date: rv.date });
touched.add(a.key);
}
}
const kinds = healthAlerts(a.health);
if (kinds.length) { health.push({ app: a.key, name: a.name, hue: a.hue, kinds }); touched.add(a.key); }
}
// halted first (it needs the owner), then review, waiting for release, rolling out
const order = { halted: 0, review: 1, approved: 2, rollout: 3 } as const;
releases.sort((x, y) => order[x.phase] - order[y.phase] || x.name.localeCompare(y.name));
low.sort((x, y) => y.date.localeCompare(x.date));
return {
at: o.at, busy: o.busy, muted: o.muted, apps: apps.length,
rejections: rejections.slice(0, MAX_ROWS), releases: releases.slice(0, MAX_ROWS),
reviews: { recent, low: low.slice(0, MAX_LOW) }, health: health.slice(0, MAX_ROWS),
links: apps.filter(a => touched.has(a.key)).map(a => ({ key: a.key, name: a.name, hue: a.hue, asc: a.asc ?? null, play: a.play ?? null })),
};
}

View file

@ -0,0 +1,103 @@
// The tray card's words in every language the app has (en, tr; same keys, tr typed against en). Kept apart from the
// main catalogs so the card bundle stays small.
export const en = {
title: 'Store status',
rejected: 'Rejected',
rejections: 'Needs you',
moving: 'Releases on the move',
review: 'In review',
rollout: 'Rolling out',
rolloutPct: 'Rolling out {pct}%',
approved: 'Waiting for release',
halted: 'Rollout halted',
reviews: 'Reviews',
recent: '{n} new in the last 7 days',
recentNone: 'No new reviews in the last 7 days',
health: 'Crash and ANR alerts',
crash: 'Crash rate over the bad behavior threshold',
anr: 'ANR rate over the bad behavior threshold',
crashAnr: 'Crash and ANR rates over the bad behavior threshold',
calm: 'All quiet: nothing in review, rolling out or waiting on you.',
noData: 'No store read yet. Refresh reads every app.',
lastRead: 'Read {ago}',
never: 'never',
justNow: 'just now',
min: '{n} min ago',
hour: '{n} h ago',
day: '{n} d ago',
refresh: 'Refresh',
refreshing: 'Reading…',
openMain: 'Open storebound',
more: 'More',
mute: 'Mute notifications for 1 hour',
unmute: 'Unmute notifications',
muted: 'Muted',
quit: 'Quit storebound',
openApp: 'Open {name}',
asc: 'App Store Connect',
play: 'Play Console',
appStore: 'App Store',
playStore: 'Play',
stars: '{n} of 5 stars',
};
export type CardKey = keyof typeof en;
export const tr: Record<CardKey, string> = {
title: 'Mağaza durumu',
rejected: 'Reddedildi',
rejections: 'Seni bekliyor',
moving: 'İlerleyen sürümler',
review: 'İncelemede',
rollout: 'Yayılıyor',
rolloutPct: '%{pct} yayılıyor',
approved: 'Yayın bekliyor',
halted: 'Yayılım durduruldu',
reviews: 'Yorumlar',
recent: 'Son 7 günde {n} yeni',
recentNone: 'Son 7 günde yeni yorum yok',
health: 'Çökme ve ANR uyarıları',
crash: 'Çökme oranı kötü davranış eşiğinin üstünde',
anr: 'ANR oranı kötü davranış eşiğinin üstünde',
crashAnr: 'Çökme ve ANR oranları kötü davranış eşiğinin üstünde',
calm: 'Her şey sakin: incelemede, yayılan ya da seni bekleyen bir şey yok.',
noData: 'Henüz mağaza okunmadı. Yenile her uygulamayı okur.',
lastRead: '{ago} okundu',
never: 'hiç',
justNow: 'az önce',
min: '{n} dk önce',
hour: '{n} sa önce',
day: '{n} gün önce',
refresh: 'Yenile',
refreshing: 'Okunuyor…',
openMain: 'storebound\'u aç',
more: 'Diğer',
mute: 'Bildirimleri 1 saat sustur',
unmute: 'Bildirimleri aç',
muted: 'Sessiz',
quit: 'storebound\'dan çık',
openApp: '{name} uygulamasını aç',
asc: 'App Store Connect',
play: 'Play Console',
appStore: 'App Store',
playStore: 'Play',
stars: '5 üzerinden {n} yıldız',
};
export const CARD_TEXT = { en, tr } as const;
export type CardLang = keyof typeof CARD_TEXT;
export function cardT(lang: CardLang, key: CardKey, vars?: Record<string, string | number>): string {
const s = CARD_TEXT[lang][key] ?? en[key];
return vars ? s.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k]))) : s;
}
/** "3 min ago" style age of the last read. */
export function ago(lang: CardLang, at: string | null, now: number): string {
if (!at) return cardT(lang, 'never');
const m = Math.floor((now - Date.parse(at)) / 60_000);
if (!Number.isFinite(m)) return cardT(lang, 'never');
if (m < 1) return cardT(lang, 'justNow');
if (m < 60) return cardT(lang, 'min', { n: m });
if (m < 48 * 60) return cardT(lang, 'hour', { n: Math.floor(m / 60) });
return cardT(lang, 'day', { n: Math.floor(m / 1440) });
}