22/**
33 * ExperimentRunManager
44 *
5- * Minimal admin UI for Ourmoji experiment runs (US4).
6- * Lists existing runs, lets the operator create a new one, and exposes
7- * pause/resume controls. Statistics + assignment management land in
8- * later phases .
5+ * UI for pairing + running Ourmoji experiments. Invite flow: pick a
6+ * partner by username, propose dates, send. The invitee sees the invite
7+ * in the "Incoming invitations" section and accepts or declines. On
8+ * accept, the underlying experiment run is created automatically .
99 */
1010
1111import type { OurmojiExperimentRun } from " ~/types/ourmoji" ;
1212
13- interface CreateForm {
13+ interface Partner {
14+ id: string ;
15+ username: string ;
16+ }
17+
18+ interface Invite {
19+ id: string ;
20+ fromUserId: string ;
21+ toUserId: string ;
22+ fromUsername: string ;
23+ toUsername: string ;
24+ name: string ;
25+ startDate: string ;
26+ endDate: string ;
27+ status: " pending" | " accepted" | " declined" | " cancelled" ;
28+ runId: string | null ;
29+ createdAt: string ;
30+ respondedAt: string | null ;
31+ }
32+
33+ interface InviteForm {
34+ toUserId: string ;
1435 name: string ;
1536 startDate: string ;
1637 endDate: string ;
17- participantUserIds: string ; // comma-separated
1838}
1939
2040const runs = ref <OurmojiExperimentRun []>([]);
41+ const partners = ref <Partner []>([]);
42+ const incoming = ref <Invite []>([]);
43+ const outgoing = ref <Invite []>([]);
2144const loading = ref (false );
2245const error = ref <string | null >(null );
2346
24- const form = reactive <CreateForm >({
47+ const form = reactive <InviteForm >({
48+ toUserId: " " ,
2549 name: " " ,
2650 startDate: " " ,
2751 endDate: " " ,
28- participantUserIds: " " ,
2952});
3053
3154async function refresh() {
3255 loading .value = true ;
3356 error .value = null ;
3457 try {
35- const res = await $fetch <{ runs: OurmojiExperimentRun [] }>(
36- " /api/ourmoji/experiments" ,
37- );
38- runs .value = res .runs ;
58+ const [runsRes, partnersRes, invitesRes] = await Promise .all ([
59+ $fetch <{ runs: OurmojiExperimentRun [] }>(" /api/ourmoji/experiments" ),
60+ $fetch <{ partners: Partner [] }>(" /api/ourmoji/partners" ),
61+ $fetch <{ incoming: Invite []; outgoing: Invite [] }>(
62+ " /api/ourmoji/invites" ,
63+ ),
64+ ]);
65+ runs .value = runsRes .runs ;
66+ partners .value = partnersRes .partners ;
67+ incoming .value = invitesRes .incoming ;
68+ outgoing .value = invitesRes .outgoing ;
3969 } catch (err : unknown ) {
40- error .value = err instanceof Error ? err .message : " Failed to load runs " ;
70+ error .value = err instanceof Error ? err .message : " Failed to load data " ;
4171 } finally {
4272 loading .value = false ;
4373 }
4474}
4575
46- async function createRun () {
76+ async function sendInvite () {
4777 error .value = null ;
78+ if (! form .toUserId ) {
79+ error .value = " Pick a partner" ;
80+ return ;
81+ }
4882 try {
49- const ids = form .participantUserIds
50- .split (" ," )
51- .map ((s ) => s .trim ())
52- .filter (Boolean );
53- await $fetch (" /api/ourmoji/experiments" , {
83+ await $fetch (" /api/ourmoji/invites" , {
5484 method: " POST" ,
5585 body: {
86+ toUserId: form .toUserId ,
5687 name: form .name ,
5788 startDate: form .startDate ,
5889 endDate: form .endDate ,
59- participantUserIds: ids ,
6090 },
6191 });
92+ form .toUserId = " " ;
6293 form .name = " " ;
6394 form .startDate = " " ;
6495 form .endDate = " " ;
65- form .participantUserIds = " " ;
6696 await refresh ();
6797 } catch (err : unknown ) {
6898 error .value =
69- err instanceof Error ? err .message : " Failed to create experiment" ;
99+ err instanceof Error ? err .message : " Failed to send invite" ;
100+ }
101+ }
102+
103+ async function accept(id : string ) {
104+ error .value = null ;
105+ try {
106+ await $fetch (` /api/ourmoji/invites/${id }/accept ` , { method: " POST" });
107+ await refresh ();
108+ } catch (err : unknown ) {
109+ error .value =
110+ err instanceof Error ? err .message : " Failed to accept invite" ;
111+ }
112+ }
113+
114+ async function decline(id : string ) {
115+ error .value = null ;
116+ try {
117+ await $fetch (` /api/ourmoji/invites/${id }/decline ` , { method: " POST" });
118+ await refresh ();
119+ } catch (err : unknown ) {
120+ error .value =
121+ err instanceof Error ? err .message : " Failed to decline invite" ;
122+ }
123+ }
124+
125+ async function cancel(id : string ) {
126+ error .value = null ;
127+ try {
128+ await $fetch (` /api/ourmoji/invites/${id }/cancel ` , { method: " POST" });
129+ await refresh ();
130+ } catch (err : unknown ) {
131+ error .value =
132+ err instanceof Error ? err .message : " Failed to cancel invite" ;
70133 }
71134}
72135
@@ -87,21 +150,66 @@ onMounted(() => {
87150
88151<template >
89152 <div class =" space-y-8" >
153+ <div v-if =" error" class =" rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300" >
154+ {{ error }}
155+ </div >
156+
157+ <section v-if =" incoming.length > 0" class =" space-y-3" >
158+ <h2 class =" text-lg font-semibold" >Incoming invitations</h2 >
159+ <ul class =" divide-y divide-gray-200 dark:divide-gray-800" >
160+ <li v-for =" inv in incoming" :key =" inv.id" class =" flex flex-wrap items-center justify-between gap-3 py-3" >
161+ <div >
162+ <div class =" font-medium" >
163+ From <span class =" font-mono" >@{{ inv.fromUsername }}</span > · {{ inv.name }}
164+ </div >
165+ <div class =" text-xs text-gray-500" >
166+ {{ inv.startDate }} → {{ inv.endDate }} · status: <span class =" font-mono" >{{ inv.status }}</span >
167+ </div >
168+ </div >
169+ <div v-if =" inv.status === 'pending'" class =" flex gap-2" >
170+ <button
171+ type =" button"
172+ class =" rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700"
173+ @click =" accept(inv.id)"
174+ >
175+ Accept
176+ </button >
177+ <button
178+ type =" button"
179+ class =" rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
180+ @click =" decline(inv.id)"
181+ >
182+ Decline
183+ </button >
184+ </div >
185+ </li >
186+ </ul >
187+ </section >
188+
90189 <section class =" space-y-3" >
91- <h2 class =" text-lg font-semibold" >Create experiment run</h2 >
92- <form class =" grid gap-3 sm:grid-cols-2" @submit.prevent =" createRun" >
190+ <h2 class =" text-lg font-semibold" >Invite a partner</h2 >
191+ <p v-if =" partners.length === 0" class =" text-sm text-gray-500" >
192+ No other Ourmoji-enabled users yet. When someone else has Ourmoji
193+ enabled, you'll be able to pair with them here.
194+ </p >
195+ <form v-else class =" grid gap-3 sm:grid-cols-2" @submit.prevent =" sendInvite" >
93196 <label class =" flex flex-col text-sm" >
94- <span class =" mb-1 font-medium" >Name </span >
95- <input
96- v-model =" form.name "
197+ <span class =" mb-1 font-medium" >Partner </span >
198+ <select
199+ v-model =" form.toUserId "
97200 class =" rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2"
98201 required
99- />
202+ >
203+ <option value =" " disabled >Select a user…</option >
204+ <option v-for =" p in partners" :key =" p.id" :value =" p.id" >
205+ @{{ p.username }}
206+ </option >
207+ </select >
100208 </label >
101209 <label class =" flex flex-col text-sm" >
102- <span class =" mb-1 font-medium" >Participants (user ids, comma-separated) </span >
210+ <span class =" mb-1 font-medium" >Experiment name </span >
103211 <input
104- v-model =" form.participantUserIds "
212+ v-model =" form.name "
105213 class =" rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2"
106214 required
107215 />
@@ -129,12 +237,37 @@ onMounted(() => {
129237 type =" submit"
130238 class =" rounded-md bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700"
131239 >
132- Create run
240+ Send invitation
133241 </button >
134242 </div >
135243 </form >
136244 </section >
137245
246+ <section v-if =" outgoing.length > 0" class =" space-y-3" >
247+ <h2 class =" text-lg font-semibold" >Sent invitations</h2 >
248+ <ul class =" divide-y divide-gray-200 dark:divide-gray-800" >
249+ <li v-for =" inv in outgoing" :key =" inv.id" class =" flex flex-wrap items-center justify-between gap-3 py-3" >
250+ <div >
251+ <div class =" font-medium" >
252+ To <span class =" font-mono" >@{{ inv.toUsername }}</span > · {{ inv.name }}
253+ </div >
254+ <div class =" text-xs text-gray-500" >
255+ {{ inv.startDate }} → {{ inv.endDate }} · status: <span class =" font-mono" >{{ inv.status }}</span >
256+ </div >
257+ </div >
258+ <div v-if =" inv.status === 'pending'" class =" flex gap-2" >
259+ <button
260+ type =" button"
261+ class =" rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
262+ @click =" cancel(inv.id)"
263+ >
264+ Cancel
265+ </button >
266+ </div >
267+ </li >
268+ </ul >
269+ </section >
270+
138271 <section class =" space-y-3" >
139272 <header class =" flex items-center justify-between" >
140273 <h2 class =" text-lg font-semibold" >Runs</h2 >
@@ -147,7 +280,6 @@ onMounted(() => {
147280 </button >
148281 </header >
149282
150- <div v-if =" error" class =" text-sm text-red-600" >{{ error }}</div >
151283 <div v-if =" loading && runs.length === 0" class =" text-sm text-gray-500" >
152284 Loading…
153285 </div >
0 commit comments