Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 32 additions & 36 deletions apps/admin/src/app/(authed)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
IconUserCheck,
} from '@tabler/icons-react';
import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas';
import { classifyTerm, TERM_LABELS } from '@tecnova/shared/venue-schedule';
import { toJstDateString } from '@tecnova/shared/venue-schedule';
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Badge } from '@tecnova/ui/components/badge';
import { Button } from '@tecnova/ui/components/button';
Expand All @@ -30,6 +30,7 @@ import {
TableRow,
} from '@tecnova/ui/components/table';
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/page-header';
Expand All @@ -52,10 +53,6 @@ const fmtTime = (iso: string): string =>
minute: '2-digit',
}).format(new Date(iso));

// JST の YYYY-MM-DD を返す(events.date と同形)。
const todayInJst = (): string =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date());

export default function DashboardPage() {
const [sessions, setSessions] = useState<SessionsState>({ kind: 'loading' });
const [events, setEvents] = useState<EventsListResponse['events']>([]);
Expand Down Expand Up @@ -92,7 +89,7 @@ export default function DashboardPage() {
void loadSessions(selectedDate);
}, [selectedDate, loadSessions]);

const today = todayInJst();
const today = toJstDateString(new Date());
// 「本日」ラベル + イベントとして登録済みの過去日を結合する。
// 今日の event が events に含まれていてもメニューの重複は避ける。
const pastEvents = events.filter((e) => e.date !== today);
Expand Down Expand Up @@ -218,36 +215,35 @@ function DashboardBody({
</TableCell>
</TableRow>
) : (
rows.map((s) => {
// セッションは term を持たないので、チェックイン時刻から JST 壁時計で導出する。
const term = classifyTerm(new Date(s.checkedInAt));
return (
<TableRow
key={s.sessionId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelectParticipant(s.participantId)}
>
<TableCell className="font-mono">{s.participantId}</TableCell>
<TableCell>{s.fullName}</TableCell>
<TableCell>{s.nickname}</TableCell>
<TableCell>{s.grade}</TableCell>
<TableCell>
{term ? (
<Badge variant="secondary">{TERM_LABELS[term]}</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{fmtTime(s.checkedInAt)}</TableCell>
<TableCell>{s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'}</TableCell>
<TableCell>
<Badge variant={s.isPresent ? 'default' : 'secondary'}>
{s.isPresent ? '来場中' : '退出済'}
</Badge>
</TableCell>
</TableRow>
);
})
rows.map((s) => (
<TableRow
key={s.sessionId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelectParticipant(s.participantId)}
>
<TableCell className="font-mono">{s.participantId}</TableCell>
<TableCell>{s.fullName}</TableCell>
<TableCell>{s.nickname}</TableCell>
<TableCell>{s.grade}</TableCell>
<TableCell>
{s.term ? (
<div className="flex flex-wrap items-center gap-1">
<TermBadge term={s.term} counted={s.counted} />
{!s.counted && <UncountedBadge />}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{fmtTime(s.checkedInAt)}</TableCell>
<TableCell>{s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'}</TableCell>
<TableCell>
<Badge variant={s.isPresent ? 'default' : 'secondary'}>
{s.isPresent ? '来場中' : '退出済'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
Expand Down
26 changes: 22 additions & 4 deletions apps/admin/src/app/(authed)/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { TableSkeleton } from '@tecnova/ui/components/table-skeleton';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
import { formatJstDate } from '@tecnova/ui/lib/format';
import { cn } from '@tecnova/ui/lib/utils';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/page-header';

Expand Down Expand Up @@ -152,9 +153,24 @@ function StatsBody({ summary }: { summary: SummaryState }) {
<>
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<SummaryCard label="総参加回数" value={totals.total} Icon={IconChartBar} />
<SummaryCard label="朝" value={totals.morning} Icon={IconSunHigh} />
<SummaryCard label="昼" value={totals.afternoon} Icon={IconClockHour12} />
<SummaryCard label="夕方" value={totals.evening} Icon={IconSunset2} />
<SummaryCard
label="朝"
value={totals.morning}
Icon={IconSunHigh}
iconClassName="text-sky-600"
/>
<SummaryCard
label="昼"
value={totals.afternoon}
Icon={IconClockHour12}
iconClassName="text-amber-600"
/>
<SummaryCard
label="夕方"
value={totals.evening}
Icon={IconSunset2}
iconClassName="text-violet-600"
/>
<SummaryCard label="開催日数" value={totals.days} Icon={IconCalendarStats} />
</section>

Expand Down Expand Up @@ -201,16 +217,18 @@ function SummaryCard({
label,
value,
Icon,
iconClassName,
}: {
label: string;
value: number;
Icon: typeof IconChartBar;
iconClassName?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="size-5 text-muted-foreground" />
<Icon className={cn('size-5 text-muted-foreground', iconClassName)} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
Expand Down
11 changes: 5 additions & 6 deletions apps/admin/src/components/participant-detail-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

import type { ParticipantProfileResponse } from '@tecnova/shared/schemas';
import { TERM_LABELS } from '@tecnova/shared/venue-schedule';
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Badge } from '@tecnova/ui/components/badge';
import {
Expand All @@ -20,6 +19,7 @@ import {
TableHeader,
TableRow,
} from '@tecnova/ui/components/table';
import { TermBadge } from '@tecnova/ui/components/term-badge';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
import { formatJstDate } from '@tecnova/ui/lib/format';
import { useEffect, useState } from 'react';
Expand Down Expand Up @@ -187,12 +187,11 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) {
<span className="inline-flex flex-wrap items-center gap-1">
{fmtHistoryDateTime(s.checkedInAt)}
{s.term && (
<Badge
variant={s.counted ? 'secondary' : 'outline'}
<TermBadge
term={s.term}
counted={s.counted}
className="px-1 py-0 text-[10px] leading-tight"
>
{TERM_LABELS[s.term]}
</Badge>
/>
)}
</span>
</TableCell>
Expand Down
59 changes: 32 additions & 27 deletions apps/api/src/lib/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import type {
UpdateMentorRequest,
} from '@tecnova/shared/schemas';
import {
classifyTerm,
countsTowardParticipation,
classifyVisit,
participationKey,
type TermId,
toJstDateString,
} from '@tecnova/shared/venue-schedule';
import { and, asc, count, desc, eq, gte, like, lte, or, type SQL } from 'drizzle-orm';
import type { DrizzleD1Database } from 'drizzle-orm/d1';
Expand All @@ -36,8 +37,7 @@ export class MentorError extends Error {

// JST 基準で「今日」の日付文字列 'YYYY-MM-DD' を返す。
// events.date は JST の開催日として保存しているため、ここも JST で判定する。
const todayInJst = (): string =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date());
const todayInJst = (): string => toJstDateString(new Date());

// 指定日(YYYY-MM-DD, JST)の event とそのセッション一覧を返す。
// date が null の場合は「今日(JST)」として解決する。
Expand Down Expand Up @@ -73,16 +73,22 @@ export const fetchSessionsForEvent = async (
.where(eq(sessions.eventId, event.id))
.orderBy(desc(sessions.checkedInAt));

const items = rows.map((r) => ({
sessionId: r.sessionId,
participantId: r.participantId,
fullName: r.fullName,
nickname: r.nickname,
grade: r.grade,
checkedInAt: r.checkedInAt.toISOString(),
checkedOutAt: r.checkedOutAt ? r.checkedOutAt.toISOString() : null,
isPresent: r.checkedOutAt === null,
}));
const items = rows.map((r) => {
// ターム判定・30分ルールは venue-schedule に集約。フロントへは確定値だけ渡す。
const { term, counted } = classifyVisit(r.checkedInAt);
return {
sessionId: r.sessionId,
participantId: r.participantId,
fullName: r.fullName,
nickname: r.nickname,
grade: r.grade,
checkedInAt: r.checkedInAt.toISOString(),
checkedOutAt: r.checkedOutAt ? r.checkedOutAt.toISOString() : null,
isPresent: r.checkedOutAt === null,
term,
counted,
};
});

const currentlyPresent = items.filter((i) => i.isPresent).length;
return {
Expand Down Expand Up @@ -144,23 +150,22 @@ export const fetchParticipationSummary = async (
.innerJoin(events, eq(sessions.eventId, events.id))
.where(where);

// 同一参加者は「日付 + ターム」ごとに1回だけ数える。`date#term#participantId` で重複排除。
const countedKeys = new Set<string>();
for (const row of rows) {
const term = classifyTerm(row.checkedInAt);
if (term === null || !countsTowardParticipation(row.checkedInAt)) continue;
countedKeys.add(`${row.eventDate}#${term}#${row.participantId}`);
}

// 重複排除済みのキーから日別・全体を集計する。
// 同一参加者の「日付 + ターム」は1回だけ数える。dedup キー(`date#term#participantId`)で
// 重複を弾きつつ、その場で日別・全体バケットへ加算する(キー文字列を再パースしない)。
const seen = new Set<string>();
const byDateMap = new Map<string, TermBuckets>();
const totals = emptyBuckets();
for (const key of countedKeys) {
const [date, term] = key.split('#') as [string, TermId, string];
let buckets = byDateMap.get(date);
for (const row of rows) {
const { term, counted } = classifyVisit(row.checkedInAt);
if (term === null || !counted) continue;
const dedupKey = `${participationKey(row.eventDate, term)}#${row.participantId}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);

let buckets = byDateMap.get(row.eventDate);
if (!buckets) {
buckets = emptyBuckets();
byDateMap.set(date, buckets);
byDateMap.set(row.eventDate, buckets);
}
incrementBuckets(buckets, term);
incrementBuckets(totals, term);
Expand Down
Loading
Loading