diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index f770906..98b28c9 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 { classifyTerm, 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'; @@ -160,7 +161,7 @@ function DashboardBody({ - + ); } @@ -196,6 +197,7 @@ function DashboardBody({ 氏名 ニックネーム 学年 + ターム チェックイン チェックアウト 状態 @@ -204,7 +206,7 @@ function DashboardBody({ {rows.length === 0 ? ( - +
@@ -216,25 +218,36 @@ function DashboardBody({ ) : ( - rows.map((s) => ( - onSelectParticipant(s.participantId)} - > - {s.participantId} - {s.fullName} - {s.nickname} - {s.grade} - {fmtTime(s.checkedInAt)} - {s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'} - - - {s.isPresent ? '来場中' : '退出済'} - - - - )) + rows.map((s) => { + // セッションは term を持たないので、チェックイン時刻から JST 壁時計で導出する。 + const term = classifyTerm(new Date(s.checkedInAt)); + return ( + onSelectParticipant(s.participantId)} + > + {s.participantId} + {s.fullName} + {s.nickname} + {s.grade} + + {term ? ( + {TERM_LABELS[term]} + ) : ( + + )} + + {fmtTime(s.checkedInAt)} + {s.checkedOutAt ? fmtTime(s.checkedOutAt) : '—'} + + + {s.isPresent ? '来場中' : '退出済'} + + + + ); + }) )} 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..4511de7 --- /dev/null +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -0,0 +1,220 @@ +'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 { 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, +}: { + label: string; + value: number; + Icon: typeof IconChartBar; +}) { + 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..dc0ac77 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -1,6 +1,7 @@ '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 { @@ -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,17 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) { {sessions.map((s) => ( - {fmtHistoryDateTime(s.checkedInAt)} + + {fmtHistoryDateTime(s.checkedInAt)} + {s.term && ( + + {TERM_LABELS[s.term]} + + )} + {fmtHistoryDateTime(s.checkedOutAt)} diff --git a/apps/api/src/lib/admin.ts b/apps/api/src/lib/admin.ts index b4c368d..3b20a55 100644 --- a/apps/api/src/lib/admin.ts +++ b/apps/api/src/lib/admin.ts @@ -7,10 +7,17 @@ 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 { + classifyTerm, + countsTowardParticipation, + type TermId, +} 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; @@ -103,6 +110,73 @@ 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回だけ数える。`date#term#participantId` で重複排除。 + const countedKeys = new Set(); + for (const row of rows) { + const term = classifyTerm(row.checkedInAt); + if (term === null || !countsTowardParticipation(row.checkedInAt)) continue; + countedKeys.add(`${row.eventDate}#${term}#${row.participantId}`); + } + + // 重複排除済みのキーから日別・全体を集計する。 + const byDateMap = new Map(); + const totals = emptyBuckets(); + for (const key of countedKeys) { + const [date, term] = key.split('#') as [string, TermId, string]; + let buckets = byDateMap.get(date); + if (!buckets) { + buckets = emptyBuckets(); + byDateMap.set(date, 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..e3cc16e 100644 --- a/apps/api/src/lib/checkin.ts +++ b/apps/api/src/lib/checkin.ts @@ -2,6 +2,11 @@ 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 { + classifyTerm, + countsTowardParticipation, + type TermId, +} from '@tecnova/shared/venue-schedule'; import { and, asc, desc, eq, inArray, isNull, like, or } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; @@ -372,6 +377,7 @@ export interface ParticipantProfile { participant: ProfileParticipant; stats: { visitCount: number; + participationCount: number; lastVisitedAt: Date | null; totalStayDurationMinutes: number; }; @@ -386,6 +392,8 @@ export interface ParticipantProfile { checkedOutAt: Date | null; stayDurationMinutes: number | null; isPresent: boolean; + term: TermId | null; + counted: boolean; }>; } @@ -399,8 +407,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)); @@ -418,12 +428,16 @@ export const fetchParticipantProfile = async ( const stayDurationMinutes = end ? Math.max(0, Math.floor((end.getTime() - session.checkedInAt.getTime()) / 60_000)) : null; + const term = classifyTerm(session.checkedInAt); + const counted = countsTowardParticipation(session.checkedInAt); return { sessionId: session.id, checkedInAt: session.checkedInAt, checkedOutAt: session.checkedOutAt, stayDurationMinutes, isPresent: session.id === openToday?.id, + term, + counted, }; }); const totalStayDurationMinutes = sessionsHistory.reduce( @@ -431,10 +445,21 @@ export const fetchParticipantProfile = async ( 0, ); + // 参加回数は「同一イベント日 × 同一区分」で重複排除した実参加コマ数。 + // チェックイン時刻が区分内かつ終了30分前までのセッションだけをカウントする。 + const participationKeys = new Set(); + for (const session of sessionRows) { + const term = classifyTerm(session.checkedInAt); + if (countsTowardParticipation(session.checkedInAt) && term !== null) { + participationKeys.add(`${session.eventDate}#${term}`); + } + } + return { participant, stats: { visitCount: sessionRows.length, + participationCount: participationKeys.size, lastVisitedAt: sessionRows[0]?.checkedInAt ?? null, totalStayDurationMinutes, }, 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..90d619a 100644 --- a/apps/api/src/routes/checkin.ts +++ b/apps/api/src/routes/checkin.ts @@ -198,6 +198,7 @@ checkinRoute.get('/participants/:participantId', async (c) => { }, stats: { visitCount: profile.stats.visitCount, + participationCount: profile.stats.participationCount, lastVisitedAt: profile.stats.lastVisitedAt ? profile.stats.lastVisitedAt.toISOString() : null, totalStayDurationMinutes: profile.stats.totalStayDurationMinutes, }, @@ -212,6 +213,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/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx index 17969d5..00dcc00 100644 --- a/apps/checkin/src/app/history/page.tsx +++ b/apps/checkin/src/app/history/page.tsx @@ -14,6 +14,7 @@ import type { TodaySessionItem, TodaySessionsResponse, } from '@tecnova/shared/schemas'; +import { classifyTerm, TERM_LABELS } from '@tecnova/shared/venue-schedule'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { AlertDialog, @@ -314,6 +315,7 @@ export default function HistoryPage() {
{eventLabel}の受付履歴と参加者の状態を確認できます。 + 「滞在中全員をチェックアウト」は12:00や各タームの終わりに締めるときに使います。
@@ -446,6 +448,7 @@ export default function HistoryPage() { {filteredSessions.map((session) => { const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); + const term = classifyTerm(new Date(session.checkedInAt)); return ( @@ -477,17 +480,28 @@ export default function HistoryPage() {
- - {session.isPresent ? '滞在中' : '退室済み'} - +
+ + {session.isPresent ? '滞在中' : '退室済み'} + + {term && ( + + {TERM_LABELS[term]} + + )} +
{formatJapaneseDateTimeWithYear(session.checkedInAt)} diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index 82fbd6c..1e23c9b 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -11,6 +11,7 @@ import { IconUser, } 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'; @@ -261,8 +262,8 @@ export default function ReceptionParticipantPage() { : 'まだありません', }, { - label: '来場回数', - value: `${profile.stats.visitCount}回`, + label: '参加回数', + value: `${profile.stats.participationCount}回`, }, { label: '累計滞在時間', @@ -518,7 +519,20 @@ export default function ReceptionParticipantPage() { {profile.sessions.map((session) => ( - {formatJapaneseDateTimeWithYear(session.checkedInAt)} +
+ {formatJapaneseDateTimeWithYear(session.checkedInAt)} + {session.term ? ( + + {TERM_LABELS[session.term]} + + ) : ( + + )} +
{session.checkedOutAt @@ -529,17 +543,28 @@ export default function ReceptionParticipantPage() { {formatHistoryDuration(session.stayDurationMinutes, session.isPresent)} - - {session.isPresent ? '滞在中' : '退室済み'} - +
+ + {session.isPresent ? '滞在中' : '退室済み'} + + {!session.counted && ( + + カウント対象外 + + )} +
))} diff --git a/docs/mvp.md b/docs/mvp.md index 0efb8fc..6e8ebfc 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -242,6 +242,24 @@ async function getOrCreateTodayEvent( タイムゾーン注意: Workersのデフォルトタイムゾーンはサーバーロケーションに依存しないUTC。JSTで「今日」を判定する必要があるので、上記のように明示的に変換する。SQLite の `onConflictDoNothing` も Drizzle の同名メソッドで動作する(`UNIQUE` 制約に対する `ON CONFLICT DO NOTHING`)。 +### 4.4 ターム区分と参加回数(`venue-schedule`) + +会場の時間帯(ターム)と「参加回数」のカウントは **`packages/shared/src/venue-schedule.ts`** に集約した純粋ロジックで判定する(API・フロント共通)。DB スキーマは変更せず、`sessions.checked_in_at` から都度導出する(_derive_ 方式)。 + +| ターム (`TermId`) | ラベル | 時間帯(JST, `[start, end)`) | +| ----------------- | ------ | ----------------------------- | +| `morning` | 朝 | 09:00–12:00 | +| `afternoon` | 昼 | 13:00–16:00 | +| `evening` | 夕方 | 16:00–19:00 | + +主な関数: + +- `classifyTerm(instant: Date): TermId | null` — 来場時刻が属するターム。どの区間にも入らなければ `null`(昼休み 12–13 時・営業時間外)。 +- `countsTowardParticipation(instant: Date): boolean` — ターム内かつ終了まで `MIN_COUNTING_MINUTES`(=30) 以上残っていれば `true`。**「残り30分未満」は `false`**(チェックイン/アウト自体は通常どおり行う)。 +- `TERM_LABELS: Record` — 表示用ラベル(朝/昼/夕方)。 + +**参加回数(`participationCount`)の数え方**: `counted` なセッションを `(開催日, ターム)` 単位で重複排除した件数。朝+昼に来れば 2、同一タームの事故的な再チェックインは 1 に集約。日本は DST が無く `Asia/Tokyo` は固定 UTC+9 のため、JST 壁時計 ↔ UTC 変換は単純な時差減算で正しく求まる。設計背景は `requirements.md` §5.4。 + --- ## 5. 学生側スプシ仕様 @@ -612,6 +630,7 @@ admin 権限不要・ページネーションなし・active=true のみ・最 }, "stats": { "visitCount": 5, + "participationCount": 4, "lastVisitedAt": "2026-05-08T14:00:00+09:00", "totalStayDurationMinutes": 920 }, @@ -626,12 +645,17 @@ admin 権限不要・ページネーションなし・active=true のみ・最 "checkedInAt": "2026-05-08T13:02:00+09:00", "checkedOutAt": "2026-05-08T15:10:00+09:00", "stayDurationMinutes": 128, + "term": "afternoon", + "counted": true, "isPresent": false } ] } ``` +- `visitCount` は生のセッション数(後方互換のため維持)。`participationCount` は §4.4 のルールで数えた参加回数(スキルカードのチェック数に対応)。 +- 各セッションの `term`(`morning`/`afternoon`/`evening`、営業時間外は `null`)と `counted`(30分ルールを満たし参加回数に数えられるか)は `checked_in_at` から導出した値。 + #### `POST /checkin/participants/:participantId/attendance` プロフィール画面の「チェックイン」「チェックアウト」ボタンから呼ばれる実行 @@ -738,6 +762,28 @@ admin 権限不要・ページネーションなし・active=true のみ・最 } ``` +#### `GET /api/stats/participation` + +会場全体の参加回数集計。ターム別・日別の参加回数を返す。任意の期間で絞り込める。`counted` 判定(§4.4)は SQL では表現できないため、対象セッションを取得して JS で集計する(`(開催日, ターム, 参加者)` 単位で重複排除)。 + +**クエリパラメータ**: + +- `from` — 任意。集計開始日(`YYYY-MM-DD`, JST, 含む) +- `to` — 任意。集計終了日(`YYYY-MM-DD`, JST, 含む) + +**レスポンス**: + +```json +{ + "range": { "from": "2026-05-01", "to": "2026-05-31" }, + "totals": { "morning": 120, "afternoon": 98, "evening": 45, "total": 263, "days": 12 }, + "byDate": [ + { "date": "2026-05-17", "morning": 18, "afternoon": 15, "evening": 0, "total": 33 }, + { "date": "2026-05-16", "morning": 0, "afternoon": 0, "evening": 12, "total": 12 } + ] +} +``` + #### `GET /api/mentors` / `POST /api/mentors` / `PATCH /api/mentors/:id`(admin) メンター(運営者)の一覧・追加・編集(いずれも admin 権限必須)。 @@ -840,7 +886,8 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - 大きな単一の実行ボタン(`current.nextAction` に応じて「チェックイン」/「チェックアウト」) - タップで `POST /checkin/participants/:id/attendance` を呼ぶ - レスポンス(`action: 'check_in' | 'check_out'`)に応じて結果サマリを表示 -- 通算来場回数・直近来場日・累計滞在時間と、活動カレンダーのタイル表示(`attendanceIntensityClasses`) +- **参加回数**(`participationCount`・スキルカードのチェック数に対応)・直近来場日・累計滞在時間と、来場日数の活動カレンダータイル表示(`attendanceIntensityClasses`) +- セッション履歴の各行にタームバッジ(朝/昼/夕方)と、30分ルールで参加回数に数えない来場の「カウント対象外」表示 #### 7.1.3 初めての方一覧画面 `/first-time` @@ -852,8 +899,8 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 #### 7.1.4 受付履歴画面 `/history` - `GET /checkin/history/today` で当日のセッション一覧を取得 -- 各行に「現在在場 / 退室済」バッジ、チェックイン時刻、滞在時間を表示 -- 在場中の参加者を選択して「一括チェックアウト」を確認ダイアログ越しに実行(`POST /checkin/history/check-out-bulk`) +- 各行に「現在在場 / 退室済」バッジ、タームバッジ(朝/昼/夕方・`classifyTerm` でクライアント導出)、チェックイン時刻、滞在時間を表示 +- 在場中の参加者を選択して「一括チェックアウト」を確認ダイアログ越しに実行(`POST /checkin/history/check-out-bulk`)。タームの終わり(12:00・各回終了時)の締めに使う - 行タップで `/reception/participants/[id]` に遷移 #### 7.1.5 マニュアル入力画面 `/manual` @@ -887,8 +934,8 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - 日付ピッカー: 過去の開催日(`GET /api/events`)+今日を切り替えて表示 - サマリカード: 「現在の来場者数」「今日の総チェックイン数」「チェックアウト済」 - セッション一覧テーブル - - ID / **氏名** / ニックネーム / 学年 / チェックイン時刻 / チェックアウト時刻 / 状態 - - 行クリックで `ParticipantDetailSheet`(参加者詳細)を開く + - ID / **氏名** / ニックネーム / 学年 / ターム(朝/昼/夕方・`classifyTerm` でクライアント導出)/ チェックイン時刻 / チェックアウト時刻 / 状態 + - 行クリックで `ParticipantDetailSheet`(参加者詳細)を開く(**参加回数** `participationCount` とセッションごとのターム表記を表示) #### 7.2.3 参加者一覧 @@ -909,6 +956,12 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - 未アクティベート一覧: 事前登録ID / 氏名 / ニックネーム / 学年 / 事前登録日 + 削除(確認ダイアログ) - 折りたたみ「ID発行済みの利用者」セクション: アクティベート済み一覧(`internalId` / `activatedAt` を表示) +#### 7.2.6 集計画面 `/stats` + +- `GET /api/stats/participation`(任意で `?from=&to=`)で会場全体の参加回数集計を取得 +- 期間フィルタ(from/to)+ KPI カード(総参加回数/朝・昼・夕方の内訳/開催日数)+ 日別×ターム別テーブル(開催日降順) +- ナビ(`app-shell`)に「集計」を追加 + --- ## 8. セットアップ手順 @@ -1074,6 +1127,8 @@ export default defineConfig({ - 同時アクティベートでID採番衝突が起きた場合はエラーになる(現行は手動再試行で回復) - 活動ログ記入は引き続き従来通りスプシ手作業(Phase 1.5まで) - スプシ書き戻し失敗時はDBもロールバック(saga 補償)するため、ユーザーに再試行を求める +- **ターム境界の締めは手動**: 12:00(午前タームの終わり)と各回終了時に、受付端末「受付りれき」画面の「滞在中全員をチェックアウト」を押して締める運用。Cron 自動化は Phase 1.5 以降(§3.2) +- **押し忘れ時の午後再スキャン誤動作**: 午前の全員チェックアウトをし忘れたまま、午後も来た子が再スキャンすると、`processScanValue` が開いたままの午前セッションを検知して**チェックアウト**してしまう(午後の参加が記録されない)。もう一度スキャンすればチェックインに復帰する。当面は運用ルール(12:00 で必ず締める)で回避する --- diff --git a/docs/requirements.md b/docs/requirements.md index c6295ba..a7f3776 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -80,6 +80,7 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に - 「初めての方」フロー(スプシ参照→ニックネーム選択→ID採番→アクティベート) - 当日来場状況閲覧の管理画面(ダッシュボード・参加者一覧・メンター管理・事前登録管理) - Google OAuth認証(Better Auth・許可リスト方式) +- 参加回数の集計・表示(ターム制/30分ルール。参加者ごと+会場全体の集計ビュー。詳細は §5.4) ### 3.2 Phase 1.5(運用開始後・継続実装) @@ -91,6 +92,7 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に - 共同活動者の記録 - ログCSVエクスポート - 管理画面の機能拡張(参加者検索・編集・マスタ管理) +- ターム境界の自動チェックアウト(Cron Trigger)・会場スケジュール/休講カレンダー設定(現状はタームを来場時刻から自動判定し、締めは手動の「滞在中全員をチェックアウト」で運用する) ### 3.3 Phase 2(中長期改善) @@ -152,6 +154,8 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に 4. ネームカードを所定の保管場所へ片付ける 5. 閉場後、メンターが紙の振り返りシートをスプシ等へ手転記(Phase 2でOCR化予定) +> 土日は午前(9:00–12:00)と午後(13:00–16:00)でタームが分かれ、またがらない。**12:00 に受付端末の「滞在中全員をチェックアウト」を押して午前タームを締め**、午後も活動する子は 13:00 に再チェックインする(その日2回の参加としてカウント)。各回の終了時も同様に締める。詳細は §5.4。 + ### 4.5 管理者業務 1. 当日中、管理画面でリアルタイムに来場状況を確認 @@ -203,6 +207,8 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に | checked_in_at | timestamp | | | checked_out_at | timestamp NULL | | +> 1日に参加者あたり複数の `sessions` が生まれうる(朝/昼/夕方のタームごと)。ターム区分は `checked_in_at` から都度導出し、列としては保存しない。数え方は §5.4 を参照。 + #### `mentors`(メンター・運営者) | カラム | 型 | 説明 | @@ -230,6 +236,23 @@ mentors 1 ─── n activity_logs (Phase 1.5) sessions 1 ─── n activity_logs (Phase 1.5) ``` +### 5.4 ターム制と参加回数の数え方 + +会場は時間帯(ターム)ごとに運用する。 + +| ターム | 時間帯(JST) | 主な開催日 | +| ------ | ------------- | -------------- | +| 朝 | 9:00–12:00 | 土日 | +| 昼 | 13:00–16:00 | 土日 | +| 夕方 | 16:00–19:00 | 平日(主に木) | + +- **参加日数ではなく「参加回数」で数える**。土日に朝と昼の両方に来れば、その日は **2回**。利用者は来場ごとに物理スキルカードへチェックを付けており、システム上の集計もこの参加回数に揃える。 +- 午前・午後はまたがない。12:00 に一旦全員チェックアウトし、午後継続者は 13:00 に再チェックインする(`sessions` が2件作られる)。 +- **30分ルール**: そのタームの終了まで残り30分未満に来場した場合、チェックイン/チェックアウトは通常どおり行うが、**参加回数にはカウントしない**。 +- ターム区分とカウント可否は `checked_in_at`(来場時刻)から**都度導出**し、`sessions` には保存しない。判定ロジックは `packages/shared/src/venue-schedule.ts`(`classifyTerm` / `countsTowardParticipation`)に集約し、API・フロント双方が同じ実装を使う。 +- 同一タームに事故的な再チェックインが複数あっても、参加回数は `(開催日, ターム)` 単位で重複排除して1回として数える。 +- 締めの「全員チェックアウト」は手動運用(受付端末の既存ボタン)。Cronによる自動化や会場スケジュール設定は Phase 1.5 以降の候補(§3.2)。 + --- ## 6. データソース構成 @@ -486,6 +509,11 @@ Phase 1 を運用に乗せるために満たすべき基準: | 学生側スプシ | バックエンドが参照する転記版スプレッドシート。教員側スプシとは完全分離 | | 教員側スプシ | 事前登録フォーム由来の本名等を含むスプレッドシート。学生側からは非アクセス | | スロット | 30分単位の時間区切り(Phase 1.5以降で使用) | +| ターム | 会場の時間帯区分(朝 9–12 / 昼 13–16 / 夕方 16–19)。来場時刻から判定する | +| 参加回数 | タームごとの参加を数えた累計(朝+昼で2)。物理スキルカードのチェック数に対応 | +| 来場日数 | 重複排除した開催日数。参加回数とは別指標 | +| 30分ルール | タームの残り30分未満に来た来場は参加回数に数えない(チェックイン/アウトは行う) | +| スキルカード | 利用者が所持し、参加ごとにチェックを付ける物理カード | | ネームカード | QR/バーコード印字済みの紙カード。子どもが常設で利用 | | 未記入ハイライト | Phase 1.5で実装。現スロットでログが未記入の子を赤く表示する機能 | | GAS | Google Apps Script | diff --git a/packages/shared/package.json b/packages/shared/package.json index 95c3519..1d5507d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./google-sheets": "./src/google-sheets.ts", - "./schemas": "./src/schemas/index.ts" + "./schemas": "./src/schemas/index.ts", + "./venue-schedule": "./src/venue-schedule.ts" }, "scripts": { "type-check": "tsc --noEmit" diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 7bd8b1f..85ac4a8 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -49,6 +49,42 @@ export const eventsListResponseSchema = z.object({ events: z.array(eventItemSchema), }); +// `/api/stats/participation` +// 会場全体の参加回数集計(ターム別・日別)。from/to で期間を絞れる(いずれも JST・含む)。 +// counted 判定は SQL で表現できないため backend が JS 集計する(requirements.md §5.4 / mvp.md §4.4)。 +export const participationSummaryQuerySchema = z.object({ + from: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD format required') + .optional(), + to: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD format required') + .optional(), +}); + +const participationTermBreakdownSchema = z.object({ + morning: z.number().int().nonnegative(), + afternoon: z.number().int().nonnegative(), + evening: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), +}); + +export const participationSummaryResponseSchema = z.object({ + range: z.object({ + from: z.string().nullable(), // 'YYYY-MM-DD' (JST) + to: z.string().nullable(), + }), + totals: participationTermBreakdownSchema.extend({ + days: z.number().int().nonnegative(), // 集計対象の開催日数 + }), + byDate: z.array( + participationTermBreakdownSchema.extend({ + date: z.string(), // 'YYYY-MM-DD' (JST) + }), + ), +}); + // `/api/participants` // 検索(ID / 氏名 / ニックネーム部分一致)+ 学年 + 有効/無効 のフィルタを受け付ける。 // active は文字列で受けるが、'true' / 'false' のみ許容する。Zod の coerce は @@ -179,6 +215,8 @@ export type TodaySessionsResponse = z.infer; export type SessionsByDateQuery = z.infer; export type EventItem = z.infer; export type EventsListResponse = z.infer; +export type ParticipationSummaryQuery = z.infer; +export type ParticipationSummaryResponse = z.infer; export type ParticipantsListQuery = z.infer; export type ParticipantListItem = z.infer; export type ParticipantsListResponse = z.infer; diff --git a/packages/shared/src/schemas/checkin.ts b/packages/shared/src/schemas/checkin.ts index a871707..5b49128 100644 --- a/packages/shared/src/schemas/checkin.ts +++ b/packages/shared/src/schemas/checkin.ts @@ -107,7 +107,8 @@ export const participantProfileResponseSchema = z.object({ activatedAt: z.string(), // ISO 8601 }), stats: z.object({ - visitCount: z.number().int().nonnegative(), + visitCount: z.number().int().nonnegative(), // 生のセッション数(後方互換のため維持) + participationCount: z.number().int().nonnegative(), // 参加回数(ターム単位・30分ルール適用) lastVisitedAt: z.string().nullable(), // ISO 8601 totalStayDurationMinutes: z.number().int().nonnegative(), }), @@ -122,6 +123,10 @@ export const participantProfileResponseSchema = z.object({ checkedInAt: z.string(), // ISO 8601 checkedOutAt: z.string().nullable(), // ISO 8601 stayDurationMinutes: z.number().int().nonnegative().nullable(), + // ターム区分。営業時間外の来場は null。venue-schedule の TermId と同期。 + term: z.enum(['morning', 'afternoon', 'evening']).nullable(), + // 30分ルールを満たし参加回数に数えられるか。 + counted: z.boolean(), isPresent: z.boolean(), }), ), diff --git a/packages/shared/src/venue-schedule.ts b/packages/shared/src/venue-schedule.ts new file mode 100644 index 0000000..cb1a120 --- /dev/null +++ b/packages/shared/src/venue-schedule.ts @@ -0,0 +1,113 @@ +// 会場の開催タイム(ターム)定義と、参加回数カウントの純粋ロジック。 +// API(Cloudflare Workers)とフロント(Next.js)の両方から使うため packages/shared に置く。 +// Node 専用 API は使わず Intl のみ(Workers 制約)。日本は DST が無く Asia/Tokyo は +// 固定 UTC+9 のため、JST 壁時計 ↔ UTC instant の変換は単純な時差減算で正しく行える。 + +export type TermId = 'morning' | 'afternoon' | 'evening'; + +export interface TermDefinition { + id: TermId; + label: string; + // JST の壁時計 'HH:mm'。start は含み、end は含まない(半開区間 [start, end))。 + start: string; + end: string; +} + +// 平日(主に木)= evening の1ターム。土日 = morning + afternoon の2ターム。 +// 12:00–13:00 はどのタームにも属さない昼休み。16:00 は afternoon の外(end 排他)かつ +// evening の内(start 包含)。両者は曜日で排他なので実運用の衝突は起きない。 +export const TERMS: readonly TermDefinition[] = [ + { id: 'morning', label: '朝', start: '09:00', end: '12:00' }, + { id: 'afternoon', label: '昼', start: '13:00', end: '16:00' }, + { id: 'evening', label: '夕方', start: '16:00', end: '19:00' }, +]; + +export const TERM_LABELS: Record = { + morning: '朝', + afternoon: '昼', + evening: '夕方', +}; + +// タームの終了まで残りがこの分数未満で来場した場合は参加回数に数えない(30分ルール)。 +export const MIN_COUNTING_MINUTES = 30; + +// Asia/Tokyo は DST が無く固定 UTC+9。 +const JST_OFFSET_HOURS = 9; + +export interface JstWallClock { + year: number; + month: number; // 1-12 + day: number; // 1-31 + hour: number; // 0-23 + minute: number; // 0-59 +} + +const jstFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tokyo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +// UTC instant を JST の壁時計(年月日時分)に分解する。 +export const toJstWallClock = (instant: Date): JstWallClock => { + const parts = jstFormatter.formatToParts(instant); + const read = (type: 'year' | 'month' | 'day' | 'hour' | 'minute'): number => { + const value = parts.find((part) => part.type === type)?.value ?? '0'; + return Number.parseInt(value, 10); + }; + // hour12:false でも実装によっては深夜を '24' で返すため 0 に正規化する。 + const hour = read('hour'); + return { + year: read('year'), + month: read('month'), + day: read('day'), + hour: hour === 24 ? 0 : hour, + minute: read('minute'), + }; +}; + +// 'HH:mm' を 0:00 からの通算分に変換する。区間判定を整数比較に落とすためのヘルパ。 +const toMinutesOfDay = (hhmm: string): number => + Number.parseInt(hhmm.slice(0, 2), 10) * 60 + Number.parseInt(hhmm.slice(3, 5), 10); + +// 来場時刻(instant)が JST 壁時計でどのタームの [start, end) に入るか。 +// どのタームにも属さなければ null(昼休み・営業時間外)。 +export const classifyTerm = (instant: Date): TermId | null => { + const { hour, minute } = toJstWallClock(instant); + const current = hour * 60 + minute; + for (const term of TERMS) { + if (current >= toMinutesOfDay(term.start) && current < toMinutesOfDay(term.end)) { + return term.id; + } + } + return null; +}; + +const findTerm = (id: TermId): TermDefinition => { + const term = TERMS.find((candidate) => candidate.id === id); + if (!term) throw new Error(`unknown term id: ${id}`); // TERMS は網羅的なので実際には到達しない + return term; +}; + +// instant が属する JST カレンダー日における、指定タームの終了時刻を UTC instant で返す。 +// UTC+9 固定なので JST の終了「時」から 9 を引けば UTC の時になる(Date.UTC が日跨ぎを正規化)。 +export const termEndInstant = (instant: Date, id: TermId): Date => { + const { year, month, day } = toJstWallClock(instant); + const { end } = findTerm(id); + const endHour = Number.parseInt(end.slice(0, 2), 10); + const endMinute = Number.parseInt(end.slice(3, 5), 10); + return new Date(Date.UTC(year, month - 1, day, endHour - JST_OFFSET_HOURS, endMinute, 0, 0)); +}; + +// この来場が参加回数に数えられるか。ターム内であり、かつそのタームの終了まで +// MIN_COUNTING_MINUTES 以上残っているとき true。「残り30分未満」や営業時間外は false。 +export const countsTowardParticipation = (instant: Date): boolean => { + const term = classifyTerm(instant); + if (!term) return false; + const remainingMs = termEndInstant(instant, term).getTime() - instant.getTime(); + return remainingMs >= MIN_COUNTING_MINUTES * 60_000; +};