Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b0859c6
docs: unify branding to tec-nova Nagasaki and correct host attribution
ut42tech May 28, 2026
a10e95a
docs: specify term-based participation count, 30-minute rule, and ven…
ut42tech May 29, 2026
f90d55d
feat: add venue-schedule module for term classification and 30-minute…
ut42tech May 29, 2026
7db3e1d
feat: extend shared schemas with participation count and participatio…
ut42tech May 29, 2026
0c762be
feat: derive participation count and per-session term in participant …
ut42tech May 29, 2026
eea5313
feat: add /api/stats/participation venue-wide aggregation endpoint
ut42tech May 29, 2026
b5a83f9
feat: surface participation count and term badges in checkin receptio…
ut42tech May 29, 2026
32aa8fa
feat: add admin participation stats page with term badges and nav entry
ut42tech May 29, 2026
7e6ac2e
Merge pull request #33 from ut42tech/feat/participation-count-terms
ut42tech May 29, 2026
136c93f
feat: consolidate term and counting logic in venue-schedule
ut42tech May 29, 2026
d3d2440
feat: extend shared schemas with session term/counted and participati…
ut42tech May 29, 2026
e2c5ac6
feat: add color-coded TermBadge and UncountedBadge components
ut42tech May 29, 2026
ac084f8
feat: derive term, counted, and participation stats in the API
ut42tech May 29, 2026
5b894ca
feat: surface term badges and participation breakdown in checkin UI
ut42tech May 29, 2026
0bda392
feat: color-code terms and accent participation stats in admin UI
ut42tech May 29, 2026
e107043
docs: document term colors, x-marked heatmap, and venue-schedule API
ut42tech May 29, 2026
d21b58f
docs: update handoff notes with latest session status and development…
ut42tech May 29, 2026
49ea36f
chore: add motion to checkin
ut42tech May 29, 2026
790aaf5
feat: add reduced-motion-aware AnimatedNumber to checkin
ut42tech May 29, 2026
efd0aed
feat: elevate reception participant profile with motion
ut42tech May 29, 2026
2001d8a
feat: update attendance tile grid logic and increase minimum tile count
ut42tech May 29, 2026
38dc867
Merge pull request #34 from ut42tech/feat/participation-breakdown-ter…
ut42tech May 29, 2026
5d18871
feat: add .vercel and .superpowers to .gitignore
ut42tech May 29, 2026
2a52358
docs: add signage + chime app design spec
ut42tech May 29, 2026
ae1e293
docs: switch signage app to mentor-whitelist auth, reuse /api/session…
ut42tech May 29, 2026
6e56748
docs: add signage + chime implementation plan
ut42tech May 29, 2026
0465973
fix: add missing tailwindCSS lint configuration to settings
ut42tech May 29, 2026
e519420
docs: add checkin redesign spec and plan
ut42tech May 29, 2026
da48a6d
feat(checkin): add shared motion primitives
ut42tech May 29, 2026
009b298
feat(checkin): add success icon and animate result card
ut42tech May 29, 2026
7ccb1a3
feat(checkin): redesign home screen with scan reticle
ut42tech May 29, 2026
08a7d1d
feat(checkin): redesign remaining checkin screens
ut42tech May 29, 2026
d83675e
feat(checkin): add scan-to-profile view transition
ut42tech May 29, 2026
1bafe8b
fix(checkin): gate manual mode-switch transition under reduced-motion
ut42tech May 29, 2026
444c0d8
Merge pull request #35 from ut42tech/feat/checkin-redesign
ut42tech May 29, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,5 @@ temp/
# =============================================================================
drizzle/.snapshot.*

.vercel
.vercel
.superpowers
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"js/ts.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"*.css": "tailwindcss"
}
},
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

## プロジェクト概要

**tecnova-platform** は長崎大学NUTICで開催される子ども向けファブリケーション活動「テクノバながさきの運営基盤プラットフォームです。
**tecnova-platform** は、長崎市と長崎大学による共同事業として開催される子ども向けファブリケーション活動 **tec-nova Nagasaki(テクノバながさき)** の運営基盤プラットフォームです。
モノレポ構成で、APIサーバ(Hono on Cloudflare Workers)と複数のフロントエンド(Next.js)を含みます。

詳細な要件・設計は以下を参照してください。**実装前に必ず読むこと**:
Expand Down
23 changes: 16 additions & 7 deletions apps/admin/src/app/(authed)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
IconUserCheck,
} from '@tabler/icons-react';
import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas';
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 @@ -29,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 @@ -51,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 @@ -91,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 @@ -160,7 +158,7 @@ function DashboardBody({
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</section>
<TableSkeleton columns={7} rows={6} />
<TableSkeleton columns={8} rows={6} />
</>
);
}
Expand Down Expand Up @@ -196,6 +194,7 @@ function DashboardBody({
<TableHead>氏名</TableHead>
<TableHead>ニックネーム</TableHead>
<TableHead>学年</TableHead>
<TableHead>ターム</TableHead>
<TableHead>チェックイン</TableHead>
<TableHead>チェックアウト</TableHead>
<TableHead>状態</TableHead>
Expand All @@ -204,7 +203,7 @@ function DashboardBody({
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<TableCell colSpan={8}>
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<IconCalendarOff className="size-8" />
<span className="text-sm">
Expand All @@ -226,6 +225,16 @@ function DashboardBody({
<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>
Expand Down
238 changes: 238 additions & 0 deletions apps/admin/src/app/(authed)/stats/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
'use client';

import {
IconCalendarOff,
IconCalendarStats,
IconChartBar,
IconClockHour12,
IconSunHigh,
IconSunset2,
} from '@tabler/icons-react';
import type { ParticipationSummaryResponse } from '@tecnova/shared/schemas';
import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card';
import { Input } from '@tecnova/ui/components/input';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@tecnova/ui/components/table';
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';

type SummaryState =
| { kind: 'loading' }
| { kind: 'ok'; data: ParticipationSummaryResponse }
| { kind: 'error'; message: string };

export default function StatsPage() {
const [summary, setSummary] = useState<SummaryState>({ kind: 'loading' });
// 入力中の値(適用ボタンを押すまで反映しない)。空文字 = フィルタなし。
const [fromInput, setFromInput] = useState('');
const [toInput, setToInput] = useState('');
// 実際に API へ送る確定済みレンジ。
const [appliedFrom, setAppliedFrom] = useState('');
const [appliedTo, setAppliedTo] = useState('');

const loadSummary = useCallback(async (from: string, to: string) => {
setSummary({ kind: 'loading' });
try {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
const query = params.toString();
const path = query ? `/api/stats/participation?${query}` : '/api/stats/participation';
const data = await apiJson<ParticipationSummaryResponse>(path);
setSummary({ kind: 'ok', data });
} catch (e) {
setSummary({ kind: 'error', message: apiErrorMessage(e) });
}
}, []);

useEffect(() => {
void loadSummary(appliedFrom, appliedTo);
}, [appliedFrom, appliedTo, loadSummary]);

const applyFilter = () => {
setAppliedFrom(fromInput);
setAppliedTo(toInput);
};

const clearFilter = () => {
setFromInput('');
setToInput('');
setAppliedFrom('');
setAppliedTo('');
};

const hasFilter = appliedFrom !== '' || appliedTo !== '';

return (
<main className="flex flex-1 flex-col gap-6 p-4 md:p-8">
<PageHeader
title="集計"
description="ターム単位の参加回数を期間で集計します"
actions={
<>
<Input
type="date"
aria-label="集計開始日"
value={fromInput}
max={toInput || undefined}
onChange={(e) => setFromInput(e.target.value)}
className="w-40"
/>
<span className="text-sm text-muted-foreground">〜</span>
<Input
type="date"
aria-label="集計終了日"
value={toInput}
min={fromInput || undefined}
onChange={(e) => setToInput(e.target.value)}
className="w-40"
/>
<Button
type="button"
size="sm"
onClick={applyFilter}
disabled={summary.kind === 'loading'}
>
適用
</Button>
{hasFilter && (
<Button type="button" variant="outline" size="sm" onClick={clearFilter}>
全期間
</Button>
)}
</>
}
/>

<StatsBody summary={summary} />
</main>
);
}

function StatsBody({ summary }: { summary: SummaryState }) {
if (summary.kind === 'loading') {
return (
<>
<section className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</section>
<TableSkeleton columns={5} rows={8} />
</>
);
}

if (summary.kind === 'error') {
return (
<Alert variant="destructive">
<AlertTitle>集計を読み込めませんでした</AlertTitle>
<AlertDescription>{summary.message}</AlertDescription>
</Alert>
);
}

const { totals, byDate } = summary.data;

return (
<>
<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}
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>

<Card className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>開催日</TableHead>
<TableHead className="text-right">朝</TableHead>
<TableHead className="text-right">昼</TableHead>
<TableHead className="text-right">夕方</TableHead>
<TableHead className="text-right">計</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byDate.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<IconCalendarOff className="size-8" />
<span className="text-sm">この期間の参加実績はありません</span>
</div>
</TableCell>
</TableRow>
) : (
byDate.map((row) => (
<TableRow key={row.date}>
<TableCell>{formatJstDate(row.date)}</TableCell>
<TableCell className="text-right tabular-nums">{row.morning}</TableCell>
<TableCell className="text-right tabular-nums">{row.afternoon}</TableCell>
<TableCell className="text-right tabular-nums">{row.evening}</TableCell>
<TableCell className="text-right font-medium tabular-nums">{row.total}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</>
);
}

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={cn('size-5 text-muted-foreground', iconClassName)} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
</CardContent>
</Card>
);
}
2 changes: 2 additions & 0 deletions apps/admin/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import {
IconChartBar,
IconChevronDown,
IconClipboardList,
IconLayoutDashboard,
Expand Down Expand Up @@ -48,6 +49,7 @@ export function AppShell({ children }: Props) {
const navItems: NavItem[] = [
{ href: '/', label: 'ダッシュボード', Icon: IconLayoutDashboard },
{ href: '/participants', label: '利用者一覧', Icon: IconUsers },
{ href: '/stats', label: '集計', Icon: IconChartBar },
...(me.mentor.role === 'admin'
? [
{
Expand Down
Loading
Loading