diff --git a/.gitignore b/.gitignore index 2604579..1c91b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -227,4 +227,5 @@ temp/ # ============================================================================= drizzle/.snapshot.* -.vercel \ No newline at end of file +.vercel +.superpowers \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d35e09e..2d31296 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "js/ts.preferences.importModuleSpecifier": "non-relative", "files.associations": { "*.css": "tailwindcss" - } + }, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore" } diff --git a/CLAUDE.md b/CLAUDE.md index 91179b7..1c19992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## プロジェクト概要 -**tecnova-platform** は長崎大学NUTICで開催される子ども向けファブリケーション活動「テクノバながさき」の運営基盤プラットフォームです。 +**tecnova-platform** は、長崎市と長崎大学による共同事業として開催される子ども向けファブリケーション活動 **tec-nova Nagasaki(テクノバながさき)** の運営基盤プラットフォームです。 モノレポ構成で、APIサーバ(Hono on Cloudflare Workers)と複数のフロントエンド(Next.js)を含みます。 詳細な要件・設計は以下を参照してください。**実装前に必ず読むこと**: diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index f770906..a2d5475 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -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'; @@ -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'; @@ -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({ kind: 'loading' }); const [events, setEvents] = useState([]); @@ -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); @@ -160,7 +158,7 @@ function DashboardBody({ - + ); } @@ -196,6 +194,7 @@ function DashboardBody({ 氏名 ニックネーム 学年 + ターム チェックイン チェックアウト 状態 @@ -204,7 +203,7 @@ function DashboardBody({ {rows.length === 0 ? ( - +
@@ -226,6 +225,16 @@ function DashboardBody({ {s.fullName} {s.nickname} {s.grade} + + {s.term ? ( +
+ + {!s.counted && } +
+ ) : ( + + )} +
{fmtTime(s.checkedInAt)} {s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'} diff --git a/apps/admin/src/app/(authed)/stats/page.tsx b/apps/admin/src/app/(authed)/stats/page.tsx new file mode 100644 index 0000000..0cc75f8 --- /dev/null +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -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({ 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(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 ( +
+ + setFromInput(e.target.value)} + className="w-40" + /> + + setToInput(e.target.value)} + className="w-40" + /> + + {hasFilter && ( + + )} + + } + /> + + +
+ ); +} + +function StatsBody({ summary }: { summary: SummaryState }) { + if (summary.kind === 'loading') { + return ( + <> +
+ + + + + +
+ + + ); + } + + if (summary.kind === 'error') { + return ( + + 集計を読み込めませんでした + {summary.message} + + ); + } + + const { totals, byDate } = summary.data; + + return ( + <> +
+ + + + + +
+ + + + + + 開催日 + + + 夕方 + + + + + {byDate.length === 0 ? ( + + +
+ + この期間の参加実績はありません +
+
+
+ ) : ( + byDate.map((row) => ( + + {formatJstDate(row.date)} + {row.morning} + {row.afternoon} + {row.evening} + {row.total} + + )) + )} +
+
+
+ + ); +} + +function SummaryCard({ + label, + value, + Icon, + iconClassName, +}: { + label: string; + value: number; + Icon: typeof IconChartBar; + iconClassName?: string; +}) { + return ( + + + {label} + + + +
{value}
+
+
+ ); +} diff --git a/apps/admin/src/components/app-shell.tsx b/apps/admin/src/components/app-shell.tsx index 7be7dbf..e62a598 100644 --- a/apps/admin/src/components/app-shell.tsx +++ b/apps/admin/src/components/app-shell.tsx @@ -1,6 +1,7 @@ 'use client'; import { + IconChartBar, IconChevronDown, IconClipboardList, IconLayoutDashboard, @@ -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' ? [ { diff --git a/apps/admin/src/components/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index fed2657..f353289 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -19,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'; @@ -143,8 +144,13 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) {
ID発行日
{formatJstDate(participant.activatedAt)}
-
累計来場
-
{stats.visitCount} 回
+
参加回数
+
+ {stats.participationCount} 回 + + (累計来場 {stats.visitCount} 回) + +
直近の来場
{fmtDateTime(stats.lastVisitedAt)}
累計滞在
@@ -178,7 +184,16 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) { {sessions.map((s) => ( - {fmtHistoryDateTime(s.checkedInAt)} + + {fmtHistoryDateTime(s.checkedInAt)} + {s.term && ( + + )} + {fmtHistoryDateTime(s.checkedOutAt)} diff --git a/apps/api/src/lib/admin.ts b/apps/api/src/lib/admin.ts index b4c368d..fd6932f 100644 --- a/apps/api/src/lib/admin.ts +++ b/apps/api/src/lib/admin.ts @@ -7,10 +7,18 @@ import type { MentorsListResponse, ParticipantsListQuery, ParticipantsListResponse, + ParticipationSummaryQuery, + ParticipationSummaryResponse, TodaySessionsResponse, UpdateMentorRequest, } from '@tecnova/shared/schemas'; -import { and, asc, count, desc, eq, like, or, type SQL } from 'drizzle-orm'; +import { + 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'; type Db = DrizzleD1Database; @@ -29,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)」として解決する。 @@ -66,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 { @@ -103,6 +116,72 @@ export const fetchEventsList = async (db: Db, limit = 50): Promise ({ morning: 0, afternoon: 0, evening: 0, total: 0 }); + +const incrementBuckets = (buckets: TermBuckets, term: TermId): void => { + buckets[term] += 1; + buckets.total += 1; +}; + +// 会場全体の参加回数集計(ターム別・日別)。from/to は events.date('YYYY-MM-DD' JST)で絞る。 +// 「カウント対象」の判定(ターム内 かつ ターム終了の30分以上前)は SQL で表現できないため、 +// 候補セッションを取得して JS で集計する(会場のデータ量は小規模 = 最大でも数千行)。 +export const fetchParticipationSummary = async ( + db: Db, + query: ParticipationSummaryQuery, +): Promise => { + // events.date は TEXT 'YYYY-MM-DD'。ISO 日付は辞書順比較で日付順と一致するため gte/lte で範囲指定できる。 + const conditions: SQL[] = []; + if (query.from) conditions.push(gte(events.date, query.from)); + if (query.to) conditions.push(lte(events.date, query.to)); + const where = conditions.length > 0 ? and(...conditions) : undefined; + + // active フィルタは掛けない(全セッションを数える = 管理画面のセッション一覧と同じ方針)。 + const rows = await db + .select({ + participantId: sessions.participantId, + eventDate: events.date, + checkedInAt: sessions.checkedInAt, + }) + .from(sessions) + .innerJoin(events, eq(sessions.eventId, events.id)) + .where(where); + + // 同一参加者の「日付 + ターム」は1回だけ数える。dedup キー(`date#term#participantId`)で + // 重複を弾きつつ、その場で日別・全体バケットへ加算する(キー文字列を再パースしない)。 + const seen = new Set(); + const byDateMap = new Map(); + const totals = emptyBuckets(); + 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(row.eventDate, buckets); + } + incrementBuckets(buckets, term); + incrementBuckets(totals, term); + } + + const byDate = [...byDateMap.entries()] + .map(([date, buckets]) => ({ date, ...buckets })) + .sort((a, b) => b.date.localeCompare(a.date)); + + return { + range: { from: query.from ?? null, to: query.to ?? null }, + totals: { ...totals, days: byDate.length }, + byDate, + }; +}; + export const fetchParticipantsList = async ( db: Db, query: ParticipantsListQuery, diff --git a/apps/api/src/lib/checkin.ts b/apps/api/src/lib/checkin.ts index 81fbabc..d1e925b 100644 --- a/apps/api/src/lib/checkin.ts +++ b/apps/api/src/lib/checkin.ts @@ -2,6 +2,12 @@ import type * as schema from '@tecnova/db'; import { events, participants, sessions } from '@tecnova/db'; import { fetchSheetRows, updateSheetRow } from '@tecnova/shared/google-sheets'; import type { ParticipantSearchItem, TodaySessionsResponse } from '@tecnova/shared/schemas'; +import { + classifyVisit, + participationKey, + type TermId, + toJstDateString, +} from '@tecnova/shared/venue-schedule'; import { and, asc, desc, eq, inArray, isNull, like, or } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; @@ -77,13 +83,7 @@ const generateNextParticipantId = async (db: Db): Promise => { return `${yearPrefix}${String(nextNum).padStart(3, '0')}`; }; -const todayJST = (): string => - new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Tokyo', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); +const todayJST = (): string => toJstDateString(new Date()); const getOrCreateTodayEvent = async (db: Db): Promise => { const today = todayJST(); @@ -372,6 +372,9 @@ export interface ParticipantProfile { participant: ProfileParticipant; stats: { visitCount: number; + participationCount: number; + visitDayCount: number; + uncountedVisitCount: number; lastVisitedAt: Date | null; totalStayDurationMinutes: number; }; @@ -386,6 +389,8 @@ export interface ParticipantProfile { checkedOutAt: Date | null; stayDurationMinutes: number | null; isPresent: boolean; + term: TermId | null; + counted: boolean; }>; } @@ -399,8 +404,10 @@ export const fetchParticipantProfile = async ( id: sessions.id, checkedInAt: sessions.checkedInAt, checkedOutAt: sessions.checkedOutAt, + eventDate: events.date, }) .from(sessions) + .innerJoin(events, eq(sessions.eventId, events.id)) .where(eq(sessions.participantId, participantId)) .orderBy(desc(sessions.checkedInAt)); @@ -413,17 +420,31 @@ export const fetchParticipantProfile = async ( ? await findActiveSessionToday(db, participantId, todayEvent.id) : null; const now = new Date(); + // 1 パスで履歴整形と集計を同時に行う。term/counted は classifyVisit で + // 一度だけ判定する(旧実装は map と参加回数ループで二重に classify していた)。 + // - participationKeys: 「同一イベント日 × 同一区分」で重複排除した実参加コマ数 + // - visitDays: 重複排除した来場開催日数 + // - uncountedVisitCount: 30分ルール等でカウント対象外になったセッション数 + const participationKeys = new Set(); + const visitDays = new Set(); + let uncountedVisitCount = 0; const sessionsHistory = sessionRows.map((session) => { const end = session.checkedOutAt ?? (session.id === openToday?.id ? now : null); const stayDurationMinutes = end ? Math.max(0, Math.floor((end.getTime() - session.checkedInAt.getTime()) / 60_000)) : null; + const { term, counted } = classifyVisit(session.checkedInAt); + visitDays.add(session.eventDate); + if (!counted) uncountedVisitCount += 1; + if (counted && term !== null) participationKeys.add(participationKey(session.eventDate, term)); return { sessionId: session.id, checkedInAt: session.checkedInAt, checkedOutAt: session.checkedOutAt, stayDurationMinutes, isPresent: session.id === openToday?.id, + term, + counted, }; }); const totalStayDurationMinutes = sessionsHistory.reduce( @@ -435,6 +456,9 @@ export const fetchParticipantProfile = async ( participant, stats: { visitCount: sessionRows.length, + participationCount: participationKeys.size, + visitDayCount: visitDays.size, + uncountedVisitCount, lastVisitedAt: sessionRows[0]?.checkedInAt ?? null, totalStayDurationMinutes, }, @@ -505,16 +529,22 @@ export const fetchReceptionHistoryToday = async (db: Db): Promise ({ - 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((item) => item.isPresent).length; return { diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index e302788..706a04c 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,6 +1,7 @@ import { createMentorRequestSchema, participantsListQuerySchema, + participationSummaryQuerySchema, sessionsByDateQuerySchema, updateMentorRequestSchema, } from '@tecnova/shared/schemas'; @@ -10,6 +11,7 @@ import { fetchEventsList, fetchMentorsList, fetchParticipantsList, + fetchParticipationSummary, fetchSessionsForEvent, fetchTodaySessions, updateMentor, @@ -47,6 +49,16 @@ adminRoute.get('/sessions', async (c) => { // 過去開催日のセレクタ用(最新 50 件)。 adminRoute.get('/events', async (c) => c.json(await fetchEventsList(createDb(c.env)))); +// 会場全体の参加回数集計(ターム別・日別)。from/to で期間を絞れる(いずれも JST・含む)。 +// counted 判定は SQL で表現できないため lib 側で JS 集計する。 +adminRoute.get('/stats/participation', async (c) => { + const parsed = participationSummaryQuerySchema.safeParse(c.req.query()); + if (!parsed.success) { + return c.json(invalidQueryError, 400); + } + return c.json(await fetchParticipationSummary(createDb(c.env), parsed.data)); +}); + // 利用者一覧。ページネーション + ID / 氏名 / ニックネーム検索 + 学年 / 有効状態フィルタ。 adminRoute.get('/participants', async (c) => { const parsed = participantsListQuerySchema.safeParse({ diff --git a/apps/api/src/routes/checkin.ts b/apps/api/src/routes/checkin.ts index e0c5121..515287f 100644 --- a/apps/api/src/routes/checkin.ts +++ b/apps/api/src/routes/checkin.ts @@ -198,6 +198,9 @@ checkinRoute.get('/participants/:participantId', async (c) => { }, stats: { visitCount: profile.stats.visitCount, + participationCount: profile.stats.participationCount, + visitDayCount: profile.stats.visitDayCount, + uncountedVisitCount: profile.stats.uncountedVisitCount, lastVisitedAt: profile.stats.lastVisitedAt ? profile.stats.lastVisitedAt.toISOString() : null, totalStayDurationMinutes: profile.stats.totalStayDurationMinutes, }, @@ -212,6 +215,8 @@ checkinRoute.get('/participants/:participantId', async (c) => { checkedOutAt: session.checkedOutAt ? session.checkedOutAt.toISOString() : null, stayDurationMinutes: session.stayDurationMinutes, isPresent: session.isPresent, + term: session.term, + counted: session.counted, })), }); }); diff --git a/apps/checkin/next.config.ts b/apps/checkin/next.config.ts index 8c125ca..e8be3c8 100644 --- a/apps/checkin/next.config.ts +++ b/apps/checkin/next.config.ts @@ -3,6 +3,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // モノレポ内 workspace パッケージを Next の transpile 対象にする transpilePackages: ['@tecnova/shared', '@tecnova/ui'], + experimental: { + viewTransition: true, + }, }; export default nextConfig; diff --git a/apps/checkin/package.json b/apps/checkin/package.json index 391c001..1b61061 100644 --- a/apps/checkin/package.json +++ b/apps/checkin/package.json @@ -14,6 +14,7 @@ "@tecnova/ui": "workspace:*", "@zxing/browser": "^0.2.0", "better-auth": "^1.6.9", + "motion": "^12.40.0", "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/apps/checkin/src/app/first-time/page.tsx b/apps/checkin/src/app/first-time/page.tsx index 508a985..6354c30 100644 --- a/apps/checkin/src/app/first-time/page.tsx +++ b/apps/checkin/src/app/first-time/page.tsx @@ -29,10 +29,15 @@ import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; import { formatJapaneseDate } from '@/lib/format'; +import { listItemTransition } from '@/lib/motion'; type State = | { kind: 'loading' } @@ -73,6 +78,7 @@ function ParticipantDetails({ item }: { item: PreRegisteredParticipant }) { } function RegistrationSteps() { + const prefersReduced = useReducedMotion(); const steps = [ { number: '1', @@ -93,8 +99,14 @@ function RegistrationSteps() { return (
    - {steps.map((step) => ( -
  1. + {steps.map((step, index) => ( + {step.number} @@ -104,13 +116,14 @@ function RegistrationSteps() { {step.description} -
  2. + ))}
); } export default function FirstTimePage() { + const prefersReduced = useReducedMotion(); const [state, setState] = useState({ kind: 'loading' }); const [query, setQuery] = useState(''); @@ -151,7 +164,7 @@ export default function FirstTimePage() { if (state.kind === 'loading') { return ( -
+
@@ -170,7 +183,7 @@ export default function FirstTimePage() {
-
+ ); } @@ -201,158 +214,170 @@ export default function FirstTimePage() { } return ( -
+
- - } title="初回登録" tone="emerald" /> - -
- - IDカードをまだ持っていない人を、事前登録の一覧から選んでください。 - - - 未登録 {state.items.length}人 - -
+ + + } + title="初回登録" + tone="emerald" + /> + +
+ + IDカードをまだ持っていない人を、事前登録の一覧から選んでください。 + + + 未登録 + 人 + +
- + - {state.items.length === 0 ? ( - - - ) : ( - <> -
-
-
-
- {query ? ( + {state.items.length === 0 ? ( + + + ) : ( + <> +
+
+
+
+ {query ? ( + + ) : null} - ) : null} - +
-
- {filteredItems.length === 0 ? ( - - - ) : ( -
    - {filteredItems.map((item) => ( -
  • - - - - - - - - - {item.nickname}さんで進みますか - - 次に参加ガイドラインを確認します。最後に同意するとIDが発行されます。 - - - - - キャンセル - - - - -
  • - ))} -
- )} - - )} - - + + + + + + {item.nickname}さんで進みますか + + 次に参加ガイドラインを確認します。最後に同意するとIDが発行されます。 + + + + + キャンセル + + + + + + ))} + + )} + + )} + + +
-
+ ); } diff --git a/apps/checkin/src/app/guideline/page.tsx b/apps/checkin/src/app/guideline/page.tsx index ab5bf62..95557dd 100644 --- a/apps/checkin/src/app/guideline/page.tsx +++ b/apps/checkin/src/app/guideline/page.tsx @@ -42,12 +42,15 @@ import { Skeleton } from '@tecnova/ui/components/skeleton'; import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { type ReactNode, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; import { ResultSummaryCard } from '@/components/result-summary-card'; import { formatJapaneseDate, formatJapaneseDateTime } from '@/lib/format'; +import { popAnimate, popInitial, popTransition } from '@/lib/motion'; type State = | { kind: 'loading' } @@ -376,7 +379,7 @@ function ParticipantStatusChip({ item }: { item: PreRegisteredParticipant }) { function LoadingScreen() { return ( -
+
@@ -389,7 +392,7 @@ function LoadingScreen() {
-
+ ); } @@ -449,7 +452,7 @@ function ErrorScreen({ function ActivatingScreen({ item }: { item: PreRegisteredParticipant }) { return ( -
+ } @@ -463,7 +466,7 @@ function ActivatingScreen({ item }: { item: PreRegisteredParticipant }) {

-
+ ); } @@ -474,6 +477,7 @@ function GuidelineSlideView({ total, isLast, agreed, + direction, onAgreeChange, onPrev, onNext, @@ -485,6 +489,7 @@ function GuidelineSlideView({ total: number; isLast: boolean; agreed: boolean; + direction: number; onAgreeChange: (checked: boolean) => void; onPrev: () => void; onNext: () => void; @@ -492,9 +497,21 @@ function GuidelineSlideView({ }) { const tone = guidelineToneClasses[slide.tone]; const progress = `${(current / total) * 100}%`; + const prefersReduced = useReducedMotion(); + const offset = 40; + + // 矢印キーでスライドを前後に移動できるようにする(iPad の外付けキーボード運用を想定)。 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight' && !isLast) onNext(); + if (e.key === 'ArrowLeft' && current > 1) onPrev(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [current, isLast, onNext, onPrev]); return ( -
+
-
-
-
- -

{slide.visual}

-
+
+ +

{slide.visual}

+
-
-

{slide.group}

-

- {slide.title} -

-

- {slide.rule} -

-

- {slide.explanation} -

-
-
+
+

{slide.group}

+

+ {slide.title} +

+

+ {slide.rule} +

+

+ {slide.explanation} +

+
+ + +
{isLast ? (
@@ -620,7 +659,7 @@ function GuidelineSlideView({
- + ); } @@ -630,6 +669,16 @@ function GuidelinePageContent() { const [state, setState] = useState({ kind: 'loading' }); const [slideIndex, setSlideIndex] = useState(0); const [agreed, setAgreed] = useState(false); + const [direction, setDirection] = useState(1); + + const goPrev = () => { + setDirection(-1); + setSlideIndex((index) => Math.max(0, index - 1)); + }; + const goNext = () => { + setDirection(1); + setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1)); + }; const loadTarget = useCallback(async () => { if (!preRegistrationId) { @@ -719,22 +768,12 @@ function GuidelinePageContent() { tone="emerald" icon={} rows={[ - { - label: 'ID', - value: state.data.participantId, - valueClassName: 'tabular-nums', - }, + { label: 'ID', value: state.data.participantId, valueClassName: 'tabular-nums' }, { label: '氏名', value: state.data.fullName }, { label: 'ニックネーム', value: state.data.nickname }, { label: '学年', value: state.data.grade }, - { - label: '初回チェックイン', - value: formatJapaneseDateTime(state.data.checkedInAt), - }, - { - label: '事前登録日', - value: formatJapaneseDate(state.registeredAt), - }, + { label: '初回チェックイン', value: formatJapaneseDateTime(state.data.checkedInAt) }, + { label: '事前登録日', value: formatJapaneseDate(state.registeredAt) }, ]} note="表示されたIDでカードを作ってください" footer={ @@ -763,9 +802,10 @@ function GuidelinePageContent() { total={GUIDELINE_SLIDES.length} isLast={slideIndex === GUIDELINE_SLIDES.length - 1} agreed={agreed} + direction={direction} onAgreeChange={setAgreed} - onPrev={() => setSlideIndex((index) => Math.max(0, index - 1))} - onNext={() => setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1))} + onPrev={goPrev} + onNext={goNext} onSubmit={() => void activate()} /> ); diff --git a/apps/checkin/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx index 17969d5..94f5ba3 100644 --- a/apps/checkin/src/app/history/page.tsx +++ b/apps/checkin/src/app/history/page.tsx @@ -41,16 +41,24 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; +import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; +import { LiveDot } from '@/components/live-dot'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; +import { StatTile } from '@/components/stat-tile'; import { formatDuration, formatJapaneseDate, formatJapaneseDateTime, formatJapaneseDateTimeWithYear, } from '@/lib/format'; +import { listItemTransition } from '@/lib/motion'; import { participantProfilePath } from '@/lib/participant-id'; const fetchTodayHistory = async (): Promise => { @@ -82,7 +90,7 @@ const getSessionStayDurationMinutes = (session: TodaySessionItem, nowMs: number) function LoadingScreen() { return ( -
+
@@ -96,7 +104,7 @@ function LoadingScreen() {
-
+ ); } @@ -176,6 +184,7 @@ function CheckoutDialog({ } export default function HistoryPage() { + const prefersReduced = useReducedMotion(); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -301,220 +310,255 @@ export default function HistoryPage() { const eventLabel = data?.event ? formatJapaneseDate(data.event.date) : '今日'; return ( -
+
- - } - title="受付りれき" - tone="sky" - /> - -
-
- - {eventLabel}の受付履歴と参加者の状態を確認できます。 - -
-
- - void checkoutParticipants(presentIds)} - /> + + + } + title="受付りれき" + tone="sky" + /> + +
+
+ + {eventLabel}の受付履歴と参加者の状態を確認できます。 + 「滞在中全員をチェックアウト」は12:00や各タームの終わりに締めるときに使います。 + +
+
+ + void checkoutParticipants(presentIds)} + /> +
-
-
-
-

今日の受付

-

- {summary.totalCheckedIn} - -

-
-
-

滞在中

-

- {summary.currentlyPresent} - -

-
-
-

退室済み

-

- {summary.checkedOut} - -

+
+ + + + + } + /> + + 0} /> + 滞在中 + + } + value={ + <> + + + + } + /> + + + + + } + />
-
- {lastResult && ( - - - )} + {lastResult && ( + + + )} - {error && ( - - - )} + {error && ( + + + )} -
- setQuery(e.target.value)} - placeholder="ID・ニックネーム・氏名・学年で検索" - className="h-14 rounded-lg bg-white px-5 text-lg" - /> -
- - void checkoutParticipants(selectedPresentIds)} +
+ setQuery(e.target.value)} + placeholder="ID・ニックネーム・氏名・学年で検索" + className="h-14 rounded-lg bg-white px-5 text-lg" /> +
+ + void checkoutParticipants(selectedPresentIds)} + /> +
-
- - + + + - - - {filteredSessions.length === 0 ? ( -
-
-
- ) : ( -
- - - - - toggleFilteredPresent(checked === true)} - className="size-6 rounded-md" - /> - - 参加者 - 状態 - 受付時刻 - 滞在時間 - 操作 - - - - {filteredSessions.map((session) => { - const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); - return ( - - - {session.isPresent ? ( - - toggleParticipant(session.participantId, checked === true) - } - className="size-6 rounded-md" - /> - ) : null} - - -
- - {session.nickname} - - - {session.fullName} - - - ID {session.participantId} - / - {session.grade} - -
-
- - - {session.isPresent ? '滞在中' : '退室済み'} - - - - {formatJapaneseDateTimeWithYear(session.checkedInAt)} - - - {session.isPresent - ? `${formatDuration(stayDurationMinutes)} 経過` - : formatDuration(stayDurationMinutes)} - - - - -
- ); - })} -
-
-
- )} - - + ) : ( +
+ + + + + toggleFilteredPresent(checked === true)} + className="size-6 rounded-md" + /> + + 参加者 + 状態 + 受付時刻 + 滞在時間 + 操作 + + + + {filteredSessions.map((session, index) => { + const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); + return ( + + + {session.isPresent ? ( + + toggleParticipant(session.participantId, checked === true) + } + className="size-6 rounded-md" + /> + ) : null} + + +
+ + {session.nickname} + + + {session.fullName} + + + ID {session.participantId} + / + {session.grade} + +
+
+ +
+ + {session.isPresent ? '滞在中' : '退室済み'} + + {session.term ? ( + + ) : null} + {!session.counted && } +
+
+ + {formatJapaneseDateTimeWithYear(session.checkedInAt)} + + + {session.isPresent + ? `${formatDuration(stayDurationMinutes)} 経過` + : formatDuration(stayDurationMinutes)} + + + + +
+ ); + })} +
+
+
+ )} + + +
-
+ ); } diff --git a/apps/checkin/src/app/layout.tsx b/apps/checkin/src/app/layout.tsx index 4e729d0..678a33f 100644 --- a/apps/checkin/src/app/layout.tsx +++ b/apps/checkin/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next'; import { LINE_Seed_JP } from 'next/font/google'; import '@tecnova/ui/globals.css'; +import './view-transitions.css'; import { cn } from '@tecnova/ui/lib/utils'; import { AppShell } from '@/components/app-shell'; diff --git a/apps/checkin/src/app/login/page.tsx b/apps/checkin/src/app/login/page.tsx index 9365ce5..829577a 100644 --- a/apps/checkin/src/app/login/page.tsx +++ b/apps/checkin/src/app/login/page.tsx @@ -3,20 +3,19 @@ import { IconBrandGoogleFilled } from '@tabler/icons-react'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Button } from '@tecnova/ui/components/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@tecnova/ui/components/card'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import { motion, useReducedMotion } from 'motion/react'; +import Image from 'next/image'; import { useState } from 'react'; +import { PageShell } from '@/components/page-shell'; +import { Reveal } from '@/components/reveal'; import { authClient } from '@/lib/auth-client'; +import { tapScale } from '@/lib/motion'; export default function LoginPage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const prefersReduced = useReducedMotion(); const signIn = async () => { setBusy(true); @@ -39,27 +38,54 @@ export default function LoginPage() { }; return ( -
- - - うけつけシステム - 許可リストに登録されたメンターのみ利用できます - - {error && ( - - - ログインエラー - {error} - - - )} - - - - -
+ + + + +
+ TECNOVA +
+ ようこそ +

+ うけつけシステムにサインインしてください +

+
+
+
+ {error && ( + + + ログインエラー + {error} + + + )} + + + + +

+ 許可リストに登録されたメンターのみ利用できます +

+
+
+
+
); } diff --git a/apps/checkin/src/app/manual/page.tsx b/apps/checkin/src/app/manual/page.tsx index 06781a9..b78c74e 100644 --- a/apps/checkin/src/app/manual/page.tsx +++ b/apps/checkin/src/app/manual/page.tsx @@ -3,7 +3,6 @@ import { IconAlertCircle, IconArrowRight, - IconBug, IconChevronRight, IconKeyboard, IconSearch, @@ -18,73 +17,45 @@ import { Input } from '@tecnova/ui/components/input'; import { Skeleton } from '@tecnova/ui/components/skeleton'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { useRouter } from 'next/navigation'; -import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader } from '@/components/panel-header'; +import { SegmentedControl } from '@/components/segmented-control'; +import { listItemTransition } from '@/lib/motion'; import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id'; type Mode = 'id' | 'name'; export default function ManualPage() { const [mode, setMode] = useState('id'); - + const prefersReduced = useReducedMotion(); return ( -
+
- - {mode === 'id' ? : } + }, + { value: 'name', label: '名前で探す', icon: }, + ]} + /> + + + {mode === 'id' ? : } + +
-
- ); -} - -function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (mode: Mode) => void }) { - return ( -
- onChange('id')} - icon={} - label="IDで入力" - /> - onChange('name')} - icon={} - label="名前で探す" - /> -
- ); -} - -function ToggleButton({ - active, - onClick, - icon, - label, -}: { - active: boolean; - onClick: () => void; - icon: ReactNode; - label: string; -}) { - return ( - + ); } @@ -103,7 +74,11 @@ function IdEntryPanel() { return (
- } title="マニュアル入力" tone="slate" /> + } + title="マニュアル入力" + tone="slate" + /> 参加者IDがわかる場合は、5桁の数字を入力してください。 @@ -242,6 +217,7 @@ function SearchResults({ navigatingId: string | null; onSelect: (id: string) => void; }) { + const prefersReduced = useReducedMotion(); if (state.kind === 'idle') { return (

@@ -286,15 +262,20 @@ function SearchResults({ {state.results.length}件の候補(タップして開く)

    - {state.results.map((participant) => ( -
  • + {state.results.map((participant, index) => ( + onSelect(participant.id)} /> -
  • + ))}
diff --git a/apps/checkin/src/app/page.tsx b/apps/checkin/src/app/page.tsx index f080874..0407721 100644 --- a/apps/checkin/src/app/page.tsx +++ b/apps/checkin/src/app/page.tsx @@ -2,10 +2,11 @@ import { IconArrowRight, - IconBug, IconCamera, IconCameraRotate, + IconCircleCheck, IconClipboardCheck, + IconKeyboard, IconQrcode, IconRefresh, IconUserPlus, @@ -13,13 +14,18 @@ import { import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardDescription } from '@tecnova/ui/components/card'; import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useRef, useState, ViewTransition } from 'react'; +import { PageShell } from '@/components/page-shell'; import { PanelHeader, type PanelTone } from '@/components/panel-header'; +import { Reveal } from '@/components/reveal'; +import { ScanReticle } from '@/components/scan-reticle'; import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id'; function ActionPanel({ + index, title, description, icon, @@ -28,6 +34,7 @@ function ActionPanel({ action, buttonVariant = 'default', }: { + index: number; title: string; description: string; icon: ReactNode; @@ -37,26 +44,29 @@ function ActionPanel({ buttonVariant?: 'default' | 'outline'; }) { return ( - - - - - {description} - -
- - - + + + + + + {description} + +
+ + + + ); } export default function Home() { const router = useRouter(); + const prefersReduced = useReducedMotion(); const [cameraError, setCameraError] = useState(null); const [scannerAttempt, setScannerAttempt] = useState(0); const [videoDevices, setVideoDevices] = useState([]); @@ -65,6 +75,7 @@ export default function Home() { const videoRef = useRef(null); const controlsRef = useRef(null); const navigatingRef = useRef(false); + const navTimerRef = useRef(null); const refreshVideoDevices = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) return []; @@ -98,7 +109,9 @@ export default function Home() { navigatingRef.current = true; controlsRef.current?.stop(); setNavigatingId(value); - router.push(participantProfilePath(value)); + navTimerRef.current = window.setTimeout(() => { + router.push(participantProfilePath(value)); + }, 450); }) .then((controls) => { if (cancelled) { @@ -125,6 +138,16 @@ export default function Home() { }; }, [navigatingId, refreshVideoDevices, router, scannerAttempt, selectedDeviceId]); + // ナビゲーション遅延タイマーはアンマウント時のみ掃除する。 + // スキャナ effect の cleanup(navigatingId 変更で毎回走る)で消すと遷移がキャンセルされるため分離する。 + useEffect(() => { + return () => { + if (navTimerRef.current) { + window.clearTimeout(navTimerRef.current); + } + }; + }, []); + const switchCamera = async () => { const devices = videoDevices.length > 0 ? videoDevices : await refreshVideoDevices(); if (devices.length <= 1) { @@ -140,63 +163,88 @@ export default function Home() { }; return ( -
+
- - } - title="QRコードをかざしてね" - tone="sky" - /> - -
-
-
- - -
-
-
+ + + } + title="QRコードをかざしてね" + tone="sky" + /> + +
+
+
+ + +
+
+
+
} @@ -204,8 +252,8 @@ export default function Home() { href="/first-time" action="初回登録へ" /> - } @@ -213,11 +261,11 @@ export default function Home() { href="/history" action="履歴を見る" /> - } + icon={} tone="slate" href="/manual" action="入力へ" @@ -226,6 +274,6 @@ export default function Home() {
-
+ ); } diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index 82fbd6c..9682c81 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -3,14 +3,23 @@ import { IconAlertCircle, IconArrowBack, + IconAward, + IconCalendarEvent, + IconCalendarPlus, IconCalendarStats, + IconCircleX, + IconClock, + IconDoorEnter, IconHistory, IconHome, + IconHourglass, IconLogin2, IconLogout2, IconUser, + IconX, } from '@tabler/icons-react'; import type { ParticipantProfileResponse, ScanResponse } 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 { Button } from '@tecnova/ui/components/button'; @@ -24,10 +33,13 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; +import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client'; +import { motion, useReducedMotion } from 'motion/react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, ViewTransition } from 'react'; +import { AnimatedNumber } from '@/components/animated-number'; import { PanelHeader } from '@/components/panel-header'; import { ResultSummaryCard } from '@/components/result-summary-card'; import { @@ -72,7 +84,13 @@ const formatHistoryDuration = (minutes: number | null, isPresent: boolean): stri return isPresent ? `${formatDuration(minutes)} 経過` : formatDuration(minutes); }; -const MIN_ATTENDANCE_TILE_COUNT = 35; +// グリッドは 7 列。最低 6 行(42 タイル)ぶんのマスを敷いて草の下限とする。 +// 実際の表示高さはカードを flex で伸ばして横の参加者カードに揃え、あふれた分はタイル内を縦スクロールさせる。 +const MIN_ATTENDANCE_TILE_COUNT = 42; + +// ヒートマップの pop-in スタッガーの遅延上限(秒)。来場数が多くても演出が間延びしないよう頭打ちにする。 +const TILE_STAGGER_MAX_DELAY = 0.5; +const TILE_STAGGER_STEP = 0.012; const attendanceDateFormatter = new Intl.DateTimeFormat('ja-JP', { timeZone: 'Asia/Tokyo', @@ -81,13 +99,6 @@ const attendanceDateFormatter = new Intl.DateTimeFormat('ja-JP', { weekday: 'short', }); -const attendanceDateKeyFormatter = new Intl.DateTimeFormat('ja-JP', { - timeZone: 'Asia/Tokyo', - year: 'numeric', - month: '2-digit', - day: '2-digit', -}); - const attendanceIntensityClasses = [ 'border-slate-200 bg-slate-100', 'border-emerald-200 bg-emerald-100', @@ -96,29 +107,22 @@ const attendanceIntensityClasses = [ 'border-emerald-700 bg-emerald-700', ] as const; -type AttendanceTile = { +// 来場回数ヒートマップは「1 来場 = 1 タイル」。色の濃さはその来場単体の滞在時間で決まる。 +type AttendanceTileSlot = { key: string; label: string; stayDurationMinutes: number; - firstCheckedInAt: string; + checkedInAt: string; isPresent: boolean; -}; - -type AttendanceTileSlot = AttendanceTile & { + termLabel: string | null; + counted: boolean; intensity: number; }; -const formatAttendanceDateKey = (value: string): string => { - const parts = attendanceDateKeyFormatter.formatToParts(new Date(value)); - const year = parts.find((part) => part.type === 'year')?.value ?? '0000'; - const month = parts.find((part) => part.type === 'month')?.value ?? '00'; - const day = parts.find((part) => part.type === 'day')?.value ?? '00'; - return `${year}-${month}-${day}`; -}; - +// その来場の滞在時間(分)を 4 段階に量子化する。3 時間(180分)で最濃。 const getAttendanceIntensity = (minutes: number): number => { - if (minutes >= 360) return 4; - if (minutes >= 180) return 3; + if (minutes >= 180) return 4; + if (minutes >= 120) return 3; if (minutes >= 60) return 2; return 1; }; @@ -126,56 +130,27 @@ const getAttendanceIntensity = (minutes: number): number => { const buildAttendanceTiles = ( sessions: ParticipantProfileResponse['sessions'], ): AttendanceTileSlot[] => { - const visitsByDate = new Map(); - - for (const session of sessions) { - const key = formatAttendanceDateKey(session.checkedInAt); - const checkedInAtMs = new Date(session.checkedInAt).getTime(); - const stayDurationMinutes = session.stayDurationMinutes ?? 0; - const existing = visitsByDate.get(key); - - if (!existing) { - visitsByDate.set(key, { - key, + // profile.sessions は新しい順で届くため、タイルは古い順に並べ直す。 + return [...sessions] + .sort((a, b) => new Date(a.checkedInAt).getTime() - new Date(b.checkedInAt).getTime()) + .map((session) => { + const stayDurationMinutes = session.stayDurationMinutes ?? 0; + return { + key: session.sessionId, label: attendanceDateFormatter.format(new Date(session.checkedInAt)), stayDurationMinutes, - firstCheckedInAt: session.checkedInAt, + checkedInAt: session.checkedInAt, isPresent: session.isPresent, - }); - continue; - } - - const existingCheckedInAtMs = new Date(existing.firstCheckedInAt).getTime(); - visitsByDate.set(key, { - ...existing, - stayDurationMinutes: existing.stayDurationMinutes + stayDurationMinutes, - firstCheckedInAt: - checkedInAtMs < existingCheckedInAtMs ? session.checkedInAt : existing.firstCheckedInAt, - isPresent: existing.isPresent || session.isPresent, + termLabel: session.term ? TERM_LABELS[session.term] : null, + counted: session.counted, + intensity: getAttendanceIntensity(stayDurationMinutes), + }; }); - } - - return Array.from(visitsByDate.values()) - .sort((a, b) => new Date(a.firstCheckedInAt).getTime() - new Date(b.firstCheckedInAt).getTime()) - .map((visit) => ({ - ...visit, - intensity: getAttendanceIntensity(visit.stayDurationMinutes), - })); -}; - -const buildAttendanceTileSlots = ( - visits: AttendanceTileSlot[], -): Array => { - const tileCount = Math.max( - MIN_ATTENDANCE_TILE_COUNT, - Math.ceil(Math.max(visits.length, 1) / 7) * 7, - ); - return Array.from({ length: tileCount }, (_, index) => visits[index] ?? null); }; function LoadingScreen() { return ( -
+
@@ -223,6 +198,7 @@ export default function ReceptionParticipantPage() { const params = useParams<{ id: string }>(); const participantId = String(params.id ?? ''); const [state, setState] = useState({ kind: 'loading' }); + const prefersReduced = useReducedMotion(); const loadProfile = useCallback(async () => { if (!PARTICIPANT_ID_PATTERN.test(participantId)) { @@ -253,20 +229,41 @@ export default function ReceptionParticipantPage() { { label: '登録日', value: formatJapaneseDateFromIso(profile.participant.activatedAt), + Icon: IconCalendarPlus, }, { label: '最後に来た日', value: profile.stats.lastVisitedAt ? formatJapaneseDateTimeWithYear(profile.stats.lastVisitedAt) : 'まだありません', + Icon: IconClock, + }, + { + label: '累計滞在時間', + value: formatDuration(profile.stats.totalStayDurationMinutes), + Icon: IconHourglass, }, + ]; + }, [profile]); + + // 参加状況タイル内に並べる内訳。参加回数(スキルカードの押印数)を主役にする。 + const participationBreakdown = useMemo(() => { + if (!profile) return []; + return [ { - label: '来場回数', + label: '総来場回数', value: `${profile.stats.visitCount}回`, + Icon: IconDoorEnter, }, { - label: '累計滞在時間', - value: formatDuration(profile.stats.totalStayDurationMinutes), + label: '来場日数', + value: `${profile.stats.visitDayCount}日`, + Icon: IconCalendarEvent, + }, + { + label: '無効な来場回数', + value: `${profile.stats.uncountedVisitCount}回`, + Icon: IconCircleX, }, ]; }, [profile]); @@ -276,10 +273,43 @@ export default function ReceptionParticipantPage() { return buildAttendanceTiles(profile.sessions); }, [profile]); - const attendanceTileSlots = useMemo( - () => buildAttendanceTileSlots(attendanceTiles), - [attendanceTiles], - ); + const [fillRows, setFillRows] = useState(null); + + // lg 以上ではヒートマップカードを横の参加者カードに合わせて伸ばす。空いた高さに収まる行数を実測して + // その行数ぶんだけ草を敷き詰め、収まらない来場はタイル内を縦スクロールさせる。lg 未満は実測せず静的な下限を使う。 + // React 19 のクリーンアップ付き ref コールバックで、ul の出現/消滅に合わせて監視を着脱する。 + const measureTileGrid = useCallback((node: HTMLUListElement | null) => { + if (!node || typeof ResizeObserver === 'undefined') return; + const mediaQuery = window.matchMedia('(min-width: 1024px)'); + const TILE_GAP = 8; // gap-2 = 0.5rem + const measure = () => { + if (!mediaQuery.matches) { + setFillRows(null); + return; + } + const { clientWidth, clientHeight } = node; + if (clientWidth === 0 || clientHeight === 0) return; + const tileSize = (clientWidth - TILE_GAP * 6) / 7; // 7 列・正方形タイル + const rows = Math.max(1, Math.floor((clientHeight + TILE_GAP) / (tileSize + TILE_GAP))); + setFillRows(rows); + }; + const observer = new ResizeObserver(measure); + observer.observe(node); + mediaQuery.addEventListener('change', measure); + measure(); + return () => { + observer.disconnect(); + mediaQuery.removeEventListener('change', measure); + }; + }, []); + + // 表示するマス数。実際の来場行数を下限に、lg は実測行数・それ以外は静的な下限行数まで草で埋める。 + const attendanceTileSlots = useMemo(() => { + const visitRows = Math.ceil(Math.max(attendanceTiles.length, 1) / 7); + const baseRows = fillRows ?? Math.ceil(MIN_ATTENDANCE_TILE_COUNT / 7); + const tileCount = Math.max(visitRows, baseRows) * 7; + return Array.from({ length: tileCount }, (_, index) => attendanceTiles[index] ?? null); + }, [attendanceTiles, fillRows]); const submitAttendance = async () => { if (!profile) return; @@ -340,215 +370,362 @@ export default function ReceptionParticipantPage() { if (!profile) return null; return ( -
+
- - -
-
-
-
-
- - {profile.participant.nickname} - -

- {profile.participant.fullName} -

-
- - ID {profile.participant.id} - - - {profile.participant.grade} - + + + + +
+
+
+
+
+ + {profile.participant.nickname} + +

+ {profile.participant.fullName} +

+
+ + ID {profile.participant.id} + + + {profile.participant.grade} + +
+
+
+
+ {profile.current.isPresent ? ( +
-
- - {profile.current.isPresent ? 'チェックイン中' : '未チェックイン'} - -
- -
-
-

今日の入室

-

- {profile.current.checkedInAt - ? formatJapaneseDateTime(profile.current.checkedInAt) - : 'まだありません'} -

-
-
- -
- {stats.map((item) => ( -
-

{item.label}

-

- {item.value} + +

+

+

+

+ {profile.current.checkedInAt + ? formatJapaneseDateTime(profile.current.checkedInAt) + : 'まだありません'}

- ))} -
- - + +
+
+

+

+

+ + +

+
+ {participationBreakdown.map((item) => ( +
+
+
+
+ {item.value} +
+
+ ))} +
+
+ {stats.map((item) => ( +
+

+

+

+ {item.value} +

+
+ ))} +
+ + + +
- - : - } - title="受付操作" - tone={isCheckIn ? 'emerald' : 'amber'} - /> - -

- {isCheckIn ? '今日はまだチェックインしていません' : 'いまチェックイン中です'} -

- -
-
- - - } - title="来場日数" - tone="emerald" - /> - -
-

- {attendanceTiles.length} - + title="受付操作" + tone={isCheckIn ? 'emerald' : 'amber'} + /> + +

+ {isCheckIn ? '今日はまだチェックインしていません' : 'いまチェックイン中です'}

-
- - {[1, 2, 3, 4].map((intensity) => ( -
-
    - {attendanceTileSlots.map((tile, index) => { - const label = tile - ? `${tile.label} ${formatDuration(tile.stayDurationMinutes)}${ - tile.isPresent ? ' 経過' : '' - }` - : '未記録'; - return ( -
  • - ); - })} -
- - +
    + {attendanceTileSlots.map((tile, index) => { + const tileEntrance = prefersReduced ? undefined : { opacity: 0, scale: 0.6 }; + const tileTransition = { + duration: 0.25, + ease: 'easeOut' as const, + delay: Math.min(index * TILE_STAGGER_STEP, TILE_STAGGER_MAX_DELAY), + }; + + // 空きスロット(パディング)はニュートラルな空タイル。 + if (!tile) { + return ( + + ); + } + + const baseLabel = `${tile.label}${ + tile.termLabel ? ` ${tile.termLabel}` : '' + } ${formatDuration(tile.stayDurationMinutes)}${tile.isPresent ? ' 経過' : ''}`; + + // カウント対象外の来場は色を付けず、× アイコンで「来たが無効」を示す。 + if (!tile.counted) { + const label = `${baseLabel}・カウント対象外`; + return ( + + + ); + } + + // カウント対象の来場は滞在時間の濃淡で色付け。 + return ( + + ); + })} +
+ + +
- - } title="入退場履歴" tone="slate" /> - - {profile.sessions.length === 0 ? ( -
- 履歴はまだありません -
- ) : ( -
- - - - 入室 - 退室 - 滞在時間 - 状態 - - - - {profile.sessions.map((session) => ( - - - {formatJapaneseDateTimeWithYear(session.checkedInAt)} - - - {session.checkedOutAt - ? formatJapaneseDateTimeWithYear(session.checkedOutAt) - : '未退室'} - - - {formatHistoryDuration(session.stayDurationMinutes, session.isPresent)} - - - - {session.isPresent ? '滞在中' : '退室済み'} - - + + + } + title="入退場履歴" + tone="slate" + /> + + {profile.sessions.length === 0 ? ( +
+ 履歴はまだありません +
+ ) : ( +
+
+ + + 入室 + 退室 + 滞在時間 + 状態 - ))} - -
-
- )} -
-
+ + + {profile.sessions.map((session) => ( + + +
+ {formatJapaneseDateTimeWithYear(session.checkedInAt)} + {session.term ? ( + + ) : ( + + )} +
+
+ + {session.checkedOutAt + ? formatJapaneseDateTimeWithYear(session.checkedOutAt) + : '未退室'} + + + {formatHistoryDuration(session.stayDurationMinutes, session.isPresent)} + + +
+ + {session.isPresent ? '滞在中' : '退室済み'} + + {!session.counted && } +
+
+
+ ))} +
+ +
+ )} + + +
); diff --git a/apps/checkin/src/app/settings/page.tsx b/apps/checkin/src/app/settings/page.tsx index 2509159..791b10c 100644 --- a/apps/checkin/src/app/settings/page.tsx +++ b/apps/checkin/src/app/settings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { IconLogout2, IconSettings } from '@tabler/icons-react'; +import { IconLogout2 } from '@tabler/icons-react'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { AlertDialog, @@ -28,6 +28,8 @@ import { } from '@tecnova/ui/components/table'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { PageShell } from '@/components/page-shell'; +import { Reveal } from '@/components/reveal'; import { authClient } from '@/lib/auth-client'; export default function SettingsPage() { @@ -59,89 +61,99 @@ export default function SettingsPage() { ]; return ( -
+
- - -
-
-
- - - {error && ( - - ログアウトエラー - {error} - - )} -
- - - - 項目 - 内容 - - - - {rows.map((row) => ( - - - {row.label} - - - {row.value} - + + + {error && ( + + ログアウトエラー + {error} + + )} +
+
+ + + 項目 + 内容 - ))} - -
-
-
- - - - - - - - - ログアウトしますか - - 受付システムを使うには、もう一度 Google ログインが必要です。 - - - - - キャンセル - - + + {rows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + + +
+
+ + +
-
- + + ログアウト + + + + + + + ログアウトしますか + + 受付システムを使うには、もう一度 Google ログインが必要です。 + + + + + キャンセル + + void signOut()} + > + {isSigningOut ? 'ログアウト中' : 'ログアウトする'} + + + + +
+ +
+
-
+ ); } diff --git a/apps/checkin/src/app/view-transitions.css b/apps/checkin/src/app/view-transitions.css new file mode 100644 index 0000000..63bebd3 --- /dev/null +++ b/apps/checkin/src/app/view-transitions.css @@ -0,0 +1,22 @@ +/* View Transitions(experimental.viewTransition)の調整。 + ルート間の既定クロスフェードと、スキャン→プロフィールの共有要素モーフの時間を整える。 */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.28s; +} + +/* スキャン→プロフィールの共有要素モーフ(name="participant-portal") */ +::view-transition-group(participant-portal) { + animation-duration: 0.4s; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +/* アクセシビリティ: モーション低減時はビュー遷移アニメーションを無効化する */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + /* biome-ignore lint/complexity/noImportantStyles: モーション低減時のアクセシビリティ上書き。上の named morph 規則(participant-portal)に確実に勝つため !important が必要。 */ + animation-duration: 0s !important; + } +} diff --git a/apps/checkin/src/components/animated-number.tsx b/apps/checkin/src/components/animated-number.tsx new file mode 100644 index 0000000..3604512 --- /dev/null +++ b/apps/checkin/src/components/animated-number.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react'; +import { useEffect } from 'react'; + +type AnimatedNumberProps = { + value: number; + className?: string; + // カウントアップの長さ(ms)。reduced-motion 時は無視して即値を出す。 + durationMs?: number; +}; + +// 0 → value をカウントアップ表示する。prefers-reduced-motion を尊重し、その時はアニメーションせず即値を出す。 +// 値は整数想定(参加回数・来場回数)。桁揃えは呼び出し側で tabular-nums を付ける。 +export function AnimatedNumber({ value, className, durationMs = 700 }: AnimatedNumberProps) { + const prefersReduced = useReducedMotion(); + // reduced-motion なら最初から value で初期化し、0 からの一瞬のちらつきも避ける。 + const motionValue = useMotionValue(prefersReduced ? value : 0); + const text = useTransform(motionValue, (latest) => String(Math.round(latest))); + + useEffect(() => { + if (prefersReduced) { + motionValue.set(value); + return; + } + const controls = animate(motionValue, value, { + duration: durationMs / 1000, + ease: 'easeOut', + }); + return () => controls.stop(); + }, [value, durationMs, prefersReduced, motionValue]); + + return {text}; +} diff --git a/apps/checkin/src/components/live-dot.tsx b/apps/checkin/src/components/live-dot.tsx new file mode 100644 index 0000000..ff10452 --- /dev/null +++ b/apps/checkin/src/components/live-dot.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { motion, useReducedMotion } from 'motion/react'; + +// 滞在中=emerald の脈動、未滞在=slate の静止。プロフィール画面の在席ドットと同じ演出。 +export function LiveDot({ active, className }: { active: boolean; className?: string }) { + const prefersReduced = useReducedMotion(); + + if (!active) { + return ( +