Skip to content

Commit d6140b4

Browse files
author
bryan
committed
Complete session-first dashboard flow
1 parent 0ea6b13 commit d6140b4

8 files changed

Lines changed: 462 additions & 38 deletions

File tree

app/[locale]/sessions/[sessionId]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export default async function SessionPage({
257257
<SessionReviewRuntime
258258
locale={locale}
259259
sessionId={params.sessionId}
260+
groupId={data.group.id}
260261
sessionTitle={data.session.name ?? data.group.name}
261262
questionGoal={questionGoal}
262263
initialQuestionIndex={currentIndex}

app/api/sessions/[sessionId]/finish-review/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,31 @@ export async function POST(request: Request, { params }: RouteContext) {
9696
);
9797
}
9898

99+
const { data: plannedNextSession } = await supabase
100+
.schema('public')
101+
.from('sessions')
102+
.select('id')
103+
.eq('group_id', session.group_id)
104+
.eq('status', 'scheduled')
105+
.neq('id', sessionId)
106+
.gte('scheduled_at', new Date().toISOString())
107+
.limit(1)
108+
.maybeSingle();
109+
perf.step('next_session_loaded');
110+
111+
if (!plannedNextSession?.id) {
112+
return NextResponse.json(
113+
{
114+
ok: false,
115+
message:
116+
locale === 'fr'
117+
? 'Planifie la prochaine session avant de terminer.'
118+
: 'Schedule the next session before finishing.',
119+
},
120+
{ status: 409 },
121+
);
122+
}
123+
99124
await supabase
100125
.schema('public')
101126
.from('sessions')

app/api/sessions/[sessionId]/review-answer/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type ReviewPayload = {
2424
nextQuestionIndex?: number;
2525
advanceAfterSave?: boolean;
2626
correctOption?: string | null;
27+
reviewDurationSeconds?: number;
2728
};
2829

2930
export async function POST(request: Request, { params }: RouteContext) {
@@ -35,6 +36,7 @@ export async function POST(request: Request, { params }: RouteContext) {
3536
const nextQuestionIndex = Number(body?.nextQuestionIndex);
3637
const advanceAfterSave = body?.advanceAfterSave === true;
3738
const correctOption = body?.correctOption?.toUpperCase() ?? '';
39+
const reviewDurationSeconds = Number(body?.reviewDurationSeconds);
3840
const perf = createPerfTracker(
3941
`saveReviewAnswerRoute:${sessionId}:${questionIndex}`,
4042
);
@@ -156,6 +158,22 @@ export async function POST(request: Request, { params }: RouteContext) {
156158
);
157159
}
158160
perf.step('review_updates_saved');
161+
if (Number.isFinite(reviewDurationSeconds) && reviewDurationSeconds > 0) {
162+
void (
163+
supabase.schema('public') as unknown as {
164+
rpc: (
165+
fn: 'activeboard_record_review_duration',
166+
args: {
167+
target_user_id: string;
168+
duration_seconds: number;
169+
},
170+
) => Promise<unknown>;
171+
}
172+
).rpc('activeboard_record_review_duration', {
173+
target_user_id: user.id,
174+
duration_seconds: Math.min(3600, Math.max(1, Math.round(reviewDurationSeconds))),
175+
});
176+
}
159177
void logAppEvent({
160178
eventName: APP_EVENTS.answerRevealed,
161179
locale,

components/dashboard/dashboard-group-zone.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,19 @@ export const DashboardGroupZone = memo(function DashboardGroupZone({
366366
return (
367367
<section className="v11-card px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
368368
<div className="sm:hidden">
369+
<MobileSessionFirstDashboardZone
370+
locale={locale}
371+
selectedGroup={selectedGroup}
372+
selectedMembers={selectedMembers}
373+
selectedActiveSession={selectedActiveSession}
374+
selectedNextSession={selectedNextSession}
375+
sessionHref={sessionHref}
376+
canStartSelectedGroup={canStartSelectedGroup}
377+
labels={labels}
378+
/>
379+
</div>
380+
381+
<div className="hidden sm:block">
369382
<div className="flex items-center justify-between gap-3">
370383
<div ref={groupMenuRef} className="relative min-w-0 flex-1">
371384
<button
@@ -1428,6 +1441,187 @@ export const DashboardGroupZone = memo(function DashboardGroupZone({
14281441
);
14291442
});
14301443

1444+
function MobileSessionFirstDashboardZone({
1445+
locale,
1446+
selectedGroup,
1447+
selectedMembers,
1448+
selectedActiveSession,
1449+
selectedNextSession,
1450+
sessionHref,
1451+
canStartSelectedGroup,
1452+
labels,
1453+
}: {
1454+
locale: string;
1455+
selectedGroup: DashboardGroupZoneGroup | null;
1456+
selectedMembers: DashboardGroupZoneGroup['membersPreview'];
1457+
selectedActiveSession: DashboardGroupZoneSession | null;
1458+
selectedNextSession: DashboardGroupZoneSession | null;
1459+
sessionHref: string | null;
1460+
canStartSelectedGroup: boolean;
1461+
labels: DashboardGroupZoneProps['labels'];
1462+
}) {
1463+
const primarySession = selectedActiveSession ?? selectedNextSession;
1464+
const secondarySession =
1465+
selectedNextSession && selectedNextSession.id !== primarySession?.id
1466+
? selectedNextSession
1467+
: (selectedGroup?.recentSessions ?? []).find(
1468+
(session) => session.id !== primarySession?.id,
1469+
);
1470+
const primaryHref = primarySession
1471+
? `/${locale}/sessions/${primarySession.id}`
1472+
: (sessionHref ?? `/${locale}/dashboard`);
1473+
1474+
if (!selectedGroup) {
1475+
return (
1476+
<div className="rounded-[16px] border border-dashed border-white/[0.08] px-4 py-5 text-sm font-semibold text-[#8fa7a2]">
1477+
{labels.noGroups}
1478+
</div>
1479+
);
1480+
}
1481+
1482+
return (
1483+
<div className="space-y-3">
1484+
<section className="rounded-[15px] border border-white/[0.045] bg-[#071a18]/75 px-4 py-3">
1485+
<div className="flex items-center justify-between">
1486+
<h2 className="text-[13px] font-semibold text-[#d7e3df]">
1487+
{locale === 'fr' ? 'Score de calibration' : 'Calibration score'}
1488+
</h2>
1489+
<a
1490+
href={`/${locale}/dashboard/progression`}
1491+
className="text-[11px] font-semibold text-[#20D9A3]"
1492+
>
1493+
{locale === 'fr' ? 'Voir plus' : 'View more'}
1494+
</a>
1495+
</div>
1496+
<div className="mt-3 grid grid-cols-2 divide-x divide-white/[0.06]">
1497+
<div className="pr-3 text-center">
1498+
<p className="text-[25px] font-semibold leading-none text-[#20D9A3]">
1499+
{primarySession?.accuracyPercent ?? 0}%
1500+
</p>
1501+
<p className="mt-1 text-[11px] text-[#8fa7a2]">
1502+
{labels.accuracy}
1503+
</p>
1504+
</div>
1505+
<div className="pl-3 text-center">
1506+
<p className="text-[25px] font-semibold leading-none text-[#9FF0CE]">
1507+
{primarySession?.completionPercent ?? 0}%
1508+
</p>
1509+
<p className="mt-1 text-[11px] text-[#8fa7a2]">
1510+
{labels.completion}
1511+
</p>
1512+
</div>
1513+
</div>
1514+
</section>
1515+
1516+
<section className="relative rounded-[18px] border border-white/[0.045] bg-[#0b2a25] px-4 pb-5 pt-4 shadow-[0_18px_60px_rgba(0,0,0,0.2)]">
1517+
<h2 className="truncate text-[15px] font-semibold leading-none text-[#d7e3df]">
1518+
{selectedGroup.name}
1519+
</h2>
1520+
1521+
<div className="mt-3 space-y-2.5 pr-[58px]">
1522+
<a
1523+
href={primaryHref}
1524+
className="grid min-h-[58px] grid-cols-[96px_minmax(0,1fr)_44px] items-center gap-2 rounded-[13px] border border-white/[0.055] bg-white/[0.018] px-2 py-2"
1525+
>
1526+
<span className="flex min-w-0 items-center">
1527+
<CompactAvatarStack members={selectedMembers?.slice(0, 3)} />
1528+
</span>
1529+
<span className="min-w-0">
1530+
<span
1531+
className={`block truncate text-[12px] font-semibold ${
1532+
selectedActiveSession ? 'text-[#20D9A3]' : 'text-[#8fa7a2]'
1533+
}`}
1534+
>
1535+
{selectedActiveSession ? labels.live : labels.nextSession}
1536+
</span>
1537+
<span className="block truncate text-[13px] font-semibold text-[#e8f4f0]">
1538+
{primarySession?.name ?? labels.noUpcomingSession}
1539+
</span>
1540+
<span className="block truncate text-[10px] text-[#8fa7a2]">
1541+
{primarySession
1542+
? getCompactSessionMeta(primarySession)
1543+
: `${selectedGroup.memberCount} ${labels.members}`}
1544+
</span>
1545+
</span>
1546+
<span className="flex flex-col items-center justify-center">
1547+
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#20D9A3] text-[#062b22]">
1548+
<Play className="h-4 w-4 fill-current" aria-hidden="true" />
1549+
</span>
1550+
<span className="mt-0.5 text-[10px] font-semibold text-[#20D9A3]">
1551+
{selectedActiveSession ? getCompactJoinLabel(labels.joinLiveSession) : 'Start'}
1552+
</span>
1553+
</span>
1554+
</a>
1555+
1556+
<button
1557+
type="button"
1558+
disabled={!canStartSelectedGroup}
1559+
onClick={() => {
1560+
if (!canStartSelectedGroup) return;
1561+
window.dispatchEvent(
1562+
new CustomEvent('activeboard:open-create-session', {
1563+
detail: { groupId: selectedGroup.id },
1564+
}),
1565+
);
1566+
}}
1567+
className="grid min-h-[58px] w-full grid-cols-[96px_minmax(0,1fr)_68px] items-center gap-2 rounded-[13px] border border-white/[0.035] bg-white/[0.012] px-2 py-2 text-left transition disabled:opacity-70"
1568+
>
1569+
<span className="flex items-center gap-1">
1570+
<PlaceholderAvatar />
1571+
<PlaceholderAvatar />
1572+
<PlaceholderAvatar />
1573+
</span>
1574+
<span className="min-w-0">
1575+
<span className="block truncate text-[13px] font-semibold text-[#e8f4f0]">
1576+
{secondarySession?.name ?? labels.scheduleSession}
1577+
</span>
1578+
<span className="block truncate text-[10px] text-[#8fa7a2]">
1579+
{secondarySession
1580+
? `${secondarySession.question_goal}Q · ${secondarySession.timer_seconds}sec`
1581+
: `20Q · ${labels.timerLabel.replace('{seconds}', '90')}`}
1582+
</span>
1583+
</span>
1584+
<span className="truncate text-right text-[10px] font-semibold text-[#8fa7a2]">
1585+
{secondarySession
1586+
? formatSessionDate(secondarySession.scheduled_at, locale)
1587+
: labels.scheduledFor.replace('{date}', '')}
1588+
</span>
1589+
</button>
1590+
</div>
1591+
1592+
<button
1593+
type="button"
1594+
disabled={!canStartSelectedGroup}
1595+
onClick={() => {
1596+
if (!canStartSelectedGroup) return;
1597+
window.dispatchEvent(
1598+
new CustomEvent('activeboard:open-create-session', {
1599+
detail: { groupId: selectedGroup.id },
1600+
}),
1601+
);
1602+
}}
1603+
className={`absolute bottom-4 right-4 flex h-[52px] w-[52px] items-center justify-center rounded-full border text-[#9FF0CE] shadow-[0_18px_44px_rgba(0,0,0,0.35)] transition ${
1604+
canStartSelectedGroup
1605+
? 'border-[#20D9A3]/30 bg-[#0d3a34] hover:bg-[#114940]'
1606+
: 'cursor-not-allowed border-white/[0.06] bg-white/[0.04] text-[#5f7b75]'
1607+
}`}
1608+
aria-label={labels.startSession}
1609+
>
1610+
<Plus className="h-7 w-7" aria-hidden="true" />
1611+
</button>
1612+
</section>
1613+
</div>
1614+
);
1615+
}
1616+
1617+
function PlaceholderAvatar() {
1618+
return (
1619+
<span className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-[#0e2c28] bg-white/[0.045] text-[#8fa7a2]">
1620+
<UsersRound className="h-3.5 w-3.5" aria-hidden="true" />
1621+
</span>
1622+
);
1623+
}
1624+
14311625
function MobileSessionList({
14321626
locale,
14331627
labels,

0 commit comments

Comments
 (0)