feat: v0.17.0 — ISS-008 toasts for errors/events + browser notifications
CI / build + unit + e2e (pull_request) Failing after 6m59s
CI / build + unit + e2e (pull_request) Failing after 6m59s
This commit is contained in:
+43
-2
@@ -16,6 +16,8 @@ interface LeftPaneProps {
|
||||
onRefresh: () => void;
|
||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||
onHover: (target: HoverTarget | null) => void;
|
||||
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
|
||||
@@ -46,7 +48,7 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca
|
||||
return pendingScale !== null && serverTimeScale === pendingScale;
|
||||
}
|
||||
|
||||
export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) {
|
||||
export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
|
||||
@@ -90,6 +92,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('failed to apply sim controls', err);
|
||||
// ISS-008: surface the failure as an error toast too — previously console-only.
|
||||
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
|
||||
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
@@ -97,7 +101,7 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
|
||||
}
|
||||
return sentOwn ? 'sent' : 'failed';
|
||||
},
|
||||
[onRefresh],
|
||||
[onRefresh, onError],
|
||||
);
|
||||
|
||||
const paused = state?.simState.paused ?? false;
|
||||
@@ -166,6 +170,25 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
|
||||
[],
|
||||
);
|
||||
|
||||
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
|
||||
// in an effect after hydration, so server and client render identically) and follows the
|
||||
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
|
||||
const [notifPermission, setNotifPermission] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) setNotifPermission(Notification.permission);
|
||||
else setNotifPermission('unsupported');
|
||||
}, []);
|
||||
|
||||
// null (pre-effect) and 'default' both read as the opt-in prompt.
|
||||
const notifLabel: string =
|
||||
notifPermission === 'granted'
|
||||
? 'Notifications enabled'
|
||||
: notifPermission === 'denied'
|
||||
? 'Notifications blocked'
|
||||
: notifPermission === 'unsupported'
|
||||
? 'Notifications unavailable'
|
||||
: 'Enable notifications';
|
||||
|
||||
const displayedScale = pendingScale ?? timeScale;
|
||||
|
||||
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
|
||||
@@ -253,6 +276,24 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
|
||||
/>
|
||||
<span data-testid="time-scale-value">{displayedScale}×</span>
|
||||
</div>
|
||||
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="enable-notifications"
|
||||
className="notif-toggle"
|
||||
disabled={notifPermission === 'unsupported'}
|
||||
title={notifPermission === 'unsupported' ? 'This browser does not support notifications' : undefined}
|
||||
onClick={() => {
|
||||
// A rejection (rare — e.g. some headless setups) reads as "blocked".
|
||||
if ('Notification' in window) {
|
||||
void Notification.requestPermission()
|
||||
.then(setNotifPermission)
|
||||
.catch(() => setNotifPermission('denied'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{notifLabel}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -16,6 +16,9 @@ interface RightPaneProps {
|
||||
// ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or
|
||||
// the package's endpoint airports.
|
||||
onHover: (target: HoverTarget | null) => void;
|
||||
// ISS-008 (v0.17.0): report a failed action to the shell as an error toast — in ADDITION
|
||||
// to the inline pane error, which stays (both surfaces by design).
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
|
||||
@@ -104,7 +107,7 @@ function LoadControls({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) {
|
||||
export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
|
||||
// snapshot prop changes every tick) or the next successful action.
|
||||
@@ -139,12 +142,14 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri
|
||||
setActionError(null);
|
||||
applySnapshot(data);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(message); // inline pane error stays…
|
||||
onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008)
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[applySnapshot, busy],
|
||||
[applySnapshot, busy, onError],
|
||||
);
|
||||
|
||||
if (loading && !state) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import type { Toast } from '@/lib/useToasts';
|
||||
|
||||
// ISS-008 (v0.17.0): the bottom-right toast stack — action errors and game events derived
|
||||
// from snapshot diffs. Fixed-position overlay above the map; each item auto-dismisses via
|
||||
// the hook's TTL or its × button.
|
||||
export default function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss?: (id: number) => void }) {
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div className="toasts" role="status">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} data-testid="toast" className={`toast toast-${t.kind}`}>
|
||||
<span>{t.message}</span>
|
||||
{onDismiss && (
|
||||
<button type="button" data-testid={`toast-dismiss-${t.id}`} className="toast-close" onClick={() => onDismiss(t.id)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user