From b0859c62c1802d920ad80d8120bb55d8e71a8aba Mon Sep 17 00:00:00 2001 From: tmba Date: Thu, 28 May 2026 20:41:46 +0900 Subject: [PATCH 01/32] docs: unify branding to tec-nova Nagasaki and correct host attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the project name to "tec-nova Nagasaki" (with テクノバながさき gloss on first mention) and the host fact to "長崎市と長崎大学による共同事業" across requirements.md and CLAUDE.md, matching the README revamp. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- docs/requirements.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/docs/requirements.md b/docs/requirements.md index 8f65ad9..c6295ba 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,7 @@ | 項目 | 内容 | | ---------------------- | ---------------------------------------------------------- | | ドキュメントバージョン | v1.3 | -| 対象システム | テクノバながさき統合管理プラットフォーム | +| 対象システム | tec-nova Nagasaki 統合管理プラットフォーム | | 運用状況 | Phase 1(MVP)を本番デプロイ・稼働中 | | 関連ドキュメント | 下表参照 | @@ -22,13 +22,13 @@ ### 1.1 背景 -テクノバながさきは長崎大学NUTICで開催される子ども向けファブリケーション活動である。週3日の開催日に小学1年生から高校3年生までを中心とした利用者が自由来場し、3Dプリンタ、プログラミング、マイクラ、ロボット、3Dモデリングなど多様な技術活動に取り組む。 +tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学による共同事業として開催される子ども向けファブリケーション活動である。週3日の開催日に小学1年生から高校3年生までを中心とした利用者が自由来場し、3Dプリンタ、プログラミング、マイクラ、ロボット、3Dモデリングなど多様な技術活動に取り組む。 本活動は研究対象でもあり、参加者属性のクラスター分析等の研究データ収集も並行して行われている。 ### 1.2 目的 -本プロジェクトは、テクノバながさきの運営を支える統合管理プラットフォームを内製で開発するものである。コアな目的は以下の4点。 +本プロジェクトは、tec-nova Nagasaki の運営を支える統合管理プラットフォームを内製で開発するものである。コアな目的は以下の4点。 1. 来場・退出のチェックインシステムとしての基本機能の安定化 2. メンターによる活動ログ記入のDX化(従来スプシ運用のボトルネック解消) From a10e95af6d30a56faee0cf000397d57ae3139d51 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:58:36 +0900 Subject: [PATCH 02/32] docs: specify term-based participation count, 30-minute rule, and venue-wide aggregation Co-Authored-By: Claude Opus 4.8 --- docs/mvp.md | 65 ++++++++++++++++++++++++++++++++++++++++---- docs/requirements.md | 28 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) 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 | From f90d55d16d584ec43315a700510ab170cf59ffbb Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:58:43 +0900 Subject: [PATCH 03/32] feat: add venue-schedule module for term classification and 30-minute counting rule Pure, Workers-safe (Intl only) logic shared by API and frontends. Terms: morning 9-12 / afternoon 13-16 / evening 16-19 (JST). countsTowardParticipation applies the 'arrived <30min before term end is not counted' rule. Co-Authored-By: Claude Opus 4.8 --- packages/shared/package.json | 3 +- packages/shared/src/venue-schedule.ts | 113 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/venue-schedule.ts 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/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; +}; From 7db3e1da5b384adbb3450d27500dd75fa2da113c Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:58:49 +0900 Subject: [PATCH 04/32] feat: extend shared schemas with participation count and participation summary Adds stats.participationCount plus per-session term/counted to the profile response, and participationSummaryQuery/Response for the venue-wide aggregation endpoint. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/schemas/admin.ts | 38 ++++++++++++++++++++++++++ packages/shared/src/schemas/checkin.ts | 7 ++++- 2 files changed, 44 insertions(+), 1 deletion(-) 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(), }), ), From 0c762be868432b200e0867c618186a56db755b2a Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:58:57 +0900 Subject: [PATCH 05/32] feat: derive participation count and per-session term in participant profile API fetchParticipantProfile joins events for the JST date, classifies each session's term, applies the 30-min rule, and counts distinct (date, term) pairs. visitCount is kept for backward compatibility. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/lib/checkin.ts | 25 +++++++++++++++++++++++++ apps/api/src/routes/checkin.ts | 3 +++ 2 files changed, 28 insertions(+) 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/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, })), }); }); From eea5313622d2e1c3c2317df60c834789c0e756ba Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:59:04 +0900 Subject: [PATCH 06/32] feat: add /api/stats/participation venue-wide aggregation endpoint Aggregates participation counts by term and date over an optional date range. Counting (the 30-min rule) cannot be expressed in SQL, so candidate sessions are fetched and deduped per (date, term, participant) in JS. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/lib/admin.ts | 76 +++++++++++++++++++++++++++++++++++- apps/api/src/routes/admin.ts | 12 ++++++ 2 files changed, 87 insertions(+), 1 deletion(-) 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/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({ From b5a83f932b2aad612236e1859c6139f8d861c7a8 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:59:11 +0900 Subject: [PATCH 07/32] feat: surface participation count and term badges in checkin reception UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile shows 参加回数 (participationCount) and tags each session with its term and a カウント対象外 badge when the 30-min rule excludes it; today's history derives term client-side. Co-Authored-By: Claude Opus 4.8 --- apps/checkin/src/app/history/page.tsx | 36 +++++++++---- .../app/reception/participants/[id]/page.tsx | 53 ++++++++++++++----- 2 files changed, 64 insertions(+), 25 deletions(-) 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 && ( + + カウント対象外 + + )} +
))} From 32aa8fa67798d99bf3c1f9edaff3df14e3ed243f Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 14:59:19 +0900 Subject: [PATCH 08/32] feat: add admin participation stats page with term badges and nav entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 集計 page renders KPI cards and a per-date/per-term table from /api/stats/participation. Participant detail shows 参加回数; dashboard gains a term column; nav gets a 集計 link. Co-Authored-By: Claude Opus 4.8 --- apps/admin/src/app/(authed)/page.tsx | 55 +++-- apps/admin/src/app/(authed)/stats/page.tsx | 220 ++++++++++++++++++ apps/admin/src/components/app-shell.tsx | 2 + .../components/participant-detail-sheet.tsx | 22 +- 4 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 apps/admin/src/app/(authed)/stats/page.tsx 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)} From 136c93f207354e6a8fa9e8acbaf98175dd940638 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 16:05:31 +0900 Subject: [PATCH 09/32] 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 10/32] 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 11/32] 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 12/32] 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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 19/32] 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 20/32] 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 = { From 5d1887178f1db5752485552e7e816bee65899d44 Mon Sep 17 00:00:00 2001 From: tmba Date: Fri, 29 May 2026 23:52:59 +0900 Subject: [PATCH 21/32] feat: add .vercel and .superpowers to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 2a5235818f153bc4e9a01e2bc9069d2d67806898 Mon Sep 17 00:00:00 2001 From: tmba Date: Sat, 30 May 2026 00:00:01 +0900 Subject: [PATCH 22/32] docs: add signage + chime app design spec Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-05-29-signage-chime-design.md | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-signage-chime-design.md diff --git a/docs/superpowers/specs/2026-05-29-signage-chime-design.md b/docs/superpowers/specs/2026-05-29-signage-chime-design.md new file mode 100644 index 0000000..d643885 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-signage-chime-design.md @@ -0,0 +1,296 @@ +# サイネージ+チャイム アプリ(`apps/signage`)設計 + +- 作成日: 2026-05-29 +- ステータス: 設計合意済み(実装計画はこの後に作成) +- 関連: `docs/architecture.md`(拡張ロードマップに本アプリを追記予定) + +--- + +## 背景・目的(Context) + +会場(tec-nova Nagasaki)の大型モニターに常時表示する**デジタルサイネージ兼チャイム**アプリを新設する。 +現状、サイネージ/チャイム/大型モニターは `requirements.md` / `mvp.md` / `architecture.md` のいずれにも記載がなく、本アプリは**新規スコープ**である。 + +会場運用は「**活動50分 → 休憩10分**」のリズムで進む(例: 9:00–9:50 活動 / 9:50–10:00 休憩 / 10:00 再開 …)。 +このリズムの区切りを**チャイム**で知らせ、普段は**動画を流し**つつ、時間が近づくと**シームレスに画面を切り替える**ことで、子どもと運営の双方に「いまは活動/休憩」「次の区切りまであと何分」を直感的に伝える。 + +狙う成果: +- 区切りの合図(チャイム)と視覚表示を自動化し、運営の手間と「時間を見失う」事故を減らす。 +- 動画で会場の雰囲気をつくる(ウェルカム・空気づくり)。 +- 既存プラットフォームの**チェックインデータ**を稼働シグナルに使い、無人でも「今日・このタームが稼働中か」を正しく判定する。 + +--- + +## スコープ + +### v1 に含む +- 動画フルスクリーン再生(ループ・プレイリスト)+常時表示の情報バー(**レイアウト L2**)。 +- **50/10サイクル**に連動した状態遷移(活動↔休憩↔待機)と**シームレスなクロスフェード**。 +- **チャイム**(Web Audio 合成、種別ごとに音色を変える)。 +- **在館人数のライブ表示**(個人情報なし)と、**ターム最初のチェックインで稼働開始**するデータ駆動ロジック。 +- 休憩中画面は**「再開まで M:SS」のカウントダウンを主役**に。 +- 営業時間外・昼休みの**待機(ロゴ)画面**。 +- キオスク運用(フルスクリーン・横向き・スリープ防止・起動時タップで音声解放)。 + +### v1 に含まない(将来拡張) +- admin での動画CMS(アップロード・並べ替え)。 +- ニックネーム表示(v1は人数のみ)。 +- 任意時刻のカスタムアナウンス登録。 +- YouTube/Vimeo 埋め込み(v1は HTML5 `
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/components/segmented-control.tsx b/apps/checkin/src/components/segmented-control.tsx new file mode 100644 index 0000000..643f22d --- /dev/null +++ b/apps/checkin/src/components/segmented-control.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { motion, useReducedMotion } from 'motion/react'; +import { type ReactNode, useId } from 'react'; + +type SegmentedOption = { value: T; label: string; icon?: ReactNode }; + +// 2 値のモード切替。選択中インジケータが layoutId でスライドする。reduced 時は即切替。 +export function SegmentedControl({ + options, + value, + onChange, + ariaLabel, +}: { + options: SegmentedOption[]; + value: T; + onChange: (value: T) => void; + ariaLabel: string; +}) { + const prefersReduced = useReducedMotion(); + const layoutId = useId(); + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +} From d83675ea3009b15d09425eac9bddd2a513403501 Mon Sep 17 00:00:00 2001 From: tmba Date: Sat, 30 May 2026 02:02:54 +0900 Subject: [PATCH 31/32] feat(checkin): add scan-to-profile view transition Enable React view transitions and morph the scan-lock puck into the participant profile header; reduced-motion safe. Co-Authored-By: Claude Opus 4.8 --- apps/checkin/next.config.ts | 3 + apps/checkin/src/app/layout.tsx | 1 + .../app/reception/participants/[id]/page.tsx | 253 +++++++++--------- apps/checkin/src/app/view-transitions.css | 22 ++ 4 files changed, 151 insertions(+), 128 deletions(-) create mode 100644 apps/checkin/src/app/view-transitions.css 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/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/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx index 74457b7..9682c81 100644 --- a/apps/checkin/src/app/reception/participants/[id]/page.tsx +++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx @@ -38,7 +38,7 @@ 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'; @@ -350,27 +350,20 @@ export default function ReceptionParticipantPage() { { label: '滞在時間', value: formatDuration(data.stayDurationMinutes) }, ]; return ( - - : } - rows={resultRows} - footer={ - - } - /> - + : } + rows={resultRows} + footer={ + + } + /> ); } @@ -380,122 +373,126 @@ export default function ReceptionParticipantPage() {
- - - -
-
-
-
-
- - {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.current.checkedInAt + ? formatJapaneseDateTime(profile.current.checkedInAt) + : 'まだありません'}

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

-

+
+

+

-

- {item.value} +

+ +

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

+

+

+ {item.value} +

+
+ ))} +
+ + + +
Date: Sat, 30 May 2026 02:09:21 +0900 Subject: [PATCH 32/32] fix(checkin): gate manual mode-switch transition under reduced-motion The exit crossfade still played a 0.2s fade when motion is reduced; make it instant for consistency with the rest of the app. Co-Authored-By: Claude Opus 4.8 --- apps/checkin/src/app/manual/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/checkin/src/app/manual/page.tsx b/apps/checkin/src/app/manual/page.tsx index 881d821..b78c74e 100644 --- a/apps/checkin/src/app/manual/page.tsx +++ b/apps/checkin/src/app/manual/page.tsx @@ -49,7 +49,7 @@ export default function ManualPage() { initial={prefersReduced ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -8 }} - transition={{ duration: 0.2, ease: 'easeOut' }} + transition={prefersReduced ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }} > {mode === 'id' ? : }