From 3123cb73fb998a7d5019b8143b3f9c2693b9b963 Mon Sep 17 00:00:00 2001 From: alvis Date: Wed, 15 Apr 2026 08:53:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=200=20walking=20skeleton=20?= =?UTF-8?q?=E2=80=94=20auth,=20Todoist=20integration,=20tip=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google OAuth2/PKCE flow via openid-client v6; session cookie (30-day) - Next.js middleware auth guard — redirects before any client render - Todoist OAuth2 connect/disconnect; REST v1 task fetch (today|overdue) - RandomPolicy recommender behind stable POST /recommend contract - Feedback endpoint (done/dismiss/snooze); marks task complete in Todoist - 30s in-memory task cache per user (~1ms recommend on cache hit) - Tip page: pure opacity fade-in (3.5s), fast fade-out (0.3s), no motion - "reading you…" loading text with breathe animation - PWA icons + manifest - Ports pinned: API=3078, web=3079; Caddy at o.alogins.net Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +- apps/web/next-env.d.ts | 6 + apps/web/next.config.ts | 3 +- apps/web/package.json | 4 +- apps/web/public/favicon.ico | Bin 0 -> 82 bytes apps/web/public/icon-192.png | Bin 0 -> 187 bytes apps/web/public/icon-512.png | Bin 0 -> 842 bytes apps/web/src/app/connect/page.tsx | 9 +- apps/web/src/app/sign-in/page.tsx | 12 +- apps/web/src/app/tip/page.tsx | 330 ++++++++++++++----------- apps/web/src/middleware.ts | 34 +++ services/api/src/config.ts | 7 +- services/api/src/routes/auth.ts | 6 +- services/api/src/routes/recommender.ts | 37 +-- 14 files changed, 276 insertions(+), 177 deletions(-) create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/public/favicon.ico create mode 100644 apps/web/public/icon-192.png create mode 100644 apps/web/public/icon-512.png create mode 100644 apps/web/src/middleware.ts diff --git a/.env.example b/.env.example index 975b9a2..0b55ce3 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,11 @@ # API SESSION_SECRET=change-me-to-a-random-32-char-string -PORT=3001 +PORT=3078 NODE_ENV=development DATABASE_PATH=./data/oo.db -API_BASE_URL=http://localhost:3001 +# API_BASE_URL = public origin only, no path suffix (used to build OAuth redirect URIs) +API_BASE_URL=http://localhost:3078 WEB_BASE_URL=http://localhost:3000 ML_SERVING_URL=http://localhost:8000 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d118973..c3db28f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -5,7 +5,8 @@ const nextConfig: NextConfig = { return [ { source: '/api/:path*', - destination: `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'}/api/:path*`, + destination: `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3078'}/api/:path*`, + // In production, Caddy routes /api/* directly to the API — this rewrite only fires in dev }, ]; }, diff --git a/apps/web/package.json b/apps/web/package.json index 887a81a..44050db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3079", "build": "next build", - "start": "next start", + "start": "next start -p 3079", "lint": "next lint", "type-check": "tsc --noEmit", "clean": "rm -rf .next" diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8c1870d9916639a823063b9ff3956cabbfb83a3e GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WBt2amLn`LHJ!r@X%?hE&XXd(e;($UC&)tA8%H f{sJJAUZ6r^!+8cq?Z^j}AR9eh{an^LB{Ts5$afa9 literal 0 HcmV?d00001 diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..5220d0f9dbfa747cdb356761a73beb1b1f1c96bb GIT binary patch literal 842 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_ow9PZ!6KiaBo&HUh;M4lno_ o9RH9Zlo7-k1*0J_;zHm+KGPNk#z+z0_aLu(y85}Sb4q9e0Lk|gumAu6 literal 0 HcmV?d00001 diff --git a/apps/web/src/app/connect/page.tsx b/apps/web/src/app/connect/page.tsx index e7271a9..ce70199 100644 --- a/apps/web/src/app/connect/page.tsx +++ b/apps/web/src/app/connect/page.tsx @@ -1,25 +1,22 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { getSession, getIntegrations, disconnectIntegration } from '@/lib/api'; +import { useSearchParams } from 'next/navigation'; +import { 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([]); const [loading, setLoading] = useState(true); const [disconnecting, setDisconnecting] = useState(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]); diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx index 8786a02..fd569c0 100644 --- a/apps/web/src/app/sign-in/page.tsx +++ b/apps/web/src/app/sign-in/page.tsx @@ -1,17 +1,7 @@ 'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { getSession } from '@/lib/api'; - +// Auth redirect is handled by middleware — no client-side session check needed here. export default function SignIn() { - const router = useRouter(); - - useEffect(() => { - getSession().then(({ user }) => { - if (user) router.replace('/connect'); - }); - }, [router]); return (
+ {children} + + ); +} + export default function TipPage() { - const router = useRouter(); const [tip, setTip] = useState(null); const [state, setState] = useState('loading'); + const [visible, setVisible] = useState(false); const holdTimer = useRef | 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; + // Fade in after state change settles + useEffect(() => { + if (state === 'loading' || state === 'done') { + setVisible(false); + } else { + const t = setTimeout(() => setVisible(true), 30); + return () => clearTimeout(t); } - setTip(rec.tip); - setState('tip'); - }, [router]); + }, [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]); const react = async (action: 'done' | 'dismiss' | 'snooze') => { if (!tip) return; + setVisible(false); setState('done'); await sendFeedback(tip.id, { action }); - setTimeout(() => loadTip(), 600); + setTimeout(() => loadTip(), 700); }; const onPointerDown = () => { @@ -42,6 +73,7 @@ export default function TipPage() { setPressed(true); holdTimer.current = setTimeout(() => { setState('actions'); + setVisible(true); setPressed(false); }, 600); }; @@ -55,144 +87,165 @@ export default function TipPage() { }; return ( -
- {/* Radial glow when pressed */} -
+ <> + - {state === 'loading' && ( -
- ··· -
- )} +
+ {/* Ambient glow — breathes while loading */} +
- {state === 'tip' && tip && ( -
+ {/* Loading label */} + {(state === 'loading' || state === 'done') && (

- {tip.content} -

-

- hold to act + reading you…

-
- )} + )} - {state === 'empty' && ( -
-

All clear.

- -
- )} + color: 'rgba(255,255,255,0.18)', + fontSize: '0.65rem', + letterSpacing: '0.12em', + textTransform: 'uppercase', + }}> + hold to act +

+ + )} - {state === 'done' && ( -
- ··· -
- )} - - {/* Action sheet */} - {state === 'actions' && ( - <> -
setState('tip')} - style={{ - position: 'fixed', - inset: 0, - background: 'rgba(0,0,0,0.5)', - }} - /> -
- {tip && ( -

- {tip.content} -

- )} - react('done')} primary /> - react('snooze')} /> - react('dismiss')} /> + {/* Empty */} + {state === 'empty' && ( + +

+ All clear. +

-
- - )} -
+ + )} + + {/* Action sheet */} + {state === 'actions' && ( + <> +
{ setState('tip'); }} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.5)', + animation: 'none', + }} + /> +
+ {tip && ( +

+ {tip.content} +

+ )} + react('done')} primary /> + react('snooze')} /> + react('dismiss')} /> + +
+ + )} +
+ ); } @@ -210,6 +263,7 @@ function ActionButton({ label, onClick, primary }: { label: string; onClick: () fontWeight: primary ? 500 : 400, width: '100%', textAlign: 'center', + cursor: 'pointer', }} > {label} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..4317636 --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +const PUBLIC = ['/sign-in', '/legal']; + +export function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + const hasCookie = req.cookies.has('sid'); + + // Already on a public page with no session — allow through + if (!hasCookie && PUBLIC.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // No session — redirect to sign-in + if (!hasCookie) { + const url = req.nextUrl.clone(); + url.pathname = '/sign-in'; + return NextResponse.redirect(url); + } + + // Has session but hitting sign-in — send to tip + if (hasCookie && pathname.startsWith('/sign-in')) { + const url = req.nextUrl.clone(); + url.pathname = '/tip'; + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|icon-.*\\.png|manifest\\.json).*)'], +}; diff --git a/services/api/src/config.ts b/services/api/src/config.ts index b01e787..4a12855 100644 --- a/services/api/src/config.ts +++ b/services/api/src/config.ts @@ -1,4 +1,7 @@ -import 'dotenv/config'; +import { config as dotenvConfig } from 'dotenv'; +// Load .env.local first (takes precedence), then .env as fallback +dotenvConfig({ path: '../../.env.local', override: false }); +dotenvConfig({ path: '../../.env', override: false }); function require(name: string): string { const val = process.env[name]; @@ -11,7 +14,7 @@ function optional(name: string, fallback: string): string { } export const config = { - PORT: parseInt(optional('PORT', '3001'), 10), + PORT: parseInt(optional('PORT', '3078'), 10), NODE_ENV: optional('NODE_ENV', 'development'), DATABASE_PATH: optional('DATABASE_PATH', './data/oo.db'), diff --git a/services/api/src/routes/auth.ts b/services/api/src/routes/auth.ts index 205760b..b3e33e6 100644 --- a/services/api/src/routes/auth.ts +++ b/services/api/src/routes/auth.ts @@ -36,6 +36,7 @@ router.get('/login', async (req: Request, res: Response) => { setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000); const redirectUri = `${config.API_BASE_URL}/api/auth/callback`; + console.log('[auth] redirect_uri sent to Google:', redirectUri); const authUrl = client.buildAuthorizationUrl(cfg, { redirect_uri: redirectUri, scope: 'openid email profile', @@ -59,7 +60,10 @@ router.get('/callback', async (req: Request, res: Response) => { pendingStates.delete(state); const redirectUri = `${config.API_BASE_URL}/api/auth/callback`; - const currentUrl = new URL(req.url, config.API_BASE_URL); + // req.url is router-relative (/callback?...), so reconstruct the full callback URL + // using the known redirectUri base + the query string from the incoming request. + const incomingSearch = new URL(req.url, 'http://localhost').search; + const currentUrl = new URL(redirectUri + incomingSearch); let tokens: client.TokenEndpointResponse; try { diff --git a/services/api/src/routes/recommender.ts b/services/api/src/routes/recommender.ts index 16678e5..799e56a 100644 --- a/services/api/src/routes/recommender.ts +++ b/services/api/src/routes/recommender.ts @@ -8,27 +8,33 @@ import type { Tip } from '@oo/shared-types'; const router: ExpressRouter = Router(); -/** Fetch active Todoist tasks for a user via their stored token */ -async function fetchTodoistTasks(accessToken: string): Promise { - const res = await fetch('https://api.todoist.com/rest/v2/tasks?filter=today|overdue', { +const CACHE_TTL_MS = 30_000; // 30 seconds +const taskCache = new Map(); + +/** Fetch active Todoist tasks, with a 30s in-memory cache per user */ +async function fetchTodoistTasks(userId: string, accessToken: string): Promise { + const cached = taskCache.get(userId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.tips; + } + + const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', { headers: { Authorization: `Bearer ${accessToken}` }, }); - if (!res.ok) return []; + if (!res.ok) return cached?.tips ?? []; - const tasks = (await res.json()) as Array<{ - id: string; - content: string; - due?: { string: string }; - }>; - - return tasks.map((t) => ({ + const body = (await res.json()) as { results: Array<{ id: string; content: string }> }; + const tips: Tip[] = (body.results ?? []).map((t) => ({ id: `todoist:${t.id}`, content: t.content, source: 'todoist' as const, sourceId: t.id, createdAt: new Date().toISOString(), })); + + taskCache.set(userId, { tips, fetchedAt: Date.now() }); + return tips; } /** @@ -41,7 +47,7 @@ function randomPolicy(candidates: Tip[]): Tip | null { } /** POST /api/recommend */ -router.post('/', requireAuth, async (req: AuthenticatedRequest, res: Response) => { +router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => { const [token] = await db .select() .from(integrationTokens) @@ -58,7 +64,7 @@ router.post('/', requireAuth, async (req: AuthenticatedRequest, res: Response) = return; } - const candidates = await fetchTodoistTasks(token.accessToken); + const candidates = await fetchTodoistTasks(req.userId!, token.accessToken); const tip = randomPolicy(candidates); if (!tip) { @@ -88,6 +94,9 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, createdAt: new Date().toISOString(), }); + // Invalidate cache so next recommend fetches fresh tasks + taskCache.delete(req.userId!); + // If done, mark complete in Todoist if (action === 'done' && tipId.startsWith('todoist:')) { const todoistId = tipId.slice(8); @@ -103,7 +112,7 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, .limit(1); if (token) { - await fetch(`https://api.todoist.com/rest/v2/tasks/${todoistId}/close`, { + await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, { method: 'POST', headers: { Authorization: `Bearer ${token.accessToken}` }, }).catch(() => {});