Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests
This commit is contained in:
parent
5d8a0f5e9a
commit
03ef999dac
27 changed files with 1050 additions and 55 deletions
|
|
@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides {
|
|||
function pick<T>(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; }
|
||||
const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x);
|
||||
const isBool = (x: unknown) => typeof x === 'boolean';
|
||||
const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x);
|
||||
|
||||
export function normalizeSettings(v: unknown): Settings {
|
||||
const d = DEFAULT_SETTINGS;
|
||||
if (!isRec(v)) return d;
|
||||
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
|
||||
const q = isRec(n.quietHours) ? n.quietHours : {};
|
||||
return {
|
||||
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool),
|
||||
autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) },
|
||||
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
|
||||
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
|
||||
notify: {
|
||||
reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool),
|
||||
lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool),
|
||||
quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) },
|
||||
},
|
||||
channel: v.channel === 'beta' ? 'beta' : 'stable',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api';
|
|||
import { sameVersion } from '../../../../cli/lib/portfolio';
|
||||
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model';
|
||||
|
||||
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string }
|
||||
/** Which Settings > Notifications toggle a release change belongs to. */
|
||||
export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state';
|
||||
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string }
|
||||
|
||||
/** The notification kind of a transition into phase `to`.*/
|
||||
export function releaseKind(to: string): ReleaseKind {
|
||||
if (to === 'rejected') return 'rejected';
|
||||
if (to === 'approved') return 'approved';
|
||||
if (to === 'live' || to === 'rollout') return 'live';
|
||||
if (to === 'halted') return 'halted';
|
||||
return 'state';
|
||||
}
|
||||
|
||||
/** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */
|
||||
export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] {
|
||||
|
|
@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P
|
|||
if (!r.version) continue;
|
||||
const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version)));
|
||||
if (!old || old.phase === r.phase) continue;
|
||||
if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase });
|
||||
// a version the developer withdrew on their own is not a store decision
|
||||
if (r.state === 'DEVELOPER_REJECTED') continue;
|
||||
if (WATCH.has(r.phase) || WATCH.has(old.phase)) {
|
||||
const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined;
|
||||
out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal
|
|||
for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- when a notification may show: kind toggle, quiet hours, mute ----
|
||||
|
||||
export interface QuietHours { enabled: boolean; from: string; to: string }
|
||||
export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours }
|
||||
export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test';
|
||||
|
||||
/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */
|
||||
export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean {
|
||||
switch (kind) {
|
||||
case 'rejected': return n.rejected;
|
||||
case 'approved': return n.approved;
|
||||
case 'live': return n.live;
|
||||
case 'halted': case 'crash': return n.crash;
|
||||
case 'state': return n.reviewState;
|
||||
case 'lowReviews': return n.lowReviews;
|
||||
case 'test': return true;
|
||||
}
|
||||
}
|
||||
|
||||
const minutesOf = (hhmm: string): number | null => {
|
||||
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm);
|
||||
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||
};
|
||||
|
||||
/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */
|
||||
export function inQuietHours(q: QuietHours, now: Date): boolean {
|
||||
if (!q.enabled) return false;
|
||||
const from = minutesOf(q.from), to = minutesOf(q.to);
|
||||
if (from === null || to === null || from === to) return false;
|
||||
const cur = now.getHours() * 60 + now.getMinutes();
|
||||
return from < to ? cur >= from && cur < to : cur >= from || cur < to;
|
||||
}
|
||||
|
||||
/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */
|
||||
export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now;
|
||||
|
||||
/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */
|
||||
export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean {
|
||||
if (kind === 'test') return true;
|
||||
const now = o.now ?? new Date();
|
||||
return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most
|
||||
* x16, capped at 2 hours before the jitter). `rand` is in [0, 1).
|
||||
*/
|
||||
export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number {
|
||||
const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4);
|
||||
return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2));
|
||||
}
|
||||
|
|
|
|||
48
apps/desktop/src/lib/tray.ts
Normal file
48
apps/desktop/src/lib/tray.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host
|
||||
// (src-tauri/src/tray.rs) only draws it.
|
||||
import type { AppStatus } from '../api';
|
||||
import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol';
|
||||
|
||||
export interface TrayApp { key: string; name: string; state: string }
|
||||
export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string }
|
||||
export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels }
|
||||
|
||||
type Phase = ReleasePhase;
|
||||
|
||||
/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */
|
||||
const RANK: Record<string, number> = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 };
|
||||
|
||||
/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */
|
||||
export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } {
|
||||
const out: { ios?: Phase; android?: Phase } = {};
|
||||
for (const r of s?.release.all ?? []) {
|
||||
const cur = out[r.store];
|
||||
if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True for a phase that needs the owner (rejected, halted). */
|
||||
export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted';
|
||||
export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent';
|
||||
|
||||
/**
|
||||
* Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the
|
||||
* caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps.
|
||||
*/
|
||||
export function traySummary(
|
||||
apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean,
|
||||
tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels },
|
||||
): TraySummary {
|
||||
let review = 0, attention = 0;
|
||||
const list: TrayApp[] = [];
|
||||
for (const a of apps) {
|
||||
const ph = trayPhases(a.status);
|
||||
if (inReview(ph.ios) || inReview(ph.android)) review++;
|
||||
if (needsYou(ph.ios) || needsYou(ph.android)) attention++;
|
||||
const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`);
|
||||
list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none });
|
||||
}
|
||||
const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean);
|
||||
return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue