From 136c93f207354e6a8fa9e8acbaf98175dd940638 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 01/12] feat: consolidate term and counting logic in venue-schedule Add classifyVisit (single-pass term+counted), participationKey, and toJstDateString so the API and frontends share one source for term classification, the 30-minute rule, and JST date handling. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/venue-schedule.ts | 38 +++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/venue-schedule.ts b/packages/shared/src/venue-schedule.ts index cb1a120..ba56613 100644 --- a/packages/shared/src/venue-schedule.ts +++ b/packages/shared/src/venue-schedule.ts @@ -70,6 +70,19 @@ export const toJstWallClock = (instant: Date): JstWallClock => { }; }; +// JST 暦日専用フォーマッタ。en-CA ロケールは 'YYYY-MM-DD' を直接返す。 +const jstDateFormatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tokyo', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +// UTC instant を JST の暦日 'YYYY-MM-DD' に整形する(events.date と同形)。 +// 「今日(JST)」が欲しいときは現在時刻を渡す。API・フロント双方の JST 日付判定を一本化する。 +// 日付は en-CA フォーマッタから直接得る(壁時計の hour 正規化と独立させ、日跨ぎでも安全)。 +export const toJstDateString = (instant: Date): string => jstDateFormatter.format(instant); + // 'HH:mm' を 0:00 からの通算分に変換する。区間判定を整数比較に落とすためのヘルパ。 const toMinutesOfDay = (hhmm: string): number => Number.parseInt(hhmm.slice(0, 2), 10) * 60 + Number.parseInt(hhmm.slice(3, 5), 10); @@ -103,11 +116,26 @@ export const termEndInstant = (instant: Date, id: TermId): Date => { 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 => { +export interface VisitClassification { + // 来場時刻が属するターム。営業時間外・昼休みは null。 + term: TermId | null; + // 30分ルールを満たし参加回数に数えるか。term が null のときは必ず false。 + counted: boolean; +} + +// 来場時刻から「ターム」と「参加回数に数えるか」を一度の走査で判定する。 +// term と counted の両方が要る箇所はこれを使い、classifyTerm の二度呼びを避ける。 +export const classifyVisit = (instant: Date): VisitClassification => { const term = classifyTerm(instant); - if (!term) return false; + if (term === null) return { term: null, counted: false }; const remainingMs = termEndInstant(instant, term).getTime() - instant.getTime(); - return remainingMs >= MIN_COUNTING_MINUTES * 60_000; + return { term, counted: remainingMs >= MIN_COUNTING_MINUTES * 60_000 }; }; + +// この来場が参加回数に数えられるか。ターム内であり、かつそのタームの終了まで +// MIN_COUNTING_MINUTES 以上残っているとき true。「残り30分未満」や営業時間外は false。 +export const countsTowardParticipation = (instant: Date): boolean => classifyVisit(instant).counted; + +// 参加回数の重複排除キー。同一 (開催日, ターム) を 1 参加として数えるための文字列キー。 +// 会場横断集計では参加者を区別するため `${participationKey(date, term)}#${participantId}` を使う。 +export const participationKey = (eventDate: string, term: TermId): string => `${eventDate}#${term}`; From d3d24408b6e758848d77e5c13b3cc4c7759c843b Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 02/12] feat: extend shared schemas with session term/counted and participation stats Add term/counted to today-session items and visitDayCount/uncountedVisitCount to the participant profile stats. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/schemas/admin.ts | 5 +++++ packages/shared/src/schemas/checkin.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 85ac4a8..3596eee 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -10,6 +10,11 @@ export const todaySessionItemSchema = z.object({ checkedInAt: z.string(), // ISO 8601 (UTC) checkedOutAt: z.string().nullable(), isPresent: z.boolean(), + // ターム区分は backend が checkedInAt(JST 壁時計)から導出する。営業時間外は null。 + // 重要な区分判定ロジックをフロントに持たせないため、venue-schedule の結果を API で返す。 + term: z.enum(['morning', 'afternoon', 'evening']).nullable(), + // 30分ルールを満たし参加回数に数えられるか(タームの残り30分以上前の来場か)。 + counted: z.boolean(), }); export const todaySessionsResponseSchema = z.object({ diff --git a/packages/shared/src/schemas/checkin.ts b/packages/shared/src/schemas/checkin.ts index 5b49128..6e4f40d 100644 --- a/packages/shared/src/schemas/checkin.ts +++ b/packages/shared/src/schemas/checkin.ts @@ -107,8 +107,10 @@ export const participantProfileResponseSchema = z.object({ activatedAt: z.string(), // ISO 8601 }), stats: z.object({ - visitCount: z.number().int().nonnegative(), // 生のセッション数(後方互換のため維持) - participationCount: z.number().int().nonnegative(), // 参加回数(ターム単位・30分ルール適用) + visitCount: z.number().int().nonnegative(), // 総来場回数(生のセッション数) + participationCount: z.number().int().nonnegative(), // 参加回数(ターム単位・30分ルール適用・有効) + visitDayCount: z.number().int().nonnegative(), // 来場日数(重複排除した開催日数) + uncountedVisitCount: z.number().int().nonnegative(), // 無効な来場回数(30分ルール・営業時間外などで参加回数に数えないセッション数) lastVisitedAt: z.string().nullable(), // ISO 8601 totalStayDurationMinutes: z.number().int().nonnegative(), }), From e2c5ac6445e6a3fe10c6df1193a2d8b9849f11f8 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 03/12] feat: add color-coded TermBadge and UncountedBadge components Morning=sky, afternoon=amber, evening=violet, dimmed when uncounted; UncountedBadge marks visits excluded from participation. Adds @tecnova/shared as a workspace dependency of @tecnova/ui. Co-Authored-By: Claude Opus 4.8 --- packages/ui/package.json | 1 + packages/ui/src/components/term-badge.tsx | 49 +++++++++++++++++++++++ pnpm-lock.yaml | 3 ++ 3 files changed, 53 insertions(+) create mode 100644 packages/ui/src/components/term-badge.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 18bf88a..0e9402d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tabler/icons-react": "^3.42.0", + "@tecnova/shared": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "radix-ui": "^1.4.3", diff --git a/packages/ui/src/components/term-badge.tsx b/packages/ui/src/components/term-badge.tsx new file mode 100644 index 0000000..444adad --- /dev/null +++ b/packages/ui/src/components/term-badge.tsx @@ -0,0 +1,49 @@ +import { TERM_LABELS, type TermId } from '@tecnova/shared/venue-schedule'; +import { Badge } from '@tecnova/ui/components/badge'; +import { cn } from '@tecnova/ui/lib/utils'; +import type { ComponentProps } from 'react'; + +// ターム別の配色。朝=水色 / 昼=黄色 / 夕方=紫 で運営側が一目で区別できるようにする。 +// 値の出どころ(区分判定)は packages/shared/venue-schedule に集約しており、 +// ここは「TermId → 見た目」だけを担当する。 +const TERM_BADGE_CLASSES: Record = { + morning: 'bg-sky-100 text-sky-700', + afternoon: 'bg-amber-100 text-amber-800', + evening: 'bg-violet-100 text-violet-700', +}; + +type TermBadgeProps = Omit, 'variant' | 'children'> & { + term: TermId; + // 30分ルールで参加回数に数えられない場合は false。色は残しつつ薄く表示する。 + counted?: boolean; +}; + +// チェックイン時刻のタームを色分けして表示するバッジ。 +// `counted=false`(30分ルールで対象外)のときは彩度を落として区別する。 +export function TermBadge({ term, counted = true, className, ...props }: TermBadgeProps) { + return ( + + {TERM_LABELS[term]} + + ); +} + +// 「30分ルールで参加回数に数えない」ことを明示する補助バッジ。 +// ターム自体は TermBadge で色分けし、こちらは対象外の事実だけを淡色で添える。 +export function UncountedBadge({ className, ...props }: ComponentProps) { + return ( + + カウント対象外 + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4895177..b0591f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@tabler/icons-react': specifier: ^3.42.0 version: 3.42.0(react@19.2.4) + '@tecnova/shared': + specifier: workspace:* + version: link:../shared class-variance-authority: specifier: ^0.7.1 version: 0.7.1 From ac084f890c2ccef1450484e33245dcfb5f44819a Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 04/12] feat: derive term, counted, and participation stats in the API Compute per-session term/counted and the participation/visit/day/uncounted aggregates server-side via a single classifyVisit pass; unify JST-today helpers through toJstDateString. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/lib/admin.ts | 59 ++++++++++++++++-------------- apps/api/src/lib/checkin.ts | 67 ++++++++++++++++++---------------- apps/api/src/routes/checkin.ts | 2 + 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/apps/api/src/lib/admin.ts b/apps/api/src/lib/admin.ts index 3b20a55..fd6932f 100644 --- a/apps/api/src/lib/admin.ts +++ b/apps/api/src/lib/admin.ts @@ -13,9 +13,10 @@ import type { UpdateMentorRequest, } from '@tecnova/shared/schemas'; import { - classifyTerm, - countsTowardParticipation, + classifyVisit, + participationKey, type TermId, + toJstDateString, } from '@tecnova/shared/venue-schedule'; import { and, asc, count, desc, eq, gte, like, lte, or, type SQL } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; @@ -36,8 +37,7 @@ export class MentorError extends Error { // JST 基準で「今日」の日付文字列 'YYYY-MM-DD' を返す。 // events.date は JST の開催日として保存しているため、ここも JST で判定する。 -const todayInJst = (): string => - new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date()); +const todayInJst = (): string => toJstDateString(new Date()); // 指定日(YYYY-MM-DD, JST)の event とそのセッション一覧を返す。 // date が null の場合は「今日(JST)」として解決する。 @@ -73,16 +73,22 @@ export const fetchSessionsForEvent = async ( .where(eq(sessions.eventId, event.id)) .orderBy(desc(sessions.checkedInAt)); - const items = rows.map((r) => ({ - sessionId: r.sessionId, - participantId: r.participantId, - fullName: r.fullName, - nickname: r.nickname, - grade: r.grade, - checkedInAt: r.checkedInAt.toISOString(), - checkedOutAt: r.checkedOutAt ? r.checkedOutAt.toISOString() : null, - isPresent: r.checkedOutAt === null, - })); + const items = rows.map((r) => { + // ターム判定・30分ルールは venue-schedule に集約。フロントへは確定値だけ渡す。 + const { term, counted } = classifyVisit(r.checkedInAt); + return { + sessionId: r.sessionId, + participantId: r.participantId, + fullName: r.fullName, + nickname: r.nickname, + grade: r.grade, + checkedInAt: r.checkedInAt.toISOString(), + checkedOutAt: r.checkedOutAt ? r.checkedOutAt.toISOString() : null, + isPresent: r.checkedOutAt === null, + term, + counted, + }; + }); const currentlyPresent = items.filter((i) => i.isPresent).length; return { @@ -144,23 +150,22 @@ export const fetchParticipationSummary = async ( .innerJoin(events, eq(sessions.eventId, events.id)) .where(where); - // 同一参加者は「日付 + ターム」ごとに1回だけ数える。`date#term#participantId` で重複排除。 - const countedKeys = new Set(); - for (const row of rows) { - const term = classifyTerm(row.checkedInAt); - if (term === null || !countsTowardParticipation(row.checkedInAt)) continue; - countedKeys.add(`${row.eventDate}#${term}#${row.participantId}`); - } - - // 重複排除済みのキーから日別・全体を集計する。 + // 同一参加者の「日付 + ターム」は1回だけ数える。dedup キー(`date#term#participantId`)で + // 重複を弾きつつ、その場で日別・全体バケットへ加算する(キー文字列を再パースしない)。 + const seen = new Set(); 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); + for (const row of rows) { + const { term, counted } = classifyVisit(row.checkedInAt); + if (term === null || !counted) continue; + const dedupKey = `${participationKey(row.eventDate, term)}#${row.participantId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + let buckets = byDateMap.get(row.eventDate); if (!buckets) { buckets = emptyBuckets(); - byDateMap.set(date, buckets); + byDateMap.set(row.eventDate, buckets); } incrementBuckets(buckets, term); incrementBuckets(totals, term); diff --git a/apps/api/src/lib/checkin.ts b/apps/api/src/lib/checkin.ts index e3cc16e..d1e925b 100644 --- a/apps/api/src/lib/checkin.ts +++ b/apps/api/src/lib/checkin.ts @@ -3,9 +3,10 @@ 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, + 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'; @@ -82,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(); @@ -378,6 +373,8 @@ export interface ParticipantProfile { stats: { visitCount: number; participationCount: number; + visitDayCount: number; + uncountedVisitCount: number; lastVisitedAt: Date | null; totalStayDurationMinutes: number; }; @@ -423,13 +420,23 @@ 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 = classifyTerm(session.checkedInAt); - const counted = countsTowardParticipation(session.checkedInAt); + 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, @@ -445,21 +452,13 @@ 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, + visitDayCount: visitDays.size, + uncountedVisitCount, lastVisitedAt: sessionRows[0]?.checkedInAt ?? null, totalStayDurationMinutes, }, @@ -530,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/checkin.ts b/apps/api/src/routes/checkin.ts index 90d619a..515287f 100644 --- a/apps/api/src/routes/checkin.ts +++ b/apps/api/src/routes/checkin.ts @@ -199,6 +199,8 @@ 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, }, From 5b894ca7e6cc60c5bad91f97e97317a7d9807fc7 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 05/12] feat: surface term badges and participation breakdown in checkin UI History rows show color-coded term + counted state from the API; the profile heatmap colors counted visits by stay duration and marks uncounted ones with an x; stats show participation count with total visits / visit days / uncounted visits. Co-Authored-By: Claude Opus 4.8 --- apps/checkin/src/app/history/page.tsx | 16 +- .../app/reception/participants/[id]/page.tsx | 211 ++++++++++-------- 2 files changed, 121 insertions(+), 106 deletions(-) diff --git a/apps/checkin/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx index 00dcc00..71520d0 100644 --- a/apps/checkin/src/app/history/page.tsx +++ b/apps/checkin/src/app/history/page.tsx @@ -14,7 +14,6 @@ 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, @@ -42,6 +41,7 @@ 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 Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -448,7 +448,6 @@ export default function HistoryPage() { {filteredSessions.map((session) => { const stayDurationMinutes = getSessionStayDurationMinutes(session, nowMs); - const term = classifyTerm(new Date(session.checkedInAt)); return ( @@ -492,15 +491,10 @@ export default function HistoryPage() { > {session.isPresent ? '滞在中' : '退室済み'} - {term && ( - - {TERM_LABELS[term]} - - )} + {session.term ? ( + + ) : null} + {!session.counted && } diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index 1e23c9b..c966ccf 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -9,6 +9,7 @@ import { IconLogin2, IconLogout2, IconUser, + IconX, } from '@tabler/icons-react'; import type { ParticipantProfileResponse, ScanResponse } from '@tecnova/shared/schemas'; import { TERM_LABELS } from '@tecnova/shared/venue-schedule'; @@ -25,6 +26,7 @@ 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 Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -82,13 +84,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', @@ -97,29 +92,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; }; @@ -127,41 +115,22 @@ 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 = ( @@ -261,10 +230,6 @@ export default function ReceptionParticipantPage() { ? formatJapaneseDateTimeWithYear(profile.stats.lastVisitedAt) : 'まだありません', }, - { - label: '参加回数', - value: `${profile.stats.participationCount}回`, - }, { label: '累計滞在時間', value: formatDuration(profile.stats.totalStayDurationMinutes), @@ -272,6 +237,25 @@ export default function ReceptionParticipantPage() { ]; }, [profile]); + // 参加状況タイル内に並べる内訳。参加回数(スキルカードの押印数)を主役にする。 + const participationBreakdown = useMemo(() => { + if (!profile) return []; + return [ + { + label: '総来場回数', + value: `${profile.stats.visitCount}回`, + }, + { + label: '来場日数', + value: `${profile.stats.visitDayCount}日`, + }, + { + label: '無効な来場回数', + value: `${profile.stats.uncountedVisitCount}回`, + }, + ]; + }, [profile]); + const attendanceTiles = useMemo(() => { if (!profile) return []; return buildAttendanceTiles(profile.sessions); @@ -401,6 +385,23 @@ export default function ReceptionParticipantPage() {
+
+

参加回数

+

+ {profile.stats.participationCount} + +

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

{item.label}

@@ -454,40 +455,74 @@ export default function ReceptionParticipantPage() { } - title="来場日数" + title="来場回数" tone="emerald" />

- {attendanceTiles.length} - + {profile.stats.visitCount} +

-
- - {[1, 2, 3, 4].map((intensity) => ( -
    {attendanceTileSlots.map((tile, index) => { - const label = tile - ? `${tile.label} ${formatDuration(tile.stayDurationMinutes)}${ - tile.isPresent ? ' 経過' : '' - }` - : '未記録'; + // 空きスロット(パディング)はニュートラルな空タイル。 + if (!tile) { + return ( +
  • + ); + } + + const baseLabel = `${tile.label}${ + tile.termLabel ? ` ${tile.termLabel}` : '' + } ${formatDuration(tile.stayDurationMinutes)}${tile.isPresent ? ' 経過' : ''}`; + + // カウント対象外の来場は色を付けず、× アイコンで「来たが無効」を示す。 + if (!tile.counted) { + const label = `${baseLabel}・カウント対象外`; + return ( +
  • +
  • + ); + } + + // カウント対象の来場は滞在時間の濃淡で色付け。 return (
  • ); })} @@ -522,13 +557,7 @@ export default function ReceptionParticipantPage() {
    {formatJapaneseDateTimeWithYear(session.checkedInAt)} {session.term ? ( - - {TERM_LABELS[session.term]} - + ) : ( )} @@ -555,15 +584,7 @@ export default function ReceptionParticipantPage() { > {session.isPresent ? '滞在中' : '退室済み'} - {!session.counted && ( - - カウント対象外 - - )} + {!session.counted && }
    From 0bda392a3c838282f41ca8bc9987bbba1e47a765 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 06/12] feat: color-code terms and accent participation stats in admin UI Dashboard and detail sheet render server-provided term/counted via TermBadge; stats page tints the morning/afternoon/evening KPI cards. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/(authed)/page.tsx | 68 +++++++++---------- apps/admin/src/app/(authed)/stats/page.tsx | 26 +++++-- .../components/participant-detail-sheet.tsx | 11 ++- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 98b28c9..a2d5475 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -8,7 +8,7 @@ import { IconUserCheck, } from '@tabler/icons-react'; import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas'; -import { classifyTerm, TERM_LABELS } from '@tecnova/shared/venue-schedule'; +import { toJstDateString } from '@tecnova/shared/venue-schedule'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; @@ -30,6 +30,7 @@ import { TableRow, } from '@tecnova/ui/components/table'; import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; +import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; import { useCallback, useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; @@ -52,10 +53,6 @@ const fmtTime = (iso: string): string => minute: '2-digit', }).format(new Date(iso)); -// JST の YYYY-MM-DD を返す(events.date と同形)。 -const todayInJst = (): string => - new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(new Date()); - export default function DashboardPage() { const [sessions, setSessions] = useState({ kind: 'loading' }); const [events, setEvents] = useState([]); @@ -92,7 +89,7 @@ export default function DashboardPage() { void loadSessions(selectedDate); }, [selectedDate, loadSessions]); - const today = todayInJst(); + const today = toJstDateString(new Date()); // 「本日」ラベル + イベントとして登録済みの過去日を結合する。 // 今日の event が events に含まれていてもメニューの重複は避ける。 const pastEvents = events.filter((e) => e.date !== today); @@ -218,36 +215,35 @@ function DashboardBody({ ) : ( - 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 ? '来場中' : '退出済'} - - - - ); - }) + rows.map((s) => ( + onSelectParticipant(s.participantId)} + > + {s.participantId} + {s.fullName} + {s.nickname} + {s.grade} + + {s.term ? ( +
    + + {!s.counted && } +
    + ) : ( + + )} +
    + {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 index 4511de7..0cc75f8 100644 --- a/apps/admin/src/app/(authed)/stats/page.tsx +++ b/apps/admin/src/app/(authed)/stats/page.tsx @@ -25,6 +25,7 @@ import { import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; import { formatJstDate } from '@tecnova/ui/lib/format'; +import { cn } from '@tecnova/ui/lib/utils'; import { useCallback, useEffect, useState } from 'react'; import { PageHeader } from '@/components/page-header'; @@ -152,9 +153,24 @@ function StatsBody({ summary }: { summary: SummaryState }) { <>
    - - - + + +
    @@ -201,16 +217,18 @@ 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/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index dc0ac77..f353289 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -1,7 +1,6 @@ 'use client'; import type { ParticipantProfileResponse } from '@tecnova/shared/schemas'; -import { TERM_LABELS } from '@tecnova/shared/venue-schedule'; import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; import { Badge } from '@tecnova/ui/components/badge'; import { @@ -20,6 +19,7 @@ import { TableHeader, TableRow, } from '@tecnova/ui/components/table'; +import { TermBadge } from '@tecnova/ui/components/term-badge'; import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client'; import { formatJstDate } from '@tecnova/ui/lib/format'; import { useEffect, useState } from 'react'; @@ -187,12 +187,11 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) { {fmtHistoryDateTime(s.checkedInAt)} {s.term && ( - - {TERM_LABELS[s.term]} - + /> )} From e107043fb2aa9887dfa63856e4bddb2ca352edbd Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 07/12] docs: document term colors, x-marked heatmap, and venue-schedule API Update mvp.md and requirements.md for the participation breakdown, the counted/uncounted heatmap, server-side term derivation, and the consolidated venue-schedule helpers. Co-Authored-By: Claude Opus 4.8 --- docs/mvp.md | 34 ++++++++++++++++++++++------------ docs/requirements.md | 5 +++-- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/mvp.md b/docs/mvp.md index 6e8ebfc..9f4fa00 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -203,7 +203,7 @@ async function generateNextParticipantId( - D1 はインタラクティブ・トランザクションを持たないため、PG時代のような「SELECT → INSERT を同一トランザクションで保護」はできない - 同時アクティベートはほぼ起こらない(運用上、複数の子が同時タップする確率は低い) -- **現行実装では採番衝突時の自動リトライは未実装**。`UNIQUE constraint failed: participants.id` が出た場合は再試行(手動)で回復する。将来的には最大3回の自動リトライを実装予定(`apps/api/src/lib/checkin.ts` の TODO) +- **採番衝突時の自動リトライは実装しない方針で確定**(同時タップはほぼ発生しないため)。`UNIQUE constraint failed: participants.id` が出た場合は手動再試行で回復する運用とする。衝突が実運用で問題化した場合のみ最大3回の自動リトライを検討(`apps/api/src/lib/checkin.ts` の TODO) - 年度判定は会計年度ではなく西暦下2桁とする ### 4.3 events自動生成ロジック @@ -252,13 +252,16 @@ async function getOrCreateTodayEvent( | `afternoon` | 昼 | 13:00–16:00 | | `evening` | 夕方 | 16:00–19:00 | -主な関数: +主な関数(来場判定はこのモジュールが唯一の出どころ。API もフロントもここを使い、各所で再計算しない): +- `classifyVisit(instant: Date): { term: TermId | null; counted: boolean }` — **term と counted を一度の走査で確定する主 API**。term/counted の両方が要る箇所(プロフィール集計・当日一覧・会場集計)はこれを使う。 - `classifyTerm(instant: Date): TermId | null` — 来場時刻が属するターム。どの区間にも入らなければ `null`(昼休み 12–13 時・営業時間外)。 -- `countsTowardParticipation(instant: Date): boolean` — ターム内かつ終了まで `MIN_COUNTING_MINUTES`(=30) 以上残っていれば `true`。**「残り30分未満」は `false`**(チェックイン/アウト自体は通常どおり行う)。 +- `countsTowardParticipation(instant: Date): boolean` — `classifyVisit(instant).counted` の薄いラッパ。ターム内かつ終了まで `MIN_COUNTING_MINUTES`(=30) 以上残っていれば `true`。**「残り30分未満」は `false`**(チェックイン/アウト自体は通常どおり行う)。 +- `participationKey(eventDate, term): string` — 参加回数の重複排除キー(`${eventDate}#${term}`)。会場集計では `#participantId` を足す。 +- `toJstDateString(instant: Date): string` — JST 暦日 'YYYY-MM-DD'(`events.date` と同形)。API・フロントの「今日(JST)」判定を一本化。 - `TERM_LABELS: Record` — 表示用ラベル(朝/昼/夕方)。 -**参加回数(`participationCount`)の数え方**: `counted` なセッションを `(開催日, ターム)` 単位で重複排除した件数。朝+昼に来れば 2、同一タームの事故的な再チェックインは 1 に集約。日本は DST が無く `Asia/Tokyo` は固定 UTC+9 のため、JST 壁時計 ↔ UTC 変換は単純な時差減算で正しく求まる。設計背景は `requirements.md` §5.4。 +**参加回数(`participationCount`)の数え方**: `counted` なセッションを `participationKey(開催日, ターム)` 単位で重複排除した件数。朝+昼に来れば 2、同一タームの事故的な再チェックインは 1 に集約。日本は DST が無く `Asia/Tokyo` は固定 UTC+9 のため、JST 壁時計 ↔ UTC 変換は単純な時差減算で正しく求まる。設計背景は `requirements.md` §5.4。 --- @@ -631,6 +634,8 @@ admin 権限不要・ページネーションなし・active=true のみ・最 "stats": { "visitCount": 5, "participationCount": 4, + "visitDayCount": 3, + "uncountedVisitCount": 1, "lastVisitedAt": "2026-05-08T14:00:00+09:00", "totalStayDurationMinutes": 920 }, @@ -653,8 +658,8 @@ admin 権限不要・ページネーションなし・active=true のみ・最 } ``` -- `visitCount` は生のセッション数(後方互換のため維持)。`participationCount` は §4.4 のルールで数えた参加回数(スキルカードのチェック数に対応)。 -- 各セッションの `term`(`morning`/`afternoon`/`evening`、営業時間外は `null`)と `counted`(30分ルールを満たし参加回数に数えられるか)は `checked_in_at` から導出した値。 +- `participationCount` は §4.4 のルールで数えた参加回数(スキルカードのチェック数に対応・有効)。`visitCount` は総来場回数(生のセッション数)、`visitDayCount` は重複排除した来場日数、`uncountedVisitCount` は 30分ルール・営業時間外などで参加回数に数えない来場数。`participationCount` は同一タームの再来場を集約するため、`visitCount = participationCount + uncountedVisitCount` とは限らない(同一タームに2回来た分は参加回数では1に集約される)。 +- 各セッションの `term`(`morning`/`afternoon`/`evening`、営業時間外は `null`)と `counted`(30分ルールを満たし参加回数に数えられるか)は `checked_in_at` から導出した値。これらは API(`classifyVisit`)が確定し、フロントは再計算しない。 #### `POST /checkin/participants/:participantId/attendance` @@ -705,7 +710,9 @@ admin 権限不要・ページネーションなし・active=true のみ・最 "grade": "小4", "checkedInAt": "2026-05-15T09:32:15+09:00", "checkedOutAt": null, - "isPresent": true + "isPresent": true, + "term": "morning", + "counted": true } ], "summary": { @@ -716,6 +723,8 @@ admin 権限不要・ページネーションなし・active=true のみ・最 } ``` +- 各セッションの `term`(`morning`/`afternoon`/`evening`、営業時間外は `null`)と `counted`(30分ルールを満たし参加回数に数えられるか)は §4.4 の `venue-schedule` で `checked_in_at` から **サーバ側で確定**した値。重要な区分判定ロジックをフロントに置かないため、ダッシュボード/受付りれきはこの値をそのまま表示する(クライアントで `classifyTerm` を再計算しない)。 + #### `GET /api/sessions?date=YYYY-MM-DD` 指定日のセッション一覧。`date` を省略すると当日(JST)として解決する。レスポンスは @@ -886,8 +895,9 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - 大きな単一の実行ボタン(`current.nextAction` に応じて「チェックイン」/「チェックアウト」) - タップで `POST /checkin/participants/:id/attendance` を呼ぶ - レスポンス(`action: 'check_in' | 'check_out'`)に応じて結果サマリを表示 -- **参加回数**(`participationCount`・スキルカードのチェック数に対応)・直近来場日・累計滞在時間と、来場日数の活動カレンダータイル表示(`attendanceIntensityClasses`) -- セッション履歴の各行にタームバッジ(朝/昼/夕方)と、30分ルールで参加回数に数えない来場の「カウント対象外」表示 +- 「参加状況」タイルに **参加回数**(`participationCount`・スキルカードのチェック数に対応・主役表示)を大きく出し、内訳として **総来場回数**(`visitCount`)/ **来場日数**(`visitDayCount`)/ **無効な来場回数**(`uncountedVisitCount`)を併記。加えて 登録日 / 最後に来た日 / 累計滞在時間。 +- **来場回数**の活動カレンダータイル表示(1 来場 = 1 タイル)。**カウントされた来場は滞在時間の濃淡で色付け(3 時間で最濃=`attendanceIntensityClasses`)、カウント対象外の来場は色を付けず × アイコンで埋める**。凡例に濃淡グラデーションと「× 対象外」を併記。 +- セッション履歴の各行に色分けタームバッジ(朝=水色/昼=黄色/夕方=紫の `TermBadge`)と、30分ルールで参加回数に数えない来場の「カウント対象外」表示 #### 7.1.3 初めての方一覧画面 `/first-time` @@ -899,7 +909,7 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 #### 7.1.4 受付履歴画面 `/history` - `GET /checkin/history/today` で当日のセッション一覧を取得 -- 各行に「現在在場 / 退室済」バッジ、タームバッジ(朝/昼/夕方・`classifyTerm` でクライアント導出)、チェックイン時刻、滞在時間を表示 +- 各行に「現在在場 / 退室済」バッジ、色分けタームバッジ(朝/昼/夕方・API の `term` を表示)、30分ルールで対象外の来場には「カウント対象外」バッジ、チェックイン時刻、滞在時間を表示 - 在場中の参加者を選択して「一括チェックアウト」を確認ダイアログ越しに実行(`POST /checkin/history/check-out-bulk`)。タームの終わり(12:00・各回終了時)の締めに使う - 行タップで `/reception/participants/[id]` に遷移 @@ -934,7 +944,7 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - 日付ピッカー: 過去の開催日(`GET /api/events`)+今日を切り替えて表示 - サマリカード: 「現在の来場者数」「今日の総チェックイン数」「チェックアウト済」 - セッション一覧テーブル - - ID / **氏名** / ニックネーム / 学年 / ターム(朝/昼/夕方・`classifyTerm` でクライアント導出)/ チェックイン時刻 / チェックアウト時刻 / 状態 + - ID / **氏名** / ニックネーム / 学年 / ターム(色分けバッジ・API の `term` を表示。30分ルールで対象外なら「カウント対象外」併記)/ チェックイン時刻 / チェックアウト時刻 / 状態 - 行クリックで `ParticipantDetailSheet`(参加者詳細)を開く(**参加回数** `participationCount` とセッションごとのターム表記を表示) #### 7.2.3 参加者一覧 @@ -1175,7 +1185,7 @@ export default defineConfig({ **対応**: -- 同時アクティベートで採番が衝突した。現行は自動リトライ未実装のため運用側で手動再試行する(将来的には `generateNextParticipantId` からの最大3回リトライを `apps/api/src/lib/checkin.ts` に実装予定) +- 同時アクティベートで採番が衝突した。手動再試行で回復する運用で確定(同時タップはほぼ発生しないため自動リトライは入れない)。衝突が実運用で頻発した場合のみ `generateNextParticipantId` からの最大3回リトライを `apps/api/src/lib/checkin.ts` で検討 ### 10.4 iPadのカメラが動かない diff --git a/docs/requirements.md b/docs/requirements.md index a7f3776..a876f1e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -510,8 +510,9 @@ Phase 1 を運用に乗せるために満たすべき基準: | 教員側スプシ | 事前登録フォーム由来の本名等を含むスプレッドシート。学生側からは非アクセス | | スロット | 30分単位の時間区切り(Phase 1.5以降で使用) | | ターム | 会場の時間帯区分(朝 9–12 / 昼 13–16 / 夕方 16–19)。来場時刻から判定する | -| 参加回数 | タームごとの参加を数えた累計(朝+昼で2)。物理スキルカードのチェック数に対応 | -| 来場日数 | 重複排除した開催日数。参加回数とは別指標 | +| 参加回数 | タームごとの参加を数えた累計(朝+昼で2、30分ルール適用)。物理スキルカードのチェック数に対応(`participationCount`) | +| 来場回数 | チェックインの生の回数(=セッション数、`visitCount`)。受付プロフィールの活動カレンダーは1来場=1タイルで滞在時間が長いほど濃く表示する | +| 来場日数 | 重複排除した開催日数。参加回数・来場回数とは別指標 | | 30分ルール | タームの残り30分未満に来た来場は参加回数に数えない(チェックイン/アウトは行う) | | スキルカード | 利用者が所持し、参加ごとにチェックを付ける物理カード | | ネームカード | QR/バーコード印字済みの紙カード。子どもが常設で利用 | From d21b58fe64afede3acaca6f01d2ea0cf912dee69 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:06:07 +0900 Subject: [PATCH 08/12] docs: update handoff notes with latest session status and development insights --- docs/handoff.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/handoff.md b/docs/handoff.md index c95506f..33c3305 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,4 +1,4 @@ -# セッション引き継ぎノート(2026-05-28時点) +# セッション引き継ぎノート(2026-05-29時点) 新しい Claude セッションがこのリポジトリで作業を再開するときの起点。 このファイルは「今ここまで来ている」を素早く把握するためのもの。詳細仕様は引き続き @@ -55,6 +55,17 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 触る端末ではなく受付メンターの端末である前提に倒し、`apiCors` + `requireAuthenticatedMentor` を `/api/*` と同じく適用している(`apps/api/src/index.ts`)。 +### develop にあり main 未反映(意図的に据え置き) + +- **参加回数統計(PR #33: ターム分類・30 分カウント・会場横断集計)** は `develop` に + マージ済みだが **`main` には入れていない**=本番(Worker / Vercel)未反映。デモでは + 使ったが、2026-05-29 に「本番には出さず develop 据え置き」と判断した。本番に出す場合は + `develop` → `main` マージで `deploy-api.yml` 経由の Worker デプロイ + admin/checkin の + Vercel 反映が走る。 + - 該当 commit: `eea5313`(`/api/stats/participation`), `0c762be`(profile API 拡張), + `f90d55d`(venue-schedule モジュール), `7db3e1d`(shared schema 拡張), + `b5a83f9`(checkin 受付 UI), `32aa8fa`(admin 統計ページ) + ### 現状動作している範囲 ローカル開発 (`pnpm --filter @tecnova/api dev` + `pnpm --filter checkin dev` + @@ -117,30 +128,24 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 --- -## 次に取り掛かるフェーズ(残タスク) - -Phase 1(MVP)はデプロイ済み・稼働中。残るのは運用開始前の最終調整と、その後の Phase 1.5。 +## 次に取り掛かるフェーズ -### MVP 仕上げ(運用開始前の最終調整) +**Phase 1(MVP)は完了。デモンストレーションも実施済み。** -操作モデルは「QR → 受付プロフィール画面で確定」に揃っている。残タスク: +運用開始前の最終調整として挙げていた残タスク(受付画面の実機検証 / 採番衝突の自動リトライ / +運用手順の文書化 / 昨年度データの D1 移行)は、2026-05-29 に **不要と判断して取り下げた**。 +同時タップ起因の採番衝突などは、発生時に手動再試行で回復する運用で確定(`apps/api/src/lib/checkin.ts` +の TODO は将来 problem 化した場合のみ着手。`docs/mvp.md` 4.2 / 10.3 も同方針に更新済み)。 -1. **受付プロフィール画面の本番リハーサル / 実機検証** - - 複数受付タブレットの同時利用、同時タップ / Wi-Fi 切断時の挙動・状態復帰を実機で確認 - - 「チェックインしました」のフィードバック視認性、戻る導線 -2. **同時アクティベート時の採番衝突リトライ** - - `apps/api/src/lib/checkin.ts` の TODO。PK 衝突時に最大3回のリトライ(現状は手動再試行で運用) -3. **運用手順の確定** - - Wi-Fi 断フォールバック、受付開始〜終了の通し手順、権限者向け手順を文書化 -4. **昨年度データの D1 反映** - - 個人情報を持ち込まず、participants / events / sessions の最小構成で移行 - - JST/UTC 変換と参照整合(participant_id, event_id)を検証、ロールバック手順整備 +次に着手するのは Phase 1.5(運用開始後の機能拡張)。 ### Phase 1.5(運用開始後) - メンタースマホアプリ(`apps/mentor` — 未着手。30 分グリッドのログ記入・未記入ハイライト) - 活動ログ記入機能、活動カテゴリ・機材マスタ管理 - ログ CSV エクスポート +- ターム境界の締め自動化(現状は受付端末「受付りれき」からの手動一括チェックアウト運用。 + `docs/mvp.md` §3.2 / 7章参照) --- From 49ea36f9f2cfc4de58843dcb5eb98f6f3b9b4f00 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:44:47 +0900 Subject: [PATCH 09/12] chore: add motion to checkin Add the motion package (Framer Motion's current name, imported from motion/react and compatible with React 19) as a checkin dependency for profile-screen UI animations. Co-Authored-By: Claude Opus 4.8 --- apps/checkin/package.json | 1 + pnpm-lock.yaml | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) 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/pnpm-lock.yaml b/pnpm-lock.yaml index b0591f1..1b8601f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: better-auth: specifier: ^1.6.9 version: 1.6.9(@cloudflare/workers-types@4.20260502.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260502.1)(kysely@0.28.17))(next@16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.2.4 version: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2382,6 +2385,20 @@ packages: engines: {node: '>=18'} hasBin: true + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2498,6 +2515,26 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4460,6 +4497,15 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + framer-motion@12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.3: optional: true @@ -4546,6 +4592,20 @@ snapshots: - bufferutil - utf-8-validate + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + nanoid@3.3.12: {} nanostores@1.3.0: {} From 790aaf50927bbc1800892fb44564c6debd097f1f Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:44:54 +0900 Subject: [PATCH 10/12] feat: add reduced-motion-aware AnimatedNumber to checkin Reusable count-up component that animates 0 -> value and falls back to the final value instantly when the user prefers reduced motion. Co-Authored-By: Claude Opus 4.8 --- .../src/components/animated-number.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/checkin/src/components/animated-number.tsx 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}; +} From efd0aedc4cce664bb717cfa530dd7977950b015d Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:45:03 +0900 Subject: [PATCH 11/12] feat: elevate reception participant profile with motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cohesive visual pass on the reception profile screen, staying within the existing sky/emerald + LINE Seed JP + PanelHeader language: - make 参加回数 a hero block with a count-up and icon'd breakdown chips - treat the visit heatmap as a build log: tiles pop in with a staggered reveal (delay capped so large grids stay snappy) - add a pulsing presence dot, stat icons, count-up on 来場回数, and tap feedback on the check-in/out action - gentle gradient background for depth All animation respects prefers-reduced-motion. No data, state machine, or shared-component changes. Co-Authored-By: Claude Opus 4.8 --- .../app/reception/participants/[id]/page.tsx | 621 ++++++++++-------- 1 file changed, 363 insertions(+), 258 deletions(-) diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index c966ccf..a05a8d8 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -3,9 +3,16 @@ import { IconAlertCircle, IconArrowBack, + IconAward, + IconCalendarEvent, + IconCalendarPlus, IconCalendarStats, + IconCircleX, + IconClock, + IconDoorEnter, IconHistory, IconHome, + IconHourglass, IconLogin2, IconLogout2, IconUser, @@ -28,9 +35,11 @@ import { } 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 { AnimatedNumber } from '@/components/animated-number'; import { PanelHeader } from '@/components/panel-header'; import { ResultSummaryCard } from '@/components/result-summary-card'; import { @@ -77,6 +86,10 @@ const formatHistoryDuration = (minutes: number | null, isPresent: boolean): stri const MIN_ATTENDANCE_TILE_COUNT = 35; +// ヒートマップの 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', month: 'numeric', @@ -145,7 +158,7 @@ const buildAttendanceTileSlots = ( function LoadingScreen() { return ( -
    +
    @@ -193,6 +206,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)) { @@ -223,16 +237,19 @@ 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]); @@ -244,14 +261,17 @@ export default function ReceptionParticipantPage() { { label: '総来場回数', value: `${profile.stats.visitCount}回`, + Icon: IconDoorEnter, }, { label: '来場日数', value: `${profile.stats.visitDayCount}日`, + Icon: IconCalendarEvent, }, { label: '無効な来場回数', value: `${profile.stats.uncountedVisitCount}回`, + Icon: IconCircleX, }, ]; }, [profile]); @@ -305,296 +325,381 @@ export default function ReceptionParticipantPage() { { label: '滞在時間', value: formatDuration(data.stayDurationMinutes) }, ]; return ( - : } - rows={resultRows} - footer={ - - } - /> + + : } + rows={resultRows} + footer={ + + } + /> + ); } 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) : 'まだありません'}

    -
    - -
    -
    -

    参加回数

    -

    - {profile.stats.participationCount} - -

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

    {item.label}

    -

    - {item.value} + +

    +
    +

    +

    +

    + + +

    +
    + {participationBreakdown.map((item) => ( +
    +
    +
    +
    + {item.value} +
    +
    + ))} +
    - ))} -
    - - - -
    - - : - } - title="受付操作" - tone={isCheckIn ? 'emerald' : 'amber'} - /> - -

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

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

    +

    +

    + {item.value} +

    +
    + ))} +
    + - - } - title="来場回数" - tone="emerald" - /> - -
    -

    - {profile.stats.visitCount} - +

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

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

    -
    - - - {[1, 2, 3, 4].map((intensity) => ( - - - + + + + + + + + + + } + title="来場回数" + tone="emerald" + /> + +
    +

    + + +

    +
    + + + {[1, 2, 3, 4].map((intensity) => ( + + + +
    -
    -
      - {attendanceTileSlots.map((tile, index) => { - // 空きスロット(パディング)はニュートラルな空タイル。 - if (!tile) { - return ( -
    • - ); - } - - const baseLabel = `${tile.label}${ - tile.termLabel ? ` ${tile.termLabel}` : '' - } ${formatDuration(tile.stayDurationMinutes)}${tile.isPresent ? ' 経過' : ''}`; - - // カウント対象外の来場は色を付けず、× アイコンで「来たが無効」を示す。 - if (!tile.counted) { - const label = `${baseLabel}・カウント対象外`; +
        + {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 ( -
      • -
      • + className={`aspect-square rounded-md border ${attendanceIntensityClasses[tile.intensity]}`} + title={baseLabel} + aria-label={baseLabel} + initial={tileEntrance} + animate={{ opacity: 1, scale: 1 }} + transition={tileTransition} + /> ); - } - - // カウント対象の来場は滞在時間の濃淡で色付け。 - return ( -
      • - ); - })} -
      - - + })} +
    +
    +
    +
    - - } 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 && } -
    -
    + + + } + 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 && } +
    +
    +
    + ))} +
    + +
    + )} + + +
); From 2001d8a4b9e602e81f5202ed90b18e9bbe13dad9 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 18:54:10 +0900 Subject: [PATCH 12/12] feat: update attendance tile grid logic and increase minimum tile count --- .../app/reception/participants/[id]/page.tsx | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index a05a8d8..74457b7 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -84,7 +84,9 @@ 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; @@ -146,16 +148,6 @@ const buildAttendanceTiles = ( }); }; -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 (
@@ -281,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; @@ -526,17 +551,18 @@ export default function ReceptionParticipantPage() { - + } title="来場回数" tone="emerald" /> - +

@@ -560,7 +586,10 @@ export default function ReceptionParticipantPage() {

-
    +
      {attendanceTileSlots.map((tile, index) => { const tileEntrance = prefersReduced ? undefined : { opacity: 0, scale: 0.6 }; const tileTransition = {