From bf42fe2d600e4fee59dc191947253aa07de8343c Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 18 Nov 2025 20:25:00 +0100 Subject: [PATCH] introduced supabase and experiment management UI --- backend/server/app.py | 1 + experiments/procesing/extract.py | 38 ++- experiments/procesing/pipeline.py | 10 +- requirements.txt | 1 + web/package-lock.json | 140 +++++++++- web/package.json | 2 + web/src/app/admin/experiments/page.tsx | 294 ++++++++++---------- web/src/app/api/admin/experiments/route.ts | 81 +++++- web/src/app/api/admin/tasks/route.ts | 58 ++++ web/src/app/api/session/route.ts | 56 +++- web/src/app/start-task/page.tsx | 93 +++++++ web/src/components/admin/ExperimentForm.tsx | 118 ++++++++ web/src/components/admin/TaskManager.tsx | 178 ++++++++++++ web/src/hooks/useInteractionTracking.ts | 8 +- web/src/proxy.ts | 1 + web/src/utils/supabase/client.ts | 10 + web/src/utils/supabase/middleware.ts | 37 +++ web/src/utils/supabase/server.ts | 28 ++ 18 files changed, 978 insertions(+), 176 deletions(-) create mode 100644 web/src/app/api/admin/tasks/route.ts create mode 100644 web/src/app/start-task/page.tsx create mode 100644 web/src/components/admin/ExperimentForm.tsx create mode 100644 web/src/components/admin/TaskManager.tsx create mode 100644 web/src/utils/supabase/client.ts create mode 100644 web/src/utils/supabase/middleware.ts create mode 100644 web/src/utils/supabase/server.ts diff --git a/backend/server/app.py b/backend/server/app.py index 3830058..4093c7d 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -41,6 +41,7 @@ def get_producer() -> KafkaProducer: class EventPayload(BaseModel): sessionId: str + experimentId: Optional[str] = None eventName: str page: str productId: Optional[str] = None diff --git a/experiments/procesing/extract.py b/experiments/procesing/extract.py index cfe73e2..7fbb88c 100644 --- a/experiments/procesing/extract.py +++ b/experiments/procesing/extract.py @@ -5,11 +5,16 @@ import os import requests from dotenv import load_dotenv from sklearn.base import BaseEstimator, TransformerMixin +from supabase import create_client, Client load_dotenv() BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000") +SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL") +SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") N_PRICE_BUCKETS = 5 +supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) + def get_data_from_kafka() -> pd.DataFrame: """fetch all events from backend dump endpoint""" resp = requests.get(f"{BACKEND_URL}/api/kafka/dump") @@ -28,7 +33,38 @@ def get_data_from_kafka() -> pd.DataFrame: def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame: - # TODO: Get experiments db from supabase and join on session_id + if df.empty or 'experimentId' not in df.columns: + return df + + unique_exp_ids = df['experimentId'].dropna().unique() + if len(unique_exp_ids) == 0: + return df + + resp = supabase.table('experiments').select( + 'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)' + ).in_('id', unique_exp_ids.tolist()).execute() + + if not resp.data: + return df + + exp_df = pd.DataFrame(resp.data) + + # flatten task nested object if present + if 'task' in exp_df.columns and exp_df['task'].notnull().any(): + task_normalized = pd.json_normalize(exp_df['task'].dropna()) + task_normalized.index = exp_df[exp_df['task'].notnull()].index + exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task') + + # rename experiment columns for clarity + exp_df = exp_df.rename(columns={ + 'id': 'experimentId', + 'subject_name': 'exp_subject', + 'xp_human_only': 'exp_human_only', + 'xp_market_mode': 'exp_market_mode', + 'xp_task_id': 'exp_task_id' + }) + + df = df.merge(exp_df, on='experimentId', how='left') return df diff --git a/experiments/procesing/pipeline.py b/experiments/procesing/pipeline.py index 6b742b2..54aae61 100644 --- a/experiments/procesing/pipeline.py +++ b/experiments/procesing/pipeline.py @@ -7,13 +7,9 @@ from mapping import SessionTransitionProbMatrixTransformer, render_graph if __name__ == "__main__": steps = [ ('data_extraction', DataExtractor()), - ('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)), + #('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)), ] pipeline = Pipeline(steps) result = pipeline.fit_transform(None) - print(f"Number of sessions: {len(result)}\n") - - for session_id, sess_data in result.items(): - fname = f"session_{session_id}" - render_graph(fname, sess_data['matrix'], ls_index=sess_data['labels'], threshold=0.05, fmt="svg", view=False) - print(f"Rendered {fname}.svg") + print(result) + print(result.info()) diff --git a/requirements.txt b/requirements.txt index 8bb3ed7..22d3fcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pytest pytest-asyncio uv scikit-learn +supabase diff --git a/web/package-lock.json b/web/package-lock.json index e773ffb..207a105 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,8 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@supabase/ssr": "^0.7.0", + "@supabase/supabase-js": "^2.81.1", "next": "16.0.0", "react": "19.2.0", "react-dom": "19.2.0", @@ -657,6 +659,97 @@ "node": ">= 10" } }, + "node_modules/@supabase/auth-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", + "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", + "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", + "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", + "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz", + "integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.43.4" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", + "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", + "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.81.1", + "@supabase/functions-js": "2.81.1", + "@supabase/postgrest-js": "2.81.1", + "@supabase/realtime-js": "2.81.1", + "@supabase/storage-js": "2.81.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -941,12 +1034,17 @@ "version": "20.19.23", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -967,6 +1065,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001751", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", @@ -993,6 +1100,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1605,9 +1721,29 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/web/package.json b/web/package.json index 0a32603..e5452f6 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,8 @@ "start": "next start" }, "dependencies": { + "@supabase/ssr": "^0.7.0", + "@supabase/supabase-js": "^2.81.1", "next": "16.0.0", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/web/src/app/admin/experiments/page.tsx b/web/src/app/admin/experiments/page.tsx index ef8f89e..d8dd68c 100755 --- a/web/src/app/admin/experiments/page.tsx +++ b/web/src/app/admin/experiments/page.tsx @@ -1,20 +1,26 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useSession } from '@/hooks/useSession'; +import { TaskManager } from '@/components/admin/TaskManager'; +import { ExperimentForm } from '@/components/admin/ExperimentForm'; type Experiment = { id: string; - status: 'active' | 'stopped'; - sessionIds: string[]; - createdAt: number; + subject_name: string; + xp_human_only: boolean; + xp_market_mode: string; + created_at: string; + task?: { + id: string; + task_name: string; + }; }; export default function ExperimentsAdmin() { - const { sessionId, isLoading: sessionLoading } = useSession(); const [exps, setExps] = useState([]); - const [loading, setLoading] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(); const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); const fetchExps = async () => { try { @@ -31,86 +37,22 @@ export default function ExperimentsAdmin() { fetchExps(); }, []); - const handleStart = async () => { - if (!sessionId) { - setError('no session available'); - return; - } - - setLoading(true); - setError(null); - - try { - const res = await fetch('/api/admin/experiments/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'start failed'); - } - - await fetchExps(); // refresh list - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } + const handleExperimentCreated = async () => { + setShowForm(false); + setSelectedTaskId(undefined); + await fetchExps(); }; - const handleStop = async (expId: string) => { - setLoading(true); - setError(null); - - try { - const res = await fetch('/api/admin/experiments/stop', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ experimentId: expId }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'stop failed'); - } - - await fetchExps(); // refresh list - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - if (sessionLoading) { - return ( -
-

loading session...

-
- ); - } - return (
-
-
-
-

- Experiments -

-

- current session: {sessionId || 'none'} -

-
- +
+
+

+ Experiment Management +

+

+ configure tasks and run experiments +

{error && ( @@ -119,79 +61,123 @@ export default function ExperimentsAdmin() {
)} -
- - - - - - - - - - - - {exps.length === 0 ? ( - - - - ) : ( - exps.map((exp) => ( - - - - - - +
+ {/* left column: task manager */} +
+ +
+ + {/* right column: experiment form + list */} +
+
+

+ Experiments +

+ +
+ + {showForm && ( + + )} + +
+
- experiment id - - status - - session count - - created - - action -
- no experiments yet -
- {exp.id.slice(0, 8)}... - - - {exp.status} - - - {exp.sessionIds.length} - - {new Date(exp.createdAt).toLocaleString()} - - {exp.status === 'active' && ( - - )} -
+ + + + + + + + - )) - )} - -
+ subject + + mode + + human + + task + + created + + link +
+ + + {exps.length === 0 ? ( + + + no experiments yet + + + ) : ( + exps.map((exp) => { + const baseUrl = exp.xp_market_mode === 'airline' + ? 'https://phantom-airline.vercel.app' + : 'https://phantom-hotel.vercel.app'; + const link = `${baseUrl}/start-task?uuid=${exp.id}`; + + return ( + + + {exp.subject_name} + + + + {exp.xp_market_mode || 'none'} + + + + {exp.xp_human_only ? ( + + yes + + ) : ( + no + )} + + + {exp.task ? exp.task.task_name : '—'} + + + {new Date(exp.created_at).toLocaleDateString()} + + + + + + ); + }) + )} + + +
+
diff --git a/web/src/app/api/admin/experiments/route.ts b/web/src/app/api/admin/experiments/route.ts index 58fcede..eeba9d6 100644 --- a/web/src/app/api/admin/experiments/route.ts +++ b/web/src/app/api/admin/experiments/route.ts @@ -1,10 +1,40 @@ -import { NextResponse } from 'next/server'; -import { getAllExperiments } from '@/lib/sessionStore'; +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { cookies } from 'next/headers'; -export async function GET() { +export async function GET(req: NextRequest) { try { - const exps = getAllExperiments(); - return NextResponse.json({ experiments: exps }); + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + + if (id) { + const { data, error } = await supabase + .from('experiments') + .select(` + *, + task:tasks(*) + `) + .eq('id', id) + .single(); + + if (error) throw error; + return NextResponse.json({ experiment: data }); + } + + const { data, error } = await supabase + .from('experiments') + .select(` + *, + task:tasks(*) + `) + .order('created_at', { ascending: false }); + + if (error) throw error; + + return NextResponse.json({ experiments: data || [] }); } catch (err: any) { console.error('experiments list error:', err); return NextResponse.json( @@ -13,3 +43,44 @@ export async function GET() { ); } } + +export async function POST(req: NextRequest) { + try { + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + const body = await req.json(); + + const { subject_name, xp_human_only, xp_market_mode, xp_task_id } = body; + + if (!subject_name) { + return NextResponse.json( + { error: 'subject_name is required' }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('experiments') + .insert([{ + subject_name, + xp_human_only: xp_human_only ?? false, + xp_market_mode: xp_market_mode || null, + xp_task_id: xp_task_id || null, + }]) + .select(` + *, + task:tasks(*) + `) + .single(); + + if (error) throw error; + + return NextResponse.json({ experiment: data }); + } catch (err: any) { + console.error('experiment creation error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/admin/tasks/route.ts b/web/src/app/api/admin/tasks/route.ts new file mode 100644 index 0000000..7f79dd5 --- /dev/null +++ b/web/src/app/api/admin/tasks/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { cookies } from 'next/headers'; + +export async function GET() { + try { + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + + const { data, error } = await supabase + .from('tasks') + .select('*') + .order('created_at', { ascending: false }); + + if (error) throw error; + + return NextResponse.json({ tasks: data || [] }); + } catch (err: any) { + console.error('tasks fetch error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + const body = await req.json(); + + const { task_name, task_description, task_def_of_done } = body; + + if (!task_name) { + return NextResponse.json( + { error: 'task_name is required' }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('tasks') + .insert([{ task_name, task_description, task_def_of_done }]) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ task: data }); + } catch (err: any) { + console.error('task creation error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts index 7f5b4c6..455fd1d 100644 --- a/web/src/app/api/session/route.ts +++ b/web/src/app/api/session/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { randomUUID } from 'crypto'; -import { getSession, createSession } from '@/lib/sessionStore'; +import { getSession, createSession, setExperiment } from '@/lib/sessionStore'; const COOKIE_NAME = 'phantom_session_id'; const isProd = process.env.NODE_ENV === 'production'; export async function GET(req: NextRequest) { try { - // check for existing session cookie const existingSession = req.cookies.get(COOKIE_NAME)?.value; if (existingSession) { @@ -18,13 +17,11 @@ export async function GET(req: NextRequest) { }); } - // mint new session id const sessionId = randomUUID(); createSession(sessionId); const res = NextResponse.json({ sessionId, experimentId: undefined }); - // set httpOnly cookie with security flags res.cookies.set({ name: COOKIE_NAME, value: sessionId, @@ -32,7 +29,7 @@ export async function GET(req: NextRequest) { sameSite: 'lax', secure: isProd, path: '/', - maxAge: 60 * 60 * 24 * 30, // 30 days + maxAge: 60 * 60 * 24 * 30, }); return res; @@ -44,3 +41,52 @@ export async function GET(req: NextRequest) { ); } } + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { experimentId } = body; + + if (!experimentId) { + return NextResponse.json( + { error: 'experimentId is required' }, + { status: 400 } + ); + } + + let sessionId = req.cookies.get(COOKIE_NAME)?.value; + + if (!sessionId) { + sessionId = randomUUID(); + createSession(sessionId); + } + + setExperiment(sessionId, experimentId); + + const res = NextResponse.json({ + sessionId, + experimentId, + success: true + }); + + if (!req.cookies.get(COOKIE_NAME)) { + res.cookies.set({ + name: COOKIE_NAME, + value: sessionId, + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/', + maxAge: 60 * 60 * 24 * 30, + }); + } + + return res; + } catch (err: any) { + console.error('session update error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/start-task/page.tsx b/web/src/app/start-task/page.tsx new file mode 100644 index 0000000..0e08f7d --- /dev/null +++ b/web/src/app/start-task/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; + +const StartTaskContent = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const [status, setStatus] = useState<'loading' | 'error' | 'redirecting'>('loading'); + const [error, setError] = useState(null); + + useEffect(() => { + const uuid = searchParams.get('uuid'); + + if (!uuid) { + setError('no experiment UUID provided'); + setStatus('error'); + return; + } + + const validateAndStore = async () => { + try { + const res = await fetch(`/api/admin/experiments?id=${uuid}`); + if (!res.ok) throw new Error('experiment not found'); + + const data = await res.json(); + const exp = data.experiment; + + if (!exp) throw new Error('invalid experiment UUID'); + + localStorage.setItem('phantom_experiment_id', uuid); + + await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ experimentId: uuid }), + }); + + setStatus('redirecting'); + + setTimeout(() => { + router.push("/"); + }, 800); + + } catch (err: any) { + setError(err.message || 'failed to start task'); + setStatus('error'); + } + }; + + validateAndStore(); + }, [searchParams, router]); + + return ( +
+
+ {status === 'loading' && ( +
+
+

validating browser...

+
+ )} + + {status === 'redirecting' && ( +
+
+

website loaded

+

redirecting to page...

+
+ )} + + {status === 'error' && ( +
+

error

+

{error}

+
+ )} +
+
+ ); +}; + +export default function StartTaskPage() { + return ( + +

loading...

+
+ }> + + + ); +} diff --git a/web/src/components/admin/ExperimentForm.tsx b/web/src/components/admin/ExperimentForm.tsx new file mode 100644 index 0000000..6343822 --- /dev/null +++ b/web/src/components/admin/ExperimentForm.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; + +type ExperimentFormProps = { + selectedTaskId?: string; + onSuccess?: () => void; +}; + +export const ExperimentForm = ({ selectedTaskId, onSuccess }: ExperimentFormProps) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + subject_name: '', + xp_human_only: false, + xp_market_mode: 'hotel' as 'hotel' | 'airline', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/admin/experiments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...form, + xp_task_id: selectedTaskId || null, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'creation failed'); + } + + setForm({ subject_name: '', xp_human_only: false, xp_market_mode: 'hotel' }); + onSuccess?.(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Create Experiment +

+ + {error && ( +
+ {error} +
+ )} + +
+ + setForm({ ...form, subject_name: e.target.value })} + className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100" + placeholder="e.g., baseline_dynamic_pricing_v1" + required + /> +
+ +
+ + +
+ +
+ setForm({ ...form, xp_human_only: e.target.checked })} + className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 dark:border-zinc-700 dark:bg-zinc-900" + /> + +
+ + {selectedTaskId && ( +
+

+ task selected: {selectedTaskId.slice(0, 8)}... +

+
+ )} + + +
+ ); +}; diff --git a/web/src/components/admin/TaskManager.tsx b/web/src/components/admin/TaskManager.tsx new file mode 100644 index 0000000..a114c73 --- /dev/null +++ b/web/src/components/admin/TaskManager.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +type Task = { + id: string; + task_name: string; + task_description: string; + task_def_of_done: string; + created_at: string; +}; + +type TaskManagerProps = { + onTaskSelect?: (taskId: string) => void; + selectedTaskId?: string; +}; + +export const TaskManager = ({ onTaskSelect, selectedTaskId }: TaskManagerProps) => { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ + task_name: '', + task_description: '', + task_def_of_done: '', + }); + const [error, setError] = useState(null); + + const fetchTasks = async () => { + try { + const res = await fetch('/api/admin/tasks'); + if (!res.ok) throw new Error(`fetch failed: ${res.status}`); + const data = await res.json(); + setTasks(data.tasks || []); + } catch (err: any) { + setError(err.message); + } + }; + + useEffect(() => { + fetchTasks(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/admin/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'creation failed'); + } + + setForm({ task_name: '', task_description: '', task_def_of_done: '' }); + setShowForm(false); + await fetchTasks(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Tasks +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {showForm && ( +
+
+ + setForm({ ...form, task_name: e.target.value })} + className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100" + placeholder="e.g., Book cheapest flight to Paris" + required + /> +
+ +
+ +