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(() => {});