Files
oO/apps/web/src/app/tip/page.tsx
alvis e62c726ea4 feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure
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>
2026-04-16 03:56:48 +00:00

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>
);
}