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,156 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { getSession, getIntegrations, disconnectIntegration } from '@/lib/api';
import type { Integration } from '@oo/shared-types';
import { Suspense } from 'react';
function ConnectPageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const load = useCallback(async () => {
const { user } = await getSession();
if (!user) { router.replace('/sign-in'); return; }
const { integrations: list } = await getIntegrations();
setIntegrations(list);
setLoading(false);
}, [router]);
useEffect(() => { load(); }, [load]);
// Show banner if just connected
const justConnected = searchParams.get('connected');
const isConnected = (provider: string) =>
integrations.some((i) => i.provider === provider && i.status === 'connected');
const handleDisconnect = async (provider: string) => {
setDisconnecting(provider);
await disconnectIntegration(provider);
await load();
setDisconnecting(null);
};
if (loading) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: 'var(--gray)', fontSize: '0.875rem' }}>Loading</div>
</main>
);
}
const todoistConnected = isConnected('todoist');
return (
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
Connect your apps
</h2>
<p style={{ color: 'var(--gray)', fontSize: '0.875rem', marginBottom: '3rem' }}>
oO reads what you need, when you need it.
</p>
{justConnected && (
<div style={{
background: 'rgba(255,255,255,0.06)',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1.5rem',
fontSize: '0.875rem',
color: 'var(--white)',
}}>
{justConnected} connected.
</div>
)}
{/* Todoist card */}
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
{/* Todoist logomark */}
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
<rect width="24" height="24" rx="6" fill="#DB4035"/>
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Todoist</div>
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
{todoistConnected ? 'Connected' : 'Tasks & to-dos'}
</div>
</div>
</div>
{todoistConnected ? (
<button
onClick={() => handleDisconnect('todoist')}
disabled={disconnecting === 'todoist'}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
color: 'var(--gray)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
}}
>
{disconnecting === 'todoist' ? '…' : 'Disconnect'}
</button>
) : (
<a
href="/api/integrations/todoist/connect?redirectTo=/connect"
style={{
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Connect
</a>
)}
</div>
{todoistConnected && (
<div style={{ marginTop: '3rem' }}>
<a
href="/tip"
style={{
display: 'block',
textAlign: 'center',
background: 'var(--white)',
color: 'var(--black)',
borderRadius: '0.5rem',
padding: '0.875rem',
fontWeight: 500,
fontSize: '0.9rem',
}}
>
See my tip
</a>
</div>
)}
</main>
);
}
export default function ConnectPage() {
return (
<Suspense>
<ConnectPageInner />
</Suspense>
);
}