Admin console (issues #63–72): - Event stream viewer: live-tail ring buffer (500 events) with subject/user filters - Feature store browser: per-user feature vector history from ml/serving - Model registry panel: MLflow embed at /admin/models - Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset - Recommendation log: per-tip explainability (policy, score, features, latency) - Reward analytics: daily reaction breakdown + per-policy compare - Data quality widget: missing-feature rate, stale-token rate, daily completeness - Ops actions: replay-signal, policy enable/disable; user actions link to Users page - SQL runner: read-only SELECT runner with saved queries - Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh Backend: - tip_scores table: logs features+policy+score+latency at every scoring call (#67) - saved_queries table: per-admin saved SQL (#71) - Event bus: 500-event ring buffer + tail() API (#63) - Admin routes: /events, /tips, /reward-analytics, /data-quality, /health, /policies, /replay-signal, /sql, /saved-queries endpoints - /api/ml/* admin-gated proxy to ml/serving (#64, #66) - Shadow-policy registry in recommender (#56) ML serving: - /reset/{user_id}: clear bandit state + feature history (#66) - /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66) - /features/{user_id}: last 100 feature vectors logged at scoring time (#64) - Meta (pulls, rewards) persisted alongside A/b matrices Web: - Tip action sheet adds Helpful / Not helpful buttons (#62) - TipFeedback type extended with helpful/not_helpful actions - Rewards mapped: helpful=+0.5, not_helpful=−0.5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
324 lines
9.9 KiB
TypeScript
324 lines
9.9 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
|
|
import type { Tip } from '@oo/shared-types';
|
|
|
|
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
|
|
|
// Fade wrapper — children fade in when `visible`, fade out when not
|
|
function Fade({ visible, children, style }: {
|
|
visible: boolean;
|
|
children: React.ReactNode;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<div style={{
|
|
opacity: visible ? 1 : 0,
|
|
transition: visible ? 'opacity 3.5s ease' : 'opacity 0.3s ease',
|
|
pointerEvents: visible ? 'auto' : 'none',
|
|
...style,
|
|
}}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TipPage() {
|
|
const [tip, setTip] = useState<Tip | null>(null);
|
|
const [state, setState] = useState<State>('loading');
|
|
const [visible, setVisible] = useState(false);
|
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [pressed, setPressed] = useState(false);
|
|
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
|
|
|
|
// Fade in after state change settles
|
|
useEffect(() => {
|
|
if (state === 'loading' || state === 'done') {
|
|
setVisible(false);
|
|
} else {
|
|
const t = setTimeout(() => setVisible(true), 30);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [state]);
|
|
|
|
const loadTip = useCallback(async () => {
|
|
setVisible(false);
|
|
setState('loading');
|
|
try {
|
|
const rec = await getRecommendation();
|
|
if (!rec) {
|
|
setState('empty');
|
|
return;
|
|
}
|
|
setTip(rec.tip);
|
|
setState('tip');
|
|
} catch (err: any) {
|
|
console.error('[tip] loadTip error', err?.status, err?.message);
|
|
setState('empty');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadTip(); }, [loadTip]);
|
|
|
|
// Check existing push permission on mount
|
|
useEffect(() => {
|
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
setPushState('subscribed');
|
|
} else if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
|
|
setPushState('denied');
|
|
}
|
|
}, []);
|
|
|
|
const requestPush = useCallback(async () => {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') { setPushState('denied'); return; }
|
|
try {
|
|
const reg = await navigator.serviceWorker.register('/sw.js');
|
|
const vapidKey = await getVapidPublicKey();
|
|
const sub = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: vapidKey,
|
|
});
|
|
await subscribePush(sub.toJSON());
|
|
setPushState('subscribed');
|
|
} catch { setPushState('denied'); }
|
|
}, []);
|
|
|
|
const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
|
|
if (!tip) return;
|
|
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
|
|
if (isNavigating) {
|
|
setVisible(false);
|
|
setState('done');
|
|
} else {
|
|
setState('tip');
|
|
}
|
|
await sendFeedback(tip.id, { action });
|
|
if (isNavigating) setTimeout(() => loadTip(), 700);
|
|
};
|
|
|
|
const onPointerDown = () => {
|
|
if (state !== 'tip') return;
|
|
setPressed(true);
|
|
holdTimer.current = setTimeout(() => {
|
|
setState('actions');
|
|
setVisible(true);
|
|
setPressed(false);
|
|
}, 600);
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
setPressed(false);
|
|
if (holdTimer.current) {
|
|
clearTimeout(holdTimer.current);
|
|
holdTimer.current = null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
|
|
<style>{`
|
|
@keyframes breathe {
|
|
0%, 100% { opacity: 0.3; }
|
|
50% { opacity: 1; }
|
|
}
|
|
`}</style>
|
|
|
|
<main
|
|
onPointerDown={onPointerDown}
|
|
onPointerUp={onPointerUp}
|
|
onPointerLeave={onPointerUp}
|
|
style={{
|
|
height: '100dvh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
userSelect: 'none',
|
|
WebkitUserSelect: 'none',
|
|
cursor: state === 'tip' ? 'default' : 'auto',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Ambient glow — breathes while loading */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 65%)',
|
|
animation: state === 'loading' ? 'breathe 4s ease-in-out infinite' : undefined,
|
|
opacity: state === 'loading' ? undefined : pressed ? 0.3 : 0,
|
|
transition: state !== 'loading' ? 'opacity 0.4s ease' : undefined,
|
|
pointerEvents: 'none',
|
|
}} />
|
|
|
|
{/* Loading label */}
|
|
{(state === 'loading' || state === 'done') && (
|
|
<p style={{
|
|
margin: 0,
|
|
color: 'rgba(255,255,255,0.55)',
|
|
fontSize: '0.7rem',
|
|
letterSpacing: '0.18em',
|
|
textTransform: 'uppercase',
|
|
animation: 'breathe 4s ease-in-out infinite',
|
|
}}>
|
|
reading you…
|
|
</p>
|
|
)}
|
|
|
|
{/* Tip */}
|
|
{(state === 'tip' || state === 'actions') && tip && (
|
|
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
|
|
<p style={{
|
|
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
|
|
fontWeight: 300,
|
|
lineHeight: 1.45,
|
|
letterSpacing: '-0.01em',
|
|
color: 'rgba(255,255,255,1)',
|
|
transition: 'opacity 0.2s ease',
|
|
opacity: pressed ? 0.5 : 1,
|
|
}}>
|
|
{tip.content}
|
|
</p>
|
|
<p style={{
|
|
marginTop: '2rem',
|
|
color: 'rgba(255,255,255,0.18)',
|
|
fontSize: '0.65rem',
|
|
letterSpacing: '0.12em',
|
|
textTransform: 'uppercase',
|
|
}}>
|
|
hold to act
|
|
</p>
|
|
{pushState === 'idle' && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); requestPush(); }}
|
|
style={{
|
|
marginTop: '2.5rem',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'rgba(255,255,255,0.18)',
|
|
fontSize: '0.65rem',
|
|
letterSpacing: '0.12em',
|
|
textTransform: 'uppercase',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
notify me
|
|
</button>
|
|
)}
|
|
</Fade>
|
|
)}
|
|
|
|
{/* Empty */}
|
|
{state === 'empty' && (
|
|
<Fade visible={visible} style={{ textAlign: 'center' }}>
|
|
<p style={{ fontSize: '1.1rem', fontWeight: 300, color: 'rgba(255,255,255,0.35)' }}>
|
|
All clear.
|
|
</p>
|
|
<button
|
|
onClick={loadTip}
|
|
style={{
|
|
marginTop: '2rem',
|
|
background: 'transparent',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
color: 'rgba(255,255,255,0.35)',
|
|
borderRadius: '0.375rem',
|
|
padding: '0.5rem 1rem',
|
|
fontSize: '0.8rem',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Check again
|
|
</button>
|
|
</Fade>
|
|
)}
|
|
|
|
{/* Action sheet */}
|
|
{state === 'actions' && (
|
|
<>
|
|
<div
|
|
onClick={() => { setState('tip'); }}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.5)',
|
|
animation: 'none',
|
|
}}
|
|
/>
|
|
<div style={{
|
|
position: 'fixed',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
background: '#111',
|
|
borderRadius: '1rem 1rem 0 0',
|
|
padding: '1.5rem 1.5rem 2.5rem',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '0.75rem',
|
|
transform: 'translateY(0)',
|
|
transition: 'transform 0.3s ease',
|
|
}}>
|
|
{tip && (
|
|
<p style={{
|
|
color: 'rgba(255,255,255,0.35)',
|
|
fontSize: '0.8rem',
|
|
marginBottom: '0.5rem',
|
|
lineHeight: 1.4,
|
|
}}>
|
|
{tip.content}
|
|
</p>
|
|
)}
|
|
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
|
<ActionButton label="Helpful" onClick={() => react('helpful')} />
|
|
<ActionButton label="Not helpful" onClick={() => react('not_helpful')} />
|
|
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
|
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
|
<button
|
|
onClick={() => setState('tip')}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'rgba(255,255,255,0.25)',
|
|
padding: '0.5rem',
|
|
fontSize: '0.8rem',
|
|
cursor: 'pointer',
|
|
marginTop: '0.25rem',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ActionButton({ label, onClick, primary }: { label: string; onClick: () => void; primary?: boolean }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
style={{
|
|
background: primary ? 'var(--white)' : 'rgba(255,255,255,0.06)',
|
|
color: primary ? 'var(--black)' : 'var(--white)',
|
|
border: 'none',
|
|
borderRadius: '0.625rem',
|
|
padding: '1rem',
|
|
fontSize: '0.95rem',
|
|
fontWeight: primary ? 500 : 400,
|
|
width: '100%',
|
|
textAlign: 'center',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|