feat: Phase 0 walking skeleton — monorepo, API, web, ML stub

Sets up the full Phase 0 foundation:

- pnpm workspaces + turbo build graph; native module build approval
- packages/shared-types: HTTP contracts (Tip, Auth, Integrations, User)
- services/api: Express modular monolith with better-sqlite3/drizzle
  - auth: Google OAuth2 + PKCE via openid-client v6, cookie sessions
  - integrations: Todoist OAuth2 connect/disconnect, token vault
  - recommender: RandomPolicy over Todoist tasks, feedback sink
  - user: profile, consent capture, full account deletion (GDPR)
- apps/web: Next.js 15, three pages (sign-in → connect → tip)
  - tip page: black canvas, hold-to-act gesture, action sheet
  - PWA manifest + theme
- ml/serving: FastAPI stub implementing the POST /score contract
- infra: docker-compose (core/full profiles), Dockerfiles, CI skeleton
- .env.example with all required vars documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 12:41:24 +00:00
parent 7f173f88d3
commit 65218762be
44 changed files with 4574 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { getSession, getRecommendation, sendFeedback } from '@/lib/api';
import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
export default function TipPage() {
const router = useRouter();
const [tip, setTip] = useState<Tip | null>(null);
const [state, setState] = useState<State>('loading');
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pressed, setPressed] = useState(false);
const loadTip = useCallback(async () => {
setState('loading');
const { user } = await getSession();
if (!user) { router.replace('/sign-in'); return; }
const rec = await getRecommendation();
if (!rec) {
setState('empty');
return;
}
setTip(rec.tip);
setState('tip');
}, [router]);
useEffect(() => { loadTip(); }, [loadTip]);
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
if (!tip) return;
setState('done');
await sendFeedback(tip.id, { action });
setTimeout(() => loadTip(), 600);
};
const onPointerDown = () => {
if (state !== 'tip') return;
setPressed(true);
holdTimer.current = setTimeout(() => {
setState('actions');
setPressed(false);
}, 600);
};
const onPointerUp = () => {
setPressed(false);
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
};
return (
<main
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem 2rem',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: state === 'tip' ? 'default' : 'pointer',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Radial glow when pressed */}
<div style={{
position: 'absolute',
inset: 0,
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.04) 0%, transparent 70%)',
opacity: pressed ? 1 : 0,
transition: 'opacity 0.3s ease',
pointerEvents: 'none',
}} />
{state === 'loading' && (
<div style={{ color: 'rgba(255,255,255,0.2)', fontSize: '0.75rem', letterSpacing: '0.15em' }}>
···
</div>
)}
{state === 'tip' && tip && (
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
<p style={{
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
fontWeight: 300,
lineHeight: 1.45,
letterSpacing: '-0.01em',
color: 'var(--white)',
transition: 'opacity 0.2s ease',
opacity: pressed ? 0.6 : 1,
}}>
{tip.content}
</p>
<p style={{
marginTop: '2rem',
color: 'rgba(255,255,255,0.2)',
fontSize: '0.65rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
hold to act
</p>
</div>
)}
{state === 'empty' && (
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>
<p style={{ fontSize: '1.1rem', fontWeight: 300 }}>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.4)',
borderRadius: '0.375rem',
padding: '0.5rem 1rem',
fontSize: '0.8rem',
}}
>
Check again
</button>
</div>
)}
{state === 'done' && (
<div style={{ color: 'rgba(255,255,255,0.15)', fontSize: '0.75rem', letterSpacing: '0.15em' }}>
···
</div>
)}
{/* Action sheet */}
{state === 'actions' && (
<>
<div
onClick={() => setState('tip')}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
}}
/>
<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',
}}>
{tip && (
<p style={{
color: 'rgba(255,255,255,0.4)',
fontSize: '0.8rem',
marginBottom: '0.5rem',
lineHeight: 1.4,
}}>
{tip.content}
</p>
)}
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
<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',
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',
}}
>
{label}
</button>
);
}