diff --git a/.claude/skills/pre-pr-check/SKILL.md b/.claude/skills/pre-pr-check/SKILL.md index 84c8ab1..2fb3f0c 100644 --- a/.claude/skills/pre-pr-check/SKILL.md +++ b/.claude/skills/pre-pr-check/SKILL.md @@ -19,9 +19,10 @@ description: Pre-PR verification gate for tecnova-platform. Use before committin ```bash pnpm type-check ``` -3. **テスト(CI は実行しない・shared を触ったら必須)**: +3. **テスト(CI は実行しない・該当パッケージを触ったら必須)**: ```bash pnpm --filter @tecnova/shared test + pnpm --filter @tecnova/api test ``` 4. **シークレット/PII 走査**(このスキルに同梱): ```bash diff --git a/.gitignore b/.gitignore index 1981252..710c136 100644 --- a/.gitignore +++ b/.gitignore @@ -229,4 +229,6 @@ temp/ drizzle/.snapshot.* .vercel -.superpowers \ No newline at end of file +.superpowers +# Playwright MCP の作業ファイル(スクリプト・スクリーンショット) +.playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index ddd6710..6b6ac58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,8 @@ tecnova-platform/ └── docs/ # 設計ドキュメント ``` -`apps/mentor`(メンタースマホPWA)は Phase 1.5 でスコープ化されるが現時点では未実装。 +メンターアプリ(旧 `apps/mentor` 構想)は 2026-10 の計画見直しで取りやめた。子ども向けの記録入力(Phase 1.5)は +方式検討中で、自前で作る場合は `apps/` 配下に追加する(`docs/requirements.md` §3.5)。 Better Auth の設定は `packages/auth` ではなく `apps/api/src/lib/auth.ts` に集約している (リクエスト毎に instance を作る制約上、Workers の Env を直接受け取れる場所に置くのが自然なため)。 @@ -251,6 +252,10 @@ pnpm biome check --write . # 型チェック pnpm type-check +# テスト(CI では走らない。PR 前に手元で実行) +pnpm --filter @tecnova/shared test +pnpm --filter @tecnova/api test + # DB マイグレーション生成(drizzle-kit が SQL を packages/db/drizzle/ に出力) pnpm --filter @tecnova/db db:generate diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx index 602cf01..0fb9e41 100644 --- a/apps/admin/src/app/(authed)/page.tsx +++ b/apps/admin/src/app/(authed)/page.tsx @@ -4,15 +4,22 @@ import { IconCalendarOff, IconLogin2, IconLogout2, + IconPlus, IconRefresh, IconUserCheck, } from '@tabler/icons-react'; -import type { EventsListResponse, TodaySessionsResponse } from '@tecnova/shared/schemas'; +import type { + EventsListResponse, + SessionMutationResponse, + TodaySessionItem, + TodaySessionsResponse, +} from '@tecnova/shared/schemas'; import { toJstDateString } from '@tecnova/shared/venue-schedule'; import { Badge } from '@tecnova/ui/components/badge'; import { Button } from '@tecnova/ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card'; import { DataError } from '@tecnova/ui/components/data-error'; +import { useMe } from '@tecnova/ui/components/me-provider'; import { Select, SelectContent, @@ -32,12 +39,19 @@ import { import { TableSkeleton } from '@tecnova/ui/components/table-skeleton'; import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge'; import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource'; -import { useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { AnimatedNumber } from '@/components/animated-number'; import { PageHeader } from '@/components/page-header'; import { ParticipantDetailSheet } from '@/components/participant-detail-sheet'; import { RecordCard, RecordField } from '@/components/record-card'; import { Reveal } from '@/components/reveal'; +import { SessionDeleteDialog } from '@/components/session-delete-dialog'; +import { + SessionFormDialog, + type SessionFormRequest, + type SessionRecord, +} from '@/components/session-form-dialog'; +import { SessionRowActions } from '@/components/session-row-actions'; // セレクタで「今日」を選んでいる状態のセンチネル値。 // 空文字や undefined を使うと Select の制御値として扱いにくいのでこの形に。 @@ -51,6 +65,14 @@ const fmtTime = (iso: string): string => minute: '2-digit', }).format(new Date(iso)); +// 一覧の行から編集・削除の対象を組み立てる。 +const toRecord = (s: TodaySessionItem): SessionRecord => ({ + sessionId: s.sessionId, + participant: { id: s.participantId, fullName: s.fullName, nickname: s.nickname, grade: s.grade }, + checkedInAt: s.checkedInAt, + checkedOutAt: s.checkedOutAt, +}); + export default function DashboardPage() { const [selectedDate, setSelectedDate] = useState(TODAY_VALUE); const [selectedParticipantId, setSelectedParticipantId] = useState(null); @@ -71,6 +93,22 @@ export default function DashboardPage() { // 今日の event が events に含まれていてもメニューの重複は避ける。 const pastEvents = events.filter((e) => e.date !== today); + // 来場記録の修正は admin のみ(API も requireAdmin で守っている)。 + const me = useMe(); + const isAdmin = me.mentor.role === 'admin'; + const [formRequest, setFormRequest] = useState(null); + const [deleteRecord, setDeleteRecord] = useState(null); + + const refreshAll = () => { + sessions.reload({ background: true }); + eventsResource.reload({ background: true }); + }; + const handleSaved = (result: SessionMutationResponse) => { + refreshAll(); + // 記録が別の日付に属する場合(過去日の追加・日付の変更)は、その日付へ表示を切り替える。 + setSelectedDate(result.eventDate === today ? TODAY_VALUE : result.eventDate); + }; + return (
@@ -102,6 +140,22 @@ export default function DashboardPage() { 更新 + {isAdmin && ( + + )} } /> @@ -113,6 +167,17 @@ export default function DashboardPage() { setSelectedParticipantId(id)} + renderActions={ + isAdmin + ? (s) => ( + setFormRequest({ mode: 'edit', record: toRecord(s) })} + onDelete={() => setDeleteRecord(toRecord(s))} + /> + ) + : undefined + } /> @@ -121,6 +186,17 @@ export default function DashboardPage() { onOpenChange={(open) => { if (!open) setSelectedParticipantId(null); }} + onSessionsChanged={refreshAll} + /> + setFormRequest(null)} + onSaved={handleSaved} + /> + setDeleteRecord(null)} + onDeleted={refreshAll} />
); @@ -129,9 +205,12 @@ export default function DashboardPage() { function DashboardBody({ sessions, onSelectParticipant, + renderActions, }: { sessions: ResourceState; onSelectParticipant: (id: string) => void; + // admin のときだけ渡される行ごとの操作(「…」メニュー) + renderActions?: (s: TodaySessionItem) => ReactNode; }) { if (sessions.kind === 'loading' || sessions.kind === 'idle') { return ( @@ -188,9 +267,12 @@ function DashboardBody({ {s.fullName}・{s.grade}

- - {s.isPresent ? '来場中' : '退出済'} - +
+ + {s.isPresent ? '来場中' : '退出済'} + + {renderActions &&
{renderActions(s)}
} +
{s.term && ( @@ -226,12 +308,17 @@ function DashboardBody({ チェックイン チェックアウト 状態 + {renderActions && ( + + 操作 + + )} {rows.length === 0 ? ( - +
@@ -270,6 +357,9 @@ function DashboardBody({ {s.isPresent ? '来場中' : '退出済'} + {renderActions && ( + {renderActions(s)} + )} )) )} diff --git a/apps/admin/src/components/participant-detail-sheet.tsx b/apps/admin/src/components/participant-detail-sheet.tsx index fce9f4e..43aa31b 100644 --- a/apps/admin/src/components/participant-detail-sheet.tsx +++ b/apps/admin/src/components/participant-detail-sheet.tsx @@ -1,8 +1,12 @@ 'use client'; +import { IconPlus } from '@tabler/icons-react'; import type { ParticipantProfileResponse } from '@tecnova/shared/schemas'; +import { toJstDateString } from '@tecnova/shared/venue-schedule'; import { Badge } from '@tecnova/ui/components/badge'; +import { Button } from '@tecnova/ui/components/button'; import { DataError } from '@tecnova/ui/components/data-error'; +import { useMe } from '@tecnova/ui/components/me-provider'; import { Sheet, SheetContent, @@ -22,10 +26,21 @@ import { import { TermBadge } from '@tecnova/ui/components/term-badge'; import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; import { formatJstDate } from '@tecnova/ui/lib/format'; +import { useState } from 'react'; +import type { PickedParticipant } from './participant-picker'; +import { SessionDeleteDialog } from './session-delete-dialog'; +import { + SessionFormDialog, + type SessionFormRequest, + type SessionRecord, +} from './session-form-dialog'; +import { SessionRowActions } from './session-row-actions'; interface Props { participantId: string | null; onOpenChange: (open: boolean) => void; + // 来場記録を修正したときに親(ダッシュボード)の一覧も再取得させる + onSessionsChanged?: () => void; } // ISO 文字列を JST の 'YYYY/MM/DD HH:mm' に整形する。空なら '—'。 @@ -61,65 +76,122 @@ const fmtMinutes = (minutes: number | null): string => { return h > 0 ? `${h}時間${m}分` : `${m}分`; }; -export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { +export function ParticipantDetailSheet({ participantId, onOpenChange, onSessionsChanged }: Props) { const open = participantId !== null; // 開いているあいだだけ取得する(閉じている=path=null で idle)。 - const { state } = useApiResource( + const { state, reload } = useApiResource( participantId ? `/checkin/participants/${encodeURIComponent(participantId)}` : null, ); + // 来場記録の修正は admin のみ(API も requireAdmin で守っている)。 + const me = useMe(); + const isAdmin = me.mentor.role === 'admin'; + const [formRequest, setFormRequest] = useState(null); + const [deleteRecord, setDeleteRecord] = useState(null); + + // 参加回数などの集計は参照時に計算されるので、詳細を取り直せば修正が反映される。 + const handleChanged = () => { + reload({ background: true }); + onSessionsChanged?.(); + }; return ( - - - - - {state.kind === 'ok' ? state.data.participant.fullName : '利用者の詳細'} - - - {state.kind === 'ok' - ? `${state.data.participant.nickname}(${state.data.participant.grade})` - : '来場履歴と現在の状態を表示します'} - - + <> + + + + + {state.kind === 'ok' ? state.data.participant.fullName : '利用者の詳細'} + + + {state.kind === 'ok' + ? `${state.data.participant.nickname}(${state.data.participant.grade})` + : '来場履歴と現在の状態を表示します'} + + -
- {state.kind === 'loading' && ( - // 実コンテンツ(DetailBody)と同じ骨格のプレースホルダにして、 - // 読み込み完了時のレイアウトの飛びをなくす。 - <> -
-
- - -
-
- {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( - - ))} -
-
-
- -
- {[0, 1, 2, 3].map((i) => ( - - ))} -
-
- - )} +
+ {state.kind === 'loading' && ( + // 実コンテンツ(DetailBody)と同じ骨格のプレースホルダにして、 + // 読み込み完了時のレイアウトの飛びをなくす。 + <> +
+
+ + +
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( + + ))} +
+
+
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ + )} - {state.kind === 'error' && } + {state.kind === 'error' && } - {state.kind === 'ok' && } -
- - + {state.kind === 'ok' && ( + + setFormRequest({ mode: 'create', date: toJstDateString(new Date()), participant }) + } + onEdit={(record) => setFormRequest({ mode: 'edit', record })} + onDelete={(record) => setDeleteRecord(record)} + /> + )} +
+
+
+ setFormRequest(null)} + onSaved={handleChanged} + /> + setDeleteRecord(null)} + onDeleted={handleChanged} + /> + ); } -function DetailBody({ data }: { data: ParticipantProfileResponse }) { +function DetailBody({ + data, + isAdmin, + onAdd, + onEdit, + onDelete, +}: { + data: ParticipantProfileResponse; + isAdmin: boolean; + onAdd: (participant: PickedParticipant) => void; + onEdit: (record: SessionRecord) => void; + onDelete: (record: SessionRecord) => void; +}) { const { participant, stats, current, sessions } = data; + const picked: PickedParticipant = { + id: participant.id, + fullName: participant.fullName, + nickname: participant.nickname, + grade: participant.grade, + }; + const toRecord = (s: ParticipantProfileResponse['sessions'][number]): SessionRecord => ({ + sessionId: s.sessionId, + participant: picked, + checkedInAt: s.checkedInAt, + checkedOutAt: s.checkedOutAt, + }); return ( <>
@@ -153,7 +225,15 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) {
-

来場履歴

+
+

来場履歴

+ {isAdmin && ( + + )} +
{sessions.length === 0 ? (

まだ来場履歴はありません @@ -163,9 +243,20 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) { - 入室 - 退室 - 滞在 + + 入室 + + + 退室 + + + 滞在 + + {isAdmin && ( + + 操作 + + )} @@ -189,6 +280,15 @@ function DetailBody({ data }: { data: ParticipantProfileResponse }) { {fmtMinutes(s.stayDurationMinutes)} + {isAdmin && ( + + onEdit(toRecord(s))} + onDelete={() => onDelete(toRecord(s))} + /> + + )} ))} diff --git a/apps/admin/src/components/participant-picker.tsx b/apps/admin/src/components/participant-picker.tsx new file mode 100644 index 0000000..172808f --- /dev/null +++ b/apps/admin/src/components/participant-picker.tsx @@ -0,0 +1,95 @@ +'use client'; + +import type { ParticipantsListResponse } from '@tecnova/shared/schemas'; +import { Button } from '@tecnova/ui/components/button'; +import { Input } from '@tecnova/ui/components/input'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; +import { useState } from 'react'; + +export interface PickedParticipant { + id: string; + fullName: string; + nickname: string; + grade: string; +} + +// 一覧・確認ダイアログでも同じ表記にするため export する。メイン識別子はニックネーム。 +export const participantLabel = (p: PickedParticipant): string => + `${p.nickname}(${p.fullName})・${p.grade}・${p.id}`; + +interface Props { + value: PickedParticipant | null; + onChange: (participant: PickedParticipant | null) => void; + disabled?: boolean; +} + +// 来場記録の子を選ぶ。選択済みなら表示+「変更」(=別の子への付け替え)、未選択なら検索欄を出す。 +// 検索は既存の /api/participants?search=(ID・氏名・ニックネームの部分一致)を使う。 +export function ParticipantPicker({ value, onChange, disabled }: Props) { + const [query, setQuery] = useState(''); + const trimmed = query.trim(); + const { state } = useApiResource( + value === null && trimmed.length > 0 + ? `/api/participants?search=${encodeURIComponent(trimmed)}&limit=8` + : null, + ); + + if (value !== null) { + return ( +
+ {participantLabel(value)} + +
+ ); + } + + return ( +
+ setQuery(e.target.value)} + placeholder="ID・氏名・ニックネームで検索" + aria-label="子どもを検索" + disabled={disabled} + /> + {state.kind === 'loading' &&

検索中...

} + {state.kind === 'error' &&

{state.message}

} + {state.kind === 'ok' && + (state.data.participants.length === 0 ? ( +

見つかりません

+ ) : ( +
    + {state.data.participants.map((p) => ( +
  • + +
  • + ))} +
+ ))} +
+ ); +} diff --git a/apps/admin/src/components/record-card.tsx b/apps/admin/src/components/record-card.tsx index 21a58d0..44b0195 100644 --- a/apps/admin/src/components/record-card.tsx +++ b/apps/admin/src/components/record-card.tsx @@ -16,7 +16,8 @@ const BASE = 'rounded-2xl border bg-card p-4 text-card-foreground shadow-xs'; // モバイルでテーブルの代わりに使うカード。全ページで見た目を揃えるための共通チップ。 // onClick を渡すとカード全面に重ねた + + + + + ); +} diff --git a/apps/admin/src/components/session-row-actions.tsx b/apps/admin/src/components/session-row-actions.tsx new file mode 100644 index 0000000..1cc6ff3 --- /dev/null +++ b/apps/admin/src/components/session-row-actions.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { IconDots, IconPencil, IconTrash } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@tecnova/ui/components/dropdown-menu'; + +interface Props { + // スクリーンリーダー向けの対象名(例: ニックネーム) + label: string; + onEdit: () => void; + onDelete: () => void; +} + +// 来場記録の行に付ける「…」メニュー。行やカード全体のクリック(利用者詳細を開く)と干渉しないよう、 +// トリガーとメニューのクリックは伝播を止める(メニューは portal だが React のイベントは親へ伝わるため)。 +// 編集・削除のダイアログは親で持ち、ここでは開く合図だけを出す。modal={false} にして、 +// メニューが閉じる前にダイアログが開いても body のポインター操作がロックされたままにならないようにする。 +export function SessionRowActions({ label, onEdit, onDelete }: Props) { + return ( + + + + + e.stopPropagation()} + > + + + 編集 + + + + 削除 + + + + ); +} diff --git a/apps/admin/src/lib/session-error-message.ts b/apps/admin/src/lib/session-error-message.ts new file mode 100644 index 0000000..17da2cc --- /dev/null +++ b/apps/admin/src/lib/session-error-message.ts @@ -0,0 +1,15 @@ +import { SESSION_RULE_MESSAGES, type SessionRuleViolation } from '@tecnova/shared/session-rules'; +import { ApiError, apiErrorMessage } from '@tecnova/ui/lib/api-client'; + +const isRuleViolation = (code: unknown): code is SessionRuleViolation => + typeof code === 'string' && Object.hasOwn(SESSION_RULE_MESSAGES, code); + +// API の SessionError({ error: code, message })を画面の文言にする。ルール違反は画面側の検証と +// 同じ shared の文言にそろえ、それ以外(記録が無い等)は API の message をそのまま使う。 +export const sessionErrorMessage = (e: unknown): string => { + if (e instanceof ApiError) { + const code = (e.body as { error?: unknown } | undefined)?.error; + if (isRuleViolation(code)) return SESSION_RULE_MESSAGES[code]; + } + return apiErrorMessage(e); +}; diff --git a/apps/api/package.json b/apps/api/package.json index b165297..cbaae62 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "type-check": "tsc --noEmit", + "type-check": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json", + "test": "vitest run", "db:apply:local": "wrangler d1 migrations apply tecnova-db --local", "db:apply:remote": "wrangler d1 migrations apply tecnova-db --remote" }, @@ -18,8 +19,10 @@ "hono": "^4.12.16" }, "devDependencies": { + "@cloudflare/vitest-plugin": "1.2.8", "@cloudflare/workers-types": "^4.20260502.1", "typescript": "^6.0.3", + "vitest": "4.1.11", "wrangler": "^4.87.0" } } diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 22e40c5..c1b28fc 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -3,6 +3,7 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { MentorError, type MentorErrorCode } from './admin'; import { CheckinError, type CheckinErrorCode } from './checkin'; import { PreRegistrationError, type PreRegistrationErrorCode } from './pre-registrations'; +import { SessionError, type SessionErrorCode } from './session-admin'; export const invalidBodyError = { error: 'INTERNAL' as const, message: 'invalid request body' }; export const invalidQueryError = { @@ -35,6 +36,16 @@ const checkinErrorStatus: Record = { SHEETS_WRITE_FAILED: 502, }; +const sessionErrorStatus: Record = { + SESSION_NOT_FOUND: 404, + PARTICIPANT_NOT_FOUND: 404, + CHECKOUT_BEFORE_CHECKIN: 400, + SPANS_DAYS: 400, + FUTURE_TIME: 400, + OPEN_SESSION_NOT_TODAY: 400, + OVERLAP: 409, +}; + // ドメインエラーを HTTP ステータス + JSON にマップして返す Hono の onError ハンドラ。 // 未知のエラーは 500 INTERNAL に丸める。各ルートで try/catch を書かずに済むよう // app.onError(apiErrorHandler) で一括登録して使う。 @@ -48,5 +59,8 @@ export const apiErrorHandler: ErrorHandler = (e, c) => { if (e instanceof PreRegistrationError) { return c.json({ error: e.code, message: e.message }, preRegistrationErrorStatus[e.code]); } + if (e instanceof SessionError) { + return c.json({ error: e.code, message: e.message }, sessionErrorStatus[e.code]); + } return c.json(internalError(e), 500); }; diff --git a/apps/api/src/lib/session-admin.ts b/apps/api/src/lib/session-admin.ts new file mode 100644 index 0000000..6b32ed4 --- /dev/null +++ b/apps/api/src/lib/session-admin.ts @@ -0,0 +1,232 @@ +import type * as schema from '@tecnova/db'; +import { events, participants, sessions } from '@tecnova/db'; +import type { + CreateSessionRequest, + SessionMutationResponse, + UpdateSessionRequest, +} from '@tecnova/shared/schemas'; +import { + findOverlappingSession, + SESSION_RULE_MESSAGES, + type SessionRuleViolation, + type SessionTimes, + validateSessionTimes, +} from '@tecnova/shared/session-rules'; +import { classifyVisit, toJstDateString } from '@tecnova/shared/venue-schedule'; +import { and, eq, ne, type SQL, sql } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; + +type Db = DrizzleD1Database; + +// 管理画面からの来場記録の追加・編集・削除(admin 専用)。受付の操作ミスを訂正するためのもの。 +// DB に制約が無いため、ルールはここと packages/shared/src/session-rules.ts で守る。 +// D1 にトランザクションが無いので、開催日の作成・記録の書き込み・空の開催日の削除は 1 回の db.batch にまとめる。 +// 設計: docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md + +export type SessionErrorCode = 'SESSION_NOT_FOUND' | 'PARTICIPANT_NOT_FOUND' | SessionRuleViolation; + +export class SessionError extends Error { + constructor( + public code: SessionErrorCode, + message: string, + ) { + super(message); + this.name = 'SessionError'; + } +} + +const sessionNotFound = () => new SessionError('SESSION_NOT_FOUND', '記録が見つかりません'); + +// 無効化した子も対象にする(過去の記録を直す場面があるため。受付端末側は従来どおり受け付けない)。 +const requireParticipant = async (db: Db, participantId: string): Promise => { + const [found] = await db + .select({ id: participants.id }) + .from(participants) + .where(eq(participants.id, participantId)) + .limit(1); + if (!found) { + throw new SessionError('PARTICIPANT_NOT_FOUND', `ID ${participantId} の子が見つかりません`); + } +}; + +// 同じ子・同じ開催日(JST)の既存記録。編集時は自分自身を除く。 +const findSiblingSessions = async ( + db: Db, + participantId: string, + date: string, + excludeSessionId?: string, +): Promise => { + const conditions: SQL[] = [eq(sessions.participantId, participantId), eq(events.date, date)]; + if (excludeSessionId !== undefined) conditions.push(ne(sessions.id, excludeSessionId)); + return db + .select({ checkedInAt: sessions.checkedInAt, checkedOutAt: sessions.checkedOutAt }) + .from(sessions) + .innerJoin(events, eq(sessions.eventId, events.id)) + .where(and(...conditions)); +}; + +const assertSessionRules = ( + times: SessionTimes, + siblings: readonly SessionTimes[], + now: Date, +): void => { + const violation: SessionRuleViolation | null = + validateSessionTimes(times, now) ?? + (findOverlappingSession(times, siblings, now) ? 'OVERLAP' : null); + if (violation) throw new SessionError(violation, SESSION_RULE_MESSAGES[violation]); +}; + +// 開催日の get-or-create(batch 用の文)。events.date は UNIQUE なので既にあれば何もしない。 +const ensureEvent = (db: Db, date: string) => + db.insert(events).values({ date }).onConflictDoNothing(); + +// date の開催日 id。batch 内で ensureEvent の後に評価されるので必ず見つかる。 +const eventIdOf = (date: string): SQL => + sql`(select ${events.id} from ${events} where ${events.date} = ${date})`; + +const parseCheckedOutAt = (value: string | null): Date | null => + value === null ? null : new Date(value); + +// 記録が 1 件も無くなった過去の開催日を消す(日付の選択肢・サイネージの「前回の開催」に空の日を残さない)。 +// 今日の開催日は受付中のチェックインと競合させないため消さない。 +const deleteEventIfEmptyPast = (db: Db, eventId: string, today: string) => + db + .delete(events) + .where( + and( + eq(events.id, eventId), + ne(events.date, today), + sql`not exists (select 1 from ${sessions} where ${sessions.eventId} = ${eventId})`, + ), + ); + +// PATCH の checkedOutAt は「省略 = 変えない」「null = 滞在中に戻す」を区別する。 +const resolveCheckedOutAt = ( + patch: UpdateSessionRequest['checkedOutAt'], + current: Date | null, +): Date | null => (patch === undefined ? current : parseCheckedOutAt(patch)); + +// 一覧(GET /api/sessions)と同じ形で返す。ターム・30分ルールは venue-schedule に集約。 +const toResponse = async (db: Db, sessionId: string): Promise => { + const [row] = await db + .select({ + sessionId: sessions.id, + participantId: sessions.participantId, + fullName: participants.fullName, + nickname: participants.nickname, + grade: participants.grade, + checkedInAt: sessions.checkedInAt, + checkedOutAt: sessions.checkedOutAt, + eventDate: events.date, + }) + .from(sessions) + .innerJoin(participants, eq(sessions.participantId, participants.id)) + .innerJoin(events, eq(sessions.eventId, events.id)) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!row) throw sessionNotFound(); + const { term, counted } = classifyVisit(row.checkedInAt); + return { + eventDate: row.eventDate, + session: { + sessionId: row.sessionId, + participantId: row.participantId, + fullName: row.fullName, + nickname: row.nickname, + grade: row.grade, + checkedInAt: row.checkedInAt.toISOString(), + checkedOutAt: row.checkedOutAt ? row.checkedOutAt.toISOString() : null, + isPresent: row.checkedOutAt === null, + term, + counted, + }, + }; +}; + +export const createSessionByAdmin = async ( + db: Db, + input: CreateSessionRequest, + now: Date, +): Promise => { + await requireParticipant(db, input.participantId); + const times: SessionTimes = { + checkedInAt: new Date(input.checkedInAt), + checkedOutAt: parseCheckedOutAt(input.checkedOutAt), + }; + // 開催日は入場時刻の JST 日付から決める(集計が「開催日 = 入場日」を前提にしているため)。 + const date = toJstDateString(times.checkedInAt); + assertSessionRules(times, await findSiblingSessions(db, input.participantId, date), now); + + // 検証を通ってから開催日を作るので、検証エラーで空の開催日は残らない。 + const id = crypto.randomUUID(); + await db.batch([ + ensureEvent(db, date), + db.insert(sessions).values({ + id, + participantId: input.participantId, + eventId: eventIdOf(date), + checkedInAt: times.checkedInAt, + checkedOutAt: times.checkedOutAt, + }), + ]); + return toResponse(db, id); +}; + +export const updateSessionByAdmin = async ( + db: Db, + sessionId: string, + patch: UpdateSessionRequest, + now: Date, +): Promise => { + const [current] = await db + .select({ + participantId: sessions.participantId, + eventId: sessions.eventId, + checkedInAt: sessions.checkedInAt, + checkedOutAt: sessions.checkedOutAt, + }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!current) throw sessionNotFound(); + + const participantId = patch.participantId ?? current.participantId; + if (participantId !== current.participantId) await requireParticipant(db, participantId); + + const times: SessionTimes = { + checkedInAt: + patch.checkedInAt === undefined ? current.checkedInAt : new Date(patch.checkedInAt), + checkedOutAt: resolveCheckedOutAt(patch.checkedOutAt, current.checkedOutAt), + }; + const date = toJstDateString(times.checkedInAt); + assertSessionRules(times, await findSiblingSessions(db, participantId, date, sessionId), now); + + await db.batch([ + ensureEvent(db, date), + db + .update(sessions) + .set({ + participantId, + eventId: eventIdOf(date), + checkedInAt: times.checkedInAt, + checkedOutAt: times.checkedOutAt, + }) + .where(eq(sessions.id, sessionId)), + // 日付が変わって元の開催日が空になった場合だけ消える(同じ日なら NOT EXISTS で何もしない)。 + deleteEventIfEmptyPast(db, current.eventId, toJstDateString(now)), + ]); + return toResponse(db, sessionId); +}; + +export const deleteSessionByAdmin = async (db: Db, sessionId: string, now: Date): Promise => { + const [current] = await db + .select({ eventId: sessions.eventId }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!current) throw sessionNotFound(); + await db.batch([ + db.delete(sessions).where(eq(sessions.id, sessionId)), + deleteEventIfEmptyPast(db, current.eventId, toJstDateString(now)), + ]); +}; diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 706a04c..aecc6db 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,9 +1,11 @@ import { createMentorRequestSchema, + createSessionRequestSchema, participantsListQuerySchema, participationSummaryQuerySchema, sessionsByDateQuerySchema, updateMentorRequestSchema, + updateSessionRequestSchema, } from '@tecnova/shared/schemas'; import { Hono } from 'hono'; import { @@ -17,6 +19,11 @@ import { updateMentor, } from '../lib/admin'; import { invalidBodyError, invalidQueryError } from '../lib/errors'; +import { + createSessionByAdmin, + deleteSessionByAdmin, + updateSessionByAdmin, +} from '../lib/session-admin'; import { createDb, requireAdmin } from '../middleware/auth'; import type { AppEnv } from '../types'; @@ -46,6 +53,33 @@ adminRoute.get('/sessions', async (c) => { return c.json(await fetchSessionsForEvent(createDb(c.env), parsed.data.date ?? null)); }); +// 来場記録の修正(admin 専用)。受付の操作ミス(チェックイン忘れ・別の子で受付など)を訂正する。 +// GET /sessions は全メンター向けのまま残すため、/sessions にサブルーターを mount せず、 +// 書き込みのルートにだけ requireAdmin を当てる。 +adminRoute.post('/sessions', requireAdmin, async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = createSessionRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json(invalidBodyError, 400); + } + return c.json(await createSessionByAdmin(createDb(c.env), parsed.data, new Date()), 201); +}); + +adminRoute.patch('/sessions/:sessionId', requireAdmin, async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = updateSessionRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json(invalidBodyError, 400); + } + const sessionId = c.req.param('sessionId'); + return c.json(await updateSessionByAdmin(createDb(c.env), sessionId, parsed.data, new Date())); +}); + +adminRoute.delete('/sessions/:sessionId', requireAdmin, async (c) => { + await deleteSessionByAdmin(createDb(c.env), c.req.param('sessionId'), new Date()); + return c.body(null, 204); +}); + // 過去開催日のセレクタ用(最新 50 件)。 adminRoute.get('/events', async (c) => c.json(await fetchEventsList(createDb(c.env)))); diff --git a/apps/api/test/apply-migrations.ts b/apps/api/test/apply-migrations.ts new file mode 100644 index 0000000..15bad82 --- /dev/null +++ b/apps/api/test/apply-migrations.ts @@ -0,0 +1,5 @@ +import { applyD1Migrations } from 'cloudflare:test'; +import { env } from 'cloudflare:workers'; + +// 適用済みのマイグレーションは d1_migrations テーブルで記録されるので、ファイルごとに呼んでも重複しない。 +await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/apps/api/test/env.d.ts b/apps/api/test/env.d.ts new file mode 100644 index 0000000..afbeff9 --- /dev/null +++ b/apps/api/test/env.d.ts @@ -0,0 +1,11 @@ +import type { D1Migration } from 'cloudflare:test'; + +// テストから見える env(cloudflare:workers の env は Cloudflare.Env 型)に binding を足す。 +declare global { + namespace Cloudflare { + interface Env { + DB: D1Database; + TEST_MIGRATIONS: D1Migration[]; + } + } +} diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts new file mode 100644 index 0000000..171e43d --- /dev/null +++ b/apps/api/test/helpers.ts @@ -0,0 +1,53 @@ +import { env } from 'cloudflare:workers'; +import * as schema from '@tecnova/db'; +import { events, participants, sessions } from '@tecnova/db'; +import { jstDateTimeToInstant } from '@tecnova/shared/venue-schedule'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +export const testDb = () => drizzle(env.DB, { schema }); +export type TestDb = ReturnType; + +export const jst = (date: string, time: string): Date => jstDateTimeToInstant(date, time); + +// D1 のデータがテスト間でリセットされるかは公式に明記されていないため、各テストの前に明示的に空にする。 +// sessions は participants / events を FK で参照するので子テーブルから消す。 +export const resetTables = async (db: TestDb): Promise => { + await db.delete(sessions); + await db.delete(events); + await db.delete(participants); +}; + +// Public リポジトリのため、氏名・ニックネームは実在しない値にする。 +export const seedParticipant = async (db: TestDb, id: string, active = true): Promise => { + await db.insert(participants).values({ + id, + preRegistrationId: `PRE-TEST-${id}`, + fullName: `テスト${id}`, + nickname: `nick${id}`, + grade: '小3', + active, + }); +}; + +// 受付経路を通さずに既存データ(過去日の滞在中など)を再現するため、DB へ直接入れる。 +export const seedSession = async ( + db: TestDb, + input: { participantId: string; date: string; inTime: string; outTime: string | null }, +): Promise => { + await db.insert(events).values({ date: input.date }).onConflictDoNothing(); + const [event] = await db.select().from(events).where(eq(events.date, input.date)).limit(1); + if (!event) throw new Error(`failed to seed event ${input.date}`); + const id = crypto.randomUUID(); + await db.insert(sessions).values({ + id, + participantId: input.participantId, + eventId: event.id, + checkedInAt: jst(input.date, input.inTime), + checkedOutAt: input.outTime === null ? null : jst(input.date, input.outTime), + }); + return id; +}; + +export const eventDates = async (db: TestDb): Promise => + (await db.select({ date: events.date }).from(events)).map((e) => e.date).sort(); diff --git a/apps/api/test/session-admin.create.test.ts b/apps/api/test/session-admin.create.test.ts new file mode 100644 index 0000000..0e3df0e --- /dev/null +++ b/apps/api/test/session-admin.create.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); + +const input = (participantId: string, date: string, inTime: string, outTime: string | null) => ({ + participantId, + checkedInAt: jst(date, inTime).toISOString(), + checkedOutAt: outTime === null ? null : jst(date, outTime).toISOString(), +}); + +describe('createSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + }); + + it('開催日が無い日に追加すると開催日を作り、記録を返す', async () => { + const r = await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(r.eventDate).toBe(PAST); + expect(r.session).toMatchObject({ + participantId: '26001', + checkedInAt: jst(PAST, '10:00').toISOString(), + checkedOutAt: jst(PAST, '11:00').toISOString(), + isPresent: false, + term: 'morning', + counted: true, + }); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('既存の開催日を再利用する', async () => { + await seedSession(db, { + participantId: '26002', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('JST の早朝(UTC では前日)の記録も JST の日付の開催日に入る', async () => { + const r = await createSessionByAdmin(db, input('26001', PAST, '08:30', '08:50'), NOW); + expect(r.eventDate).toBe(PAST); + }); + + it('存在しない子は PARTICIPANT_NOT_FOUND', async () => { + await expect( + createSessionByAdmin(db, input('26999', PAST, '10:00', '11:00'), NOW), + ).rejects.toMatchObject({ name: 'SessionError', code: 'PARTICIPANT_NOT_FOUND' }); + }); + + it('無効化した子にも追加できる', async () => { + await seedParticipant(db, '26003', false); + const r = await createSessionByAdmin(db, input('26003', PAST, '10:00', '11:00'), NOW); + expect(r.session.participantId).toBe('26003'); + }); + + it('同じ子・同じ日の記録と重なると OVERLAP', async () => { + await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:30', + outTime: '12:00', + }); + await expect( + createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW), + ).rejects.toMatchObject({ code: 'OVERLAP' }); + }); + + it('別の子の記録とは重なってもよい', async () => { + await seedSession(db, { + participantId: '26002', + date: PAST, + inTime: '10:30', + outTime: '12:00', + }); + const r = await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(r.session.participantId).toBe('26001'); + }); + + it('過去日の滞在中は OPEN_SESSION_NOT_TODAY', async () => { + await expect( + createSessionByAdmin(db, input('26001', PAST, '10:00', null), NOW), + ).rejects.toMatchObject({ code: 'OPEN_SESSION_NOT_TODAY' }); + }); + + it('今日の滞在中は追加できる', async () => { + const r = await createSessionByAdmin(db, input('26001', TODAY, '14:00', null), NOW); + expect(r.session.isPresent).toBe(true); + expect(r.eventDate).toBe(TODAY); + }); + + it('未来の時刻は FUTURE_TIME', async () => { + await expect( + createSessionByAdmin(db, input('26001', TODAY, '16:00', null), NOW), + ).rejects.toMatchObject({ code: 'FUTURE_TIME' }); + }); + + it('検証エラーのときは開催日を作らない', async () => { + await expect( + createSessionByAdmin(db, input('26001', PAST, '11:00', '10:00'), NOW), + ).rejects.toMatchObject({ code: 'CHECKOUT_BEFORE_CHECKIN' }); + expect(await eventDates(db)).toEqual([]); + }); +}); diff --git a/apps/api/test/session-admin.delete.test.ts b/apps/api/test/session-admin.delete.test.ts new file mode 100644 index 0000000..4393e8a --- /dev/null +++ b/apps/api/test/session-admin.delete.test.ts @@ -0,0 +1,65 @@ +import { sessions } from '@tecnova/db'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { deleteSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); + +const sessionCount = async () => (await db.select({ id: sessions.id }).from(sessions)).length; + +describe('deleteSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + }); + + it('記録を消し、他の記録が残る開催日は残す', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await seedSession(db, { + participantId: '26002', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await deleteSessionByAdmin(db, id, NOW); + expect(await sessionCount()).toBe(1); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('過去の開催日の最後の 1 件を消すと開催日も消える', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await deleteSessionByAdmin(db, id, NOW); + expect(await eventDates(db)).toEqual([]); + }); + + it('今日の開催日は空になっても残す', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: TODAY, + inTime: '10:00', + outTime: '11:00', + }); + await deleteSessionByAdmin(db, id, NOW); + expect(await eventDates(db)).toEqual([TODAY]); + }); + + it('存在しない記録は SESSION_NOT_FOUND', async () => { + await expect(deleteSessionByAdmin(db, 'no-such-session', NOW)).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + }); +}); diff --git a/apps/api/test/session-admin.update.test.ts b/apps/api/test/session-admin.update.test.ts new file mode 100644 index 0000000..59fd959 --- /dev/null +++ b/apps/api/test/session-admin.update.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { updateSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const OTHER_PAST = '2026-10-05'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); +const iso = (date: string, time: string) => jst(date, time).toISOString(); + +describe('updateSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + await seedParticipant(db, '26003'); + }); + + it('同じ日のまま退場時刻を直せる(自分自身とは重ならない扱い)', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: iso(PAST, '11:30') }, NOW); + expect(r.session.checkedOutAt).toBe(iso(PAST, '11:30')); + expect(r.eventDate).toBe(PAST); + }); + + it('日付を変えると別の開催日へ移り、空になった過去の開催日は消える', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + const r = await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(OTHER_PAST, '10:00'), checkedOutAt: iso(OTHER_PAST, '11:00') }, + NOW, + ); + expect(r.eventDate).toBe(OTHER_PAST); + expect(await eventDates(db)).toEqual([OTHER_PAST]); + }); + + it('元の開催日に他の記録が残っていれば消さない', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await seedSession(db, { + participantId: '26002', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(OTHER_PAST, '10:00'), checkedOutAt: iso(OTHER_PAST, '11:00') }, + NOW, + ); + expect(await eventDates(db)).toEqual([PAST, OTHER_PAST]); + }); + + it('今日の開催日は空になっても残す', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: TODAY, + inTime: '10:00', + outTime: '11:00', + }); + await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(PAST, '10:00'), checkedOutAt: iso(PAST, '11:00') }, + NOW, + ); + expect(await eventDates(db)).toEqual([PAST, TODAY]); + }); + + it('付け替え先の子の記録と重なると OVERLAP', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await seedSession(db, { + participantId: '26002', + date: PAST, + inTime: '10:30', + outTime: '12:00', + }); + await expect( + updateSessionByAdmin(db, id, { participantId: '26002' }, NOW), + ).rejects.toMatchObject({ code: 'OVERLAP' }); + }); + + it('重ならない子へは付け替えられる', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + const r = await updateSessionByAdmin(db, id, { participantId: '26003' }, NOW); + expect(r.session.participantId).toBe('26003'); + expect(r.session.nickname).toBe('nick26003'); + }); + + it('存在しない子への付け替えは PARTICIPANT_NOT_FOUND', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await expect( + updateSessionByAdmin(db, id, { participantId: '26999' }, NOW), + ).rejects.toMatchObject({ code: 'PARTICIPANT_NOT_FOUND' }); + }); + + it('今日の記録は滞在中に戻せる', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: TODAY, + inTime: '10:00', + outTime: '11:00', + }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: null }, NOW); + expect(r.session.isPresent).toBe(true); + }); + + it('過去日の記録を滞在中に戻すのは OPEN_SESSION_NOT_TODAY', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + await expect(updateSessionByAdmin(db, id, { checkedOutAt: null }, NOW)).rejects.toMatchObject({ + code: 'OPEN_SESSION_NOT_TODAY', + }); + }); + + it('過去日に滞在中のまま残った記録は、退場時刻を入れずに直そうとすると止まり、入れれば直る', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: null, + }); + await expect( + updateSessionByAdmin(db, id, { checkedInAt: iso(PAST, '10:15') }, NOW), + ).rejects.toMatchObject({ code: 'OPEN_SESSION_NOT_TODAY' }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: iso(PAST, '11:00') }, NOW); + expect(r.session.isPresent).toBe(false); + }); + + it('存在しない記録は SESSION_NOT_FOUND', async () => { + await expect( + updateSessionByAdmin(db, 'no-such-session', { checkedOutAt: null }, NOW), + ).rejects.toMatchObject({ code: 'SESSION_NOT_FOUND' }); + }); +}); diff --git a/apps/api/test/sessions-routes.test.ts b/apps/api/test/sessions-routes.test.ts new file mode 100644 index 0000000..cc2b1fd --- /dev/null +++ b/apps/api/test/sessions-routes.test.ts @@ -0,0 +1,107 @@ +import { env } from 'cloudflare:workers'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { apiErrorHandler } from '../src/lib/errors'; +import { adminRoute } from '../src/routes/admin'; +import type { AppEnv, Bindings } from '../src/types'; +import { jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +// Better Auth を通さずに mentor を差し込んだアプリ。ルート単位の requireAdmin とエラー変換を確かめる。 +const appAs = (role: 'admin' | 'mentor') => { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', { id: 'u1', email: 'tester@example.com', name: 'Tester' }); + c.set('mentor', { id: 'm1', email: 'tester@example.com', name: 'Tester', role }); + await next(); + }); + app.route('/api', adminRoute); + app.onError(apiErrorHandler); + return app; +}; + +// ルートは new Date() を現在時刻に使うので、どの実行日でも過去になる日付を使う。 +const PAST = '2026-01-15'; +const bindings = env as unknown as Bindings; +const db = testDb(); + +const send = (app: Hono, method: string, path: string, body?: unknown) => + app.request( + path, + { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + bindings, + ); + +const validBody = { + participantId: '26001', + checkedInAt: jst(PAST, '10:00').toISOString(), + checkedOutAt: jst(PAST, '11:00').toISOString(), +}; + +describe('/api/sessions の修正ルート', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + }); + + it('admin 以外は POST / PATCH / DELETE が 403', async () => { + const app = appAs('mentor'); + expect((await send(app, 'POST', '/api/sessions', validBody)).status).toBe(403); + expect((await send(app, 'PATCH', '/api/sessions/x', { checkedOutAt: null })).status).toBe(403); + expect((await send(app, 'DELETE', '/api/sessions/x')).status).toBe(403); + }); + + it('admin 以外でも一覧の GET は使える', async () => { + const res = await send(appAs('mentor'), 'GET', `/api/sessions?date=${PAST}`); + expect(res.status).toBe(200); + }); + + it('admin の POST は 201 で eventDate と記録を返す', async () => { + const res = await send(appAs('admin'), 'POST', '/api/sessions', validBody); + expect(res.status).toBe(201); + const body = (await res.json()) as { eventDate: string; session: { participantId: string } }; + expect(body.eventDate).toBe(PAST); + expect(body.session.participantId).toBe('26001'); + }); + + it('本文の形式が違うと 400', async () => { + const res = await send(appAs('admin'), 'POST', '/api/sessions', { participantId: '1' }); + expect(res.status).toBe(400); + }); + + it('ルール違反はコードと日本語メッセージで返す(重なりは 409)', async () => { + await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:30', + outTime: '12:00', + }); + const res = await send(appAs('admin'), 'POST', '/api/sessions', validBody); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'OVERLAP', + message: '同じ子の同じ日の別の記録と時間が重なっています', + }); + }); + + it('admin の PATCH は 200、DELETE は 204、存在しない記録の DELETE は 404', async () => { + const id = await seedSession(db, { + participantId: '26001', + date: PAST, + inTime: '10:00', + outTime: '11:00', + }); + const app = appAs('admin'); + const patched = await send(app, 'PATCH', `/api/sessions/${id}`, { + checkedOutAt: jst(PAST, '11:30').toISOString(), + }); + expect(patched.status).toBe(200); + expect((await send(app, 'DELETE', `/api/sessions/${id}`)).status).toBe(204); + const missing = await send(app, 'DELETE', `/api/sessions/${id}`); + expect(missing.status).toBe(404); + expect(await missing.json()).toMatchObject({ error: 'SESSION_NOT_FOUND' }); + }); +}); diff --git a/apps/api/test/smoke.test.ts b/apps/api/test/smoke.test.ts new file mode 100644 index 0000000..a2d9f22 --- /dev/null +++ b/apps/api/test/smoke.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { eventDates, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const db = testDb(); + +describe('test D1', () => { + beforeEach(async () => { + await resetTables(db); + }); + + it('マイグレーション済みのテーブルに書き込める', async () => { + await seedParticipant(db, '26001'); + await seedSession(db, { + participantId: '26001', + date: '2026-10-04', + inTime: '10:00', + outTime: '11:00', + }); + expect(await eventDates(db)).toEqual(['2026-10-04']); + }); + + it('前のテストのデータは resetTables で消えている', async () => { + expect(await eventDates(db)).toEqual([]); + }); +}); diff --git a/apps/api/test/tsconfig.json b/apps/api/test/tsconfig.json new file mode 100644 index 0000000..15f25d5 --- /dev/null +++ b/apps/api/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..6235727 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from 'node:url'; +import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'; +import { defineConfig } from 'vitest/config'; + +// このファイルは Node で動く(Worker 本体 src/ の Node API 禁止ルールの対象外)。 +// 本番と同じ wrangler.toml の D1 binding を使い、packages/db のマイグレーションを +// テスト専用 binding(TEST_MIGRATIONS)で setup ファイルへ渡して適用する。 +const migrationsDir = fileURLToPath(new URL('../../packages/db/drizzle', import.meta.url)); + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: './wrangler.toml' }, + miniflare: { + bindings: { TEST_MIGRATIONS: await readD1Migrations(migrationsDir) }, + }, + })), + ], + test: { + include: ['test/**/*.test.ts'], + setupFiles: ['./test/apply-migrations.ts'], + }, +}); diff --git a/apps/checkin/src/app/page.tsx b/apps/checkin/src/app/page.tsx index 0407721..196328d 100644 --- a/apps/checkin/src/app/page.tsx +++ b/apps/checkin/src/app/page.tsx @@ -24,6 +24,9 @@ import { Reveal } from '@/components/reveal'; import { ScanReticle } from '@/components/scan-reticle'; import { PARTICIPANT_ID_PATTERN, participantProfilePath } from '@/lib/participant-id'; +// 読み取りを始めてから、QR が読めないままカメラを止めて待機に戻るまでの時間。 +const SCAN_TIMEOUT_MS = 20_000; + function ActionPanel({ index, title, @@ -68,7 +71,8 @@ export default function Home() { const router = useRouter(); const prefersReduced = useReducedMotion(); const [cameraError, setCameraError] = useState(null); - const [scannerAttempt, setScannerAttempt] = useState(0); + const [scanning, setScanning] = useState(false); + const [scanAttempt, setScanAttempt] = useState(0); const [videoDevices, setVideoDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceId] = useState(undefined); const [navigatingId, setNavigatingId] = useState(null); @@ -86,10 +90,13 @@ export default function Home() { return devices; }, []); - // プロフィールへ遷移中はスキャナを止めて、同じQRの連続検出を避ける。 - // biome-ignore lint/correctness/useExhaustiveDependencies: scannerAttempt is an explicit restart token for reinitializing the camera. + // カメラは「読み取る」を押したときだけ起動し、読めたら or タイムアウトで止める。 + // iPad の Safari で起動しっぱなしにすると、映像は動いているのに QR を読まなくなる事象があり、 + // 「カメラを再起動すれば読める」という現場の実績に合わせて、毎回起動し直した状態で読み取る。 + // プロフィールへ遷移中もスキャナを止めて、同じQRの連続検出を避ける。 + // biome-ignore lint/correctness/useExhaustiveDependencies: scanAttempt is an explicit restart token for reinitializing the camera. useEffect(() => { - if (navigatingId) return; + if (!scanning || navigatingId) return; const video = videoRef.current; if (!video) return; @@ -98,6 +105,7 @@ export default function Home() { setCameraError(null); void refreshVideoDevices(); + const timeoutTimer = window.setTimeout(() => setScanning(false), SCAN_TIMEOUT_MS); const startTimer = window.setTimeout(() => { reader .decodeFromVideoDevice(selectedDeviceId, video, (result) => { @@ -127,16 +135,18 @@ export default function Home() { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('play() request was interrupted')) return; setCameraError(msg); + setScanning(false); }); }, 0); return () => { cancelled = true; window.clearTimeout(startTimer); + window.clearTimeout(timeoutTimer); controlsRef.current?.stop(); controlsRef.current = null; }; - }, [navigatingId, refreshVideoDevices, router, scannerAttempt, selectedDeviceId]); + }, [navigatingId, refreshVideoDevices, router, scanAttempt, scanning, selectedDeviceId]); // ナビゲーション遅延タイマーはアンマウント時のみ掃除する。 // スキャナ effect の cleanup(navigatingId 変更で毎回走る)で消すと遷移がキャンセルされるため分離する。 @@ -148,18 +158,24 @@ export default function Home() { }; }, []); + // 読み取り中でもカメラを起動し直し、タイムアウトもリセットする。現場では「すぐ読ませたいときに押す」 + // 使い方をしているので残している。待機中に押した場合はそのまま読み取りを始める。 + const restartCamera = () => { + setScanAttempt((attempt) => attempt + 1); + setScanning(true); + }; + const switchCamera = async () => { const devices = videoDevices.length > 0 ? videoDevices : await refreshVideoDevices(); - if (devices.length <= 1) { - setScannerAttempt((attempt) => attempt + 1); - return; + if (devices.length > 1) { + const currentIndex = selectedDeviceId + ? devices.findIndex((device) => device.deviceId === selectedDeviceId) + : -1; + const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % devices.length : 1; + setSelectedDeviceId(devices[nextIndex]?.deviceId); } - - const currentIndex = selectedDeviceId - ? devices.findIndex((device) => device.deviceId === selectedDeviceId) - : -1; - const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % devices.length : 1; - setSelectedDeviceId(devices[nextIndex]?.deviceId); + // 切り替えたカメラの映像をすぐ確認できるよう、待機中なら読み取りも始める。 + setScanning(true); }; return ( @@ -176,7 +192,7 @@ export default function Home() {
@@ -221,7 +249,7 @@ export default function Home() { size="lg" className="h-12 text-base" disabled={navigatingId !== null} - onClick={() => setScannerAttempt((attempt) => attempt + 1)} + onClick={restartCamera} > カメラを再起動 diff --git a/docs/architecture.md b/docs/architecture.md index 00e8f03..deecb58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,24 +124,23 @@ flowchart LR architecture-beta group people(internet)[People And Devices] service child_kiosk(server)[Child And Reception iPad] in people - service mentor_phone(server)[Mentor Smartphone Future] in people + service child_device(server)[Child Record Input Device Future] in people service admin_pc(server)[Admin PC Browser] in people service signage_monitor(server)[Venue Large Monitor Current] in people - service researcher_pc(server)[Researcher Analysis PC Future] in people + service researcher_pc(server)[Researcher Analysis PC Current] in people service parent_browser(server)[Parent Browser Future] in people group frontend(cloud)[Vercel Frontend Apps] service checkin_app(server)[apps checkin Next PWA Current] in frontend service admin_app(server)[apps admin Next Web Current] in frontend service signage_app(server)[apps signage Next Display Current] in frontend - service mentor_app(server)[apps mentor Next PWA Future] in frontend + service child_input(server)[Child Record Input Future] in frontend service analytics_app(server)[Analytics Dashboard Future] in frontend service parent_app(server)[Parent View Future] in frontend group edge(cloud)[Cloudflare Edge Platform] service worker(server)[apps api Hono Workers Current] in edge service d1(database)[Cloudflare D1 SQLite Current] in edge - service r2(disk)[Cloudflare R2 Future] in edge service public_api(server)[Public API Future] in edge service api_keys(server)[API Key Auth Future] in edge @@ -153,11 +152,12 @@ architecture-beta group google(internet)[Google Workspace Services] service student_sheet(disk)[Student Sheet Current] in google - service teacher_sheet(disk)[Teacher Sheet Future Sync] in google + service teacher_sheet(disk)[Teacher Sheet Teacher Led] in google service sheets_api(server)[Google Sheets API Current] in google service gas_drive(server)[GAS Drive Webhook Current] in google service drive_folder(disk)[Participant Drive Folders Current] in google service form_feed(server)[Google Form Feed External] in google + service sql_dump(disk)[Daily D1 SQL Dump On Drive Current] in google group core(server)[Current Backend Domain] service participants(database)[Participants Current] in core @@ -167,13 +167,10 @@ architecture-beta service auth_tables(database)[Better Auth Tables Current] in core group planned(server)[Planned Domain Model] - service activity_logs(database)[Activity Logs Future] in planned - service activity_categories(database)[Activity Categories Future] in planned - service equipment(database)[Equipment Master Future] in planned - service collaborators(database)[Collaborators Future] in planned - service reflection_ocr(server)[Vision LLM OCR Future] in planned - service csv_export(disk)[CSV Export Future] in planned - service offline_sync(server)[Offline Sync Future] in planned + service child_records(database)[Child Reflection And Goal Records Future] in planned + service activity_categories(database)[Activity Categories On Hold] in planned + service equipment(database)[Equipment Master On Hold] in planned + service collaborators(database)[Collaborators On Hold] in planned service crowd_status(server)[Crowd Status Future] in planned group packages(disk)[Monorepo Shared Packages] @@ -192,28 +189,26 @@ architecture-beta service vercel_deploy(server)[Vercel Deploy Current] in delivery service secrets(disk)[Wrangler And Vercel Secrets Current] in delivery service ops_manual(disk)[Runbook And Rehearsal Current] in delivery - service legacy_import(disk)[Prior Year D1 Import Future] in delivery child_kiosk:R --> L:checkin_app - mentor_phone:R --> L:mentor_app + child_device:R --> L:child_input admin_pc:R --> L:admin_app signage_monitor:R --> L:signage_app researcher_pc:R --> L:analytics_app + researcher_pc:B --> T:sql_dump parent_browser:R --> L:parent_app checkin_app:R --> L:worker admin_app:R --> L:worker signage_app:R --> L:worker - mentor_app:R --> L:worker + child_input:R --> L:worker analytics_app:R --> L:worker parent_app:R --> L:public_api checkin_app:T --> B:ui_pkg admin_app:T --> B:ui_pkg - mentor_app:T --> B:ui_pkg checkin_app:B --> T:shared_pkg admin_app:B --> T:shared_pkg - mentor_app:B --> T:shared_pkg worker:L --> R:better_auth better_auth:R --> L:google_oauth @@ -227,7 +222,7 @@ architecture-beta d1:B --> T:sessions d1:B --> T:mentors d1:B --> T:auth_tables - d1:R --> L:activity_logs + d1:R --> L:child_records d1:R --> L:activity_categories d1:R --> L:equipment d1:R --> L:collaborators @@ -238,18 +233,13 @@ architecture-beta form_feed:R --> L:teacher_sheet worker:R --> L:gas_drive gas_drive:R --> L:drive_folder + d1:R --> L:sql_dump public_api:B --> T:api_keys public_api:L --> R:worker public_api:R --> L:crowd_status - worker:R --> L:r2 - r2:R --> L:reflection_ocr - reflection_ocr:R --> L:activity_logs - activity_logs:B --> T:csv_export - activity_logs:R --> L:analytics_app + child_records:R --> L:analytics_app sessions:R --> L:crowd_status - offline_sync:L --> R:checkin_app - offline_sync:R --> L:worker db_pkg:R --> L:d1 migrations:R --> L:d1 @@ -266,12 +256,10 @@ architecture-beta vercel_deploy:R --> L:checkin_app vercel_deploy:R --> L:admin_app vercel_deploy:R --> L:signage_app - vercel_deploy:R --> L:mentor_app secrets:T --> B:worker secrets:T --> B:checkin_app secrets:T --> B:admin_app ops_manual:R --> L:child_kiosk - legacy_import:R --> L:d1 ``` ## Legend @@ -279,7 +267,15 @@ architecture-beta - `Current` means the component exists in the repository or is already described as deployed in the handoff notes. - `Future` means planned Phase 1.5 or Phase 2 scope from the requirements and - MVP documentation. + MVP documentation. `On Hold` items are kept only as candidates and will be + decided together with the child record input method. +- The 2026-10 plan review (`requirements.md` §3.5) dropped the mentor app and + its 30-minute activity logs, CSV export, reflection-sheet OCR (and the R2 + storage it needed), teacher-sheet auto sync, offline sync, and the prior-year + data import. Children now record their own reflection and goal-setting; the + input may end up as a Google Form instead of an in-house app. +- Analysis runs as SQL over the daily D1 dump that an out-of-repo Google Apps + Script saves to Google Drive. - `/api/*` and `/checkin/*` both pass through Better Auth and the `mentors` allowlist. Planned `/public/v1/*` traffic is separated behind API key auth. - `apps signage` is a venue large-monitor display that runs under the same diff --git a/docs/handoff.md b/docs/handoff.md index 93bc785..1a402d8 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,4 +1,4 @@ -# セッション引き継ぎノート(2026-05-29時点) +# セッション引き継ぎノート(2026-10-11時点) 新しい Claude セッションがこのリポジトリで作業を再開するときの起点。 このファイルは「今ここまで来ている」を素早く把握するためのもの。詳細仕様は引き続き @@ -7,176 +7,202 @@ --- -## 進捗ステータス +## いまの状態(サマリ) -### 完了済み(main にマージ済み) +- **Phase 1(MVP)と、その後の拡張(参加回数統計・会場サイネージ・admin 刷新・ + checkin データ層整理)はすべて本番稼働中**。 +- `main` と `develop` は同一コミット(最終更新 2026-06-11 / `5f5c131`)。 + **develop にあって本番未反映の変更は無い**。 +- 会場サイネージ(`apps/signage`)も本番で問題なく稼働しており、完了扱い(2026-10 確認)。 +- **2026-10 に計画を見直した**:メンターによる活動ログ記入(メンターアプリ)は取りやめ、 + 子ども自身が振り返り・目標設定を記録する方針に変更(`requirements.md` §3.5)。 +- **直近の優先事項**は「チェックイン記録の修正機能」と「QR 読み取り精度の改善」→ + [次に取り掛かる候補](#次に取り掛かる候補)。 -Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目と参照 PR / commit: +--- + +## 完了済み(すべて main = 本番に反映済み) | 内容 | PR / commit | | --- | --- | | モノレポ初期化(pnpm + Turborepo + Biome) | #1 | -| docs 更新(Neon → Cloudflare D1 へ DB 切り替え) | direct commit | -| `packages/db` Drizzle/SQLite スキーマ + 初期マイグレーション | #2 | +| `packages/db` Drizzle/SQLite スキーマ + 初期マイグレーション(DB は Cloudflare D1) | #2 | | `apps/api` Hono on Workers + D1 binding + `/health` | #3 | | `packages/shared/src/google-sheets.ts` + `/sheets/health` | #4 | | 「初めての方」フロー(API + iPad UI) | #5 | -| check-in / check-out / scan エンドポイント | #6 | -| 手入力フォーム UI(QR は将来差し替え) | #7 | -| Better Auth 基盤(schema + `/api/auth/*` + middleware) | #8 | -| `apps/admin` ログイン画面 + Better Auth client | #9 | -| Bug fix: ログイン後に admin オリジンへ戻すよう callbackURL を絶対URL化 | #10 | -| API CORS/trustedOrigins を env 経由で設定可能化(本番デプロイ準備) | direct commit `9ad8603` | -| `/api/sessions/today` + `/api/participants` 参照系API | #11 | -| `/api/mentors` CRUD(admin role guard) | #12 | -| 管理ダッシュボード(当日セッション一覧) | #13, #16 | -| 参加者一覧 + メンター管理画面 | #14 | -| checkin の PWA 化(manifest, apple-icon, viewport) | #15 | +| check-in / check-out / scan エンドポイント、手入力フォーム | #6, #7 | +| Better Auth 基盤(schema + `/api/auth/*` + middleware)、admin ログイン | #8, #9, #10 | +| 参照系 API(`/api/sessions/today`, `/api/participants`)、`/api/mentors` CRUD | #11, #12 | +| 管理ダッシュボード・参加者一覧・メンター管理 | #13, #14, #16 | +| checkin の PWA 化、opt-in QR スキャン + 確認クッション | #15, feat/checkin-qr-scanner | | 事前登録管理ページ(admin) + grade enum 制約 | #23 | -| checkin に opt-in QR スキャン + 確認クッション追加 | feat/checkin-qr-scanner | -| shadcn/ui へ全置換、Maia テーマ適用 | #25, #26 | -| API ルートを `routes/` + `middleware/` にモジュール分割 | #27, 859208d | -| checkin: 手入力ページ・受付プロフィール画面・出席タイル | 8e60249, 2e91f68, cdcc8d0 | -| checkin: 受付履歴 + 一括チェックアウト | 8ad5d51, f609c5b | -| checkin: ログイン画面と設定画面、ガイドラインページ | f8e59fd, 264f970 | -| checkin に GAS Drive folder webhook 連携(アクティベート時に Drive フォルダ自動生成) | 488f42d | -| checkin: マニュアル入力に「名前で探す」モード + `/checkin/participants/search` | d21ed3a | +| shadcn/ui へ全置換(Maia テーマ) | #25, #26 | +| API ルートを `routes/` + `middleware/` にモジュール分割 | #27 | +| checkin: 受付プロフィール画面・受付履歴 + 一括チェックアウト・ログイン/設定・ガイドライン | 8e60249, 8ad5d51, f8e59fd ほか | +| アクティベート時の GAS Drive フォルダ自動生成 webhook | 488f42d | +| checkin: マニュアル入力に「名前で探す」モード | d21ed3a | | フロント共通コードを `@tecnova/ui` に集約(api-client / MeProvider / JST フォーマッタ) | eac8560, cbddf77, 9e97694 | -| 参加者データに 氏名(fullName)追加(スプシ B 列挿入・DB backfill) | リリース手順ログ参照 | -| 学年に `その他` を追加、旧値 `卒業` を移行 | `062378c`、リリース手順ログ参照 | +| 参加者データに氏名(fullName)追加、学年に `その他` 追加(旧 `卒業` を移行) | migration 0002 / 0003 | | CI/CD 整備(`ci.yml` / `deploy-api.yml`) | `.github/workflows/` | -| 本番デプロイ(Worker + admin + checkin、OAuth まで動作確認済み) | — | +| 参加回数統計(ターム分類・30 分ルール・会場横断集計)+ 受付プロフィールの参加回数表示 | #33, #34 | +| checkin 画面リデザイン(motion 共通化・スキャン→プロフィール遷移) | #35 | +| 会場サイネージ `apps/signage`(放送風・チャイム・YouTube 再生・巡回インフォ) | #37 | +| admin UI 刷新(レスポンシブ・ダークモード・PWA・公式ロゴ・motion) | #40 | +| Claude Code 用 hooks / subagents / skills(`.claude/`) | #40 に同梱 | +| admin データ層モダン化(`useApiResource` 共通化 + 即時インタラクティブシェル) | #41, #42, #43 | +| checkin データ層を `useApiResource` に移行(background reload) | #44 | +| checkin 参加ガイドラインの文言・レイアウト刷新、受付文言を「メンター」に統一 | #46, `5f5c131` | +| 来場記録の修正(admin のみ。ダッシュボード・利用者詳細から追加・編集・削除)+ API の自動テスト基盤 | `feat/admin-session-correction` の PR(Issue #48 / #49) | + +本番への反映は `develop` → `main` のリリース PR(#36 / #38 / #45 ほか)で行っている。 **設計上の判断メモ:** - **Better Auth を `/api/*` 参照系より先に実装した**。`/api/*` 系エンドポイントが認証必須で、 後から auth を retrofit するより auth 基盤を先に通したほうが安全だったため。詳細は PR #8 の本文。 -- **`/checkin/*` も Cookie 認証必須に格上げ**(5月の改修で)。受付端末は子どもが直接 - 触る端末ではなく受付メンターの端末である前提に倒し、`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 統計ページ) -- **会場サイネージ `apps/signage`(PR #37: 放送風サイネージ+チャイム・YouTube 版)** は - 2026-05-30 に `develop` にマージ済みだが **`main` には入れていない**=本番(Worker / Vercel) - 未反映。大型モニター・キオスク向けの新アプリ(Next.js 16 / React 19・dev ポート 3002・ - Vercel デプロイ)で、活動フェーズ中は YouTube 動画再生、休憩/待機はオーバーレイ、右レーンに - チャイムカウントダウン・ターム/サイクル、下部に巡回インフォを出す。チャイムは Web Audio 合成、 - 50分活動/10分休憩サイクルは `@tecnova/shared/activity-cycle`。認証は checkin/admin と同じ - メンター・ホワイトリスト(テクノバ共有の管理用 Google アカウントで1回ログイン)。稼働判定・ - 在館数は認証付き `GET /api/sessions/today` を再利用。 - - 新規 API(`/api/signage` 配下・メンター認証必須):`/playlist`(YouTube Data API・ - Worker キャッシュ)、`/previous-summary`(前回開催の来場/滞在集計・PII なし)、`/health` - (D1 到達性)。本番に出す場合は `develop` → `main` マージで Worker / Vercel に反映される。 - - 詳細仕様: `docs/superpowers/specs/2026-05-29-signage-chime-design.md`、 - 実装計画: `docs/superpowers/plans/2026-05-30-signage-chime-app.md`。 - -### 現状動作している範囲 - -ローカル開発 (`pnpm --filter @tecnova/api dev` + `pnpm --filter checkin dev` + -`pnpm --filter admin dev`) で: - -- iPad PWA 側(`localhost:3000`、Better Auth セッション必須): - - `/login` → Google OAuth → mentors 許可リスト判定 → `/` - - `/` 常時カメラ起動の QR スキャナ + 右に 3 つのショートカット - (初めての人 / 受付りれき / マニュアル入力) - - 5 桁 ID を認識すると `/reception/participants/[id]` へ遷移 - - `/reception/participants/[id]`:受付プロフィール画面。 - `GET /checkin/participants/:id` で stats を取り、`current.nextAction` に応じた - 単一ボタン(チェックイン or チェックアウト)→ `POST /checkin/participants/:id/attendance` - - `/first-time` 未アクティベート一覧 → タップ → `/checkin/activate` → ID表示 - - `/manual` ID 入力 or 名前検索(`GET /checkin/participants/search`) - - `/history` 当日履歴 + 在場中の参加者を選んで `POST /checkin/history/check-out-bulk` - - `/settings` ログアウト導線、`/guideline` 子ども向けガイド -- 管理画面(`localhost:3001`): - - `/login` → Google OAuth → mentors 許可リスト判定 → `/` でユーザー名表示 - - 未ログインで `/` を叩くと `/login` に飛ぶ - - 許可リスト外メアドだと 403「アクセス権限がありません」 - - `admin` ロールなら `/pre-registrations`(事前登録の追加/削除)と `/mentors` が利用可能 -- API(`localhost:8787`): - - public: `/health`, `/sheets/health` - - Better Auth: `/api/auth/*` - - `/checkin/*`(mentor 認証必須): - - `/pre-registered`, `/activate` - - `/sessions/check-in`, `/sessions/check-out`, `/scan` - - `/history/today`, `/history/check-out-bulk` - - `/participants/search` - - `/participants/:id`, `/participants/:id/attendance` - - `/api/*`(mentor 認証必須): - - `/me`(middleware 動作確認用) - - `/sessions/today`, `/participants` - - `/mentors`(admin CRUD) - - `/pre-registrations`(admin:学生側スプシの追加/削除) - - `/signage/*`(develop のみ・main 未反映): `/playlist`(YouTube Data API・Worker - キャッシュ)、`/previous-summary`(前回開催の集計・PII なし)、`/health`(D1 到達性) - - アクティベート時は `c.executionCtx.waitUntil()` で GAS Drive webhook を背面呼び出し - (`GAS_DRIVE_WEBHOOK_URL` / `GAS_DRIVE_WEBHOOK_SECRET` 未設定なら no-op) -- サイネージ(`localhost:3002`、develop のみ・Better Auth セッション必須): - - `/login` → Google OAuth → mentors 許可リスト判定 → `/`(共有管理アカウントで1回ログイン) - - `/` 放送風サイネージ本体(活動中=YouTube 再生・休憩/待機=オーバーレイ・右レーンに - チャイムカウントダウン・下部に巡回インフォ)。`?debug=1` で擬似時計・稼働強制・手動チャイムの - プレビューバーが出る(本番フラグ無しは影響ゼロ) - -### 本番側でできていること - -- Cloudflare D1 (`tecnova-db`) 作成済み・マイグレーション 0000/0001 適用済み -- Cloudflare Worker Secrets 登録済み: - - `GOOGLE_SERVICE_ACCOUNT_KEY`(base64 エンコード済み JSON) - - `GOOGLE_SHEETS_ID` - - `GAS_DRIVE_WEBHOOK_URL` - - `GAS_DRIVE_WEBHOOK_SECRET` - - `BETTER_AUTH_SECRET` - - `BETTER_AUTH_URL` - - `GOOGLE_OAUTH_CLIENT_ID` - - `GOOGLE_OAUTH_CLIENT_SECRET` -- mentors テーブルに `ut42.nu@gmail.com` (`たくや`, role=admin) 投入済み(local + remote) -- **Worker / admin / checkin はすでに本番デプロイ済みで、OAuth ログインまで含めて動作確認済み** - (本番URLは memory `project_production_urls.md` 参照) -- 本番反映のカットオフは固定値として管理せず、最新デプロイの run/commit を都度確認する運用に変更 -- **CI/CD 整備済み**(`.github/workflows/`): +- **`/checkin/*` も Cookie 認証必須**。受付端末は子どもが直接触る端末ではなく受付メンターの + 端末である前提に倒し、`apiCors` + `requireAuthenticatedMentor` を `/api/*` と同じく + 適用している(`apps/api/src/index.ts`)。 +- **admin のサーバー取得(RSC + Suspense、通称 SP3)は見送り**。admin API は全エンドポイントが + per-user Cookie 認証のため、サーバー取得には auth 再設計が必須になり、内製管理画面には + 割に合わない。データ層モダン化は SP1(`useApiResource`)+ SP2(即時シェル)で完了。 + 根拠: `docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md` +- **採番衝突の自動リトライ・運用手順の文書化・昨年度データの D1 移行は 2026-05-29 に取り下げ**。 + 同時タップ起因の採番衝突は、発生時に手動再試行で回復する運用で確定 + (`apps/api/src/lib/checkin.ts` の TODO は将来 problem 化した場合のみ着手。 + `docs/mvp.md` 4.2 / 10.3 も同方針)。 + +--- + +## 本番で動いている機能 + +ローカル開発時のポートを併記(`pnpm dev` で全アプリ起動)。 + +### 受付端末 `apps/checkin`(iPad PWA・:3000) + +すべて Better Auth セッション必須(`/login` → Google OAuth → mentors 許可リスト判定)。 + +- `/` QR スキャナ(「タップしてQRを読み取る」で起動、20 秒で自動停止)+ 右に 3 つのショートカット + (初めての人 / 受付りれき / マニュアル入力)。5 桁 ID を認識すると受付プロフィールへ遷移 +- `/reception/participants/[id]`:受付プロフィール画面。参加回数(ターム別)を表示し、 + `current.nextAction` に応じた単一ボタン(チェックイン or チェックアウト) +- `/first-time` 未アクティベート一覧 → タップ → アクティベート → ID 表示 +- `/manual` ID 入力 or 名前検索 +- `/history` 当日履歴 + 在場中の参加者を選んで一括チェックアウト +- `/settings` ログアウト導線、`/guideline` 子ども向け参加ガイドライン + +### 管理画面 `apps/admin`(PC・モバイル / PWA・:3001) + +- `/` ダッシュボード(日付を選んで任意の開催日のセッション一覧) +- `/participants` 利用者一覧(ID / 氏名 / ニックネーム検索・学年 / 状態フィルタ・詳細シート) +- `/stats` 参加回数統計(ターム別・日別) +- `/pre-registrations` 事前登録の追加/削除、`/mentors` メンター管理(いずれも **admin ロールのみ**。 + ナビは `apps/admin/src/components/nav-items.ts` の `adminOnly` で出し分け) + +### 会場サイネージ `apps/signage`(大型モニター・キオスク・:3002) + +- `/login` → 共有の管理用 Google アカウントで 1 回ログイン +- `/` 放送風サイネージ本体(活動中 = YouTube 再生・休憩/待機 = オーバーレイ・右レーンに + チャイムカウントダウンとターム/サイクル・下部に巡回インフォ)。50 分活動 / 10 分休憩サイクルは + `@tecnova/shared/activity-cycle` +- 詳細仕様: `docs/superpowers/specs/2026-05-29-signage-chime-design.md` + +### API `apps/api`(Hono on Cloudflare Workers・:8787) + +- public: `/health`, `/sheets/health` +- Better Auth: `/api/auth/*` +- `/checkin/*`(mentor 認証必須): + - `/pre-registered`, `/activate` + - `/sessions/check-in`, `/sessions/check-out`, `/scan` + - `/history/today`, `/history/check-out-bulk` + - `/participants/search`, `/participants/:id`, `/participants/:id/attendance` +- `/api/*`(mentor 認証必須): + - `/me` + - `/sessions/today`, `/sessions?date=`, `/events`(開催日セレクタ用・最新 50 件) + - `/stats/participation`(`from` / `to` で期間指定) + - `/participants` + - `/mentors`(GET / POST / PATCH・**admin のみ**) + - `/pre-registrations`(GET / POST / DELETE・**admin のみ**、学生側スプシを操作) + - `/signage/playlist`(YouTube Data API・Worker キャッシュ)、`/signage/previous-summary` + (前回開催の集計・PII なし)、`/signage/health` +- アクティベート時は `c.executionCtx.waitUntil()` で GAS Drive webhook を背面呼び出し + (`GAS_DRIVE_WEBHOOK_URL` / `GAS_DRIVE_WEBHOOK_SECRET` 未設定なら no-op) + +--- + +## 本番環境 + +- **Cloudflare D1**(`tecnova-db`):マイグレーション 0000〜0003 適用済み。以後のマイグレーションは + `main` への push で `deploy-api.yml` が自動適用する +- **Cloudflare Worker Secrets** 登録済み: + `GOOGLE_SERVICE_ACCOUNT_KEY`(base64)/ `GOOGLE_SHEETS_ID` / `GAS_DRIVE_WEBHOOK_URL` / + `GAS_DRIVE_WEBHOOK_SECRET` / `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` / + `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` / `YOUTUBE_API_KEY` / + `YOUTUBE_PLAYLIST_ID` / `TRUSTED_ORIGINS`(admin・checkin・signage の本番オリジン) +- **Vercel**:`tecnova-platform-admin` / `-checkin` / `-signage` の 3 プロジェクト。 + `main` への push で Production、PR で Preview がデプロイされる +- mentors テーブルに管理者(role=admin)を投入済み +- 本番 URL は Public リポジトリのため記載しない(ローカルの Claude memory で管理) +- **CI/CD**(`.github/workflows/`): - `ci.yml`:PR / main push で `biome check` + `turbo type-check` - - `deploy-api.yml`:main push(`apps/api/**` ほか paths フィルタ)で D1 リモートマイグレーション → `wrangler deploy` - - 必要 GitHub Secrets:`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`(未登録なら deploy job が失敗するので登録要) + - `deploy-api.yml`:main push(`apps/api/**`・`packages/db/**`・`packages/shared/**` ほか)で + API type-check → D1 リモートマイグレーション → `wrangler deploy` + - 必要 GitHub Secrets:`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` +- **D1 のバックアップはリポジトリ外**で運用している(GAS → Google Drive に日次 SQL ダンプ)。 + コードを読んでも見つからないので、DR / データ保全の話題ではこの存在を前提にすること --- -## 次に取り掛かるフェーズ +## 次に取り掛かる候補 + +### 直近の優先事項(2026-10 時点) + +**1. QR 読み取り精度の改善(バグ)** + +- 現場のフィードバック:受付端末(checkin `/`)の QR スキャナが、iPad を常駐させてページを開いた + まましばらく置くと、映像は動いているのに QR を読まなくなる。カメラの再起動ボタンを押すと読める +- 対応済み(現場での確認待ち):カメラを常時起動するのをやめ、「タップしてQRを読み取る」を押した + ときだけ起動する方式にした(読めたら遷移、20 秒で自動停止)。毎回「再起動した直後」の状態で読む +- 根本原因は未特定。候補は「zxing の連続読み取りが解析用キャンバスを起動時のサイズで固定する」 + (映像の解像度・向きが後で変わると一部しか解析しない)と「Safari が長時間動く処理を止める・ + 間引く」。どちらも見た目では区別できない。この方式でも再発したら、`?debug=1` で解析中の画像や + カメラの状態を表示する診断パネルを入れて実機で切り分ける + +### その他の未対応 Issue -**Phase 1(MVP)は完了。デモンストレーションも実施済み。** +| Issue | 規模 | メモ | +| --- | --- | --- | +| #47 事前登録のソート順を追加順(事前登録番号順)に | 小 | `apps/api/src/lib/pre-registrations.ts` の並び替え(現状は `registeredAt` 降順)を変えるだけ | +| #39 フライング(ターム開始前)チェックインの許容 | 中・要仕様決め | 下記参照 | -運用開始前の最終調整として挙げていた残タスク(受付画面の実機検証 / 採番衝突の自動リトライ / -運用手順の文書化 / 昨年度データの D1 移行)は、2026-05-29 に **不要と判断して取り下げた**。 -同時タップ起因の採番衝突などは、発生時に手動再試行で回復する運用で確定(`apps/api/src/lib/checkin.ts` -の TODO は将来 problem 化した場合のみ着手。`docs/mvp.md` 4.2 / 10.3 も同方針に更新済み)。 +**#39 の前提知識**:`classifyTerm`(`packages/shared/src/venue-schedule.ts`)は**曜日を見ず +時刻だけ**でタームを決める。そのため木曜 15:55 の来場は「昼(13:00–16:00)」と判定され、 +終了まで 30 分未満なので参加回数に数えられない。Issue では「開始 10 分前まで許容」 +「ターム境界 30 分の前半を前ターム・後半を後ターム扱い」などが候補。 +`/api/stats/participation` は counted 判定を毎回 JS で再計算しているため、**判定ロジックを +変えると過去の集計も遡って変わる**点を仕様決めで考慮すること。`packages/shared` に vitest の +テストがあるので TDD で進めやすい。 -次に着手するのは Phase 1.5(運用開始後の機能拡張)。 +### Phase 1.5(2026-10 見直し後) -### Phase 1.5(運用開始後) +詳細は `docs/requirements.md` §3.2 / §3.5。 -- メンタースマホアプリ(`apps/mentor` — 未着手。30 分グリッドのログ記入・未記入ハイライト) -- 活動ログ記入機能、活動カテゴリ・機材マスタ管理 -- ログ CSV エクスポート +- 子どもによる振り返り・目標設定のデジタル入力。現状は「子どもが紙に書く → メンターが Google + フォームへ転記」で、メンターの負担をなくすのが目的。入力方式(Google フォーム / 自前の画面)は + 検討中 +- 活動カテゴリ・機材マスタ、共同活動者の記録(保留。入力方式と合わせて判断) +- 管理画面の機能拡張(参加者編集・マスタ管理) - ターム境界の締め自動化(現状は受付端末「受付りれき」からの手動一括チェックアウト運用。 `docs/mvp.md` §3.2 / 7章参照) -### サイネージ(`apps/signage`)の残作業 +### Phase 2 -機能は develop で動作済み。本番運用に向けて未確定の設定値・現地作業が残る: +- 公開 API(混雑状況配信)、分析ダッシュボード(当面は日次 SQL ダンプを SQL で分析)、保護者向け機能 -- 共催(長崎市など)の実ロゴ差し替え(現状はテキスト表記でロゴ未配置) -- 公式 Instagram ハンドル等の運用設定値(`src/config/info-slides.ts`)の本番値確認(要確認) -- 本番キオスク端末(Chromium `--kiosk`・常時電源・wake lock)の現地設定 -- 本番反映(`develop` → `main` マージ)と、それに伴う Worker Secrets への - `YOUTUBE_API_KEY` / `YOUTUBE_PLAYLIST_ID` 登録・`TRUSTED_ORIGINS` へのサイネージ本番 - ドメイン追加(要確認) +### 計画から外したもの(2026-10) + +メンターアプリ・30 分グリッドの活動ログ、ログ CSV エクスポート、振り返りシートの OCR、 +教員側スプシとの自動同期、iPad オフライン対応。理由は `docs/requirements.md` 付録A D16〜D19。 --- @@ -184,13 +210,13 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 新しいセッションが知っておくべき非自明なこと。 -### `/checkin/*` の認証境界が変わっている +### `/checkin/*` の認証境界が当初設計と違う - 当初設計(`requirements.md` 8.2)では `/checkin/*` は認証なしだったが、 - 受付端末はメンターが操作する前提に倒したため、5月の改修で **mentor 認証必須に変更** + 受付端末はメンターが操作する前提に倒したため **mentor 認証必須に変更済み** - `apps/api/src/index.ts` で `/api/*` と `/checkin/*` の両方に `apiCors` + `requireAuthenticatedMentor` を適用している -- フロント側は `apps/checkin` も `apps/admin` 同様に `/login` を持ち、`MeProvider` +- フロント側は checkin / admin / signage すべてが `/login` を持ち、`MeProvider` でセッションを取得する ### Better Auth と pnpm peer 解決 @@ -198,7 +224,7 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 - Better Auth を入れると transitive で kysely が引き込まれる - pnpm の peer dep 解決で `apps/api` 側の drizzle-orm が `kysely` フレーバーで resolve される一方、`packages/db` 側はノーフレーバーで resolve される -- 同じ `0.45.2` でも別ストアコピーになり、TypeScript が `SQLiteColumn` を +- 同じバージョンでも別ストアコピーになり、TypeScript が `SQLiteColumn` を 別物扱いして型エラーになる - 対策: `packages/db` にも `kysely` を devDep として入れて peer を揃える (PR #8 で対応済み)。今後 better-auth プラグイン等で同様の peer dep 不整合が @@ -222,40 +248,53 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 ### D1 の database_id を `wrangler.toml` で変えるとローカル状態がリセットされる -- 開発中 placeholder の `00000000-...` から本番値に差し替えたら、 - miniflare の local D1 ストア先が変わってテーブルが空になる +- miniflare の local D1 ストア先が変わってテーブルが空になる - 対策: `wrangler.toml` 変更後は `pnpm --filter @tecnova/api db:apply:local` を再実行 ### CORS とクロスオリジンクッキー -- admin (3001) → API (8787) は別オリジン -- `apps/api/src/index.ts` の `/api/*` cors は `TRUSTED_ORIGINS` を参照して - 許可オリジンを動的判定 + `credentials: true` +- 各フロント(3000 / 3001 / 3002)→ API(8787)は別オリジン +- `apps/api/src/index.ts` の cors は `TRUSTED_ORIGINS` を参照して + 許可オリジンを動的判定 + `credentials: true`。`TRUSTED_ORIGINS` は `/api/*` と + `/checkin/*` の CORS、Better Auth の trustedOrigins の 3 か所で使われるので、 + **フロントを増やしたら必ずそのオリジンを追加する**(漏れると 401/403) - フロントの `authClient` は `fetchOptions.credentials: 'include'` で Worker のクッキーを送信 - Better Auth の `signIn.social({ callbackURL })` には**絶対URLを渡す** (相対パスだと Better Auth が baseURL = ワーカーで解決してしまう、PR #10 修正) +- admin / API は同一親ドメイン配下の兄弟サブドメインに置くこと。別ドメインに分けると + Cookie が third-party 扱いになり認証が壊れる + +### 学生側スプシの列構成を変えるときはデプロイ順序が肝 + +- 現在の `participants` シートは A=事前登録ID / B=氏名 / C=ニックネーム / D=学年 / + E=事前登録日 / F=内製ID / G=アクティベート日時 / H=アクティベート済(`A2:H` を読む) +- 新コードが旧レイアウトのスプシを読むと列マッピングがズレて読み書きが崩壊する。 + 列を足す・動かすときは「メンテ枠確保 → スプシ更新 → D1 マイグレーション → backfill → + API デプロイ → フロントデプロイ」の順で行う(フロントは Zod で新形式を必須にするため API が先) +- 氏名追加時(2026-05)の詳細手順とロールバック手順は git 履歴を参照: + `git show 5f5c131:docs/handoff.md` +- 学生側スプシ ID は Secret 扱い(Public リポジトリ運用方針のため。`[vars]` に書かない) -### 学生側スプシ ID は Secret 扱い +### フロントの hooks / effect 変更は runtime で確認する -- `docs/mvp.md` の初期版は `[vars]` だったが Public リポジトリ運用方針と - 矛盾するため Secret 扱いに統一(PR #4 のドキュメント更新) +- checkin / admin / signage にはユニットテスト基盤が無く、type-check・Biome・コードレビューは + React の無限再レンダーループのような runtime 不具合を取りこぼす + (PR #44 で `useEffect` 内 setState が毎レンダー新規生成される派生値に依存し、loading 画面で + 無限ループした → `412b605` で修正) +- 取得状態・effect を触ったら、dev サーバで該当画面を開いてコンソールエラーを確認してから完了とする +- admin / checkin のデータ取得は `@tecnova/ui` の `useApiResource` + (`packages/ui/src/hooks/use-api-resource.ts`)に統一済み。新しい画面もこれを使う -### サイネージ(`apps/signage`)の必須 env が増えた +### フロントで `@tecnova/*` パッケージを使うとき -- API 側 Secrets / `.dev.vars` に `YOUTUBE_API_KEY` / `YOUTUBE_PLAYLIST_ID` が必要 - (`/api/signage/playlist` が YouTube Data API を叩くため) -- `TRUSTED_ORIGINS` にサイネージ origin(dev: `http://localhost:3002`、本番はサイネージ - ドメイン)を追加しないと CORS で 401/403 になる(CORS と Better Auth trustedOrigins の - 両方に効く) -- フロント側は `NEXT_PUBLIC_API_URL`(未設定時 `http://localhost:8787`) -- 新しい `@tecnova/*` パッケージを使うときは signage の `next.config.ts` の - `transpilePackages` に追加する +- 各アプリの `next.config.ts` の `transpilePackages` に追加する(現状 `@tecnova/shared` と + `@tecnova/ui`) ### サイネージの YouTube 広告は埋め込み側で消せない -- 仕様 §5.3:埋め込みプレーヤーは所有しない動画の広告を削除・スキップできない +- 埋め込みプレーヤーは所有しない動画の広告を削除・スキップできない (プログラム的スキップは YouTube API Developer Policies 違反) - 広告フリーを確実にできるのは **YPP 加入チャンネルで収益化オフにした自前アップロード動画** か、self-host のみ @@ -273,12 +312,6 @@ Phase 1(MVP)は本番デプロイ済みで稼働中。主要な完了項目 手動チャイムの操作バーが出て、実時刻を待たず全状態・遷移・チャイムを検証できる - 本番(フラグ無し)は影響ゼロ(`debugEnabled=false` で全分岐が短絡=従来挙動と完全同値) -### サイネージのセキュリティレビュー済み(2026-05-30) - -- signage PR は 2026-05-30 にセキュリティレビューを実施し、新規の悪用可能な脆弱性なしを確認 -- 根拠:`/api/signage/*` はメンター認証必須・`/previous-summary` は集計のみで PII なし・ - CORS は `TRUSTED_ORIGINS` の厳格照合 - --- ## 環境ファイル(参考) @@ -294,111 +327,32 @@ GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:8787 -TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001 +TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002 +YOUTUBE_API_KEY= +YOUTUBE_PLAYLIST_ID= ``` -`TRUSTED_ORIGINS` はカンマ区切り。`/api/*` と `/checkin/*` の CORS と Better Auth の -trustedOrigins の3か所で使われる。`/checkin/*` も Cookie 認証必須になったので、 -checkin の Vercel URL も本番 Worker secret に含めること。本番では admin / checkin の -Vercel URL を両方 Worker secret に登録済み。 +フロント側は `NEXT_PUBLIC_API_URL`(未設定時 `http://localhost:8787`)。 -## ユーザーの作業スタイルメモ +--- -`~/.claude/projects/.../memory/` に保存済みの feedback と重複するが、要点: +## 作業の進め方 +- **ブランチ運用**:`main` には直接触れない。feature ブランチを切り、PR は `develop` に対して出す。 + 本番反映は `develop` → `main` のリリース PR で行う - **ミニマム・シンプル優先**:Phase 1.5 以降のものを先取りで作らない - **API 最優先**:UI/UX 整えは最後 -- **CLI 経由で初期化**:`create-next-app` 等を手書きで再現しない(だが2個目以降は - apps/checkin を複製した方が早い説を本人も認識) -- **小さい論理単位でコミット**:PR内も複数コミットに分けて積み上げる +- **CLI 経由で初期化**:`create-next-app` 等を手書きで再現しない +- **小さい論理単位でコミット**:PR 内も複数コミットに分けて積み上げる - **CLI ツールはローカル devDep**:wrangler 等をグローバルに入れない - **Public リポジトリ運用**:シークレット類は `wrangler secret put` または Vercel 環境変数。`.env.example` には変数名のみ - ---- - -## 学年「その他」追加リリース手順(2026-05-22) - -学年の正規値に `その他` を追加し、旧値 `卒業` は運用データ更新で `その他` に置換する。 -スプレッドシートの実データは以下の手順で手動更新する。 - -### デプロイ / 更新順序 - -1. **新コードをデプロイ**: - - api: `pnpm --filter @tecnova/api deploy` - - admin / checkin: 通常の Vercel デプロイ(main マージで自動) -2. **DB マイグレーション適用(remote)**: - ```bash - pnpm --filter @tecnova/api db:apply:remote - ``` - `0003_graduated_grade_to_other.sql` が `participants.grade = '卒業'` を `その他` に更新する。 -3. **学生側スプシを手動更新**: - 1. 対象スプレッドシートを開き、`participants` シートを確認する - 2. 作業前にスプレッドシート全体、または `participants` シートを複製してバックアップする - 3. D列「学年」を対象に検索またはフィルタで `卒業` を抽出する - 4. 該当セルをすべて `その他` に置換する - 5. D列で `卒業` を再検索し、0件であることを確認する - 6. `小1`〜`高3` と `その他` 以外の値が残っていないか、必要に応じてD列を目視確認する -4. **学年上げ運用の確認**: - - スプレッドシート側や外部運用で `卒業` を作っている場合、出力値を `その他` に変更する -5. **動作確認**: - - admin の事前登録追加で学年初期値が未選択になっている - - `その他` を選んで新規追加できる - - 利用者一覧の `その他` フィルタで更新済みデータが表示される - ---- - -## 氏名(fullName)追加リリース手順(2026-05-13 着手) - -参加者データに **氏名(本名)** を追加した。学生側スプシは **B列に氏名を挿入** し、既存 -列を1つずつ右にシフト(A=preRegId / B=氏名 / C=ニックネーム / D=学年 / E=事前登録日 / -F=内製ID / G=アクティベート日時 / H=アクティベート済)。 - -DBは `participants.full_name` を NOT NULL DEFAULT '' で追加。既存行は backfill する。 - -**重要**: 新コード(A2:H 前提)が旧スプシ(A2:G)を読むと列マッピングがズレて読み書きが -崩壊する。**必ず以下の順序で実施する**。途中で運用中の iPad / 管理画面からアクセスが -あると不整合が出るのでメンテ枠を取る。 - -### デプロイ順序 - -1. **メンテモード**(運用停止時間枠を確保。iPad / admin から触らない) -2. **学生側スプシを手動更新**: - 1. `participants` シートで **B 列を挿入**(既存 B〜G が C〜H にシフトされる) - 2. B1 セルに `氏名` ヘッダーを入力 - 3. 既存全行 B 列に教員管理スプシから氏名を転記(事前登録ID をキーに突合) -3. **DB マイグレーション適用(remote)**: - ```bash - pnpm --filter @tecnova/api db:apply:remote - ``` - `0002_early_network.sql` が `ALTER TABLE participants ADD full_name TEXT DEFAULT '' - NOT NULL` を適用する。 -4. **DB backfill**: スプシ B 列に入れた氏名を DB の `participants.full_name` に流す。 - 既存件数が少ないので 1 件ずつ手書きの SQL で十分: - ```bash - cd apps/api - npx wrangler d1 execute --remote --command "UPDATE participants SET full_name = '田中太郎' WHERE id = '26001'" - # ... 全件分繰り返す - ``` - 件数が増えてきたら `wrangler d1 execute --file backfill.sql` で一括投入する方が早い。 -5. **新コードをデプロイ**: - - api: `pnpm --filter @tecnova/api deploy` - - admin / checkin: 通常の Vercel デプロイ(main マージで自動) - - 順序は api → 各フロント。フロントは古い fullName 無しのレスポンスを Zod でリジェクト - するため、api が先に新形式を返す状態にしてから上げる -6. **動作確認**: - - admin 事前登録の新規追加で 氏名・ニックネーム・学年・事前登録日 が入る - - スプシ A〜E 列に新規行が `preRegId / 氏名 / ニックネーム / 学年 / 事前登録日` - 順で書き込まれていること - - iPad の「初めての方」一覧で氏名が表示されること - - 活性化後にスプシ F/G/H 列に internalId / activatedAt / TRUE が書かれていること - - QRスキャンで チェックイン/アウト の結果カードに氏名が出ること -7. **メンテモード解除** - -### ロールバック手順 - -- 万一フロントが落ちた場合、api は古いフロントとは互換しない(フロントが fullName - 必須を期待)。**api を先に旧バージョンに戻す → スプシの B 列を再削除 → DB - migration を down**(drizzle-kit が生成する down SQL は無いので、`ALTER TABLE - participants DROP COLUMN full_name` を手書き)→ フロントを戻す、の順。 -- スプシ B 列を消す前にバックアップ(シート複製)を取ること。 +- **設計 → 計画 → 実装**:大きめの作業は `docs/superpowers/specs/`(設計)と + `docs/superpowers/plans/`(実装計画)に残してから着手している +- **Claude Code 用ツール**(`.claude/`): + - hooks:シークレットファイルへの書き込みブロック、編集後の Biome フォーマット + - subagents:`workers-constraint-reviewer`(Workers 制約チェック)、 + `privacy-reviewer`(個人情報の保持範囲チェック) + - skills:`create-migration`(Drizzle/D1 マイグレーション)、`pre-pr-check` + (Biome + type-check + CI 対象外の `packages/shared` / `apps/api` テスト + シークレット/PII スキャン) +- **API のテスト**:`pnpm --filter @tecnova/api test`(`@cloudflare/vitest-plugin`。ローカルの D1 にマイグレーションを当てて `apps/api/src/lib` を直接テストする。各テストの前に `resetTables` で空にする)。`packages/shared` は `pnpm --filter @tecnova/shared test`。どちらも CI では走らないので PR 前に手元で実行する diff --git a/docs/mvp.md b/docs/mvp.md index b60c5b8..bed0280 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -29,7 +29,7 @@ ## 2. Phase 1(MVP)のゴール Phase 1 では**「子どもがiPad受付端末でチェックイン・チェックアウトでき、初回来場者は事前登録情報からアクティベートできる」**状態を実装し、本番稼働している。 -活動ログ等のメンター業務は引き続き従来通りスプシ運用で並行し、Phase 1.5 以降で順次実装していく。 +子どもの振り返り・目標設定は現状紙で運用しており(メンターが Google フォームへ転記)、Phase 1.5 でデジタル入力へ移行する([`requirements.md`](./requirements.md) §3.2)。 --- @@ -51,15 +51,13 @@ Phase 1(MVP)の実装範囲と、意図的に含めていない範囲を示 ### 3.2 含まないもの(Phase 1.5以降) -⏭️ メンタースマホアプリ -⏭️ 活動ログ機能 -⏭️ 活動カテゴリ・機材マスタ -⏭️ ログCSVエクスポート -⏭️ 教員側スプシとの自動同期 -⏭️ 振り返りシートOCR -⏭️ 公開API -⏭️ 保護者向け機能 -⏭️ オフライン対応 +⏭️ チェックイン記録の追加・編集・削除(管理画面・admin のみ) +⏭️ 子どもによる振り返り・目標設定のデジタル入力(方式検討中) +⏭️ 活動カテゴリ・機材マスタ(保留) +⏭️ ターム境界の自動チェックアウト・会場スケジュール設定 +⏭️ 公開API・分析ダッシュボード・保護者向け機能(Phase 2) + +メンターアプリ・活動ログ・ログCSVエクスポート・振り返りシートOCR・教員側スプシとの自動同期・オフライン対応は、2026-10 の見直しで計画から外した([`requirements.md`](./requirements.md) §3.5)。 --- @@ -242,6 +240,8 @@ async function getOrCreateTodayEvent( タイムゾーン注意: Workersのデフォルトタイムゾーンはサーバーロケーションに依存しないUTC。JSTで「今日」を判定する必要があるので、上記のように明示的に変換する。SQLite の `onConflictDoNothing` も Drizzle の同名メソッドで動作する(`UNIQUE` 制約に対する `ON CONFLICT DO NOTHING`)。 +**管理画面からの記録修正での開催日**(admin・2026-10): 来場記録の追加・編集では、入場時刻の JST 日付の開催日を `INSERT … ON CONFLICT(date) DO NOTHING` で作り、`event_id` は日付からのサブクエリで解決する(記録の書き込みと同じ `db.batch` 内)。編集・削除で記録が 1 件も無くなった**過去**の開催日は、同じ batch 内で削除する(今日の開催日は受付中のため残す)。詳細は §6.2「来場記録の修正」。 + ### 4.4 ターム区分と参加回数(`venue-schedule`) 会場の時間帯(ターム)と「参加回数」のカウントは **`packages/shared/src/venue-schedule.ts`** に集約した純粋ロジックで判定する(API・フロント共通)。DB スキーマは変更せず、`sessions.checked_in_at` から都度導出する(_derive_ 方式)。 @@ -741,6 +741,37 @@ admin 権限不要・ページネーションなし・active=true のみ・最 { "events": [{ "id": "uuid-...", "date": "2026-05-15" }] } ``` +#### `POST /api/sessions` / `PATCH /api/sessions/:sessionId` / `DELETE /api/sessions/:sessionId`(admin)— 来場記録の修正 + +受付の操作ミス(チェックイン忘れ・チェックアウト忘れ・別の子で受付・二重記録)を訂正するための、来場記録の追加・編集・削除。`GET /api/sessions` は全メンター向けのまま、この 3 つだけルート単位で `requireAdmin` を当てる。 + +**リクエスト**: + +```json +// POST(追加) +{ "participantId": "26001", "checkedInAt": "2026-10-04T01:05:00.000Z", "checkedOutAt": "2026-10-04T02:40:00.000Z" } + +// PATCH(編集。変える項目だけ・1 つ以上。checkedOutAt: null は「滞在中」に戻す) +{ "checkedOutAt": "2026-10-04T03:00:00.000Z" } +``` + +**レスポンス**: POST は 201、PATCH は 200 で次の形。DELETE は 204(本文なし)。 + +```json +{ "eventDate": "2026-10-04", "session": { "sessionId": "...", "participantId": "26001", "...": "todaySessionItem と同じ形" } } +``` + +**守るルール**(`packages/shared/src/session-rules.ts` を API と画面で共用): + +- 開催日は入場時刻の JST 日付から決める(無ければ作る。編集で日付が変われば、その日の開催日へ移す) +- 退場は入場より後、かつ同じ JST 日のうち。未来の時刻は不可(現在時刻 + 1 分まで許容) +- 「滞在中」(退場時刻なし)は今日の記録だけ +- 同じ子・同じ日の他の記録と時間が重ならない(半開区間 `[入場, 退場)`。滞在中は現在時刻まで続くとみなす) +- 無効化した子の記録も対象にできる +- 記録が無くなった過去の開催日は削除する(§4.3) + +**エラー**(`{ "error": code, "message": 日本語 }`): `SESSION_NOT_FOUND` 404 / `PARTICIPANT_NOT_FOUND` 404 / `CHECKOUT_BEFORE_CHECKIN` 400 / `SPANS_DAYS` 400 / `FUTURE_TIME` 400 / `OPEN_SESSION_NOT_TODAY` 400 / `OVERLAP` 409。本文の形式エラーは 400 `INTERNAL`。 + #### `GET /api/participants` 参加者一覧(ページネーション対応)。 @@ -943,16 +974,24 @@ JST の今日より前で最新の開催日を集計して返す(集計値の ### 7.1 チェックインiPadアプリ(apps/checkin) -iPad は受付メンターの端末。トップ画面はカメラを常時起動した QR スキャナ + 横の +iPad は受付メンターの端末。トップ画面は QR スキャナ(タップで起動)+ 横の ショートカット(初めての人 / 受付りれき / マニュアル入力)で構成し、子どもが 個別のフォームを操作する想定はしない。 #### 7.1.1 トップ画面(QR + ショートカット 3 つ) - 主画面はカメラビュー(`@zxing/browser` の `BrowserMultiFormatReader.decodeFromVideoDevice`) +- **カメラは「タップしてQRを読み取る」を押したときだけ起動する**。待機中はカメラを止めておく。 + 起動後は自動で読み取り、読めたら遷移、読めないまま 20 秒(`SCAN_TIMEOUT_MS`)経つとカメラを + 止めて待機に戻る。iPad の Safari でカメラを起動しっぱなしにすると、映像は動いているのに QR を + 読まなくなる事象があり(「カメラを再起動すれば読める」は確認済み)、毎回起動し直した状態で + 読み取るためにこの方式にした - 5 桁の内製ID形式(`PARTICIPANT_ID_PATTERN`)にマッチした値だけを採用 - 認識成功で即 `/reception/participants/[id]` に遷移し、controls.stop() で二重検出を防ぐ -- カメラ再起動 / カメラ切り替えボタンを提供(複数 videoDevice 切り替え対応) +- カメラ起動に失敗したら待機に戻し、ボタン上にエラーを出す(もう一度タップで再試行) +- カメラ再起動ボタンを提供(読み取り中でもカメラを起動し直し、20 秒のタイマーもリセットする。現場で + 「すぐ読ませたいときに押す」使い方をしているため残している。待機中に押すと読み取りを始める) +- カメラ切り替えボタンを提供(複数 videoDevice 切り替え対応。待機中に押すと読み取りも始める) - 右カラムに 3 つの ActionPanel: - `/first-time` 初めての人 → 事前登録者一覧 - `/history` 受付りれき → 当日履歴 + 一括チェックアウト @@ -1017,13 +1056,15 @@ iPad は受付メンターの端末。トップ画面はカメラを常時起動 - セッション一覧テーブル - ID / **氏名** / ニックネーム / 学年 / ターム(色分けバッジ・API の `term` を表示。30分ルールで対象外なら「カウント対象外」併記)/ チェックイン時刻 / チェックアウト時刻 / 状態 - 行クリックで `ParticipantDetailSheet`(参加者詳細)を開く(**参加回数** `participationCount` とセッションごとのターム表記を表示) +- **来場記録の修正(admin のみ)**: ヘッダーの「記録を追加」と、各行(PC の表・スマホのカード)の「…」メニュー(編集/削除)。フォームは中央のダイアログ(`SessionFormDialog`)、削除は確認ダイアログ(`SessionDeleteDialog`)。保存後は一覧と日付の選択肢を再取得し、記録が別の日付に属する場合はその日付へ表示を切り替える +- `ParticipantDetailSheet` の「来場履歴」にも admin のみ「記録を追加」(その子を選んだ状態・日付は今日)と各行の「…」メニュー。変更後は詳細とダッシュボードの一覧を再取得する #### 7.2.3 参加者一覧 - 検索ボックス(**ID / 氏名 / ニックネーム**の部分一致)+ 学年フィルタ + 状態(有効/無効)フィルタ - テーブル: ID / **氏名** / ニックネーム / 学年 / ID発行日 / 状態 - ページネーション -- 閲覧専用(編集は Phase 1.5) +- 参加者の属性(氏名・ニックネーム・学年)は閲覧専用(編集は Phase 1.5)。行クリックで開く `ParticipantDetailSheet` から、admin は来場記録を修正できる(§7.2.2) #### 7.2.4 メンター管理(admin権限のみ) @@ -1211,7 +1252,7 @@ export default defineConfig({ - 当日朝に追加された事前登録者は反映されない可能性がある(読み取りキャッシュ+スプシ手作業転記の遅延) - Wi-Fi切断時はチェックイン業務が一時停止する - 同時アクティベートでID採番衝突が起きた場合はエラーになる(現行は手動再試行で回復) -- 活動ログ記入は引き続き従来通りスプシ手作業(Phase 1.5まで) +- 子どもの振り返り・目標設定は紙で記入し、メンターが Google フォームへ転記している(デジタル入力への移行は 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 f6463bd..12d5d99 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -2,9 +2,9 @@ | 項目 | 内容 | | ---------------------- | ---------------------------------------------------------- | -| ドキュメントバージョン | v1.3 | +| ドキュメントバージョン | v1.4(2026-10 計画見直しを反映) | | 対象システム | tec-nova Nagasaki 統合管理プラットフォーム | -| 運用状況 | Phase 1(MVP)を本番デプロイ・稼働中 | +| 運用状況 | Phase 1(MVP)+会場サイネージを本番デプロイ・稼働中 | | 関連ドキュメント | 下表参照 | | ドキュメント | 役割 | @@ -31,7 +31,7 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に 本プロジェクトは、tec-nova Nagasaki の運営を支える統合管理プラットフォームを内製で開発するものである。コアな目的は以下の4点。 1. 来場・退出のチェックインシステムとしての基本機能の安定化 -2. メンターによる活動ログ記入のDX化(従来スプシ運用のボトルネック解消) +2. 活動記録のDX化(子ども自身が振り返り・目標設定をデジタルで記録し、メンターの転記負担をなくす) 3. 研究用データの構造化収集(自然言語ログの分類負荷軽減) 4. API-firstな基幹システムとして将来の機能拡張に耐える設計 @@ -56,12 +56,12 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に | 役割 | システムへの関わり方 | | -------------------------- | ------------------------------------------------------------------------ | -| 利用者(小1〜高3中心・その他含む) | チェックイン/アウト、紙ネームカード(QR付き)を使用 | +| 利用者(小1〜高3中心・その他含む) | チェックイン/アウト、紙ネームカード(QR付き)を使用。振り返り・目標設定を自分で記録する(現状は紙、デジタル入力へ移行予定) | | 保護者 | 付き添いのみ。MVP範囲ではシステム未関与(Phase 2で検討) | -| メンター(学生スタッフ) | スマホPWAでログ記入、QRスキャンで子ども特定 | +| メンター(学生スタッフ) | 受付端末の操作、子どもの活動支援。活動ログの記入は担わない(現状は紙の記録を Google フォームへ転記しているが、デジタル化で不要にする) | | エリアマネージャー | ネームカード作成、初回登録対応支援、教員側スプシから学生側スプシへの転記 | | 教員陣 | 事前登録情報の管理(学生側システムには非開示) | -| 研究者(研究室メンバー) | 蓄積データの分析・エクスポートCSV利用 | +| 研究者(研究室メンバー) | 蓄積データの分析(D1 の日次 SQL ダンプを SQL で分析) | | 開発・保守担当 | プロジェクトメンテナ | --- @@ -88,33 +88,39 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に 運用と並行して実装・追加していく機能群。 -- **会場サイネージ(`apps/signage`)**: 大型モニター・キオスク向けの配信風表示。活動サイクル(50分活動/10分休憩)のチャイムと進行、来場・にぎわい・前回開催の集計を提示。メンター認証・PII 非表示。詳細は §9.4 と付録B、`docs/superpowers/specs/2026-05-29-signage-chime-design.md` を参照(2026-05-30 出荷) -- メンターアプリ(スマホPWA) -- 活動ログ記入(30分グリッド・未記入ハイライト・QR遷移) -- 活動カテゴリ・機材マスタ -- 共同活動者の記録 -- ログCSVエクスポート -- 管理画面の機能拡張(参加者検索・編集・マスタ管理) -- ターム境界の自動チェックアウト(Cron Trigger)・会場スケジュール/休講カレンダー設定(現状はタームを来場時刻から自動判定し、締めは手動の「滞在中全員をチェックアウト」で運用する) +- **会場サイネージ(`apps/signage`)**: 大型モニター・キオスク向けの配信風表示。活動サイクル(50分活動/10分休憩)のチャイムと進行、来場・にぎわい・前回開催の集計を提示。メンター認証・PII 非表示。詳細は §9.4 と付録B、`docs/superpowers/specs/2026-05-29-signage-chime-design.md` を参照(2026-05-30 出荷・完了) +- **子どもによる記録のデジタル入力**: 振り返りシートと目標設定シートを紙からデジタルへ移行し、子ども自身が入力する。現状の「子どもが紙に書く → メンターが最後に Google フォームへ転記」をなくし、メンターの負担を減らすのが目的。入力方式(Google フォーム / 自前の入力画面)は検討中で、記録を D1 の参加者・来場データと内製 ID で結びつけて SQL 分析したいかが判断の分かれ目になる(§4.3・§9.3) +- 活動カテゴリ・機材マスタ、共同活動者の記録(**保留**): もとはメンターのログ用に計画したもの。子どもの記録の選択肢として使うかを、入力方式と合わせて判断する +- **チェックイン記録の修正(2026-10 実装済み)**: チェックイン忘れ・押し間違いといった操作ミスが頻発しているため、管理画面(ダッシュボード・利用者詳細)から来場記録を追加・編集・削除できるようにした(admin のみ。Issue #48 / #49。仕様は `mvp.md` §6.2「来場記録の修正」) +- 管理画面の機能拡張(参加者編集・マスタ管理) +- ターム境界の自動チェックアウト(Cron Trigger)・会場スケジュール/休講カレンダー設定(現状はタームを来場時刻から自動判定し、締めは手動の「滞在中全員をチェックアウト」で運用する)。ターム開始前の早めの来場(フライング)を参加回数に数える扱いもここで検討する ### 3.3 Phase 2(中長期改善) -- 振り返りシートのOCR取り込み(Vision LLM経由) -- 教員側スプシとの自動同期(GAS Webhook) - 公開API(混雑状況配信・APIキー認証) -- 高度な分析ダッシュボード(クラスター分析支援) -- リアルタイム編集状況表示(誰が今編集中か) +- 高度な分析ダッシュボード(クラスター分析支援)。当面は D1 の日次 SQL ダンプを SQL で直接分析する - 保護者向け機能(混雑状況・活動サマリ閲覧) -- iPadオフライン対応(PWAキャッシュ+復帰時同期) -- スロット切り替えのプッシュ通知 ### 3.4 スコープ外(明確に作らないもの) -- 紙の振り返りシートのデジタル代替(教育方針として紙運用を維持) -- 紙の目標設定シートのデジタル代替(同上) -- 子ども向けのタブレット入力UI - ネームカードPDFの自動生成・印刷(手動運用で十分) - 教員側Googleフォーム置き換え +- 教員側スプシとの自動同期(事前登録情報の管理は教員主導の運用で確定) +- メンターによる活動ログ記入(メンターアプリ・30分グリッド・未記入ハイライト)。記録は子ども自身が行う方針に変更(§3.5) +- 振り返りシートのOCR取り込み(記録を完全にデジタルへ移行するため不要) +- ログCSVエクスポート(D1 の日次 SQL ダンプで代替) +- iPadオフライン対応(Wi-Fi 不安定時は紙運用でフォールバックする。§12 R1) + +### 3.5 2026-10 の計画見直し + +運用開始後の状況変化を受けて、2026-10 にフェーズ計画を見直した。判断の詳細は付録A(D16〜D19)。 + +| 変更 | 内容 | +| ---- | ---- | +| 活動記録の担い手を変更 | メンターによる 30 分グリッドのログ記入(メンターアプリ)をやめ、子ども自身が振り返り・目標設定を記録する。紙からデジタルへ移行する | +| スコープ外 → スコープ内 | 「紙の振り返り・目標設定シートのデジタル代替」「子ども向けの入力UI」は、教育方針として紙を維持する前提で作らないとしていたが、デジタル移行が決まったため Phase 1.5 の対象にした | +| 計画から外した | メンターアプリ、リアルタイム編集状況表示とスロット切り替えのプッシュ通知(どちらもメンターのログ記入が前提)、ログCSVエクスポート、振り返りシートのOCR、教員側スプシとの自動同期、iPadオフライン対応 | +| 保留 | 活動カテゴリ・機材マスタ、共同活動者の記録(子どもの記録の入力方式と合わせて判断) | --- @@ -131,7 +137,7 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に 7. 同時に最初のチェックインセッションが作成される 8. 画面に内製IDが表示される 9. エリアマネージャーがその情報を元にネームカード(QR付き)を作成し、子どもに渡す -10. 紙の目標設定シート記入後、活動開始 +10. 目標設定シートを記入後、活動開始(現状は紙。デジタル入力へ移行予定、§4.3) ### 4.2 2回目以降のチェックイン @@ -140,29 +146,34 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に 3. 「○○さん、こんにちは!」と表示、自動でチェックイン記録 4. 自分の場所に戻り活動開始 -### 4.3 メンターによる30分スロットログ記入(Phase 1.5) +### 4.3 子どもによる振り返り・目標設定の記録(Phase 1.5・方式検討中) + +**現状(紙運用)** -1. メンターはスマホPWAを常時携帯 -2. トップ画面で現スロット(例: 10:00-10:30)の未記入の子が赤くハイライト、記入済みは緑 -3. メンターが対象の子の元へ行き、ネームカードのQRをスキャン -4. 該当の子のログ記入画面が即座に開く -5. 入力フロー(3秒で完了する動線):カテゴリ選択→機材選択→自由記述→メンターサポート→共同活動者選択→保存 -6. 5秒ポーリングで複数メンター間の記入状況をリアルタイム共有 +1. 初回来場時に、子どもが紙の目標設定シートを書く +2. 帰る前に、子どもが紙の振り返りシートを書く +3. 最後にメンターが紙の内容を Google フォームへ投稿し、集計する + +**移行後(デジタル入力)** + +1. 子どもが目標設定・振り返りを自分でデジタル入力する(紙とメンターの転記をなくす) +2. 入力方式は Google フォーム / 自前の入力画面のどちらにするか検討中(§9.3) +3. 自由記述欄には個人を特定できる情報を書かせない設計にする(§11.4) ### 4.4 チェックアウト -1. 子どもが帰る前に紙の振り返りシートを記入 +1. 子どもが帰る前に振り返りを記入(現状は紙。デジタル入力へ移行予定、§4.3) 2. 自分のネームカードをiPadでQRスキャン → チェックアウト 3. 「お疲れさま!今日の滞在時間: ○時間○分」と表示 4. ネームカードを所定の保管場所へ片付ける -5. 閉場後、メンターが紙の振り返りシートをスプシ等へ手転記(Phase 2でOCR化予定) +5. 閉場後、メンターが紙の振り返りシートを Google フォームへ転記(デジタル入力への移行で廃止予定) > 土日は午前(9:00–12:00)と午後(13:00–16:00)でタームが分かれ、またがらない。**12:00 に受付端末の「滞在中全員をチェックアウト」を押して午前タームを締め**、午後も活動する子は 13:00 に再チェックインする(その日2回の参加としてカウント)。各回の終了時も同様に締める。詳細は §5.4。 ### 4.5 管理者業務 1. 当日中、管理画面でリアルタイムに来場状況を確認 -2. 閉場後、当日のログをCSVエクスポート(Phase 1.5以降) +2. 蓄積データの分析は、Google Drive に日次保存している D1 の SQL ダンプを使って SQL で行う --- @@ -224,19 +235,18 @@ tec-nova Nagasaki(テクノバながさき)は、長崎市と長崎大学に | created_at | timestamp | | | last_login_at | timestamp NULL | | -### 5.2 Phase 1.5で追加予定のテーブル +### 5.2 Phase 1.5で追加を検討しているテーブル + +- 子どもの記録(振り返り・目標設定): 入力方式を自前の画面にする場合に追加する。参加者(`participants`)と、振り返りは来場(`sessions`)にも紐づける想定。Google フォームにする場合は内製DBに持たない +- `activity_categories`(活動カテゴリマスタ)・`equipment`(機材マスタ): 保留(§3.2) -- `activity_logs`(活動ログ・30分スロット単位) -- `activity_categories`(活動カテゴリマスタ) -- `equipment`(機材マスタ) +当初計画していたメンター記入の `activity_logs`(30分スロット単位)は、2026-10 の見直しで取りやめた(§3.5)。 ### 5.3 ER関係概要 ``` participants 1 ─── n sessions events 1 ─── n sessions -mentors 1 ─── n activity_logs (Phase 1.5) -sessions 1 ─── n activity_logs (Phase 1.5) ``` ### 5.4 ターム制と参加回数の数え方 @@ -304,8 +314,8 @@ sessions 1 ─── n activity_logs (Phase 1.5) | ロール | 権限 | | ------ | ------------------------------------------------------------- | -| admin | イベント編集・マスタ編集・参加者編集・CSVエクスポート・全機能 | -| mentor | 閲覧・ログ記入(Phase 1.5以降) | +| admin | 事前登録管理・メンター管理・参加者編集・チェックイン記録の追加/編集/削除・マスタ編集・全機能 | +| mentor | 受付端末の操作・閲覧 | ### 7.3 メンター追加・削除運用 @@ -320,7 +330,7 @@ API-first を貫き、3種のフロントが同じ API 基盤を共有する。 ### 8.1 設計指針 -- API-firstを徹底。フロントエンド3種(チェックインiPad / メンタースマホ / 管理PC)が同じAPI基盤を経由 +- API-firstを徹底。フロントエンド3種(受付iPad / 管理PC・モバイル / 会場サイネージ)が同じAPI基盤を経由 - REST API、JSON Body - Hono on Cloudflare Workersで実装 - 共通の Zod スキーマ(`packages/shared/src/schemas/`)をフロント/バック両方から参照することで型整合を担保(Hono Client `hc` は使わず、フロントは `apiFetch` + 共通スキーマで型アサート) @@ -342,7 +352,7 @@ Phase 1 で以下の範囲を実装・稼働中。リクエスト/レスポン - 受付端末系(`/checkin/*`): 「初めての方」フロー(pre-registered / activate)、チェックイン・アウト(sessions/check-in・check-out)、QRスキャン(scan)、当日履歴・一括チェックアウト(history/today・history/check-out-bulk)、参加者検索・詳細・来場履歴(participants/search・:id・:id/attendance) - 管理系(`/api/*`): 自分の情報(me)、当日来場状況(sessions/today)、セッション・イベント・参加者一覧、メンター管理(mentors GET/POST/PATCH)、事前登録管理(pre-registrations GET/POST/DELETE。未活性+活性化済みの両リストを返す) -Phase 1.5以降で活動ログ・マスタ管理エンドポイントを追加予定。 +参加回数統計(`/api/stats/participation`)と会場サイネージ(`/api/signage/*`)も稼働中。Phase 1.5以降でチェックイン記録の追加/編集/削除、子どもの記録(自前の入力画面にする場合)、マスタ管理のエンドポイントを追加予定。 --- @@ -358,11 +368,14 @@ Phase 1.5以降で活動ログ・マスタ管理エンドポイントを追加 ### 9.2 管理画面(PC・Web・Phase 1ミニマム→Phase 1.5拡張) - Phase 1: ダッシュボード(当日来場状況)/参加者一覧(閲覧のみ)/メンター管理/ログイン -- Phase 1.5: 参加者編集/イベント管理/マスタ管理/CSVエクスポート +- 運用開始後に追加: 参加回数統計(ターム別・日別)、日付を選んだ過去の開催日の閲覧 +- Phase 1.5: チェックイン記録の追加・編集・削除(admin のみ)/参加者編集/イベント管理/マスタ管理 -### 9.3 メンターアプリ(スマホPWA・Phase 1.5) +### 9.3 子ども向けの記録入力(Phase 1.5・方式検討中) -- 現スロットダッシュボード/ログ記入画面/子ども詳細画面/過去履歴 +- 振り返り・目標設定を子ども自身が入力する画面(§4.3) +- Google フォームにするか、自前の入力画面にするかは検討中。自前にする場合は内製 ID で参加者・来場と紐づけられ、日次 SQL ダンプでそのまま分析できる。Google フォームなら実装が最小で済むが、データは内製DBの外に置かれる +- 子どもが直接触る画面になるため、操作の簡単さと誤操作防止(アクセスガイド等)を重視する ### 9.4 会場サイネージ(apps/signage) @@ -411,7 +424,7 @@ tecnova-platform/ │ ├── checkin/ # Next.js (iPad PWA・受付端末) │ ├── admin/ # Next.js (PC) │ └── signage/ # Next.js (会場サイネージ・大型モニター/キオスク) -│ # apps/mentor (スマホPWA) は Phase 1.5 で着手予定。現時点では未作成。 +│ # 子ども向けの記録入力を自前で作る場合は apps/ 配下に追加する(方式検討中) ├── packages/ │ ├── db/ # Drizzle schema・migrations │ ├── ui/ # 共通UIコンポーネント (shadcn/ui)、APIクライアント、JSTフォーマッタ、MeProvider @@ -444,15 +457,14 @@ tecnova-platform/ | ------------------------------------------- | -------------- | | API応答時間(中央値) | 200ms以下 | | API応答時間(95%ile) | 500ms以下 | -| メンターアプリのポーリング間隔(Phase 1.5) | 5秒 | | 同時接続デバイス数(ピーク時) | 20デバイス想定 | ### 11.2 可用性・ネットワーク前提 - 本システムは100%オンライン前提で設計する - 会場のWi-Fi切断時は、チェックイン業務を一時停止し、紙運用に切り替える -- 復帰後、メンターが手動でログを追加可能 -- Phase 2でiPadチェックインのオフライン対応を検討 +- 復帰後、メンターが手動で来場記録を補える +- iPadのオフライン対応は 2026-10 の見直しで計画から外した(紙運用でのフォールバックを維持) ### 11.3 セキュリティ @@ -470,7 +482,7 @@ tecnova-platform/ | 氏名(本名) | ✅ | 内製DB+学生側スプシ | | ニックネーム | ✅ | 内製DB+学生側スプシ | | 学年 | ✅ | 内製DB+学生側スプシ | -| 活動ログ(Phase 1.5) | ✅ | 内製DB | +| 振り返り・目標設定(Phase 1.5) | 方式次第 | 自前の入力画面なら内製DB、Google フォームならフォーム側 | | 年齢・生年月日 | ❌ | 教員管理スプシ | | 住所 | ❌ | 教員管理スプシ | | 保護者連絡先 | ❌ | 教員管理スプシ | @@ -486,7 +498,7 @@ tecnova-platform/ | # | リスク | 影響度 | 発生確率 | 対策 | | --- | -------------------------------------------------------------------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| R1 | 会場のWi-Fi不安定によりチェックイン業務停止 | 高 | 中 | 紙運用に切り替えるフォールバック手順を運用マニュアルに明記。Phase 2でオフライン対応 | +| R1 | 会場のWi-Fi不安定によりチェックイン業務停止 | 高 | 中 | 紙運用に切り替えるフォールバック手順を運用マニュアルに明記(オフライン対応は計画から外した) | | R2 | Better Auth on Workers のライフサイクル問題(`waitUntil`未使用で503/30秒ハング) | 中 | 中 | リクエスト毎にauth instanceを生成し、遅延処理を足す場合は`c.executionCtx.waitUntil`を必ず通す方針(現行は DB 書き込みを await 完結のため未配線) | | R3 | Google Sheets API書き戻し失敗時のデータ不整合 | 中 | 中 | D1にインタラクティブ・トランザクションがないため、DB書き込み後にスプシ書き戻し→失敗時は補償処理(sessions/participants削除)でロールバック。失敗時はユーザーへエラー表示&再試行 | | R4 | Cloudflare WorkersでGoogle Sheets APIクライアントが動かない | 中 | 中 | googleapisパッケージはNode.js依存のため使用不可。Web Crypto APIで自前JWT生成+fetch直叩きで対応(実装済み) | @@ -501,7 +513,7 @@ tecnova-platform/ ### 13.1 リリース方針 -Phase 1(MVP)は環境構築 → チェックイン・初めての方フローの中核実装 → 管理画面・Better Auth → リハーサルという順で進め、本番リリース済み。以降は運用と並行して Phase 1.5(メンターアプリ・活動ログ)を継続実装する。 +Phase 1(MVP)は環境構築 → チェックイン・初めての方フローの中核実装 → 管理画面・Better Auth → リハーサルという順で進め、本番リリース済み。以降は運用と並行して Phase 1.5(子どもの記録のデジタル化・管理画面の拡張ほか)を継続実装する。 進捗・残作業の最新状況は [`handoff.md`](./handoff.md) を参照。 @@ -524,7 +536,6 @@ Phase 1 を運用に乗せるために満たすべき基準: | アクティベート | 事前登録済みの参加者が初回来場時に内製IDを発行され、内製DBに登録される行為 | | 学生側スプシ | バックエンドが参照する転記版スプレッドシート。教員側スプシとは完全分離 | | 教員側スプシ | 事前登録フォーム由来の本名等を含むスプレッドシート。学生側からは非アクセス | -| スロット | 30分単位の時間区切り(Phase 1.5以降で使用) | | ターム | 会場の時間帯区分(朝 9–12 / 昼 13–16 / 夕方 16–19)。来場時刻から判定する | | 参加回数 | タームごとの参加を数えた累計(朝+昼で2、30分ルール適用)。物理スキルカードのチェック数に対応(`participationCount`) | | 来場回数 | チェックインの生の回数(=セッション数、`visitCount`)。受付プロフィールの活動カレンダーは1来場=1タイルで滞在時間が長いほど濃く表示する | @@ -532,7 +543,8 @@ Phase 1 を運用に乗せるために満たすべき基準: | 30分ルール | タームの残り30分未満に来た来場は参加回数に数えない(チェックイン/アウトは行う) | | スキルカード | 利用者が所持し、参加ごとにチェックを付ける物理カード | | ネームカード | QR/バーコード印字済みの紙カード。子どもが常設で利用 | -| 未記入ハイライト | Phase 1.5で実装。現スロットでログが未記入の子を赤く表示する機能 | +| 振り返り | 子どもが帰る前に書く、その日の活動の記録。現状は紙、デジタル入力へ移行予定 | +| 目標設定 | 子どもが初回来場時に書く目標。現状は紙、デジタル入力へ移行予定 | | GAS | Google Apps Script | | MVP | Minimum Viable Product。本プロジェクトではPhase 1を指す | @@ -544,19 +556,23 @@ Phase 1 を運用に乗せるために満たすべき基準: | --- | -------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | D1 | 事前登録の扱い | 学生側スプシ経由の片方向同期(教員側からの転記)。MVP期間は手作業 | 教員陣のプライバシー管理体制を尊重 | | D2 | ネームカード | QR印字、事前準備、常設運用 | 運用シンプル化、儀式的UX、自動印刷不要 | -| D3 | ログ記入のパラダイム | 30分グリッド方式、入力UIを構造化(Phase 1.5) | イベント駆動だと観察疲れ・記入忘れリスク | -| D4 | 振り返りシート | 紙運用維持(教育方針) | 教員の方針、子どもの学習体験 | -| D5 | OCR取り込み | Phase 2 | 運用開始の制約 | -| D6 | リアルタイム同期方式 | 5秒ポーリング(Phase 1.5) | WebSocketはオーバーエンジニアリング、規模的に十分 | +| D3 | ログ記入のパラダイム | ~~30分グリッド方式、入力UIを構造化(Phase 1.5)~~ → 2026-10 に取りやめ(D16) | イベント駆動だと観察疲れ・記入忘れリスク | +| D4 | 振り返りシート | ~~紙運用維持(教育方針)~~ → 2026-10 にデジタル移行へ変更(D16) | 教員の方針、子どもの学習体験 | +| D5 | OCR取り込み | ~~Phase 2~~ → 2026-10 に不要と判断(D16) | 運用開始の制約 | +| D6 | リアルタイム同期方式 | ~~5秒ポーリング(Phase 1.5)~~ → メンターアプリ取りやめで不要(D16) | WebSocketはオーバーエンジニアリング、規模的に十分 | | D7 | バックエンドフレームワーク | Hono on Cloudflare Workers | エッジ実行・型安全・学習価値・既存Cloudflare資産との親和性 | | D8 | 認証 | Better Auth + Google OAuth + 許可リスト方式 | 自前パスワード管理リスク回避、メンター入れ替わり管理が楽 | | D9 | リポジトリ構成 | モノレポ(pnpm + Turborepo) | 型共有・共通コンポーネント・実務標準 | -| D10 | ネットワーク前提 | 100%オンライン前提(Phase 2でオフライン対応) | 実装複雑度を抑え運用開始を優先 | +| D10 | ネットワーク前提 | 100%オンライン前提(オフライン対応は 2026-10 に計画から外した、D19) | 実装複雑度を抑え運用開始を優先 | | D11 | events テーブル | 残す(チェックイン時に自動生成) | 集計クエリのシンプルさ・将来の研究分析時のメタ情報付与 | | D12 | ID採番ロジック | 来場順・年度2桁+連番(例: 26001) | 来場しなかった事前登録者にIDを発行しない。短く覚えやすいID | | D13 | スプシ書き戻し | あり(アクティベート時に内製ID・日時・フラグを書き戻す) | 教員側もアクティベート状況が見える、双方向の状態管理 | | D14 | Phase 1スコープ | チェックイン基盤のみに絞る。ログ機能はPhase 1.5 | 運用開始に絶対必要なものだけに集中 | | D15 | データベース選定 | Cloudflare D1(SQLite)。Neon/Hyperdrive構成からの変更 | Workersネイティブで接続管理不要・レイテンシゼロ、本プロジェクトの規模(数百人・同時数十接続)には十分。トレードオフとしてインタラクティブ・トランザクションは使えないため、アクティベート処理は補償処理ベースのsagaパターンに切替 | +| D16 | 活動の記録の担い手 | メンターによる30分グリッドのログ記入をやめ、子ども自身が振り返り・目標設定を記録する。紙からデジタルへ移行(入力方式は検討中) | 運用の変化でメンターがログを書く必要がなくなり、子ども自身の記録が確定。現状の「紙 → メンターが Google フォームへ転記」の負担をなくす。完全デジタル化により OCR も不要 | +| D17 | ログCSVエクスポート | 作らない | D1 の SQL ダンプを Google Drive に日次保存しており、SQL で直接分析できるようになったため | +| D18 | 教員側スプシとの自動同期 | 作らない | 事前登録情報の管理は教員主導の運用で確定したため | +| D19 | iPadオフライン対応 | 計画から外す | 2026-10 の見直しで優先度を下げた。Wi-Fi 不安定時は紙運用でフォールバック(§12 R1) | --- diff --git a/docs/superpowers/plans/2026-10-11-checkin-record-correction.md b/docs/superpowers/plans/2026-10-11-checkin-record-correction.md new file mode 100644 index 0000000..28f6802 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-checkin-record-correction.md @@ -0,0 +1,3051 @@ +# チェックイン記録の修正機能 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** admin ロールのメンターが、管理画面(ダッシュボードと利用者詳細)から来場記録(`sessions`)を追加・編集・削除できるようにする。 + +**Architecture:** 守るべきルール(時刻の前後・同じ日・未来不可・滞在中は今日だけ・重なり禁止)を `packages/shared` の純粋関数にして、API と画面の両方で使う。API は `apps/api/src/lib/session-admin.ts` に書き込みロジックを置き、開催日の作成・記録の書き込み・空になった過去の開催日の削除を 1 回の `db.batch` にまとめる(D1 にトランザクションが無いため)。管理画面は共通の `SessionFormDialog` / `SessionDeleteDialog` / `SessionRowActions` を作り、ダッシュボードと利用者詳細の 2 か所から開く。 + +**Tech Stack:** Hono on Cloudflare Workers + D1 + Drizzle ORM / Zod 4 / Next.js 16 + React 19(admin)/ shadcn/ui(radix-maia)/ vitest 3(shared)・vitest 4 + `@cloudflare/vitest-plugin`(api)/ Playwright MCP(画面の実行時確認) + +**Spec:** `docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md` + +## Global Constraints + +- ブランチは `feat/admin-session-correction`(`fix/checkin-qr-on-demand-scan` の上に積んである)。`main` / `develop` に直接コミットしない。PR は `develop` 向け +- コミットはタスクごと。英語の `: `、末尾に `Co-Authored-By: Claude Opus 5.5 ` +- DB のテーブル構造は変えない(マイグレーションを作らない) +- 来場記録を書き換える API(POST / PATCH / DELETE)は admin のみ。`GET /api/sessions` と `GET /api/sessions/today` は今までどおり全メンター向け +- `apps/api/src/**` で Node 専用 API(`fs` / `path` / `process.env` など)を使わない。`apps/api/vitest.config.ts` は Node で動く設定ファイルなので `node:url` を使ってよい +- 時刻は DB に UTC(epoch ms)、API は ISO 8601(UTC)、画面の入力と表示は JST。JST ⇔ UTC の変換は必ず `@tecnova/shared/venue-schedule` の関数を通す +- テストデータの氏名・ニックネームは架空の値(`テスト26001` / `nick26001` / `テスト太郎` / `たろう` など)。実在の子ども・メンターの情報を入れない +- 追加する依存は版を固定する: `vitest@4.1.11`、`@cloudflare/vitest-plugin@1.2.8`(ともに `--save-exact`)、shadcn CLI は `shadcn@4.21.0` +- 受付経路(`apps/api/src/lib/checkin.ts` の check-in / check-out / scan / 一括チェックアウト)は変更しない +- コードは Biome(スペース 2・シングルクォート・セミコロン)、関数は arrow function、`any` 禁止、コメントは日本語で「なぜ」を書く +- admin の UI を作る前に `apps/admin/node_modules/next/dist/docs/` の該当ガイドを確認する(`apps/admin/AGENTS.md` の指示)。今回は App Router の API を新しく使わないが、Client Component の書き方に迷ったら参照する + +## Review Focus + +1. **別の日付に属する記録を保存したとき**、ダッシュボードの表示がその日付へ切り替わる(過去日に追加した直後に一覧から消えたように見えない)→ Task 11 の Playwright で確認 +2. **行・カード全体がクリック可能な中の「…」メニュー**を押しても利用者詳細が開かない。スマホ幅ではカード全面のボタンより前面に出て押せる → Task 11 の Playwright(PC 幅とスマホ幅)で確認 +3. **既存の「過去日に滞在中のまま」の記録**を、退場時刻を入れずに他の項目だけ直そうとすると `OPEN_SESSION_NOT_TODAY` で止まり、退場時刻を入れれば直る → Task 6 の API テストで確認 +4. **admin 以外のメンター**が API を直接叩いても 403 になり、画面にもボタンが出ない → Task 8 のルートテストと Task 11 の Playwright で確認 +5. **日付の変わり目付近の時刻**(JST の 0〜9 時は UTC では前日)が正しく変換され、開催日がずれない → Task 2 のテスト(`jstDateTimeToInstant` の早朝ケース)と Task 5 の API テスト(早朝の記録)で確認 + +--- + +### Task 1: ドキュメントを先に更新する(mvp.md) + +**Files:** +- Modify: `docs/mvp.md`(§4.3 末尾、§6.2 の `GET /api/events` と `GET /api/participants` の間、§7.2.2、§9) + +**Interfaces:** +- Consumes: なし +- Produces: 以降のタスクが従う API 契約(§6.2 に記載するもの) + +- [ ] **Step 1: §4.3 の末尾に段落を追加する** + +`docs/mvp.md` の「タイムゾーン注意: Workersのデフォルトタイムゾーンは…動作する(`UNIQUE` 制約に対する `ON CONFLICT DO NOTHING`)。」の段落の直後(`### 4.4` の前)に、空行をはさんで次を追加する。 + +````markdown +**管理画面からの記録修正での開催日**(admin・2026-10): 来場記録の追加・編集では、入場時刻の JST 日付の開催日を `INSERT … ON CONFLICT(date) DO NOTHING` で作り、`event_id` は日付からのサブクエリで解決する(記録の書き込みと同じ `db.batch` 内)。編集・削除で記録が 1 件も無くなった**過去**の開催日は、同じ batch 内で削除する(今日の開催日は受付中のため残す)。詳細は §6.2「来場記録の修正」。 +```` + +- [ ] **Step 2: §6.2 に API を追加する** + +`#### \`GET /api/events\`` のレスポンス例(`{ "events": [...] }` のコードブロック)の直後、`#### \`GET /api/participants\`` の直前に次を追加する。 + +````markdown +#### `POST /api/sessions` / `PATCH /api/sessions/:sessionId` / `DELETE /api/sessions/:sessionId`(admin)— 来場記録の修正 + +受付の操作ミス(チェックイン忘れ・チェックアウト忘れ・別の子で受付・二重記録)を訂正するための、来場記録の追加・編集・削除。`GET /api/sessions` は全メンター向けのまま、この 3 つだけルート単位で `requireAdmin` を当てる。 + +**リクエスト**: + +```json +// POST(追加) +{ "participantId": "26001", "checkedInAt": "2026-10-04T01:05:00.000Z", "checkedOutAt": "2026-10-04T02:40:00.000Z" } + +// PATCH(編集。変える項目だけ・1 つ以上。checkedOutAt: null は「滞在中」に戻す) +{ "checkedOutAt": "2026-10-04T03:00:00.000Z" } +``` + +**レスポンス**: POST は 201、PATCH は 200 で次の形。DELETE は 204(本文なし)。 + +```json +{ "eventDate": "2026-10-04", "session": { "sessionId": "...", "participantId": "26001", "...": "todaySessionItem と同じ形" } } +``` + +**守るルール**(`packages/shared/src/session-rules.ts` を API と画面で共用): + +- 開催日は入場時刻の JST 日付から決める(無ければ作る。編集で日付が変われば、その日の開催日へ移す) +- 退場は入場より後、かつ同じ JST 日のうち。未来の時刻は不可(現在時刻 + 1 分まで許容) +- 「滞在中」(退場時刻なし)は今日の記録だけ +- 同じ子・同じ日の他の記録と時間が重ならない(半開区間 `[入場, 退場)`。滞在中は現在時刻まで続くとみなす) +- 無効化した子の記録も対象にできる +- 記録が無くなった過去の開催日は削除する(§4.3) + +**エラー**(`{ "error": code, "message": 日本語 }`): `SESSION_NOT_FOUND` 404 / `PARTICIPANT_NOT_FOUND` 404 / `CHECKOUT_BEFORE_CHECKIN` 400 / `SPANS_DAYS` 400 / `FUTURE_TIME` 400 / `OPEN_SESSION_NOT_TODAY` 400 / `OVERLAP` 409。本文の形式エラーは 400 `INTERNAL`。 +```` + +- [ ] **Step 3: §7.2.2 ダッシュボードに追記する** + +`#### 7.2.2 ダッシュボード` の箇条書きの最後(「行クリックで `ParticipantDetailSheet`…」の行)の後に次の 2 行を追加する。 + +````markdown +- **来場記録の修正(admin のみ)**: ヘッダーの「記録を追加」と、各行(PC の表・スマホのカード)の「…」メニュー(編集/削除)。フォームは中央のダイアログ(`SessionFormDialog`)、削除は確認ダイアログ(`SessionDeleteDialog`)。保存後は一覧と日付の選択肢を再取得し、記録が別の日付に属する場合はその日付へ表示を切り替える +- `ParticipantDetailSheet` の「来場履歴」にも admin のみ「記録を追加」(その子を選んだ状態・日付は今日)と各行の「…」メニュー。変更後は詳細とダッシュボードの一覧を再取得する +```` + +- [ ] **Step 3b: §7.2.3 参加者一覧の「閲覧専用」を言い直す** + +`#### 7.2.3 参加者一覧` の `- 閲覧専用(編集は Phase 1.5)` を次に置き換える(来場記録は直せるようになったが、参加者の属性はまだ編集できないことを明確にする)。 + +````markdown +- 参加者の属性(氏名・ニックネーム・学年)は閲覧専用(編集は Phase 1.5)。行クリックで開く `ParticipantDetailSheet` から、admin は来場記録を修正できる(§7.2.2) +```` + +- [ ] **Step 4: §9 から解消する制約を消す** + +`## 9. 運用上の既知の制約` から次の 1 行を削除する。 + +````markdown +- チェックイン忘れ・押し間違いをシステム上で訂正する手段がまだ無い(管理画面での追加・編集・削除を Phase 1.5 で実装予定) +```` + +- [ ] **Step 5: 差分を確認してコミットする** + +Run: `git diff --stat docs/mvp.md` +Expected: `docs/mvp.md | ` の 1 ファイルだけが変わっている + +```bash +git add docs/mvp.md +git commit -m "docs(mvp): specify admin check-in record correction + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: shared — JST 変換関数と来場記録のルール + +**Files:** +- Modify: `packages/shared/src/venue-schedule.ts`(`toJstDateString` の直後に 2 関数を追加) +- Create: `packages/shared/src/venue-schedule.test.ts` +- Create: `packages/shared/src/session-rules.ts` +- Create: `packages/shared/src/session-rules.test.ts` +- Modify: `packages/shared/package.json`(`exports` に `./session-rules`) + +**Interfaces:** +- Consumes: 既存の `toJstWallClock(instant: Date): JstWallClock`、`toJstDateString(instant: Date): string`、モジュール内定数 `JST_OFFSET_HOURS = 9` +- Produces: + - `jstDateTimeToInstant(date: string, time: string): Date`(`date` は `'YYYY-MM-DD'`、`time` は `'HH:mm'`、どちらも JST) + - `toJstTimeString(instant: Date): string`(`'HH:mm'`、JST) + - `type SessionRuleViolation = 'CHECKOUT_BEFORE_CHECKIN' | 'SPANS_DAYS' | 'FUTURE_TIME' | 'OPEN_SESSION_NOT_TODAY' | 'OVERLAP'` + - `interface SessionTimes { checkedInAt: Date; checkedOutAt: Date | null }` + - `FUTURE_TOLERANCE_MS = 60_000` + - `validateSessionTimes(times: SessionTimes, now: Date): Exclude | null` + - `findOverlappingSession(target: SessionTimes, siblings: readonly T[], now: Date): T | null` + - `SESSION_RULE_MESSAGES: Record` + - import パス `@tecnova/shared/session-rules` + +- [ ] **Step 1: JST 変換関数の失敗するテストを書く** + +`packages/shared/src/venue-schedule.test.ts` を作る。 + +```ts +import { describe, expect, it } from 'vitest'; +import { jstDateTimeToInstant, toJstDateString, toJstTimeString } from './venue-schedule'; + +describe('jstDateTimeToInstant', () => { + it('JST の日付と時刻を UTC instant にする(UTC+9)', () => { + expect(jstDateTimeToInstant('2026-10-11', '10:05').toISOString()).toBe( + '2026-10-11T01:05:00.000Z', + ); + }); + + it('JST の早朝は UTC では前日になる', () => { + expect(jstDateTimeToInstant('2026-10-11', '08:30').toISOString()).toBe( + '2026-10-10T23:30:00.000Z', + ); + }); + + it('変換した instant を JST 日付に戻すと元の日付になる(深夜 0 時台)', () => { + expect(toJstDateString(jstDateTimeToInstant('2026-10-11', '00:10'))).toBe('2026-10-11'); + }); +}); + +describe('toJstTimeString', () => { + it('UTC instant を JST の HH:mm にする', () => { + expect(toJstTimeString(new Date('2026-10-10T23:30:00.000Z'))).toBe('08:30'); + }); + + it('1 桁の時・分は 0 埋めする', () => { + expect(toJstTimeString(new Date('2026-10-11T00:05:00.000Z'))).toBe('09:05'); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/shared test -- venue-schedule` +Expected: FAIL(`jstDateTimeToInstant` / `toJstTimeString` が export されていない) + +- [ ] **Step 3: JST 変換関数を実装する** + +`packages/shared/src/venue-schedule.ts` の `export const toJstDateString = …;` の直後に追加する。 + +```ts +// JST の日付 'YYYY-MM-DD' と時刻 'HH:mm' を UTC instant にする。管理画面で JST 入力した +// 来場時刻を API(ISO 8601 UTC)へ渡すときに使う。端末のタイムゾーンに依存させないため +// Date のローカル時刻 API は使わず、固定の UTC+9 を引いて Date.UTC で組み立てる。 +export const jstDateTimeToInstant = (date: string, time: string): Date => { + const year = Number.parseInt(date.slice(0, 4), 10); + const month = Number.parseInt(date.slice(5, 7), 10); + const day = Number.parseInt(date.slice(8, 10), 10); + const hour = Number.parseInt(time.slice(0, 2), 10); + const minute = Number.parseInt(time.slice(3, 5), 10); + return new Date(Date.UTC(year, month - 1, day, hour - JST_OFFSET_HOURS, minute, 0, 0)); +}; + +// UTC instant を JST の 'HH:mm' にする(フォームの初期値・表示用)。 +export const toJstTimeString = (instant: Date): string => { + const { hour, minute } = toJstWallClock(instant); + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +}; +``` + +- [ ] **Step 4: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/shared test -- venue-schedule` +Expected: PASS(5 tests) + +- [ ] **Step 5: ルールの失敗するテストを書く** + +`packages/shared/src/session-rules.test.ts` を作る。 + +```ts +import { describe, expect, it } from 'vitest'; +import { + findOverlappingSession, + SESSION_RULE_MESSAGES, + type SessionTimes, + validateSessionTimes, +} from './session-rules'; +import { jstDateTimeToInstant as jst } from './venue-schedule'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); + +const times = (date: string, inTime: string, outTime: string | null): SessionTimes => ({ + checkedInAt: jst(date, inTime), + checkedOutAt: outTime === null ? null : jst(date, outTime), +}); + +describe('validateSessionTimes', () => { + it('過去日の入場〜退場がそろった記録は OK', () => { + expect(validateSessionTimes(times(PAST, '10:00', '11:00'), NOW)).toBeNull(); + }); + + it('退場が入場と同時刻なら CHECKOUT_BEFORE_CHECKIN', () => { + expect(validateSessionTimes(times(PAST, '10:00', '10:00'), NOW)).toBe( + 'CHECKOUT_BEFORE_CHECKIN', + ); + }); + + it('退場が入場より前なら CHECKOUT_BEFORE_CHECKIN', () => { + expect(validateSessionTimes(times(PAST, '11:00', '10:00'), NOW)).toBe( + 'CHECKOUT_BEFORE_CHECKIN', + ); + }); + + it('退場が入場と別の JST 日なら SPANS_DAYS', () => { + const t = { checkedInAt: jst(PAST, '18:00'), checkedOutAt: jst('2026-10-05', '09:00') }; + expect(validateSessionTimes(t, NOW)).toBe('SPANS_DAYS'); + }); + + it('入場が現在 + 1 分より後なら FUTURE_TIME', () => { + const t = { checkedInAt: new Date(NOW.getTime() + 120_000), checkedOutAt: null }; + expect(validateSessionTimes(t, NOW)).toBe('FUTURE_TIME'); + }); + + it('入場が現在 + 30 秒(猶予内)なら今日の滞在中として OK', () => { + const t = { checkedInAt: new Date(NOW.getTime() + 30_000), checkedOutAt: null }; + expect(validateSessionTimes(t, NOW)).toBeNull(); + }); + + it('退場が未来なら FUTURE_TIME', () => { + expect(validateSessionTimes(times(TODAY, '14:00', '15:05'), NOW)).toBe('FUTURE_TIME'); + }); + + it('今日の滞在中(退場なし)は OK', () => { + expect(validateSessionTimes(times(TODAY, '14:00', null), NOW)).toBeNull(); + }); + + it('過去日の滞在中は OPEN_SESSION_NOT_TODAY', () => { + expect(validateSessionTimes(times(PAST, '10:00', null), NOW)).toBe('OPEN_SESSION_NOT_TODAY'); + }); +}); + +describe('findOverlappingSession', () => { + it('他の記録が無ければ null', () => { + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [], NOW)).toBeNull(); + }); + + it('時間が重なる記録を返す', () => { + const sibling = times(PAST, '10:30', '12:00'); + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [sibling], NOW)).toBe(sibling); + }); + + it('境界で接するだけ(11:00 退場・11:00 入場)は重ならない', () => { + const sibling = times(PAST, '11:00', '12:00'); + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [sibling], NOW)).toBeNull(); + }); + + it('滞在中の記録は現在時刻まで続くとみなす', () => { + const sibling = times(TODAY, '13:00', null); + expect(findOverlappingSession(times(TODAY, '14:00', '14:30'), [sibling], NOW)).toBe(sibling); + }); + + it('滞在中どうしは重なる(同じ子の滞在中が 2 件になるのを防ぐ)', () => { + const sibling = times(TODAY, '13:00', null); + expect(findOverlappingSession(times(TODAY, '14:00', null), [sibling], NOW)).toBe(sibling); + }); + + it('滞在中の記録より前に終わった記録とは重ならない', () => { + const sibling = times(TODAY, '10:00', '11:00'); + expect(findOverlappingSession(times(TODAY, '14:00', null), [sibling], NOW)).toBeNull(); + }); +}); + +describe('SESSION_RULE_MESSAGES', () => { + it('すべての違反に日本語の文言がある', () => { + expect(SESSION_RULE_MESSAGES.CHECKOUT_BEFORE_CHECKIN).toBe( + '退場時刻は入場時刻より後にしてください', + ); + expect(Object.keys(SESSION_RULE_MESSAGES).sort()).toEqual([ + 'CHECKOUT_BEFORE_CHECKIN', + 'FUTURE_TIME', + 'OPEN_SESSION_NOT_TODAY', + 'OVERLAP', + 'SPANS_DAYS', + ]); + }); +}); +``` + +- [ ] **Step 6: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/shared test -- session-rules` +Expected: FAIL(`./session-rules` が存在しない) + +- [ ] **Step 7: ルールを実装する** + +`packages/shared/src/session-rules.ts` を作る。 + +```ts +import { toJstDateString } from './venue-schedule'; + +// 管理画面からの来場記録の追加・編集で守るルール。DB に制約(CHECK・一意制約)が無いため、 +// API(apps/api/src/lib/session-admin.ts)と画面(SessionFormDialog)の両方がこの純粋関数で検証する。 +// 設計: docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md §2 + +export type SessionRuleViolation = + | 'CHECKOUT_BEFORE_CHECKIN' + | 'SPANS_DAYS' + | 'FUTURE_TIME' + | 'OPEN_SESSION_NOT_TODAY' + | 'OVERLAP'; + +export interface SessionTimes { + checkedInAt: Date; + // null = 滞在中(まだ退場していない) + checkedOutAt: Date | null; +} + +// 端末の時計ずれで「今」入力した時刻が未来扱いにならないための猶予。 +export const FUTURE_TOLERANCE_MS = 60_000; + +// 時刻だけで判定できるルール(重なり以外)を検証する。違反が無ければ null。 +export const validateSessionTimes = ( + times: SessionTimes, + now: Date, +): Exclude | null => { + const limit = now.getTime() + FUTURE_TOLERANCE_MS; + if (times.checkedInAt.getTime() > limit) return 'FUTURE_TIME'; + if (times.checkedOutAt !== null && times.checkedOutAt.getTime() > limit) return 'FUTURE_TIME'; + + if (times.checkedOutAt !== null) { + if (times.checkedOutAt.getTime() <= times.checkedInAt.getTime()) { + return 'CHECKOUT_BEFORE_CHECKIN'; + } + // 参加回数の集計は「開催日 = 入場日」で行うため、日をまたぐ記録は作らない。 + if (toJstDateString(times.checkedOutAt) !== toJstDateString(times.checkedInAt)) { + return 'SPANS_DAYS'; + } + return null; + } + + // 過去日に滞在中の記録が残ると、その日の一覧で「来場中」と表示され続けるため今日だけに許す。 + if (toJstDateString(times.checkedInAt) !== toJstDateString(now)) { + return 'OPEN_SESSION_NOT_TODAY'; + } + return null; +}; + +// 区間 [入場, 退場) の終端。滞在中は「今」まで続いているとみなす。 +const intervalEnd = (times: SessionTimes, now: Date): number => + Math.max(times.checkedInAt.getTime(), (times.checkedOutAt ?? now).getTime()); + +// 同じ子・同じ日の他の記録(siblings)のうち、target と時間が重なるものを 1 件返す。 +// 境界で接する(11:00 退場・11:00 入場)のは重ならない扱い。 +export const findOverlappingSession = ( + target: SessionTimes, + siblings: readonly T[], + now: Date, +): T | null => { + const start = target.checkedInAt.getTime(); + const end = intervalEnd(target, now); + return ( + siblings.find((s) => start < intervalEnd(s, now) && s.checkedInAt.getTime() < end) ?? null + ); +}; + +// 画面と API のエラー文言をそろえるため、違反ごとの日本語をここに集約する。 +export const SESSION_RULE_MESSAGES: Record = { + CHECKOUT_BEFORE_CHECKIN: '退場時刻は入場時刻より後にしてください', + SPANS_DAYS: '入場と退場は同じ日の時刻にしてください', + FUTURE_TIME: '未来の時刻は入力できません', + OPEN_SESSION_NOT_TODAY: '過去の日の記録には退場時刻が必要です', + OVERLAP: '同じ子の同じ日の別の記録と時間が重なっています', +}; +``` + +`packages/shared/package.json` の `exports` に 1 行追加する(`"./visit-summary"` の行の後)。 + +```json + "./session-rules": "./src/session-rules.ts", +``` + +- [ ] **Step 8: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/shared test` +Expected: PASS(既存の 31 件 + 新規 21 件。失敗 0) + +Run: `pnpm --filter @tecnova/shared type-check && pnpm biome check packages/shared` +Expected: エラーなし + +- [ ] **Step 9: コミットする** + +```bash +git add packages/shared/src/venue-schedule.ts packages/shared/src/venue-schedule.test.ts packages/shared/src/session-rules.ts packages/shared/src/session-rules.test.ts packages/shared/package.json +git commit -m "feat(shared): add JST time helpers and session record rules + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: shared — リクエスト/レスポンスの Zod スキーマ + +**Files:** +- Modify: `packages/shared/src/schemas/admin.ts`(`createPreRegistrationRequestSchema` の後にスキーマ、ファイル末尾に型) +- Create: `packages/shared/src/schemas/session-requests.test.ts` + +**Interfaces:** +- Consumes: 既存の `todaySessionItemSchema`(同ファイル) +- Produces: + - `createSessionRequestSchema`(`{ participantId: string; checkedInAt: string; checkedOutAt: string | null }`) + - `updateSessionRequestSchema`(`{ participantId?: string; checkedInAt?: string; checkedOutAt?: string | null }`、1 項目以上) + - `sessionMutationResponseSchema`(`{ eventDate: string; session: TodaySessionItem }`) + - 型 `CreateSessionRequest` / `UpdateSessionRequest` / `SessionMutationResponse`(`@tecnova/shared/schemas` から import) + +- [ ] **Step 1: 失敗するテストを書く** + +`packages/shared/src/schemas/session-requests.test.ts` を作る。 + +```ts +import { describe, expect, it } from 'vitest'; +import { createSessionRequestSchema, updateSessionRequestSchema } from './admin'; + +describe('createSessionRequestSchema', () => { + it('5 桁の ID と ISO 時刻を受け付ける(退場は null 可)', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '26001', + checkedInAt: '2026-10-04T01:05:00.000Z', + checkedOutAt: null, + }); + expect(r.success).toBe(true); + }); + + it('5 桁でない ID は弾く', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '2601', + checkedInAt: '2026-10-04T01:05:00.000Z', + checkedOutAt: null, + }); + expect(r.success).toBe(false); + }); + + it('ISO でない時刻は弾く', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '26001', + checkedInAt: '2026-10-04 10:05', + checkedOutAt: null, + }); + expect(r.success).toBe(false); + }); +}); + +describe('updateSessionRequestSchema', () => { + it('項目が 1 つも無いと弾く', () => { + expect(updateSessionRequestSchema.safeParse({}).success).toBe(false); + }); + + it('checkedOutAt: null だけでも受け付ける(滞在中に戻す)', () => { + expect(updateSessionRequestSchema.safeParse({ checkedOutAt: null }).success).toBe(true); + }); + + it('付け替え(participantId だけ)を受け付ける', () => { + expect(updateSessionRequestSchema.safeParse({ participantId: '26002' }).success).toBe(true); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/shared test -- session-requests` +Expected: FAIL(`createSessionRequestSchema` が export されていない) + +- [ ] **Step 3: スキーマを実装する** + +`packages/shared/src/schemas/admin.ts` の `createPreRegistrationRequestSchema` の定義の後(型 export の並びの前)に追加する。 + +```ts +// `/api/sessions`(POST / PATCH・admin role 専用)— 来場記録の修正 +// 時刻は ISO 8601(UTC・末尾 Z)。画面は JST で入力し、shared の jstDateTimeToInstant で変換して送る。 +const sessionParticipantIdSchema = z.string().regex(/^\d{5}$/, '5-digit participant id required'); +const sessionInstantSchema = z.iso.datetime(); + +export const createSessionRequestSchema = z.object({ + participantId: sessionParticipantIdSchema, + checkedInAt: sessionInstantSchema, + // null = 滞在中(今日の記録だけ許す。判定は session-rules) + checkedOutAt: sessionInstantSchema.nullable(), +}); + +// 変える項目だけを送る。checkedOutAt は「省略 = 変えない」「null = 滞在中に戻す」を区別する。 +export const updateSessionRequestSchema = z + .object({ + participantId: sessionParticipantIdSchema.optional(), + checkedInAt: sessionInstantSchema.optional(), + checkedOutAt: sessionInstantSchema.nullable().optional(), + }) + .refine( + (v) => + v.participantId !== undefined || v.checkedInAt !== undefined || v.checkedOutAt !== undefined, + { message: 'at least one of participantId, checkedInAt, checkedOutAt is required' }, + ); + +// eventDate は記録が属する開催日。画面が表示中の日付を切り替えるために返す。 +export const sessionMutationResponseSchema = z.object({ + eventDate: z.string(), // 'YYYY-MM-DD' (JST) + session: todaySessionItemSchema, +}); +``` + +ファイル末尾の型 export の並びに追加する。 + +```ts +export type CreateSessionRequest = z.infer; +export type UpdateSessionRequest = z.infer; +export type SessionMutationResponse = z.infer; +``` + +- [ ] **Step 4: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/shared test` +Expected: PASS(失敗 0) + +Run: `pnpm --filter @tecnova/shared type-check && pnpm biome check packages/shared` +Expected: エラーなし + +- [ ] **Step 5: コミットする** + +```bash +git add packages/shared/src/schemas/admin.ts packages/shared/src/schemas/session-requests.test.ts +git commit -m "feat(shared): add session correction request/response schemas + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: api — テストの仕組み(`@cloudflare/vitest-plugin`)を入れる + +**Files:** +- Modify: `apps/api/package.json`(devDependencies と `test` スクリプト。CLI で追加) +- Create: `apps/api/vitest.config.ts` +- Create: `apps/api/test/apply-migrations.ts` +- Create: `apps/api/test/env.d.ts` +- Create: `apps/api/test/tsconfig.json` +- Create: `apps/api/test/helpers.ts` +- Create: `apps/api/test/smoke.test.ts` + +**Interfaces:** +- Consumes: `apps/api/wrangler.toml`(D1 binding `DB`)、`packages/db/drizzle/*.sql`(マイグレーション)、`@tecnova/shared/venue-schedule` の `jstDateTimeToInstant` +- Produces(`apps/api/test/helpers.ts`): + - `testDb(): TestDb` / `type TestDb`(`drizzle(env.DB, { schema })`) + - `resetTables(db: TestDb): Promise` + - `seedParticipant(db: TestDb, id: string, active?: boolean): Promise` + - `seedSession(db: TestDb, input: { participantId: string; date: string; inTime: string; outTime: string | null }): Promise`(作った session の id を返す) + - `jst(date: string, time: string): Date` + - `eventDates(db: TestDb): Promise`(昇順) + - 実行コマンド `pnpm --filter @tecnova/api test` + +- [ ] **Step 1: 依存を追加する** + +Run: `pnpm --filter @tecnova/api add -D --save-exact vitest@4.1.11 @cloudflare/vitest-plugin@1.2.8` +Expected: `apps/api/package.json` の devDependencies に `"vitest": "4.1.11"` と `"@cloudflare/vitest-plugin": "1.2.8"` が入る + +`apps/api/package.json` の `scripts` に追加する(`type-check` の後)。 + +```json + "test": "vitest run", +``` + +- [ ] **Step 2: 設定ファイルとマイグレーション適用を書く** + +`apps/api/vitest.config.ts` を作る。 + +```ts +import { fileURLToPath } from 'node:url'; +import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'; +import { defineConfig } from 'vitest/config'; + +// このファイルは Node で動く(Worker 本体 src/ の Node API 禁止ルールの対象外)。 +// 本番と同じ wrangler.toml の D1 binding を使い、packages/db のマイグレーションを +// テスト専用 binding(TEST_MIGRATIONS)で setup ファイルへ渡して適用する。 +const migrationsDir = fileURLToPath(new URL('../../packages/db/drizzle', import.meta.url)); + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: './wrangler.toml' }, + miniflare: { + bindings: { TEST_MIGRATIONS: await readD1Migrations(migrationsDir) }, + }, + })), + ], + test: { + include: ['test/**/*.test.ts'], + setupFiles: ['./test/apply-migrations.ts'], + }, +}); +``` + +`apps/api/test/apply-migrations.ts` を作る。 + +```ts +import { applyD1Migrations } from 'cloudflare:test'; +import { env } from 'cloudflare:workers'; + +// 適用済みのマイグレーションは d1_migrations テーブルで記録されるので、ファイルごとに呼んでも重複しない。 +await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); +``` + +`apps/api/test/env.d.ts` を作る。 + +```ts +import type { D1Migration } from 'cloudflare:test'; + +// テストから見える env(cloudflare:workers の env は Cloudflare.Env 型)に binding を足す。 +declare global { + namespace Cloudflare { + interface Env { + DB: D1Database; + TEST_MIGRATIONS: D1Migration[]; + } + } +} +``` + +`apps/api/test/tsconfig.json` を作る。 + +```json +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} +``` + +- [ ] **Step 3: テスト用ヘルパーを書く** + +`apps/api/test/helpers.ts` を作る。 + +```ts +import * as schema from '@tecnova/db'; +import { events, participants, sessions } from '@tecnova/db'; +import { jstDateTimeToInstant } from '@tecnova/shared/venue-schedule'; +import { env } from 'cloudflare:workers'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +export const testDb = () => drizzle(env.DB, { schema }); +export type TestDb = ReturnType; + +export const jst = (date: string, time: string): Date => jstDateTimeToInstant(date, time); + +// D1 のデータがテスト間でリセットされるかは公式に明記されていないため、各テストの前に明示的に空にする。 +// sessions は participants / events を FK で参照するので子テーブルから消す。 +export const resetTables = async (db: TestDb): Promise => { + await db.delete(sessions); + await db.delete(events); + await db.delete(participants); +}; + +// Public リポジトリのため、氏名・ニックネームは実在しない値にする。 +export const seedParticipant = async (db: TestDb, id: string, active = true): Promise => { + await db.insert(participants).values({ + id, + preRegistrationId: `PRE-TEST-${id}`, + fullName: `テスト${id}`, + nickname: `nick${id}`, + grade: '小3', + active, + }); +}; + +// 受付経路を通さずに既存データ(過去日の滞在中など)を再現するため、DB へ直接入れる。 +export const seedSession = async ( + db: TestDb, + input: { participantId: string; date: string; inTime: string; outTime: string | null }, +): Promise => { + await db.insert(events).values({ date: input.date }).onConflictDoNothing(); + const [event] = await db.select().from(events).where(eq(events.date, input.date)).limit(1); + if (!event) throw new Error(`failed to seed event ${input.date}`); + const id = crypto.randomUUID(); + await db.insert(sessions).values({ + id, + participantId: input.participantId, + eventId: event.id, + checkedInAt: jst(input.date, input.inTime), + checkedOutAt: input.outTime === null ? null : jst(input.date, input.outTime), + }); + return id; +}; + +export const eventDates = async (db: TestDb): Promise => + (await db.select({ date: events.date }).from(events)).map((e) => e.date).sort(); +``` + +- [ ] **Step 4: 仕組みが動くことを確かめる smoke テストを書いて実行する** + +`apps/api/test/smoke.test.ts` を作る。 + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { eventDates, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const db = testDb(); + +describe('test D1', () => { + beforeEach(async () => { + await resetTables(db); + }); + + it('マイグレーション済みのテーブルに書き込める', async () => { + await seedParticipant(db, '26001'); + await seedSession(db, { participantId: '26001', date: '2026-10-04', inTime: '10:00', outTime: '11:00' }); + expect(await eventDates(db)).toEqual(['2026-10-04']); + }); + + it('前のテストのデータは resetTables で消えている', async () => { + expect(await eventDates(db)).toEqual([]); + }); +}); +``` + +Run: `pnpm --filter @tecnova/api test` +Expected: PASS(2 tests) + +Run: `pnpm --filter @tecnova/api exec tsc --noEmit -p test/tsconfig.json` +Expected: エラーなし。`cloudflare:test` / `cloudflare:workers` の型が二重定義でぶつかる場合は、`test/tsconfig.json` の `types` から `"@cloudflare/workers-types"` を外して再実行し、通った方を採用する + +- [ ] **Step 5: テストの型チェックを type-check に含める** + +`apps/api/package.json` の `type-check` を次に変える(CI の `pnpm type-check` でテストも型検査されるようにする)。 + +```json + "type-check": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json", +``` + +Run: `pnpm --filter @tecnova/api type-check && pnpm biome check apps/api` +Expected: エラーなし + +- [ ] **Step 6: コミットする** + +```bash +git add apps/api/package.json apps/api/vitest.config.ts apps/api/test pnpm-lock.yaml +git commit -m "test(api): add Workers vitest integration with migrated D1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: api — 来場記録の追加(`createSessionByAdmin`) + +**Files:** +- Create: `apps/api/src/lib/session-admin.ts` +- Create: `apps/api/test/session-admin.create.test.ts` + +**Interfaces:** +- Consumes: Task 2 の `validateSessionTimes` / `findOverlappingSession` / `SESSION_RULE_MESSAGES` / `SessionRuleViolation` / `SessionTimes`、Task 3 の `CreateSessionRequest` / `SessionMutationResponse`、Task 4 の test helpers +- Produces: + - `type SessionErrorCode = 'SESSION_NOT_FOUND' | 'PARTICIPANT_NOT_FOUND' | SessionRuleViolation` + - `class SessionError extends Error { code: SessionErrorCode }` + - `createSessionByAdmin(db: Db, input: CreateSessionRequest, now: Date): Promise` + - モジュール内ヘルパー(Task 6・7 で使う): `requireParticipant`、`findSiblingSessions(db, participantId, date, excludeSessionId?)`、`assertSessionRules(times, siblings, now)`、`ensureEvent(db, date)`、`eventIdOf(date)`、`toResponse(db, sessionId)`、`parseCheckedOutAt(value)` + +- [ ] **Step 1: 失敗するテストを書く** + +`apps/api/test/session-admin.create.test.ts` を作る。 + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { createSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); + +const input = (participantId: string, date: string, inTime: string, outTime: string | null) => ({ + participantId, + checkedInAt: jst(date, inTime).toISOString(), + checkedOutAt: outTime === null ? null : jst(date, outTime).toISOString(), +}); + +describe('createSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + }); + + it('開催日が無い日に追加すると開催日を作り、記録を返す', async () => { + const r = await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(r.eventDate).toBe(PAST); + expect(r.session).toMatchObject({ + participantId: '26001', + checkedInAt: jst(PAST, '10:00').toISOString(), + checkedOutAt: jst(PAST, '11:00').toISOString(), + isPresent: false, + term: 'morning', + counted: true, + }); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('既存の開催日を再利用する', async () => { + await seedSession(db, { participantId: '26002', date: PAST, inTime: '10:00', outTime: '11:00' }); + await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('JST の早朝(UTC では前日)の記録も JST の日付の開催日に入る', async () => { + const r = await createSessionByAdmin(db, input('26001', PAST, '08:30', '08:50'), NOW); + expect(r.eventDate).toBe(PAST); + }); + + it('存在しない子は PARTICIPANT_NOT_FOUND', async () => { + await expect( + createSessionByAdmin(db, input('26999', PAST, '10:00', '11:00'), NOW), + ).rejects.toMatchObject({ name: 'SessionError', code: 'PARTICIPANT_NOT_FOUND' }); + }); + + it('無効化した子にも追加できる', async () => { + await seedParticipant(db, '26003', false); + const r = await createSessionByAdmin(db, input('26003', PAST, '10:00', '11:00'), NOW); + expect(r.session.participantId).toBe('26003'); + }); + + it('同じ子・同じ日の記録と重なると OVERLAP', async () => { + await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:30', outTime: '12:00' }); + await expect( + createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW), + ).rejects.toMatchObject({ code: 'OVERLAP' }); + }); + + it('別の子の記録とは重なってもよい', async () => { + await seedSession(db, { participantId: '26002', date: PAST, inTime: '10:30', outTime: '12:00' }); + const r = await createSessionByAdmin(db, input('26001', PAST, '10:00', '11:00'), NOW); + expect(r.session.participantId).toBe('26001'); + }); + + it('過去日の滞在中は OPEN_SESSION_NOT_TODAY', async () => { + await expect( + createSessionByAdmin(db, input('26001', PAST, '10:00', null), NOW), + ).rejects.toMatchObject({ code: 'OPEN_SESSION_NOT_TODAY' }); + }); + + it('今日の滞在中は追加できる', async () => { + const r = await createSessionByAdmin(db, input('26001', TODAY, '14:00', null), NOW); + expect(r.session.isPresent).toBe(true); + expect(r.eventDate).toBe(TODAY); + }); + + it('未来の時刻は FUTURE_TIME', async () => { + await expect( + createSessionByAdmin(db, input('26001', TODAY, '16:00', null), NOW), + ).rejects.toMatchObject({ code: 'FUTURE_TIME' }); + }); + + it('検証エラーのときは開催日を作らない', async () => { + await expect( + createSessionByAdmin(db, input('26001', PAST, '11:00', '10:00'), NOW), + ).rejects.toMatchObject({ code: 'CHECKOUT_BEFORE_CHECKIN' }); + expect(await eventDates(db)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/api test -- session-admin.create` +Expected: FAIL(`../src/lib/session-admin` が存在しない) + +- [ ] **Step 3: 実装する** + +`apps/api/src/lib/session-admin.ts` を作る。 + +```ts +import type * as schema from '@tecnova/db'; +import { events, participants, sessions } from '@tecnova/db'; +import type { CreateSessionRequest, SessionMutationResponse } from '@tecnova/shared/schemas'; +import { + findOverlappingSession, + SESSION_RULE_MESSAGES, + type SessionRuleViolation, + type SessionTimes, + validateSessionTimes, +} from '@tecnova/shared/session-rules'; +import { classifyVisit, toJstDateString } from '@tecnova/shared/venue-schedule'; +import { and, eq, ne, type SQL, sql } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; + +type Db = DrizzleD1Database; + +// 管理画面からの来場記録の追加・編集・削除(admin 専用)。受付の操作ミスを訂正するためのもの。 +// DB に制約が無いため、ルールはここと packages/shared/src/session-rules.ts で守る。 +// D1 にトランザクションが無いので、開催日の作成・記録の書き込み・空の開催日の削除は 1 回の db.batch にまとめる。 +// 設計: docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md + +export type SessionErrorCode = 'SESSION_NOT_FOUND' | 'PARTICIPANT_NOT_FOUND' | SessionRuleViolation; + +export class SessionError extends Error { + constructor( + public code: SessionErrorCode, + message: string, + ) { + super(message); + this.name = 'SessionError'; + } +} + +const sessionNotFound = () => new SessionError('SESSION_NOT_FOUND', '記録が見つかりません'); + +// 無効化した子も対象にする(過去の記録を直す場面があるため。受付端末側は従来どおり受け付けない)。 +const requireParticipant = async (db: Db, participantId: string): Promise => { + const [found] = await db + .select({ id: participants.id }) + .from(participants) + .where(eq(participants.id, participantId)) + .limit(1); + if (!found) { + throw new SessionError('PARTICIPANT_NOT_FOUND', `ID ${participantId} の子が見つかりません`); + } +}; + +// 同じ子・同じ開催日(JST)の既存記録。編集時は自分自身を除く。 +const findSiblingSessions = async ( + db: Db, + participantId: string, + date: string, + excludeSessionId?: string, +): Promise => { + const conditions: SQL[] = [eq(sessions.participantId, participantId), eq(events.date, date)]; + if (excludeSessionId !== undefined) conditions.push(ne(sessions.id, excludeSessionId)); + return db + .select({ checkedInAt: sessions.checkedInAt, checkedOutAt: sessions.checkedOutAt }) + .from(sessions) + .innerJoin(events, eq(sessions.eventId, events.id)) + .where(and(...conditions)); +}; + +const assertSessionRules = ( + times: SessionTimes, + siblings: readonly SessionTimes[], + now: Date, +): void => { + const violation: SessionRuleViolation | null = + validateSessionTimes(times, now) ?? + (findOverlappingSession(times, siblings, now) ? 'OVERLAP' : null); + if (violation) throw new SessionError(violation, SESSION_RULE_MESSAGES[violation]); +}; + +// 開催日の get-or-create(batch 用の文)。events.date は UNIQUE なので既にあれば何もしない。 +const ensureEvent = (db: Db, date: string) => + db.insert(events).values({ date }).onConflictDoNothing(); + +// date の開催日 id。batch 内で ensureEvent の後に評価されるので必ず見つかる。 +const eventIdOf = (date: string): SQL => + sql`(select ${events.id} from ${events} where ${events.date} = ${date})`; + +const parseCheckedOutAt = (value: string | null): Date | null => + value === null ? null : new Date(value); + +// 一覧(GET /api/sessions)と同じ形で返す。ターム・30分ルールは venue-schedule に集約。 +const toResponse = async (db: Db, sessionId: string): Promise => { + const [row] = await db + .select({ + sessionId: sessions.id, + participantId: sessions.participantId, + fullName: participants.fullName, + nickname: participants.nickname, + grade: participants.grade, + checkedInAt: sessions.checkedInAt, + checkedOutAt: sessions.checkedOutAt, + eventDate: events.date, + }) + .from(sessions) + .innerJoin(participants, eq(sessions.participantId, participants.id)) + .innerJoin(events, eq(sessions.eventId, events.id)) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!row) throw sessionNotFound(); + const { term, counted } = classifyVisit(row.checkedInAt); + return { + eventDate: row.eventDate, + session: { + sessionId: row.sessionId, + participantId: row.participantId, + fullName: row.fullName, + nickname: row.nickname, + grade: row.grade, + checkedInAt: row.checkedInAt.toISOString(), + checkedOutAt: row.checkedOutAt ? row.checkedOutAt.toISOString() : null, + isPresent: row.checkedOutAt === null, + term, + counted, + }, + }; +}; + +export const createSessionByAdmin = async ( + db: Db, + input: CreateSessionRequest, + now: Date, +): Promise => { + await requireParticipant(db, input.participantId); + const times: SessionTimes = { + checkedInAt: new Date(input.checkedInAt), + checkedOutAt: parseCheckedOutAt(input.checkedOutAt), + }; + // 開催日は入場時刻の JST 日付から決める(集計が「開催日 = 入場日」を前提にしているため)。 + const date = toJstDateString(times.checkedInAt); + assertSessionRules(times, await findSiblingSessions(db, input.participantId, date), now); + + // 検証を通ってから開催日を作るので、検証エラーで空の開催日は残らない。 + const id = crypto.randomUUID(); + await db.batch([ + ensureEvent(db, date), + db.insert(sessions).values({ + id, + participantId: input.participantId, + eventId: eventIdOf(date), + checkedInAt: times.checkedInAt, + checkedOutAt: times.checkedOutAt, + }), + ]); + return toResponse(db, id); +}; +``` + +- [ ] **Step 4: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/api test` +Expected: PASS(smoke 2 件 + create 11 件) + +Run: `pnpm --filter @tecnova/api type-check && pnpm biome check apps/api` +Expected: エラーなし + +- [ ] **Step 5: コミットする** + +```bash +git add apps/api/src/lib/session-admin.ts apps/api/test/session-admin.create.test.ts +git commit -m "feat(api): add admin session creation with rule validation + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: api — 来場記録の編集(`updateSessionByAdmin`) + +**Files:** +- Modify: `apps/api/src/lib/session-admin.ts`(import に `UpdateSessionRequest` を追加し、関数を追加) +- Create: `apps/api/test/session-admin.update.test.ts` + +**Interfaces:** +- Consumes: Task 5 の `SessionError` / `requireParticipant` / `findSiblingSessions` / `assertSessionRules` / `ensureEvent` / `eventIdOf` / `toResponse` / `parseCheckedOutAt` / `sessionNotFound`、Task 3 の `UpdateSessionRequest` +- Produces: + - `updateSessionByAdmin(db: Db, sessionId: string, patch: UpdateSessionRequest, now: Date): Promise` + - モジュール内ヘルパー(Task 7 で使う): `deleteEventIfEmptyPast(db: Db, eventId: string, today: string)` + +- [ ] **Step 1: 失敗するテストを書く** + +`apps/api/test/session-admin.update.test.ts` を作る。 + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { updateSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const OTHER_PAST = '2026-10-05'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); +const iso = (date: string, time: string) => jst(date, time).toISOString(); + +describe('updateSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + await seedParticipant(db, '26003'); + }); + + it('同じ日のまま退場時刻を直せる(自分自身とは重ならない扱い)', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: iso(PAST, '11:30') }, NOW); + expect(r.session.checkedOutAt).toBe(iso(PAST, '11:30')); + expect(r.eventDate).toBe(PAST); + }); + + it('日付を変えると別の開催日へ移り、空になった過去の開催日は消える', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + const r = await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(OTHER_PAST, '10:00'), checkedOutAt: iso(OTHER_PAST, '11:00') }, + NOW, + ); + expect(r.eventDate).toBe(OTHER_PAST); + expect(await eventDates(db)).toEqual([OTHER_PAST]); + }); + + it('元の開催日に他の記録が残っていれば消さない', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await seedSession(db, { participantId: '26002', date: PAST, inTime: '10:00', outTime: '11:00' }); + await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(OTHER_PAST, '10:00'), checkedOutAt: iso(OTHER_PAST, '11:00') }, + NOW, + ); + expect(await eventDates(db)).toEqual([PAST, OTHER_PAST]); + }); + + it('今日の開催日は空になっても残す', async () => { + const id = await seedSession(db, { participantId: '26001', date: TODAY, inTime: '10:00', outTime: '11:00' }); + await updateSessionByAdmin( + db, + id, + { checkedInAt: iso(PAST, '10:00'), checkedOutAt: iso(PAST, '11:00') }, + NOW, + ); + expect(await eventDates(db)).toEqual([PAST, TODAY]); + }); + + it('付け替え先の子の記録と重なると OVERLAP', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await seedSession(db, { participantId: '26002', date: PAST, inTime: '10:30', outTime: '12:00' }); + await expect( + updateSessionByAdmin(db, id, { participantId: '26002' }, NOW), + ).rejects.toMatchObject({ code: 'OVERLAP' }); + }); + + it('重ならない子へは付け替えられる', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + const r = await updateSessionByAdmin(db, id, { participantId: '26003' }, NOW); + expect(r.session.participantId).toBe('26003'); + expect(r.session.nickname).toBe('nick26003'); + }); + + it('存在しない子への付け替えは PARTICIPANT_NOT_FOUND', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await expect( + updateSessionByAdmin(db, id, { participantId: '26999' }, NOW), + ).rejects.toMatchObject({ code: 'PARTICIPANT_NOT_FOUND' }); + }); + + it('今日の記録は滞在中に戻せる', async () => { + const id = await seedSession(db, { participantId: '26001', date: TODAY, inTime: '10:00', outTime: '11:00' }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: null }, NOW); + expect(r.session.isPresent).toBe(true); + }); + + it('過去日の記録を滞在中に戻すのは OPEN_SESSION_NOT_TODAY', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await expect( + updateSessionByAdmin(db, id, { checkedOutAt: null }, NOW), + ).rejects.toMatchObject({ code: 'OPEN_SESSION_NOT_TODAY' }); + }); + + it('過去日に滞在中のまま残った記録は、退場時刻を入れずに直そうとすると止まり、入れれば直る', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: null }); + await expect( + updateSessionByAdmin(db, id, { checkedInAt: iso(PAST, '10:15') }, NOW), + ).rejects.toMatchObject({ code: 'OPEN_SESSION_NOT_TODAY' }); + const r = await updateSessionByAdmin(db, id, { checkedOutAt: iso(PAST, '11:00') }, NOW); + expect(r.session.isPresent).toBe(false); + }); + + it('存在しない記録は SESSION_NOT_FOUND', async () => { + await expect( + updateSessionByAdmin(db, 'no-such-session', { checkedOutAt: null }, NOW), + ).rejects.toMatchObject({ code: 'SESSION_NOT_FOUND' }); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/api test -- session-admin.update` +Expected: FAIL(`updateSessionByAdmin` が export されていない) + +- [ ] **Step 3: 実装する** + +`apps/api/src/lib/session-admin.ts` の schemas の import を次に変える。 + +```ts +import type { + CreateSessionRequest, + SessionMutationResponse, + UpdateSessionRequest, +} from '@tecnova/shared/schemas'; +``` + +`parseCheckedOutAt` の直後に追加する。 + +```ts +// 記録が 1 件も無くなった過去の開催日を消す(日付の選択肢・サイネージの「前回の開催」に空の日を残さない)。 +// 今日の開催日は受付中のチェックインと競合させないため消さない。 +const deleteEventIfEmptyPast = (db: Db, eventId: string, today: string) => + db + .delete(events) + .where( + and( + eq(events.id, eventId), + ne(events.date, today), + sql`not exists (select 1 from ${sessions} where ${sessions.eventId} = ${eventId})`, + ), + ); + +// PATCH の checkedOutAt は「省略 = 変えない」「null = 滞在中に戻す」を区別する。 +const resolveCheckedOutAt = ( + patch: UpdateSessionRequest['checkedOutAt'], + current: Date | null, +): Date | null => (patch === undefined ? current : parseCheckedOutAt(patch)); +``` + +ファイル末尾(`createSessionByAdmin` の後)に追加する。 + +```ts +export const updateSessionByAdmin = async ( + db: Db, + sessionId: string, + patch: UpdateSessionRequest, + now: Date, +): Promise => { + const [current] = await db + .select({ + participantId: sessions.participantId, + eventId: sessions.eventId, + checkedInAt: sessions.checkedInAt, + checkedOutAt: sessions.checkedOutAt, + }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!current) throw sessionNotFound(); + + const participantId = patch.participantId ?? current.participantId; + if (participantId !== current.participantId) await requireParticipant(db, participantId); + + const times: SessionTimes = { + checkedInAt: patch.checkedInAt === undefined ? current.checkedInAt : new Date(patch.checkedInAt), + checkedOutAt: resolveCheckedOutAt(patch.checkedOutAt, current.checkedOutAt), + }; + const date = toJstDateString(times.checkedInAt); + assertSessionRules(times, await findSiblingSessions(db, participantId, date, sessionId), now); + + await db.batch([ + ensureEvent(db, date), + db + .update(sessions) + .set({ + participantId, + eventId: eventIdOf(date), + checkedInAt: times.checkedInAt, + checkedOutAt: times.checkedOutAt, + }) + .where(eq(sessions.id, sessionId)), + // 日付が変わって元の開催日が空になった場合だけ消える(同じ日なら NOT EXISTS で何もしない)。 + deleteEventIfEmptyPast(db, current.eventId, toJstDateString(now)), + ]); + return toResponse(db, sessionId); +}; +``` + +- [ ] **Step 4: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/api test` +Expected: PASS(smoke 2 + create 11 + update 11) + +Run: `pnpm --filter @tecnova/api type-check && pnpm biome check apps/api` +Expected: エラーなし + +- [ ] **Step 5: コミットする** + +```bash +git add apps/api/src/lib/session-admin.ts apps/api/test/session-admin.update.test.ts +git commit -m "feat(api): add admin session update with event move and cleanup + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: api — 来場記録の削除(`deleteSessionByAdmin`) + +**Files:** +- Modify: `apps/api/src/lib/session-admin.ts`(関数を追加) +- Create: `apps/api/test/session-admin.delete.test.ts` + +**Interfaces:** +- Consumes: Task 5 の `sessionNotFound`、Task 6 の `deleteEventIfEmptyPast` +- Produces: `deleteSessionByAdmin(db: Db, sessionId: string, now: Date): Promise` + +- [ ] **Step 1: 失敗するテストを書く** + +`apps/api/test/session-admin.delete.test.ts` を作る。 + +```ts +import { sessions } from '@tecnova/db'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { deleteSessionByAdmin } from '../src/lib/session-admin'; +import { eventDates, jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); +const db = testDb(); + +const sessionCount = async () => (await db.select({ id: sessions.id }).from(sessions)).length; + +describe('deleteSessionByAdmin', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + await seedParticipant(db, '26002'); + }); + + it('記録を消し、他の記録が残る開催日は残す', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await seedSession(db, { participantId: '26002', date: PAST, inTime: '10:00', outTime: '11:00' }); + await deleteSessionByAdmin(db, id, NOW); + expect(await sessionCount()).toBe(1); + expect(await eventDates(db)).toEqual([PAST]); + }); + + it('過去の開催日の最後の 1 件を消すと開催日も消える', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + await deleteSessionByAdmin(db, id, NOW); + expect(await eventDates(db)).toEqual([]); + }); + + it('今日の開催日は空になっても残す', async () => { + const id = await seedSession(db, { participantId: '26001', date: TODAY, inTime: '10:00', outTime: '11:00' }); + await deleteSessionByAdmin(db, id, NOW); + expect(await eventDates(db)).toEqual([TODAY]); + }); + + it('存在しない記録は SESSION_NOT_FOUND', async () => { + await expect(deleteSessionByAdmin(db, 'no-such-session', NOW)).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + }); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/api test -- session-admin.delete` +Expected: FAIL(`deleteSessionByAdmin` が export されていない) + +- [ ] **Step 3: 実装する** + +`apps/api/src/lib/session-admin.ts` の末尾に追加する。 + +```ts +export const deleteSessionByAdmin = async (db: Db, sessionId: string, now: Date): Promise => { + const [current] = await db + .select({ eventId: sessions.eventId }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!current) throw sessionNotFound(); + await db.batch([ + db.delete(sessions).where(eq(sessions.id, sessionId)), + deleteEventIfEmptyPast(db, current.eventId, toJstDateString(now)), + ]); +}; +``` + +- [ ] **Step 4: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/api test` +Expected: PASS(smoke 2 + create 11 + update 11 + delete 4) + +Run: `pnpm --filter @tecnova/api type-check && pnpm biome check apps/api` +Expected: エラーなし + +- [ ] **Step 5: コミットする** + +```bash +git add apps/api/src/lib/session-admin.ts apps/api/test/session-admin.delete.test.ts +git commit -m "feat(api): add admin session deletion with empty past event cleanup + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: api — ルートとエラー変換 + +**Files:** +- Modify: `apps/api/src/routes/admin.ts`(import と 3 ルートを追加) +- Modify: `apps/api/src/lib/errors.ts`(`SessionError` のステータス表と分岐) +- Create: `apps/api/test/sessions-routes.test.ts` + +**Interfaces:** +- Consumes: Task 3 の `createSessionRequestSchema` / `updateSessionRequestSchema`、Task 5〜7 の `createSessionByAdmin` / `updateSessionByAdmin` / `deleteSessionByAdmin` / `SessionError` / `SessionErrorCode`、既存の `requireAdmin` / `createDb` / `invalidBodyError` / `apiErrorHandler` +- Produces: `POST /api/sessions`(201)・`PATCH /api/sessions/:sessionId`(200)・`DELETE /api/sessions/:sessionId`(204) + +- [ ] **Step 1: 失敗するテストを書く** + +`apps/api/test/sessions-routes.test.ts` を作る。 + +```ts +import { env } from 'cloudflare:workers'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { apiErrorHandler } from '../src/lib/errors'; +import { adminRoute } from '../src/routes/admin'; +import type { AppEnv, Bindings } from '../src/types'; +import { jst, resetTables, seedParticipant, seedSession, testDb } from './helpers'; + +// Better Auth を通さずに mentor を差し込んだアプリ。ルート単位の requireAdmin とエラー変換を確かめる。 +const appAs = (role: 'admin' | 'mentor') => { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', { id: 'u1', email: 'tester@example.com', name: 'Tester' }); + c.set('mentor', { id: 'm1', email: 'tester@example.com', name: 'Tester', role }); + await next(); + }); + app.route('/api', adminRoute); + app.onError(apiErrorHandler); + return app; +}; + +// ルートは new Date() を現在時刻に使うので、どの実行日でも過去になる日付を使う。 +const PAST = '2026-01-15'; +const bindings = env as unknown as Bindings; +const db = testDb(); + +const send = (app: Hono, method: string, path: string, body?: unknown) => + app.request( + path, + { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + bindings, + ); + +const validBody = { + participantId: '26001', + checkedInAt: jst(PAST, '10:00').toISOString(), + checkedOutAt: jst(PAST, '11:00').toISOString(), +}; + +describe('/api/sessions の修正ルート', () => { + beforeEach(async () => { + await resetTables(db); + await seedParticipant(db, '26001'); + }); + + it('admin 以外は POST / PATCH / DELETE が 403', async () => { + const app = appAs('mentor'); + expect((await send(app, 'POST', '/api/sessions', validBody)).status).toBe(403); + expect((await send(app, 'PATCH', '/api/sessions/x', { checkedOutAt: null })).status).toBe(403); + expect((await send(app, 'DELETE', '/api/sessions/x')).status).toBe(403); + }); + + it('admin 以外でも一覧の GET は使える', async () => { + const res = await send(appAs('mentor'), 'GET', `/api/sessions?date=${PAST}`); + expect(res.status).toBe(200); + }); + + it('admin の POST は 201 で eventDate と記録を返す', async () => { + const res = await send(appAs('admin'), 'POST', '/api/sessions', validBody); + expect(res.status).toBe(201); + const body = (await res.json()) as { eventDate: string; session: { participantId: string } }; + expect(body.eventDate).toBe(PAST); + expect(body.session.participantId).toBe('26001'); + }); + + it('本文の形式が違うと 400', async () => { + const res = await send(appAs('admin'), 'POST', '/api/sessions', { participantId: '1' }); + expect(res.status).toBe(400); + }); + + it('ルール違反はコードと日本語メッセージで返す(重なりは 409)', async () => { + await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:30', outTime: '12:00' }); + const res = await send(appAs('admin'), 'POST', '/api/sessions', validBody); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'OVERLAP', + message: '同じ子の同じ日の別の記録と時間が重なっています', + }); + }); + + it('admin の PATCH は 200、DELETE は 204、存在しない記録の DELETE は 404', async () => { + const id = await seedSession(db, { participantId: '26001', date: PAST, inTime: '10:00', outTime: '11:00' }); + const app = appAs('admin'); + const patched = await send(app, 'PATCH', `/api/sessions/${id}`, { + checkedOutAt: jst(PAST, '11:30').toISOString(), + }); + expect(patched.status).toBe(200); + expect((await send(app, 'DELETE', `/api/sessions/${id}`)).status).toBe(204); + const missing = await send(app, 'DELETE', `/api/sessions/${id}`); + expect(missing.status).toBe(404); + expect(await missing.json()).toMatchObject({ error: 'SESSION_NOT_FOUND' }); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確かめる** + +Run: `pnpm --filter @tecnova/api test -- sessions-routes` +Expected: FAIL(POST が 404 を返す・ルール違反が 500 になる) + +- [ ] **Step 3: エラー変換を追加する** + +`apps/api/src/lib/errors.ts` の import に追加する。 + +```ts +import { SessionError, type SessionErrorCode } from './session-admin'; +``` + +`checkinErrorStatus` の後に追加する。 + +```ts +const sessionErrorStatus: Record = { + SESSION_NOT_FOUND: 404, + PARTICIPANT_NOT_FOUND: 404, + CHECKOUT_BEFORE_CHECKIN: 400, + SPANS_DAYS: 400, + FUTURE_TIME: 400, + OPEN_SESSION_NOT_TODAY: 400, + OVERLAP: 409, +}; +``` + +`apiErrorHandler` の `PreRegistrationError` の分岐の後に追加する。 + +```ts + if (e instanceof SessionError) { + return c.json({ error: e.code, message: e.message }, sessionErrorStatus[e.code]); + } +``` + +- [ ] **Step 4: ルートを追加する** + +`apps/api/src/routes/admin.ts` の `@tecnova/shared/schemas` の import に `createSessionRequestSchema` と `updateSessionRequestSchema` を加え(アルファベット順を保つ)、lib の import を 1 行追加する。 + +```ts +import { createSessionByAdmin, deleteSessionByAdmin, updateSessionByAdmin } from '../lib/session-admin'; +``` + +`adminRoute.get('/sessions', …)` の定義の直後に追加する。 + +```ts +// 来場記録の修正(admin 専用)。受付の操作ミス(チェックイン忘れ・別の子で受付など)を訂正する。 +// GET /sessions は全メンター向けのまま残すため、/sessions にサブルーターを mount せず、 +// 書き込みのルートにだけ requireAdmin を当てる。 +adminRoute.post('/sessions', requireAdmin, async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = createSessionRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json(invalidBodyError, 400); + } + return c.json(await createSessionByAdmin(createDb(c.env), parsed.data, new Date()), 201); +}); + +adminRoute.patch('/sessions/:sessionId', requireAdmin, async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = updateSessionRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json(invalidBodyError, 400); + } + const sessionId = c.req.param('sessionId'); + return c.json(await updateSessionByAdmin(createDb(c.env), sessionId, parsed.data, new Date())); +}); + +adminRoute.delete('/sessions/:sessionId', requireAdmin, async (c) => { + await deleteSessionByAdmin(createDb(c.env), c.req.param('sessionId'), new Date()); + return c.body(null, 204); +}); +``` + +- [ ] **Step 5: テストが通ることを確かめる** + +Run: `pnpm --filter @tecnova/api test` +Expected: PASS(smoke 2 + create 11 + update 11 + delete 4 + routes 6) + +Run: `pnpm --filter @tecnova/api type-check && pnpm biome check apps/api` +Expected: エラーなし + +- [ ] **Step 6: Workers 制約と個人情報の観点でレビューする** + +`workers-constraint-reviewer` subagent に `apps/api/src/lib/session-admin.ts`・`apps/api/src/routes/admin.ts`・`apps/api/src/lib/errors.ts`・`packages/shared/src/session-rules.ts`・`packages/shared/src/venue-schedule.ts` を、`privacy-reviewer` subagent に `packages/shared/src/schemas/admin.ts`・`apps/api/src/lib/session-admin.ts`・`apps/api/test/**` を確認させる。 +Expected: 違反なし。指摘があれば直してからコミットする + +- [ ] **Step 7: コミットする** + +```bash +git add apps/api/src/routes/admin.ts apps/api/src/lib/errors.ts apps/api/test/sessions-routes.test.ts +git commit -m "feat(api): expose admin-only session correction endpoints + +Refs #48, #49 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: ui — ダイアログ部品を追加する + +**Files:** +- Create: `packages/ui/src/components/dialog.tsx`(shadcn CLI が生成) + +**Interfaces:** +- Consumes: `packages/ui/components.json`(style `radix-maia`、ui alias `@tecnova/ui/components`)、依存 `radix-ui` +- Produces: `@tecnova/ui/components/dialog` から `Dialog` / `DialogClose` / `DialogContent` / `DialogDescription` / `DialogFooter` / `DialogHeader` / `DialogTitle` / `DialogTrigger` + +- [ ] **Step 1: CLI で追加する** + +Run: `cd packages/ui && pnpm dlx shadcn@4.21.0 add dialog && cd ../..` +Expected: `packages/ui/src/components/dialog.tsx` が作られる。既存ファイルの上書きを聞かれたら上書きしない(`button.tsx` などは既存を保つ) + +生成先が `packages/ui/src/components/dialog.tsx` 以外だった場合は、そこへ移動する。`git status` で `dialog.tsx` 以外に変更が出ていないことを確かめ、出ていれば `git checkout -- ` で戻す。 + +- [ ] **Step 2: export と型を確かめる** + +Run: `grep -nE "^export|^ Dialog" packages/ui/src/components/dialog.tsx` +Expected: `Dialog`, `DialogClose`, `DialogContent`, `DialogDescription`, `DialogFooter`, `DialogHeader`, `DialogTitle`, `DialogTrigger` が export されている + +Run: `pnpm --filter @tecnova/ui type-check && pnpm biome check packages/ui` +Expected: エラーなし(Biome の整形差分は `pnpm biome check --write packages/ui/src/components/dialog.tsx` で直す) + +- [ ] **Step 3: コミットする** + +```bash +git add packages/ui/src/components/dialog.tsx +git commit -m "feat(ui): add shadcn dialog component + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: admin — 記録フォーム・削除確認・行メニュー・子どもの検索 + +**Files:** +- Create: `apps/admin/src/lib/session-error-message.ts` +- Create: `apps/admin/src/components/participant-picker.tsx` +- Create: `apps/admin/src/components/session-form-dialog.tsx` +- Create: `apps/admin/src/components/session-delete-dialog.tsx` +- Create: `apps/admin/src/components/session-row-actions.tsx` + +**Interfaces:** +- Consumes: Task 2 の `SESSION_RULE_MESSAGES` / `validateSessionTimes` / `SessionRuleViolation` / `jstDateTimeToInstant` / `toJstTimeString`、Task 3 の `SessionMutationResponse`、Task 9 の `Dialog*`、既存の `ParticipantsListResponse`・`apiJson` / `apiFetch` / `ApiError` / `apiErrorMessage`・`toastSuccess` / `toastError`・`useApiResource` +- Produces: + - `interface PickedParticipant { id: string; fullName: string; nickname: string; grade: string }`、`participantLabel(p: PickedParticipant): string`、`ParticipantPicker({ value, onChange, disabled? })` + - `interface SessionRecord { sessionId: string; participant: PickedParticipant; checkedInAt: string; checkedOutAt: string | null }` + - `type SessionFormRequest = { mode: 'create'; date: string; participant: PickedParticipant | null } | { mode: 'edit'; record: SessionRecord }` + - `SessionFormDialog({ request: SessionFormRequest | null, onClose: () => void, onSaved: (r: SessionMutationResponse) => void })` + - `SessionDeleteDialog({ record: SessionRecord | null, onClose: () => void, onDeleted: () => void })` + - `SessionRowActions({ label: string, onEdit: () => void, onDelete: () => void })` + - `sessionErrorMessage(e: unknown): string` + +このタスクの部品は Task 11・12 で画面に組み込み、そこで Playwright による実行時の確認をする。このタスクの関門は型チェックと Lint。 + +- [ ] **Step 1: API のエラーを画面の文言にする関数を書く** + +`apps/admin/src/lib/session-error-message.ts` を作る。 + +```ts +import { SESSION_RULE_MESSAGES, type SessionRuleViolation } from '@tecnova/shared/session-rules'; +import { ApiError, apiErrorMessage } from '@tecnova/ui/lib/api-client'; + +const isRuleViolation = (code: unknown): code is SessionRuleViolation => + typeof code === 'string' && Object.hasOwn(SESSION_RULE_MESSAGES, code); + +// API の SessionError({ error: code, message })を画面の文言にする。ルール違反は画面側の検証と +// 同じ shared の文言にそろえ、それ以外(記録が無い等)は API の message をそのまま使う。 +export const sessionErrorMessage = (e: unknown): string => { + if (e instanceof ApiError) { + const code = (e.body as { error?: unknown } | undefined)?.error; + if (isRuleViolation(code)) return SESSION_RULE_MESSAGES[code]; + } + return apiErrorMessage(e); +}; +``` + +- [ ] **Step 2: 子どもの検索部品を書く** + +`apps/admin/src/components/participant-picker.tsx` を作る。 + +```tsx +'use client'; + +import type { ParticipantsListResponse } from '@tecnova/shared/schemas'; +import { Button } from '@tecnova/ui/components/button'; +import { Input } from '@tecnova/ui/components/input'; +import { useApiResource } from '@tecnova/ui/hooks/use-api-resource'; +import { useState } from 'react'; + +export interface PickedParticipant { + id: string; + fullName: string; + nickname: string; + grade: string; +} + +// 一覧・確認ダイアログでも同じ表記にするため export する。メイン識別子はニックネーム。 +export const participantLabel = (p: PickedParticipant): string => + `${p.nickname}(${p.fullName})・${p.grade}・${p.id}`; + +interface Props { + value: PickedParticipant | null; + onChange: (participant: PickedParticipant | null) => void; + disabled?: boolean; +} + +// 来場記録の子を選ぶ。選択済みなら表示+「変更」(=別の子への付け替え)、未選択なら検索欄を出す。 +// 検索は既存の /api/participants?search=(ID・氏名・ニックネームの部分一致)を使う。 +export function ParticipantPicker({ value, onChange, disabled }: Props) { + const [query, setQuery] = useState(''); + const trimmed = query.trim(); + const { state } = useApiResource( + value === null && trimmed.length > 0 + ? `/api/participants?search=${encodeURIComponent(trimmed)}&limit=8` + : null, + ); + + if (value !== null) { + return ( +
+ {participantLabel(value)} + +
+ ); + } + + return ( +
+ setQuery(e.target.value)} + placeholder="ID・氏名・ニックネームで検索" + aria-label="子どもを検索" + disabled={disabled} + /> + {state.kind === 'loading' &&

検索中...

} + {state.kind === 'error' &&

{state.message}

} + {state.kind === 'ok' && + (state.data.participants.length === 0 ? ( +

見つかりません

+ ) : ( +
    + {state.data.participants.map((p) => ( +
  • + +
  • + ))} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 3: 記録フォームを書く** + +`apps/admin/src/components/session-form-dialog.tsx` を作る。 + +```tsx +'use client'; + +import type { SessionMutationResponse } from '@tecnova/shared/schemas'; +import { SESSION_RULE_MESSAGES, validateSessionTimes } from '@tecnova/shared/session-rules'; +import { + jstDateTimeToInstant, + toJstDateString, + toJstTimeString, +} from '@tecnova/shared/venue-schedule'; +import { Button } from '@tecnova/ui/components/button'; +import { Checkbox } from '@tecnova/ui/components/checkbox'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@tecnova/ui/components/dialog'; +import { Input } from '@tecnova/ui/components/input'; +import { Label } from '@tecnova/ui/components/label'; +import { apiJson } from '@tecnova/ui/lib/api-client'; +import { toastSuccess } from '@tecnova/ui/lib/toast'; +import { useState } from 'react'; +import { sessionErrorMessage } from '@/lib/session-error-message'; +import { type PickedParticipant, ParticipantPicker } from './participant-picker'; + +// 編集・削除の対象。ダッシュボードの行と利用者詳細の履歴の両方から組み立てる。 +export interface SessionRecord { + sessionId: string; + participant: PickedParticipant; + checkedInAt: string; // ISO 8601 (UTC) + checkedOutAt: string | null; +} + +export type SessionFormRequest = + | { mode: 'create'; date: string; participant: PickedParticipant | null } + | { mode: 'edit'; record: SessionRecord }; + +interface Props { + // null = 閉じている + request: SessionFormRequest | null; + onClose: () => void; + onSaved: (result: SessionMutationResponse) => void; +} + +export function SessionFormDialog({ request, onClose, onSaved }: Props) { + return ( + { + if (!open) onClose(); + }} + > + + + {request?.mode === 'edit' ? '来場記録を編集' : '来場記録を追加'} + 時刻は日本時間で入力します。 + + {/* 閉じると DialogContent ごとアンマウントされるので、開くたびにフォームの状態が初期化される */} + {request && } + + + ); +} + +interface InitialValues { + date: string; + inTime: string; + outTime: string; + stillPresent: boolean; +} + +const initialValues = (request: SessionFormRequest, today: string): InitialValues => { + if (request.mode === 'edit') { + const { checkedInAt, checkedOutAt } = request.record; + return { + date: toJstDateString(new Date(checkedInAt)), + inTime: toJstTimeString(new Date(checkedInAt)), + outTime: checkedOutAt ? toJstTimeString(new Date(checkedOutAt)) : '', + stillPresent: checkedOutAt === null, + }; + } + // 今日の追加は「今」を入場時刻の初期値にする(チェックイン忘れをその場で直すことが多いため)。 + return { + date: request.date, + inTime: request.date === today ? toJstTimeString(new Date()) : '', + outTime: '', + stillPresent: false, + }; +}; + +function SessionForm({ + request, + onClose, + onSaved, +}: { + request: SessionFormRequest; + onClose: () => void; + onSaved: (result: SessionMutationResponse) => void; +}) { + const [today] = useState(() => toJstDateString(new Date())); + const [initial] = useState(() => initialValues(request, today)); + const [participant, setParticipant] = useState( + request.mode === 'edit' ? request.record.participant : request.participant, + ); + const [date, setDate] = useState(initial.date); + const [inTime, setInTime] = useState(initial.inTime); + const [outTime, setOutTime] = useState(initial.outTime); + const [stillPresent, setStillPresent] = useState(initial.stillPresent); + const [busy, setBusy] = useState(false); + const [serverError, setServerError] = useState(null); + + const isToday = date === today; + // 「まだ滞在中」は今日の記録だけ(過去日はルール上許されない)。 + const open = isToday && stillPresent; + const checkedInAt = date && inTime ? jstDateTimeToInstant(date, inTime) : null; + const checkedOutAt = !open && date && outTime ? jstDateTimeToInstant(date, outTime) : null; + // 未入力は保存ボタンを押せなくするだけにし、ルール違反だけを文言で知らせる。 + const incomplete = + participant === null || checkedInAt === null || (!open && checkedOutAt === null); + const violation = + !incomplete && checkedInAt !== null + ? validateSessionTimes({ checkedInAt, checkedOutAt }, new Date()) + : null; + + const save = async () => { + if (busy || incomplete || violation !== null || participant === null || checkedInAt === null) { + return; + } + setBusy(true); + setServerError(null); + const body = { + participantId: participant.id, + checkedInAt: checkedInAt.toISOString(), + checkedOutAt: checkedOutAt ? checkedOutAt.toISOString() : null, + }; + try { + const result = + request.mode === 'edit' + ? await apiJson( + `/api/sessions/${encodeURIComponent(request.record.sessionId)}`, + { method: 'PATCH', body }, + ) + : await apiJson('/api/sessions', { method: 'POST', body }); + toastSuccess(request.mode === 'edit' ? '来場記録を更新しました' : '来場記録を追加しました'); + onSaved(result); + onClose(); + } catch (e) { + setServerError(sessionErrorMessage(e)); + setBusy(false); + } + }; + + return ( +
{ + e.preventDefault(); + void save(); + }} + > +
+ 子ども + +
+
+
+ + setDate(e.target.value)} + disabled={busy} + required + /> +
+
+ + setInTime(e.target.value)} + disabled={busy} + required + /> +
+
+ + setOutTime(e.target.value)} + disabled={busy || open} + /> +
+
+ {isToday ? ( +
+ setStillPresent(v === true)} + disabled={busy} + /> + +
+ ) : ( +

過去の日の記録には退場時刻が必要です。

+ )} + {violation && ( +

+ {SESSION_RULE_MESSAGES[violation]} +

+ )} + {serverError && ( +

+ {serverError} +

+ )} + {isToday && ( +

+ 今日の記録を変えると、受付端末での次の操作(チェックイン/チェックアウト)が変わります。 +

+ )} + + + + + + + + ); +} +``` + +- [ ] **Step 4: 削除の確認ダイアログを書く** + +`apps/admin/src/components/session-delete-dialog.tsx` を作る。 + +```tsx +'use client'; + +import { toJstDateString, toJstTimeString } from '@tecnova/shared/venue-schedule'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@tecnova/ui/components/alert-dialog'; +import { ApiError, apiFetch } from '@tecnova/ui/lib/api-client'; +import { toastError, toastSuccess } from '@tecnova/ui/lib/toast'; +import { useState } from 'react'; +import type { SessionRecord } from './session-form-dialog'; + +// 例: 「26001 たろう(テスト太郎)の 2026-10-04 10:05〜11:40」 +const describeRecord = (r: SessionRecord): string => { + const at = new Date(r.checkedInAt); + const out = r.checkedOutAt ? toJstTimeString(new Date(r.checkedOutAt)) : '滞在中'; + return `${r.participant.id} ${r.participant.nickname}(${r.participant.fullName})の ${toJstDateString(at)} ${toJstTimeString(at)}〜${out}`; +}; + +interface Props { + // null = 閉じている + record: SessionRecord | null; + onClose: () => void; + onDeleted: () => void; +} + +export function SessionDeleteDialog({ record, onClose, onDeleted }: Props) { + const [busy, setBusy] = useState(false); + + const remove = async () => { + if (record === null || busy) return; + setBusy(true); + try { + // 204 を返すので apiJson ではなく apiFetch を使う。 + const r = await apiFetch(`/api/sessions/${encodeURIComponent(record.sessionId)}`, { + method: 'DELETE', + }); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new ApiError(r.status, body); + } + toastSuccess('来場記録を削除しました'); + onDeleted(); + onClose(); + } catch (e) { + toastError(e, '削除できませんでした'); + } finally { + setBusy(false); + } + }; + + return ( + { + if (!open) onClose(); + }} + > + + + 来場記録を削除しますか? + + {record ? `${describeRecord(record)} の記録を削除します。元に戻せません。` : ''} + + + + キャンセル + { + // 削除が終わるまでダイアログを閉じない(失敗時にもう一度押せるように)。 + e.preventDefault(); + void remove(); + }} + > + {busy ? '削除中...' : '削除'} + + + + + ); +} +``` + +- [ ] **Step 5: 行の「…」メニューを書く** + +`apps/admin/src/components/session-row-actions.tsx` を作る。 + +```tsx +'use client'; + +import { IconDots, IconPencil, IconTrash } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@tecnova/ui/components/dropdown-menu'; + +interface Props { + // スクリーンリーダー向けの対象名(例: ニックネーム) + label: string; + onEdit: () => void; + onDelete: () => void; +} + +// 来場記録の行に付ける「…」メニュー。行やカード全体のクリック(利用者詳細を開く)と干渉しないよう、 +// トリガーとメニューのクリックは伝播を止める(メニューは portal だが React のイベントは親へ伝わるため)。 +// 編集・削除のダイアログは親で持ち、ここでは開く合図だけを出す。modal={false} にして、 +// メニューが閉じる前にダイアログが開いても body のポインター操作がロックされたままにならないようにする。 +export function SessionRowActions({ label, onEdit, onDelete }: Props) { + return ( + + + + + e.stopPropagation()}> + + + 編集 + + + + 削除 + + + + ); +} +``` + +- [ ] **Step 6: 型チェックと Lint** + +Run: `pnpm --filter admin type-check && pnpm biome check apps/admin` +Expected: エラーなし(整形差分は `pnpm biome check --write apps/admin` で直す) + +- [ ] **Step 7: コミットする** + +```bash +git add apps/admin/src/lib/session-error-message.ts apps/admin/src/components/participant-picker.tsx apps/admin/src/components/session-form-dialog.tsx apps/admin/src/components/session-delete-dialog.tsx apps/admin/src/components/session-row-actions.tsx +git commit -m "feat(admin): add session record form, delete dialog, and row actions + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: admin — ダッシュボードに組み込む + +**Files:** +- Modify: `apps/admin/src/app/(authed)/page.tsx` +- Modify: `apps/admin/src/components/record-card.tsx`(コメントのみ) +- Modify: `apps/admin/src/components/participant-detail-sheet.tsx`(`onSessionsChanged` prop を受け取れるようにするだけ。中身の実装は Task 12) +- Modify: `.gitignore`(`.playwright-mcp/` を追加) +- Test(コミットしない): `.playwright-mcp/dashboard-sessions.js` + +**Interfaces:** +- Consumes: Task 10 の `SessionFormDialog` / `SessionFormRequest` / `SessionRecord` / `SessionDeleteDialog` / `SessionRowActions`、Task 3 の `SessionMutationResponse`、既存の `useMe` +- Produces: `ParticipantDetailSheet` の新しい任意 prop `onSessionsChanged?: () => void`(Task 12 が中で呼ぶ) + +- [ ] **Step 1: Playwright のテストを書き、今は失敗することを確かめる** + +`.gitignore` の末尾に追加する。 + +``` +# Playwright MCP の作業ファイル(スクリプト・スクリーンショット) +.playwright-mcp/ +``` + +admin の開発サーバを起動する(別プロセス)。 + +Run: `pnpm --filter admin dev`(バックグラウンド。`Ready` が出るまで待つ。ポート 3001) + +`.playwright-mcp/dashboard-sessions.js` を作る。 + +```js +async (page) => { + const BASE = 'http://localhost:3001'; + const API = 'http://localhost:8787'; + const fmt = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(d); + const today = fmt(new Date()); + const yesterday = fmt(new Date(Date.now() - 86_400_000)); + // JST の yesterday 10:05〜11:40 を UTC に(UTC+9 固定) + const isoAt = (date, hh, mm) => + new Date(Date.UTC(+date.slice(0, 4), +date.slice(5, 7) - 1, +date.slice(8, 10), hh - 9, mm)).toISOString(); + const session = { + sessionId: 's1', participantId: '26001', fullName: 'テスト太郎', nickname: 'たろう', grade: '小3', + checkedInAt: isoAt(yesterday, 10, 5), checkedOutAt: isoAt(yesterday, 11, 40), + isPresent: false, term: 'morning', counted: true, + }; + const results = {}; + + const run = async (role, viewport) => { + const p = await page.context().newPage(); + await p.setViewportSize(viewport); + const calls = []; + const logs = []; + let postCount = 0; + p.on('console', (m) => { if (['error', 'warning'].includes(m.type())) logs.push(`${m.type()}: ${m.text().slice(0, 200)}`); }); + p.on('pageerror', (e) => logs.push(`pageerror: ${String(e).slice(0, 200)}`)); + await p.route(`${API}/**`, async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const method = req.method(); + const headers = { + 'access-control-allow-origin': BASE, 'access-control-allow-credentials': 'true', + 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'content-type': 'application/json', + }; + if (method === 'OPTIONS') return route.fulfill({ status: 204, headers }); + calls.push({ method, path: url.pathname, search: url.search, body: req.postData() }); + const json = (status, body) => route.fulfill({ status, headers, body: JSON.stringify(body) }); + if (url.pathname === '/api/me') return json(200, { user: { id: 'u1', email: 'tester@example.com', name: 'Tester' }, mentor: { id: 'm1', email: 'tester@example.com', name: 'Tester', role } }); + if (url.pathname === '/api/sessions' && method === 'GET') return json(200, { event: { id: 'e1', date: today }, sessions: [session], summary: { totalCheckedIn: 1, currentlyPresent: 0, checkedOut: 1 } }); + if (url.pathname === '/api/events') return json(200, { events: [{ id: 'e1', date: today }, { id: 'e0', date: yesterday }] }); + if (url.pathname === '/api/participants') return json(200, { participants: [{ id: '26002', fullName: 'テスト花子', nickname: 'はなこ', grade: '小4', activatedAt: '2026-04-01T00:00:00.000Z', active: true }], pagination: { page: 1, limit: 8, total: 1 } }); + if (url.pathname === '/api/sessions' && method === 'POST') { + postCount += 1; + if (postCount === 2) return json(409, { error: 'OVERLAP', message: '重なり(API の文言)' }); + return json(201, { eventDate: today, session: { ...session, sessionId: 's2', participantId: '26002' } }); + } + if (url.pathname === '/api/sessions/s1' && method === 'PATCH') return json(200, { eventDate: yesterday, session }); + if (url.pathname === '/api/sessions/s1' && method === 'DELETE') return route.fulfill({ status: 204, headers }); + if (url.pathname.startsWith('/checkin/participants/')) return json(404, { error: 'NOT_FOUND', message: 'not mocked' }); + return json(404, { error: 'NOT_FOUND', message: `mock missing ${method} ${url.pathname}` }); + }); + + await p.goto(`${BASE}/`); + await p.getByRole('heading', { name: 'ダッシュボード' }).waitFor({ timeout: 20000 }); + await p.getByText('たろう').first().waitFor({ timeout: 10000 }); + const out = { logs }; + const addButton = p.getByRole('button', { name: '記録を追加' }); + // PC の表とスマホのカードは両方描画され CSS で片方を隠すので、見えている方だけを数える。 + const menu = p.locator('button[aria-label="たろうの記録の操作"]:visible'); + out.addVisible = await addButton.isVisible().catch(() => false); + out.menuCount = await menu.count(); + if (role !== 'admin') { await p.close(); return out; } + + // 「…」を押しても利用者詳細は開かない(行・カードのクリックに伝播しない) + await menu.first().click(); + out.sheetOpenedByMenu = calls.some((c) => c.path.startsWith('/checkin/participants/')); + await p.keyboard.press('Escape'); + + // 追加: 検索して子を選び、昨日 09:30〜10:00 で保存 → POST の本文が JST→UTC 変換済み + await addButton.click(); + await p.getByRole('dialog').waitFor(); + await p.getByLabel('子どもを検索').fill('はな'); + await p.getByRole('button', { name: /はなこ(テスト花子)・小4・26002/ }).click(); + await p.getByLabel('日付').fill(yesterday); + await p.getByLabel('入場時刻').fill('09:30'); + await p.getByLabel('退場時刻').fill('10:00'); + const getsBefore = calls.filter((c) => c.method === 'GET' && c.path === '/api/sessions').length; + await p.getByRole('button', { name: '保存' }).click(); + await p.getByRole('dialog').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + const post = calls.find((c) => c.method === 'POST'); + out.postBody = post ? JSON.parse(post.body) : null; + out.postOk = out.postBody?.participantId === '26002' && out.postBody?.checkedInAt === isoAt(yesterday, 9, 30) && out.postBody?.checkedOutAt === isoAt(yesterday, 10, 0); + await p.waitForTimeout(500); + out.reloadedAfterPost = calls.filter((c) => c.method === 'GET' && c.path === '/api/sessions').length > getsBefore; + + // 画面側の検証: 退場が入場より前ならメッセージが出て保存できない + await addButton.click(); + await p.getByLabel('子どもを検索').fill('はな'); + await p.getByRole('button', { name: /はなこ/ }).click(); + await p.getByLabel('日付').fill(yesterday); + await p.getByLabel('入場時刻').fill('11:00'); + await p.getByLabel('退場時刻').fill('10:00'); + out.clientRuleShown = await p.getByText('退場時刻は入場時刻より後にしてください').isVisible(); + out.saveDisabled = await p.getByRole('button', { name: '保存' }).isDisabled(); + + // サーバーのルール違反(2 回目の POST は 409 OVERLAP)は shared の文言でダイアログに出る + await p.getByLabel('退場時刻').fill('11:30'); + await p.getByRole('button', { name: '保存' }).click(); + out.serverRuleShown = await p.getByText('同じ子の同じ日の別の記録と時間が重なっています').waitFor({ timeout: 5000 }).then(() => true).catch(() => false); + await p.keyboard.press('Escape'); + + // 編集: 既存の時刻が入って開き、退場を 11:50 にして PATCH → 返った eventDate(昨日)へ表示が切り替わる + await menu.first().click(); + await p.getByRole('menuitem', { name: '編集' }).click(); + await p.getByRole('dialog').waitFor(); + out.editPrefilled = (await p.getByLabel('入場時刻').inputValue()) === '10:05'; + await p.getByLabel('退場時刻').fill('11:50'); + await p.getByRole('button', { name: '保存' }).click(); + await p.waitForTimeout(800); + const patch = calls.find((c) => c.method === 'PATCH'); + out.patchOk = patch ? JSON.parse(patch.body).checkedOutAt === isoAt(yesterday, 11, 50) : false; + out.switchedToEventDate = calls.some((c) => c.method === 'GET' && c.path === '/api/sessions' && c.search === `?date=${yesterday}`); + + // 削除: 確認文に ID と名前が入り、削除で DELETE が飛ぶ + await menu.first().click(); + await p.getByRole('menuitem', { name: '削除' }).click(); + out.deleteTextOk = await p.getByText(/26001 たろう(テスト太郎)の .* の記録を削除します/).isVisible(); + await p.getByRole('button', { name: '削除' }).click(); + await p.waitForTimeout(800); + out.deleteCalled = calls.some((c) => c.method === 'DELETE' && c.path === '/api/sessions/s1'); + + await p.close(); + return out; + }; + + results.adminDesktop = await run('admin', { width: 1280, height: 900 }); + results.adminMobile = await run('admin', { width: 390, height: 844 }); + results.mentorDesktop = await run('mentor', { width: 1280, height: 900 }); + const a = results.adminDesktop; + const m = results.adminMobile; + const n = results.mentorDesktop; + results.pass = { + adminSeesControls: a.addVisible && a.menuCount === 1, + menuDoesNotOpenSheet: a.sheetOpenedByMenu === false && m.sheetOpenedByMenu === false, + createPostsJstAsUtc: a.postOk && a.reloadedAfterPost, + clientValidation: a.clientRuleShown && a.saveDisabled, + serverValidation: a.serverRuleShown, + editPatchesAndSwitchesDate: a.editPrefilled && a.patchOk && a.switchedToEventDate, + deleteConfirmsAndDeletes: a.deleteTextOk && a.deleteCalled, + mobileWorks: m.menuCount === 1 && m.postOk && m.deleteCalled, + mentorSeesNothing: n.addVisible === false && n.menuCount === 0, + noConsoleErrors: [a, m, n].every((r) => r.logs.length === 0), + }; + return results; +} +``` + +Playwright MCP の `browser_run_code_unsafe` を `filename: ".playwright-mcp/dashboard-sessions.js"` で実行する。 +Expected(実装前): `adminSeesControls` などが `false`(「記録を追加」が無い) + +- [ ] **Step 2: ダッシュボードを実装する** + +`apps/admin/src/app/(authed)/page.tsx` を次のように変える。 + +import を追加・変更する。 + +```tsx +import { + IconCalendarOff, + IconLogin2, + IconLogout2, + IconPlus, + IconRefresh, + IconUserCheck, +} from '@tabler/icons-react'; +import type { + EventsListResponse, + SessionMutationResponse, + TodaySessionItem, + TodaySessionsResponse, +} from '@tecnova/shared/schemas'; +``` + +既存の `import { DataError } …` の後に追加する。 + +```tsx +import { useMe } from '@tecnova/ui/components/me-provider'; +``` + +`import { useState } from 'react';` を次に変える。 + +```tsx +import { type ReactNode, useState } from 'react'; +``` + +`@/components/reveal` の import の後に追加する。 + +```tsx +import { SessionDeleteDialog } from '@/components/session-delete-dialog'; +import { + type SessionFormRequest, + type SessionRecord, + SessionFormDialog, +} from '@/components/session-form-dialog'; +import { SessionRowActions } from '@/components/session-row-actions'; +``` + +`fmtTime` の定義の後に追加する。 + +```tsx +// 一覧の行から編集・削除の対象を組み立てる。 +const toRecord = (s: TodaySessionItem): SessionRecord => ({ + sessionId: s.sessionId, + participant: { id: s.participantId, fullName: s.fullName, nickname: s.nickname, grade: s.grade }, + checkedInAt: s.checkedInAt, + checkedOutAt: s.checkedOutAt, +}); +``` + +`DashboardPage` の中で、`const pastEvents = …;` の後に追加する。 + +```tsx + // 来場記録の修正は admin のみ(API も requireAdmin で守っている)。 + const me = useMe(); + const isAdmin = me.mentor.role === 'admin'; + const [formRequest, setFormRequest] = useState(null); + const [deleteRecord, setDeleteRecord] = useState(null); + + const refreshAll = () => { + sessions.reload({ background: true }); + eventsResource.reload({ background: true }); + }; + const handleSaved = (result: SessionMutationResponse) => { + refreshAll(); + // 記録が別の日付に属する場合(過去日の追加・日付の変更)は、その日付へ表示を切り替える。 + setSelectedDate(result.eventDate === today ? TODAY_VALUE : result.eventDate); + }; +``` + +`PageHeader` の `actions` の `` の後に追加する。 + +```tsx + {isAdmin && ( + + )} +``` + +`` に prop を追加する。 + +```tsx + setSelectedParticipantId(id)} + renderActions={ + isAdmin + ? (s) => ( + setFormRequest({ mode: 'edit', record: toRecord(s) })} + onDelete={() => setDeleteRecord(toRecord(s))} + /> + ) + : undefined + } + /> +``` + +`` に `onSessionsChanged={refreshAll}` を追加し、その後(`` の前)にダイアログを追加する。 + +```tsx + setFormRequest(null)} + onSaved={handleSaved} + /> + setDeleteRecord(null)} + onDeleted={refreshAll} + /> +``` + +`DashboardBody` の props に `renderActions` を加える。 + +```tsx +function DashboardBody({ + sessions, + onSelectParticipant, + renderActions, +}: { + sessions: ResourceState; + onSelectParticipant: (id: string) => void; + // admin のときだけ渡される行ごとの操作(「…」メニュー) + renderActions?: (s: TodaySessionItem) => ReactNode; +}) { +``` + +モバイルのカードで、状態の `…` を次に置き換える(カード全面のボタンより前面に出すため `relative z-10`)。 + +```tsx +
+ + {s.isPresent ? '来場中' : '退出済'} + + {renderActions &&
{renderActions(s)}
} +
+``` + +デスクトップの表で、`状態` の後に追加する。 + +```tsx + {renderActions && ( + + 操作 + + )} +``` + +空表示の `` を `` に変え、各行の状態セル(``)の後に追加する。 + +```tsx + {renderActions && {renderActions(s)}} +``` + +`apps/admin/src/components/record-card.tsx` のコメント「タップ可能カードは内部に他のインタラクティブ要素を持たない前提。」を次に変える。 + +```tsx +// タップ可能カードは原則として内部にインタラクティブ要素を持たない。置く場合は `relative z-10` で +// 全面ボタンより前に出し、クリックの伝播を止める(例: ダッシュボードの来場記録の「…」メニュー)。 +``` + +`apps/admin/src/components/participant-detail-sheet.tsx` の `Props` に追加する(Task 12 で使う)。 + +```tsx +interface Props { + participantId: string | null; + onOpenChange: (open: boolean) => void; + // 来場記録を修正したときに親(ダッシュボード)の一覧も再取得させる + onSessionsChanged?: () => void; +} +``` + +- [ ] **Step 3: Playwright のテストが通ることを確かめる** + +`browser_run_code_unsafe` を `filename: ".playwright-mcp/dashboard-sessions.js"` で再実行する。 +Expected: `results.pass` の全項目が `true` + +Run: `pnpm --filter admin type-check && pnpm biome check apps/admin` +Expected: エラーなし(import の並び順・整形の差分は `pnpm biome check --write apps/admin` で直す) + +- [ ] **Step 4: コミットする** + +```bash +git add .gitignore "apps/admin/src/app/(authed)/page.tsx" apps/admin/src/components/record-card.tsx apps/admin/src/components/participant-detail-sheet.tsx +git commit -m "feat(admin): correct check-in records from the dashboard + +Refs #48, #49 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: admin — 利用者詳細に組み込む + +**Files:** +- Modify: `apps/admin/src/components/participant-detail-sheet.tsx` +- Test(コミットしない): `.playwright-mcp/detail-sheet-sessions.js` + +**Interfaces:** +- Consumes: Task 10 の部品、Task 11 で追加した `onSessionsChanged` prop、既存の `ParticipantProfileResponse` / `useMe` / `toJstDateString` +- Produces: 利用者詳細の「来場履歴」から記録を追加・編集・削除できる画面 + +- [ ] **Step 1: Playwright のテストを書き、今は失敗することを確かめる** + +admin の開発サーバが動いていなければ起動する。Run: `pnpm --filter admin dev`(ポート 3001) + +`.playwright-mcp/detail-sheet-sessions.js` を作る。 + +```js +async (page) => { + const BASE = 'http://localhost:3001'; + const API = 'http://localhost:8787'; + const fmt = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(d); + const today = fmt(new Date()); + const yesterday = fmt(new Date(Date.now() - 86_400_000)); + const isoAt = (date, hh, mm) => + new Date(Date.UTC(+date.slice(0, 4), +date.slice(5, 7) - 1, +date.slice(8, 10), hh - 9, mm)).toISOString(); + const row = { + sessionId: 's1', participantId: '26001', fullName: 'テスト太郎', nickname: 'たろう', grade: '小3', + checkedInAt: isoAt(yesterday, 10, 5), checkedOutAt: isoAt(yesterday, 11, 40), + isPresent: false, term: 'morning', counted: true, + }; + const profile = { + participant: { id: '26001', fullName: 'テスト太郎', nickname: 'たろう', grade: '小3', activatedAt: '2026-04-01T00:00:00.000Z' }, + stats: { visitCount: 1, participationCount: 1, visitDayCount: 1, uncountedVisitCount: 0, lastVisitedAt: row.checkedInAt, totalStayDurationMinutes: 95 }, + current: { isPresent: false, checkedInAt: null, nextAction: 'check_in' }, + sessions: [{ sessionId: 's1', checkedInAt: row.checkedInAt, checkedOutAt: row.checkedOutAt, stayDurationMinutes: 95, term: 'morning', counted: true, isPresent: false }], + }; + const p = await page.context().newPage(); + await p.setViewportSize({ width: 1280, height: 900 }); + const calls = []; + const logs = []; + p.on('console', (m) => { if (['error', 'warning'].includes(m.type())) logs.push(`${m.type()}: ${m.text().slice(0, 200)}`); }); + p.on('pageerror', (e) => logs.push(`pageerror: ${String(e).slice(0, 200)}`)); + await p.route(`${API}/**`, async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const method = req.method(); + const headers = { + 'access-control-allow-origin': BASE, 'access-control-allow-credentials': 'true', + 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,PATCH,DELETE,OPTIONS', + 'content-type': 'application/json', + }; + if (method === 'OPTIONS') return route.fulfill({ status: 204, headers }); + calls.push({ method, path: url.pathname, search: url.search, body: req.postData() }); + const json = (status, body) => route.fulfill({ status, headers, body: JSON.stringify(body) }); + if (url.pathname === '/api/me') return json(200, { user: { id: 'u1', email: 'tester@example.com', name: 'Tester' }, mentor: { id: 'm1', email: 'tester@example.com', name: 'Tester', role: 'admin' } }); + if (url.pathname === '/api/sessions' && method === 'GET') return json(200, { event: { id: 'e1', date: today }, sessions: [row], summary: { totalCheckedIn: 1, currentlyPresent: 0, checkedOut: 1 } }); + if (url.pathname === '/api/events') return json(200, { events: [{ id: 'e1', date: today }] }); + if (url.pathname === '/checkin/participants/26001') return json(200, profile); + if (url.pathname === '/api/sessions' && method === 'POST') return json(201, { eventDate: today, session: { ...row, sessionId: 's2', checkedOutAt: null, isPresent: true } }); + if (url.pathname === '/api/sessions/s1' && method === 'DELETE') return route.fulfill({ status: 204, headers }); + return json(404, { error: 'NOT_FOUND', message: `mock missing ${method} ${url.pathname}` }); + }); + + const out = { logs }; + await p.goto(`${BASE}/`); + await p.getByRole('heading', { name: 'ダッシュボード' }).waitFor({ timeout: 20000 }); + // 行(氏名のセル)をクリックして利用者詳細を開く + await p.getByRole('cell', { name: 'テスト太郎' }).click(); + const sheet = p.getByRole('dialog').filter({ hasText: '来場履歴' }); + await sheet.waitFor({ timeout: 10000 }); + const sheetAdd = sheet.getByRole('button', { name: '記録を追加' }); + out.sheetAddVisible = await sheetAdd.isVisible().catch(() => false); + + // 追加: その子が選ばれた状態・日付は今日で開く → 「まだ滞在中」で保存 + if (out.sheetAddVisible) { + await sheetAdd.click(); + const form = p.getByRole('dialog').filter({ hasText: '来場記録を追加' }); + await form.waitFor(); + out.participantPrefilled = await form.getByText('たろう(テスト太郎)・小3・26001').isVisible(); + out.datePrefilled = (await form.getByLabel('日付').inputValue()) === today; + await form.getByLabel('まだ滞在中(退場時刻なし)').click(); + const profileGetsBefore = calls.filter((c) => c.path === '/checkin/participants/26001').length; + const listGetsBefore = calls.filter((c) => c.method === 'GET' && c.path === '/api/sessions').length; + await form.getByRole('button', { name: '保存' }).click(); + await p.waitForTimeout(800); + const post = calls.find((c) => c.method === 'POST'); + const body = post ? JSON.parse(post.body) : null; + out.postOk = body?.participantId === '26001' && body?.checkedOutAt === null; + out.sheetReloaded = calls.filter((c) => c.path === '/checkin/participants/26001').length > profileGetsBefore; + out.listReloaded = calls.filter((c) => c.method === 'GET' && c.path === '/api/sessions').length > listGetsBefore; + } + + // 削除: 履歴の行の「…」から削除 + const menu = sheet.getByRole('button', { name: 'たろうの記録の操作' }); + out.sheetMenuVisible = await menu.isVisible().catch(() => false); + if (out.sheetMenuVisible) { + await menu.click(); + await p.getByRole('menuitem', { name: '削除' }).click(); + await p.getByRole('button', { name: '削除' }).click(); + await p.waitForTimeout(800); + out.deleteCalled = calls.some((c) => c.method === 'DELETE' && c.path === '/api/sessions/s1'); + } + await p.close(); + + out.pass = { + addFromSheet: out.sheetAddVisible && out.participantPrefilled && out.datePrefilled && out.postOk, + reloadsSheetAndList: out.sheetReloaded && out.listReloaded, + deleteFromSheet: out.sheetMenuVisible && out.deleteCalled, + noConsoleErrors: logs.length === 0, + }; + return out; +} +``` + +`browser_run_code_unsafe` を `filename: ".playwright-mcp/detail-sheet-sessions.js"` で実行する。 +Expected(実装前): `addFromSheet` が `false`(「記録を追加」が無い) + +- [ ] **Step 2: 利用者詳細を実装する** + +`apps/admin/src/components/participant-detail-sheet.tsx` を次のように変える。 + +import を追加する。 + +```tsx +import { IconPlus } from '@tabler/icons-react'; +import { toJstDateString } from '@tecnova/shared/venue-schedule'; +import { Button } from '@tecnova/ui/components/button'; +import { useMe } from '@tecnova/ui/components/me-provider'; +import { useState } from 'react'; +import type { PickedParticipant } from './participant-picker'; +import { SessionDeleteDialog } from './session-delete-dialog'; +import { + type SessionFormRequest, + type SessionRecord, + SessionFormDialog, +} from './session-form-dialog'; +import { SessionRowActions } from './session-row-actions'; +``` + +`ParticipantDetailSheet` の先頭(関数のシグネチャから `useApiResource` の呼び出しまで)を置き換える。 + +置き換え前: + +```tsx +export function ParticipantDetailSheet({ participantId, onOpenChange }: Props) { + const open = participantId !== null; + // 開いているあいだだけ取得する(閉じている=path=null で idle)。 + const { state } = useApiResource( + participantId ? `/checkin/participants/${encodeURIComponent(participantId)}` : null, + ); +``` + +置き換え後: + +```tsx +export function ParticipantDetailSheet({ participantId, onOpenChange, onSessionsChanged }: Props) { + const open = participantId !== null; + // 開いているあいだだけ取得する(閉じている=path=null で idle)。 + const { state, reload } = useApiResource( + participantId ? `/checkin/participants/${encodeURIComponent(participantId)}` : null, + ); + // 来場記録の修正は admin のみ(API も requireAdmin で守っている)。 + const me = useMe(); + const isAdmin = me.mentor.role === 'admin'; + const [formRequest, setFormRequest] = useState(null); + const [deleteRecord, setDeleteRecord] = useState(null); + + // 参加回数などの集計は参照時に計算されるので、詳細を取り直せば修正が反映される。 + const handleChanged = () => { + reload({ background: true }); + onSessionsChanged?.(); + }; +``` + +同じ関数の `return (` 直後の `` を次の 2 行に置き換える。 + +```tsx + <> + +``` + +同じ関数の末尾の ` ` を次に置き換える(その後の ` );` と `}` はそのまま)。 + +```tsx + + setFormRequest(null)} + onSaved={handleChanged} + /> + setDeleteRecord(null)} + onDeleted={handleChanged} + /> + +``` + +`` の中のインデントは後で `pnpm biome check --write apps/admin/src/components/participant-detail-sheet.tsx` で整える。 + +`` の中の `{state.kind === 'ok' && }` を次に置き換える。 + +```tsx + {state.kind === 'ok' && ( + + setFormRequest({ mode: 'create', date: toJstDateString(new Date()), participant }) + } + onEdit={(record) => setFormRequest({ mode: 'edit', record })} + onDelete={(record) => setDeleteRecord(record)} + /> + )} +``` + +`DetailBody` のシグネチャを変える。 + +```tsx +function DetailBody({ + data, + isAdmin, + onAdd, + onEdit, + onDelete, +}: { + data: ParticipantProfileResponse; + isAdmin: boolean; + onAdd: (participant: PickedParticipant) => void; + onEdit: (record: SessionRecord) => void; + onDelete: (record: SessionRecord) => void; +}) { + const { participant, stats, current, sessions } = data; + const picked: PickedParticipant = { + id: participant.id, + fullName: participant.fullName, + nickname: participant.nickname, + grade: participant.grade, + }; + const toRecord = (s: ParticipantProfileResponse['sessions'][number]): SessionRecord => ({ + sessionId: s.sessionId, + participant: picked, + checkedInAt: s.checkedInAt, + checkedOutAt: s.checkedOutAt, + }); +``` + +「来場履歴」の見出し `

来場履歴

` を次に置き換える。 + +```tsx +
+

来場履歴

+ {isAdmin && ( + + )} +
+``` + +履歴テーブルの見出し行を、admin のときは操作列を足した幅にする。 + +```tsx + + 入室 + 退室 + 滞在 + {isAdmin && ( + + 操作 + + )} + +``` + +各行の滞在セル(`fmtMinutes(s.stayDurationMinutes)` のセル)の後に追加する。 + +```tsx + {isAdmin && ( + + onEdit(toRecord(s))} + onDelete={() => onDelete(toRecord(s))} + /> + + )} +``` + +- [ ] **Step 3: Playwright のテストが通ることを確かめる** + +`browser_run_code_unsafe` を `filename: ".playwright-mcp/detail-sheet-sessions.js"` で再実行する。 +Expected: `out.pass` の全項目が `true` + +ダッシュボードのテストも再実行して壊れていないことを確かめる(`filename: ".playwright-mcp/dashboard-sessions.js"`)。 +Expected: `results.pass` の全項目が `true` + +Run: `pnpm --filter admin type-check && pnpm biome check apps/admin` +Expected: エラーなし(import の並び順・整形の差分は `pnpm biome check --write apps/admin` で直す) + +- [ ] **Step 4: コミットする** + +```bash +git add apps/admin/src/components/participant-detail-sheet.tsx +git commit -m "feat(admin): correct check-in records from the participant detail sheet + +Refs #48, #49 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 13: 仕上げ — ドキュメント・PR 前チェック・最終確認 + +**Files:** +- Modify: `docs/requirements.md`(§3.2) +- Modify: `docs/handoff.md`(完了済み表・直近の優先事項・作業の進め方) +- Modify: `.claude/skills/pre-pr-check/SKILL.md`(API テストを追加) +- Modify: `CLAUDE.md`(よく使うコマンドに API テスト) + +**Interfaces:** +- Consumes: Task 1〜12 の成果物 +- Produces: develop 向けの PR + +- [ ] **Step 1: requirements.md を実装済みにする** + +`docs/requirements.md` §3.2 の次の行を置き換える。 + +置き換え前: + +````markdown +- **チェックイン記録の修正(直近の優先事項)**: チェックイン忘れ・押し間違いといった操作ミスが頻発しているため、管理画面から来場記録を追加・編集・削除できるようにする(admin のみ。Issue #48 / #49) +```` + +置き換え後: + +````markdown +- **チェックイン記録の修正(2026-10 実装済み)**: チェックイン忘れ・押し間違いといった操作ミスが頻発しているため、管理画面(ダッシュボード・利用者詳細)から来場記録を追加・編集・削除できるようにした(admin のみ。Issue #48 / #49。仕様は `mvp.md` §6.2「来場記録の修正」) +```` + +- [ ] **Step 2: handoff.md を更新する** + +`docs/handoff.md` の「完了済み」表の最後の行(`| checkin 参加ガイドラインの文言・レイアウト刷新…`)の後に追加する。 + +````markdown +| 来場記録の修正(admin のみ。ダッシュボード・利用者詳細から追加・編集・削除)+ API の自動テスト基盤 | このブランチの PR(Issue #48 / #49) | +```` + +「### 直近の優先事項(2026-10 時点)」の「**1. チェックイン記録の修正機能(Issue #48 / #49)**」から、その箇条書きの最後(「get-or-create が要る」の行)までを削除し、続く「**2. QR 読み取り精度の改善(バグ)**」を「**1. QR 読み取り精度の改善(バグ)**」に変える。 + +「## 作業の進め方」の箇条書きの最後に追加する。 + +````markdown +- **API のテスト**:`pnpm --filter @tecnova/api test`(`@cloudflare/vitest-plugin`。ローカルの D1 にマイグレーションを当てて `apps/api/src/lib` を直接テストする。各テストの前に `resetTables` で空にする)。`packages/shared` は `pnpm --filter @tecnova/shared test`。どちらも CI では走らないので PR 前に手元で実行する +```` + +- [ ] **Step 3: pre-pr-check と CLAUDE.md に API テストを足す** + +`.claude/skills/pre-pr-check/SKILL.md` のチェックリスト 3 を置き換える。 + +````markdown +3. **テスト(CI は実行しない・該当パッケージを触ったら必須)**: + ```bash + pnpm --filter @tecnova/shared test + pnpm --filter @tecnova/api test + ``` +```` + +`CLAUDE.md` の「よく使うコマンド」の `pnpm type-check` のブロックの後に追加する。 + +````markdown +# テスト(CI では走らない。PR 前に手元で実行) +pnpm --filter @tecnova/shared test +pnpm --filter @tecnova/api test +```` + +(`CLAUDE.md` のコマンドは 1 つの ```bash ブロックにまとまっているので、その中の `# 型チェック` の 2 行の直後に上の 3 行を入れる) + +- [ ] **Step 4: すべての確認を通す** + +Run: `pnpm lint && pnpm type-check && pnpm --filter @tecnova/shared test && pnpm --filter @tecnova/api test && .claude/skills/pre-pr-check/scripts/scan-secrets.sh` +Expected: すべて成功。テストの失敗 0、シークレット検出なし + +Playwright の 2 本(`.playwright-mcp/dashboard-sessions.js` と `.playwright-mcp/detail-sheet-sessions.js`)を再実行する。 +Expected: 全項目 `true` + +`git status --short` で `.playwright-mcp/` が出ていないこと(`.gitignore` 済み)を確かめる。admin の開発サーバを止める。 + +- [ ] **Step 5: ブランチ全体をレビューする** + +`superpowers:requesting-code-review` に従い、`fix/checkin-qr-on-demand-scan..HEAD` の差分をレビューに出す。指摘は `superpowers:receiving-code-review` に従って検証してから直す。 + +- [ ] **Step 6: コミットして PR を出す** + +```bash +git add docs/requirements.md docs/handoff.md .claude/skills/pre-pr-check/SKILL.md CLAUDE.md +git commit -m "docs: record check-in correction as shipped and add API test command + +Co-Authored-By: Claude Opus 5.5 " +git push -u origin feat/admin-session-correction +``` + +`gh pr create --base develop` で PR を出す。本文の冒頭に「#50 → #51 の順にマージしてからマージする(`docs/handoff.md`・`docs/mvp.md` を共有しているため #51 の上に積んでいる)」と書き、変更内容・テスト結果(shared / api の件数、Playwright の確認項目)・未確認事項(本番の D1 での動作は本番反映後に確認)を載せ、`Closes #48`・`Closes #49` と末尾に `🤖 Generated with [Claude Code](https://claude.com/claude-code)` を付ける。 diff --git a/docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md b/docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md new file mode 100644 index 0000000..391f27e --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md @@ -0,0 +1,194 @@ +# チェックイン記録の修正機能(追加・編集・削除)設計 + +作成日: 2026-10-11 / 関連 Issue: #48(削除)・#49(編集) +ブランチ: `feat/admin-session-correction`(`fix/checkin-qr-on-demand-scan` から分岐 → PR は develop。#50 → #51 の順にマージした後でマージする) + +## 0. 背景とゴール + +受付の現場で、操作ミスによる来場記録(`sessions`)の誤りが頻発している。システム上で訂正する手段が無いため、参加回数の集計(研究データにもなる)が誤ったまま残る。 + +現場で実際に起きている誤り(すべて対象): + +| 誤り | 必要な操作 | +| --- | --- | +| チェックイン忘れ(来ていたのに記録が無い) | 記録の**追加** | +| チェックアウト忘れ(滞在中のまま/一括チェックアウトの時刻になっている) | 退場時刻の**編集** | +| 別の子で受付した | 記録の子の**付け替え**(または削除して追加) | +| 二重・誤操作の記録(押し間違いでチェックアウト→再チェックイン等) | 記録の**削除** | + +**ゴール:** admin ロールのメンターが、管理画面から来場記録を追加・編集・削除できるようにする。誤った記録が集計を壊さないよう、守るべきルールをアプリ側で検証する。 + +## 1. 確定した要件(ユーザー決定事項) + +- 操作できるのは **admin ロールのみ**(Issue #48 / #49)。admin 以外のメンターには UI を一切出さない +- 入口は 2 か所: **A. ダッシュボード**(日付ごとの来場一覧)と **B. 利用者詳細**(子どもごとの来場履歴)。フォームと API は共通 +- **修正の履歴は残さない**(最新の状態だけを持つ。DB のテーブル構造は変えない=マイグレーション不要) +- **無効化した子の記録も**追加・修正してよい(過去の記録を直す場面のため。受付端末側は従来どおり無効化した子を受け付けない) +- 「別の子で受付した」場合は、記録の子を**付け替え**られる +- API の書き込みロジックは、`@cloudflare/vitest-plugin`(Workers 公式の Vitest 連携。旧名 `@cloudflare/vitest-pool-workers`)による**自動テスト**で確かめる + +## 2. 来場記録のルール(不変条件) + +DB には制約が無い(一意制約・CHECK・カスケードなし)ため、すべてアプリ側で守る。 + +1. **開催日は入場時刻から決める**: 記録の `event_id` は、常に `checkedInAt` の JST 日付の開催日(`events.date`)を指す。その日の開催日が無ければ作る。編集で入場日が変わったら、その日の開催日へ移す。参加回数の集計は「日付=開催日」「ターム=入場時刻の JST 壁時計」で行うため、両者がずれると集計が崩れる(現行の作成経路と同じ不変条件) +2. **退場は入場より後、かつ同じ JST 日のうち**: `checkedOutAt > checkedInAt`(同時刻は不可)。日をまたぐ記録は作らない +3. **未来の時刻は不可**: `checkedInAt` / `checkedOutAt` は現在時刻 + 1 分以内(端末の時計ずれを許容する猶予) +4. **「滞在中」(`checkedOutAt = null`)は今日の記録だけ**: 過去日の記録は退場時刻が必須。過去日に滞在中の記録が残ると、その日の一覧で「来場中」と表示され続けるため +5. **同じ子・同じ日の他の記録と時間が重ならない**: 区間は半開区間 `[入場, 退場)`。滞在中の記録は「現在時刻まで」続いているとみなす。境界で接する(11:40 退場・11:40 入場)のは重ならない扱い。この規則により、同じ子の「滞在中の記録が 2 件」も防げる +6. **子は存在する ID であること**(有効・無効は問わない)。付け替え先の子にもルール 5 を当てはめる +7. **記録が 1 件も無くなった過去の開催日は削除する**: 日付の選択肢(`/api/events`)やサイネージの「前回の開催」(`/api/signage/previous-summary`)に空の日が残らないようにする。**今日の開催日は消さない**(受付中のチェックインと競合させないため) + +**既にルールに反しているデータの扱い**: ルールは「追加・編集で保存する内容」に対して検証する。削除は常にできる。 + +- チェックアウト忘れで過去日に滞在中のまま残っている記録は、退場時刻を入れて保存すれば直る(退場時刻を入れずに他の項目だけ変えるとルール 4 で保存できない) +- 時間が重なっている二重記録は、不要な方を削除してから残りを直す(片方を編集しようとすると、もう片方との重なりでルール 5 に当たる) + +## 3. API + +すべて `/api/*`(`requireAuthenticatedMentor` 適用済み)配下。既存の `GET /api/sessions` / `GET /api/sessions/today` は今までどおり全メンターが使える。追加する 3 つは**ルート単位で `requireAdmin`** を付ける(`/sessions` にサブルーターを mount すると既存の GET と衝突するため)。 + +| メソッド | パス | 本文 | 成功時 | +| --- | --- | --- | --- | +| POST | `/api/sessions` | `{ participantId, checkedInAt, checkedOutAt }` | 201 `{ eventDate, session }` | +| PATCH | `/api/sessions/:sessionId` | `{ participantId?, checkedInAt?, checkedOutAt? }`(1 つ以上) | 200 `{ eventDate, session }` | +| DELETE | `/api/sessions/:sessionId` | なし | 204 | + +- `participantId` は 5 桁の内製 ID。`checkedInAt` / `checkedOutAt` は ISO 8601(UTC)文字列。`checkedOutAt: null` は「滞在中」(PATCH では滞在中に戻す意味になる) +- `session` は既存の `todaySessionItemSchema`(`term` / `counted` / `isPresent` を含む)と同じ形。`eventDate` は記録が属する開催日(`'YYYY-MM-DD'`)で、画面がその日付へ表示を切り替えるために使う +- 本文の形式エラーは既存の `invalidBodyError`(400) + +### 3.1 エラー(`SessionError`) + +既存のドメインエラーと同じく `{ error: code, message }` で返し、`apiErrorHandler` にステータス表を追加する。 + +| code | HTTP | 意味 | +| --- | --- | --- | +| `SESSION_NOT_FOUND` | 404 | 記録が無い | +| `PARTICIPANT_NOT_FOUND` | 404 | 子の ID が無い | +| `CHECKOUT_BEFORE_CHECKIN` | 400 | ルール 2(退場が入場以前) | +| `SPANS_DAYS` | 400 | ルール 2(日をまたぐ) | +| `FUTURE_TIME` | 400 | ルール 3 | +| `OPEN_SESSION_NOT_TODAY` | 400 | ルール 4 | +| `OVERLAP` | 409 | ルール 5 | + +### 3.2 書き込みの手順(D1 にトランザクションが無い前提) + +新規ファイル `apps/api/src/lib/session-admin.ts` に、`createSessionByAdmin` / `updateSessionByAdmin` / `deleteSessionByAdmin` を置く。現在時刻 `now` は引数で受け取る(テストで固定するため)。 + +- **追加**: 子の存在確認 → 入場日を算出 → 同じ子・同じ日(`events.date` で JOIN)の既存記録を取得 → ルール 2〜5 を検証 → `db.batch` で「開催日の作成(`INSERT … ON CONFLICT(date) DO NOTHING`)」と「記録の挿入(`event_id` は日付からのサブクエリで解決)」を一括実行。検証を開催日の作成より前に行うので、検証エラーで空の開催日が残らない +- **編集**: 記録(とその開催日)を取得 → 変更をマージ → 新しい子・新しい入場日で既存記録(自分を除く)を取得 → ルール 2〜6 を検証 → `db.batch` で「開催日の作成」「記録の更新(子・開催日・時刻)」「元の開催日が空かつ過去なら削除」を一括実行 +- **削除**: 記録を取得 → `db.batch` で「記録の削除」「その開催日が空かつ過去なら削除」を一括実行 +- 「空かつ過去なら削除」は 1 文で表す: `DELETE FROM events WHERE id = ? AND date <> <今日> AND NOT EXISTS (SELECT 1 FROM sessions WHERE event_id = ?)` +- 開催日の get-or-create は batch 内の `INSERT … ON CONFLICT(date) DO NOTHING` で行う。受付経路の `getOrCreateTodayEvent`(`apps/api/src/lib/checkin.ts`)は変更しない + +## 4. 共通ロジック(`packages/shared`) + +API と管理画面の両方で使う純粋関数。vitest で先にテストを書く。 + +- `venue-schedule.ts` に追加 + - `jstDateTimeToInstant(date: 'YYYY-MM-DD', time: 'HH:mm'): Date` — JST の日付と時刻を UTC instant にする(Asia/Tokyo は固定 UTC+9) + - `toJstTimeString(instant: Date): 'HH:mm'` — フォームの初期値用 +- 新規 `session-rules.ts` + - `type SessionRuleViolation = 'CHECKOUT_BEFORE_CHECKIN' | 'SPANS_DAYS' | 'FUTURE_TIME' | 'OPEN_SESSION_NOT_TODAY' | 'OVERLAP'` + - `validateSessionTimes({ checkedInAt, checkedOutAt }, now): SessionRuleViolation | null` — ルール 2〜4。判定順は `FUTURE_TIME` → `CHECKOUT_BEFORE_CHECKIN` → `SPANS_DAYS` → `OPEN_SESSION_NOT_TODAY` + - `findOverlappingSession(target, siblings, now)` — ルール 5。重なる記録を 1 件返す(無ければ null) + - `SESSION_RULE_MESSAGES: Record` — 画面に出す日本語(例: `CHECKOUT_BEFORE_CHECKIN` →「退場時刻は入場時刻より後にしてください」) +- `schemas/admin.ts` に追加: `createSessionRequestSchema` / `updateSessionRequestSchema`(PATCH は 1 項目以上を `.refine` で要求。既存の `updateMentorRequestSchema` と同じ書き方)/ `sessionMutationResponseSchema`、および型の export + +## 5. 画面(`apps/admin`) + +### 5.1 共通部品 + +- **ダイアログ部品**: `packages/ui` に shadcn の `dialog` を CLI(`shadcn add dialog`)で追加する。利用者詳細(Sheet)の上にも重ねて開けるよう、記録フォームは横パネルではなく中央のダイアログにする +- **`SessionFormDialog`**(追加・編集で共用。`apps/admin/src/components/session-form-dialog.tsx`) + - **子ども**: `ParticipantPicker`(`participant-picker.tsx`)で ID・氏名・ニックネームを検索して選ぶ(`GET /api/participants?search=`)。選択後は「ニックネーム(氏名)・学年・ID」を表示し、「変更」で選び直せる(=付け替え) + - **日付・入場時刻・退場時刻**: `` / ``(JST)。送信時に `jstDateTimeToInstant` で ISO に変換する + - **「まだ滞在中」**: 日付が今日のときだけ表示。オンなら退場時刻を無効化し `checkedOutAt: null` を送る + - **検証**: 入力のたびに `validateSessionTimes` を実行し、項目の下にメッセージを出す(エラー中は保存ボタンを無効化)。ルール 5(重なり)と子の存在はサーバーで判定し、返ってきた `error` コードを `SESSION_RULE_MESSAGES` などで日本語にしてダイアログ内に表示する + - **今日の記録を扱うとき**: 「受付端末での次の操作(チェックイン/チェックアウト)が変わります」と注意書きを出す + - **初期値**: 追加時、日付は入口に応じて決める(5.2 / 5.3)。日付が今日なら入場時刻は現在時刻、過去日なら空。退場時刻は空。編集時は記録の値 + - 保存中はボタンを無効化して二重送信を防ぐ。成功したら通知(`toastSuccess`)を出して閉じ、`onSaved(result)` を呼ぶ +- **`SessionDeleteDialog`**(`session-delete-dialog.tsx`): 既存の `AlertDialog` で「26001 たろう(田中太郎)の 10:05〜11:40 の記録を削除します。元に戻せません」と確認する +- **`SessionRowActions`**(`session-row-actions.tsx`): 「…」の `DropdownMenu` から「編集」「削除」を開く。行クリック(利用者詳細を開く)と干渉しないよう `stopPropagation` する +- いずれも admin のときだけ描画する(`useMe().mentor.role === 'admin'`) + +### 5.2 入口 A: ダッシュボード(`apps/admin/src/app/(authed)/page.tsx`) + +- 来場一覧の見出しの横に「記録を追加」。日付の初期値は選択中の日(「本日」なら今日) +- PC の表に操作列を追加し、スマホのカードにも「…」メニューを付ける +- 変更後は来場一覧と日付の選択肢(`/api/events`)を読み込み直す(`reload({ background: true })`)。返ってきた `eventDate` が表示中の日と違えば、その日付へ表示を切り替える(今日なら「本日」) + +### 5.3 入口 B: 利用者詳細(`apps/admin/src/components/participant-detail-sheet.tsx`) + +- 「来場履歴」の見出しの横に「記録を追加」。子はその子、日付は今日で開く +- 履歴の各行に「…」メニュー +- 変更後は利用者詳細(参加回数などを含む)を読み込み直し、親に `onSessionsChanged` で通知する。ダッシュボードから開いた場合は、裏の来場一覧も読み込み直す + +## 6. 非ゴール + +- 修正の履歴・監査ログ(ユーザー決定により作らない) +- 受付端末(checkin)からの修正、admin 以外への権限付与 +- 開催日そのもの(日付・メモ)の編集、複数記録の一括編集、重複記録の自動統合 +- 受付経路(チェックイン・チェックアウト・スキャン・一括チェックアウト)の挙動変更 + +## 7. テスト + +| 対象 | 方法 | +| --- | --- | +| `session-rules.ts` / JST 変換関数 | `packages/shared` の vitest(既存の 3 系)。先にテストを書く。境界(同時刻・境界で接する区間・1 分の猶予・日付の変わり目・滞在中の重なり)を網羅する | +| `session-admin.ts`(API の書き込み) | `apps/api` に `vitest@4.1.11` + `@cloudflare/vitest-plugin@1.2.8`(peer が vitest ^4.1 のため、`packages/shared` の 3 系とは別に入れる。公開から 2 週間以上経った版に固定する)を導入する。`wrangler.toml` の D1 binding を使い、`packages/db/drizzle` のマイグレーションをテスト用 D1 に当ててから、lib の関数を直接呼ぶ。実行は `pnpm --filter @tecnova/api test` | +| 管理画面 | Playwright で API をモックし、実ブラウザで確認する(QR 修正と同じやり方)。コンソールエラー(無限再レンダー等)が無いことも確認する | + +**API テストの主なケース** + +- 追加: 開催日が無い日 → 開催日ができる/既存の開催日を再利用する/子が無い → `PARTICIPANT_NOT_FOUND`/無効化した子でも追加できる/重なり → `OVERLAP`/過去日の滞在中 → `OPEN_SESSION_NOT_TODAY`/未来 → `FUTURE_TIME`/検証エラー時に開催日が作られない +- 編集: 同じ日の時刻変更/日付変更で別の開催日へ移り、空になった過去の開催日が消える/付け替え先の子との重なり → `OVERLAP`/今日なら滞在中に戻せる・過去日は不可/記録が無い → `SESSION_NOT_FOUND` +- 削除: 記録が消える/過去の開催日の最後の 1 件なら開催日も消える/今日の開催日は空でも残る + +**管理画面の主なケース** + +- admin には「記録を追加」と「…」が出て、admin 以外には出ない +- 追加フォームが正しい ISO(JST → UTC)で POST する/その場の検証メッセージが出る/サーバーのエラー(`OVERLAP` 等)がダイアログに表示される +- 削除の確認を経て DELETE し、一覧が読み込み直される +- 利用者詳細から追加・編集すると、詳細と裏の一覧が読み込み直される + +既存のテストスイート(`@tecnova/shared`)と `pnpm lint` / `pnpm type-check` も通す。`pre-pr-check` スキルに API のテスト実行を追加する。 + +## 8. ドキュメント更新とデリバリ + +**ドキュメント(コードより先に更新する)** + +- `docs/mvp.md`: §4.3(開催日の任意日付での作成・空になった過去の開催日の削除)、§6.2(新しい API 3 つとエラー)、§7.2.2 ダッシュボード・§7.2.3 利用者詳細(「編集は Phase 1.5」の記述を更新)、§9(「チェックイン忘れ・押し間違いを訂正する手段が無い」を削除) +- `docs/requirements.md` §3.2: 「チェックイン記録の修正」を実装済みにする +- `docs/handoff.md`: 実装後に更新(直近の優先事項から外し、API テストの実行方法を追記) + +**コミットの単位** + +1. ドキュメント(mvp.md / requirements.md) +2. shared: JST 変換関数と `session-rules.ts`(テスト込み) +3. shared: Zod スキーマ +4. api: `@cloudflare/vitest-plugin` の導入 +5. api: `session-admin.ts`(テスト込み)とルート・エラー表 +6. ui: `dialog` の追加 +7. admin: `ParticipantPicker` / `SessionFormDialog` / `SessionDeleteDialog` / `SessionRowActions` +8. admin: ダッシュボードへの組み込み +9. admin: 利用者詳細への組み込み +10. handoff と pre-pr-check の更新 + +**リリース**: マイグレーション不要。API は追加のみなので、API(Worker)と管理画面(Vercel)のどちらを先に出しても既存機能は壊れない。 + +## 9. リスクと注意点 + +- **受付中との競合**: 今日の滞在中の記録を admin が編集している間に受付端末でスキャンされると、受付側の判定(チェックイン/チェックアウト)が変わる。D1 では完全には防げないため、フォームの注意書きで運用上避けてもらう +- **集計は遡って変わる**: 参加回数・統計・サイネージの集計は参照時に計算するので、修正は即座に反映される(キャッシュの無効化は不要)。これは期待どおりの挙動 +- **管理者が作った過去の開催日**は、サイネージの「前回の開催」の候補になる(実際に開催した日なので正しい挙動) +- **時刻の変換**は必ず `packages/shared` の関数を通す。管理画面はスマホからも使われるため、端末のタイムゾーンに依存しない変換にする +- **Workers の制約**: `session-admin.ts` は Node 専用 API を使わない(`workers-constraint-reviewer` で確認する)。個人情報の扱いは変えない(`privacy-reviewer` で確認する) + +## 10. 参照 + +- データモデル・既存 API: `docs/mvp.md` §4・§6.2、`packages/db/src/schema.ts` +- 受付経路の書き込み(変更しない): `apps/api/src/lib/checkin.ts`(`getOrCreateTodayEvent`・`processScanValue`・`recordBulkCheckOut`) +- 集計: `apps/api/src/lib/admin.ts`(`fetchSessionsForEvent`・`fetchParticipationSummary`)、`packages/shared/src/venue-schedule.ts` +- 既存の管理画面の操作パターン: `apps/admin/src/app/(authed)/mentors/page.tsx`・`pre-registrations/page.tsx` diff --git a/packages/shared/package.json b/packages/shared/package.json index 4107632..b375a89 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,7 @@ "./activity-cycle": "./src/activity-cycle.ts", "./attendance-level": "./src/attendance-level.ts", "./visit-summary": "./src/visit-summary.ts", + "./session-rules": "./src/session-rules.ts", "./youtube": "./src/youtube.ts" }, "scripts": { diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 3596eee..e84ac9f 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -215,6 +215,44 @@ export const createPreRegistrationRequestSchema = z.object({ registeredAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD format required'), }); +// `/api/sessions`(POST / PATCH・admin role 専用)— 来場記録の修正 +// 時刻は ISO 8601(UTC・末尾 Z)。画面は JST で入力し、shared の jstDateTimeToInstant で変換して送る。 +const sessionParticipantIdSchema = z.string().regex(/^\d{5}$/, '5-digit participant id required'); +// 年の打ち間違い(例: 0026 → 1926 年)で開催日の形式が崩れた記録を作らないよう、下限を設ける。 +// 画面の日付入力にも同じ値を min として渡す。 +export const SESSION_EARLIEST_DATE = '2020-01-01'; +const sessionInstantSchema = z.iso + .datetime() + .refine((v) => v >= `${SESSION_EARLIEST_DATE}T00:00:00`, { + message: `must be on or after ${SESSION_EARLIEST_DATE}`, + }); + +export const createSessionRequestSchema = z.object({ + participantId: sessionParticipantIdSchema, + checkedInAt: sessionInstantSchema, + // null = 滞在中(今日の記録だけ許す。判定は session-rules) + checkedOutAt: sessionInstantSchema.nullable(), +}); + +// 変える項目だけを送る。checkedOutAt は「省略 = 変えない」「null = 滞在中に戻す」を区別する。 +export const updateSessionRequestSchema = z + .object({ + participantId: sessionParticipantIdSchema.optional(), + checkedInAt: sessionInstantSchema.optional(), + checkedOutAt: sessionInstantSchema.nullable().optional(), + }) + .refine( + (v) => + v.participantId !== undefined || v.checkedInAt !== undefined || v.checkedOutAt !== undefined, + { message: 'at least one of participantId, checkedInAt, checkedOutAt is required' }, + ); + +// eventDate は記録が属する開催日。画面が表示中の日付を切り替えるために返す。 +export const sessionMutationResponseSchema = z.object({ + eventDate: z.string(), // 'YYYY-MM-DD' (JST) + session: todaySessionItemSchema, +}); + export type TodaySessionItem = z.infer; export type TodaySessionsResponse = z.infer; export type SessionsByDateQuery = z.infer; @@ -233,3 +271,6 @@ export type PreRegistrationItem = z.infer; export type ActivatedPreRegistrationItem = z.infer; export type PreRegistrationsListResponse = z.infer; export type CreatePreRegistrationRequest = z.infer; +export type CreateSessionRequest = z.infer; +export type UpdateSessionRequest = z.infer; +export type SessionMutationResponse = z.infer; diff --git a/packages/shared/src/schemas/session-requests.test.ts b/packages/shared/src/schemas/session-requests.test.ts new file mode 100644 index 0000000..cc95a3f --- /dev/null +++ b/packages/shared/src/schemas/session-requests.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { createSessionRequestSchema, updateSessionRequestSchema } from './admin'; + +describe('createSessionRequestSchema', () => { + it('5 桁の ID と ISO 時刻を受け付ける(退場は null 可)', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '26001', + checkedInAt: '2026-10-04T01:05:00.000Z', + checkedOutAt: null, + }); + expect(r.success).toBe(true); + }); + + it('5 桁でない ID は弾く', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '2601', + checkedInAt: '2026-10-04T01:05:00.000Z', + checkedOutAt: null, + }); + expect(r.success).toBe(false); + }); + + it('ISO でない時刻は弾く', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '26001', + checkedInAt: '2026-10-04 10:05', + checkedOutAt: null, + }); + expect(r.success).toBe(false); + }); +}); + +describe('来場時刻の下限', () => { + it('2020 年より前の時刻は弾く(年の打ち間違いで 1926 年などの開催日を作らない)', () => { + const r = createSessionRequestSchema.safeParse({ + participantId: '26001', + checkedInAt: '1926-10-11T01:05:00.000Z', + checkedOutAt: null, + }); + expect(r.success).toBe(false); + expect( + updateSessionRequestSchema.safeParse({ checkedOutAt: '0999-10-11T01:05:00.000Z' }).success, + ).toBe(false); + }); +}); + +describe('updateSessionRequestSchema', () => { + it('項目が 1 つも無いと弾く', () => { + expect(updateSessionRequestSchema.safeParse({}).success).toBe(false); + }); + + it('checkedOutAt: null だけでも受け付ける(滞在中に戻す)', () => { + expect(updateSessionRequestSchema.safeParse({ checkedOutAt: null }).success).toBe(true); + }); + + it('付け替え(participantId だけ)を受け付ける', () => { + expect(updateSessionRequestSchema.safeParse({ participantId: '26002' }).success).toBe(true); + }); +}); diff --git a/packages/shared/src/session-rules.test.ts b/packages/shared/src/session-rules.test.ts new file mode 100644 index 0000000..76dafd7 --- /dev/null +++ b/packages/shared/src/session-rules.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + findOverlappingSession, + SESSION_RULE_MESSAGES, + type SessionTimes, + validateSessionTimes, +} from './session-rules'; +import { jstDateTimeToInstant as jst } from './venue-schedule'; + +const TODAY = '2026-10-11'; +const PAST = '2026-10-04'; +const NOW = jst(TODAY, '15:00'); + +const times = (date: string, inTime: string, outTime: string | null): SessionTimes => ({ + checkedInAt: jst(date, inTime), + checkedOutAt: outTime === null ? null : jst(date, outTime), +}); + +describe('validateSessionTimes', () => { + it('過去日の入場〜退場がそろった記録は OK', () => { + expect(validateSessionTimes(times(PAST, '10:00', '11:00'), NOW)).toBeNull(); + }); + + it('退場が入場と同時刻なら CHECKOUT_BEFORE_CHECKIN', () => { + expect(validateSessionTimes(times(PAST, '10:00', '10:00'), NOW)).toBe( + 'CHECKOUT_BEFORE_CHECKIN', + ); + }); + + it('退場が入場より前なら CHECKOUT_BEFORE_CHECKIN', () => { + expect(validateSessionTimes(times(PAST, '11:00', '10:00'), NOW)).toBe( + 'CHECKOUT_BEFORE_CHECKIN', + ); + }); + + it('退場が入場と別の JST 日なら SPANS_DAYS', () => { + const t = { checkedInAt: jst(PAST, '18:00'), checkedOutAt: jst('2026-10-05', '09:00') }; + expect(validateSessionTimes(t, NOW)).toBe('SPANS_DAYS'); + }); + + it('入場が現在 + 1 分より後なら FUTURE_TIME', () => { + const t = { checkedInAt: new Date(NOW.getTime() + 120_000), checkedOutAt: null }; + expect(validateSessionTimes(t, NOW)).toBe('FUTURE_TIME'); + }); + + it('入場が現在 + 30 秒(猶予内)なら今日の滞在中として OK', () => { + const t = { checkedInAt: new Date(NOW.getTime() + 30_000), checkedOutAt: null }; + expect(validateSessionTimes(t, NOW)).toBeNull(); + }); + + it('退場が未来なら FUTURE_TIME', () => { + expect(validateSessionTimes(times(TODAY, '14:00', '15:05'), NOW)).toBe('FUTURE_TIME'); + }); + + it('今日の滞在中(退場なし)は OK', () => { + expect(validateSessionTimes(times(TODAY, '14:00', null), NOW)).toBeNull(); + }); + + it('過去日の滞在中は OPEN_SESSION_NOT_TODAY', () => { + expect(validateSessionTimes(times(PAST, '10:00', null), NOW)).toBe('OPEN_SESSION_NOT_TODAY'); + }); +}); + +describe('findOverlappingSession', () => { + it('他の記録が無ければ null', () => { + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [], NOW)).toBeNull(); + }); + + it('時間が重なる記録を返す', () => { + const sibling = times(PAST, '10:30', '12:00'); + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [sibling], NOW)).toBe(sibling); + }); + + it('境界で接するだけ(11:00 退場・11:00 入場)は重ならない', () => { + const sibling = times(PAST, '11:00', '12:00'); + expect(findOverlappingSession(times(PAST, '10:00', '11:00'), [sibling], NOW)).toBeNull(); + }); + + it('滞在中の記録は現在時刻まで続くとみなす', () => { + const sibling = times(TODAY, '13:00', null); + expect(findOverlappingSession(times(TODAY, '14:00', '14:30'), [sibling], NOW)).toBe(sibling); + }); + + it('滞在中どうしは重なる(同じ子の滞在中が 2 件になるのを防ぐ)', () => { + const sibling = times(TODAY, '13:00', null); + expect(findOverlappingSession(times(TODAY, '14:00', null), [sibling], NOW)).toBe(sibling); + }); + + it('猶予内で少し未来の滞在中でも、既存の滞在中と重なる(滞在中が 2 件になるのを防ぐ)', () => { + const now = new Date(jst(TODAY, '10:30').getTime() + 30_000); + const sibling = times(TODAY, '09:00', null); + expect(findOverlappingSession(times(TODAY, '10:31', null), [sibling], now)).toBe(sibling); + }); + + it('滞在中の記録より前に終わった記録とは重ならない', () => { + const sibling = times(TODAY, '10:00', '11:00'); + expect(findOverlappingSession(times(TODAY, '14:00', null), [sibling], NOW)).toBeNull(); + }); +}); + +describe('SESSION_RULE_MESSAGES', () => { + it('すべての違反に日本語の文言がある', () => { + expect(SESSION_RULE_MESSAGES.CHECKOUT_BEFORE_CHECKIN).toBe( + '退場時刻は入場時刻より後にしてください', + ); + expect(Object.keys(SESSION_RULE_MESSAGES).sort()).toEqual([ + 'CHECKOUT_BEFORE_CHECKIN', + 'FUTURE_TIME', + 'OPEN_SESSION_NOT_TODAY', + 'OVERLAP', + 'SPANS_DAYS', + ]); + }); +}); diff --git a/packages/shared/src/session-rules.ts b/packages/shared/src/session-rules.ts new file mode 100644 index 0000000..ca0cc21 --- /dev/null +++ b/packages/shared/src/session-rules.ts @@ -0,0 +1,76 @@ +import { toJstDateString } from './venue-schedule'; + +// 管理画面からの来場記録の追加・編集で守るルール。DB に制約(CHECK・一意制約)が無いため、 +// API(apps/api/src/lib/session-admin.ts)と画面(SessionFormDialog)の両方がこの純粋関数で検証する。 +// 設計: docs/superpowers/specs/2026-10-11-checkin-record-correction-design.md §2 + +export type SessionRuleViolation = + | 'CHECKOUT_BEFORE_CHECKIN' + | 'SPANS_DAYS' + | 'FUTURE_TIME' + | 'OPEN_SESSION_NOT_TODAY' + | 'OVERLAP'; + +export interface SessionTimes { + checkedInAt: Date; + // null = 滞在中(まだ退場していない) + checkedOutAt: Date | null; +} + +// 端末の時計ずれで「今」入力した時刻が未来扱いにならないための猶予。 +export const FUTURE_TOLERANCE_MS = 60_000; + +// 時刻だけで判定できるルール(重なり以外)を検証する。違反が無ければ null。 +export const validateSessionTimes = ( + times: SessionTimes, + now: Date, +): Exclude | null => { + const limit = now.getTime() + FUTURE_TOLERANCE_MS; + if (times.checkedInAt.getTime() > limit) return 'FUTURE_TIME'; + if (times.checkedOutAt !== null && times.checkedOutAt.getTime() > limit) return 'FUTURE_TIME'; + + if (times.checkedOutAt !== null) { + if (times.checkedOutAt.getTime() <= times.checkedInAt.getTime()) { + return 'CHECKOUT_BEFORE_CHECKIN'; + } + // 参加回数の集計は「開催日 = 入場日」で行うため、日をまたぐ記録は作らない。 + if (toJstDateString(times.checkedOutAt) !== toJstDateString(times.checkedInAt)) { + return 'SPANS_DAYS'; + } + return null; + } + + // 過去日に滞在中の記録が残ると、その日の一覧で「来場中」と表示され続けるため今日だけに許す。 + if (toJstDateString(times.checkedInAt) !== toJstDateString(now)) { + return 'OPEN_SESSION_NOT_TODAY'; + } + return null; +}; + +// 区間 [入場, 退場) の終端。滞在中はまだ終わっていないので終端なし(無限)とみなす。 +// 「今」で打ち切ると、猶予内で少し未来の入場の滞在中が [t, t) の空区間になり、 +// 既存の滞在中と重ならない扱いになって同じ子の滞在中が 2 件できてしまう。 +const intervalEnd = (times: SessionTimes): number => + times.checkedOutAt === null ? Number.POSITIVE_INFINITY : times.checkedOutAt.getTime(); + +// 同じ子・同じ日の他の記録(siblings)のうち、target と時間が重なるものを 1 件返す。 +// 境界で接する(11:00 退場・11:00 入場)のは重ならない扱い。 +// _now は呼び出し側との互換のため受け取るが、滞在中は無限区間として扱うので判定には使わない。 +export const findOverlappingSession = ( + target: SessionTimes, + siblings: readonly T[], + _now: Date, +): T | null => { + const start = target.checkedInAt.getTime(); + const end = intervalEnd(target); + return siblings.find((s) => start < intervalEnd(s) && s.checkedInAt.getTime() < end) ?? null; +}; + +// 画面と API のエラー文言をそろえるため、違反ごとの日本語をここに集約する。 +export const SESSION_RULE_MESSAGES: Record = { + CHECKOUT_BEFORE_CHECKIN: '退場時刻は入場時刻より後にしてください', + SPANS_DAYS: '入場と退場は同じ日の時刻にしてください', + FUTURE_TIME: '未来の時刻は入力できません', + OPEN_SESSION_NOT_TODAY: '過去の日の記録には退場時刻が必要です', + OVERLAP: '同じ子の同じ日の別の記録と時間が重なっています', +}; diff --git a/packages/shared/src/venue-schedule.test.ts b/packages/shared/src/venue-schedule.test.ts new file mode 100644 index 0000000..a5c29cf --- /dev/null +++ b/packages/shared/src/venue-schedule.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { jstDateTimeToInstant, toJstDateString, toJstTimeString } from './venue-schedule'; + +describe('jstDateTimeToInstant', () => { + it('JST の日付と時刻を UTC instant にする(UTC+9)', () => { + expect(jstDateTimeToInstant('2026-10-11', '10:05').toISOString()).toBe( + '2026-10-11T01:05:00.000Z', + ); + }); + + it('JST の早朝は UTC では前日になる', () => { + expect(jstDateTimeToInstant('2026-10-11', '08:30').toISOString()).toBe( + '2026-10-10T23:30:00.000Z', + ); + }); + + it('変換した instant を JST 日付に戻すと元の日付になる(深夜 0 時台)', () => { + expect(toJstDateString(jstDateTimeToInstant('2026-10-11', '00:10'))).toBe('2026-10-11'); + }); +}); + +describe('toJstTimeString', () => { + it('UTC instant を JST の HH:mm にする', () => { + expect(toJstTimeString(new Date('2026-10-10T23:30:00.000Z'))).toBe('08:30'); + }); + + it('1 桁の時・分は 0 埋めする', () => { + expect(toJstTimeString(new Date('2026-10-11T00:05:00.000Z'))).toBe('09:05'); + }); +}); diff --git a/packages/shared/src/venue-schedule.ts b/packages/shared/src/venue-schedule.ts index ba56613..aa6ada0 100644 --- a/packages/shared/src/venue-schedule.ts +++ b/packages/shared/src/venue-schedule.ts @@ -83,6 +83,24 @@ const jstDateFormatter = new Intl.DateTimeFormat('en-CA', { // 日付は en-CA フォーマッタから直接得る(壁時計の hour 正規化と独立させ、日跨ぎでも安全)。 export const toJstDateString = (instant: Date): string => jstDateFormatter.format(instant); +// JST の日付 'YYYY-MM-DD' と時刻 'HH:mm' を UTC instant にする。管理画面で JST 入力した +// 来場時刻を API(ISO 8601 UTC)へ渡すときに使う。端末のタイムゾーンに依存させないため +// Date のローカル時刻 API は使わず、固定の UTC+9 を引いて Date.UTC で組み立てる。 +export const jstDateTimeToInstant = (date: string, time: string): Date => { + const year = Number.parseInt(date.slice(0, 4), 10); + const month = Number.parseInt(date.slice(5, 7), 10); + const day = Number.parseInt(date.slice(8, 10), 10); + const hour = Number.parseInt(time.slice(0, 2), 10); + const minute = Number.parseInt(time.slice(3, 5), 10); + return new Date(Date.UTC(year, month - 1, day, hour - JST_OFFSET_HOURS, minute, 0, 0)); +}; + +// UTC instant を JST の 'HH:mm' にする(フォームの初期値・表示用)。 +export const toJstTimeString = (instant: Date): string => { + const { hour, minute } = toJstWallClock(instant); + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +}; + // 'HH:mm' を 0:00 からの通算分に変換する。区間判定を整数比較に落とすためのヘルパ。 const toMinutesOfDay = (hhmm: string): number => Number.parseInt(hhmm.slice(0, 2), 10) * 60 + Number.parseInt(hhmm.slice(3, 5), 10); diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx new file mode 100644 index 0000000..8a43a1f --- /dev/null +++ b/packages/ui/src/components/dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { IconX } from '@tabler/icons-react'; +import { Button } from '@tecnova/ui/components/button'; +import { cn } from '@tecnova/ui/lib/utils'; +import { Dialog as DialogPrimitive } from 'radix-ui'; +import type * as React from 'react'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c14d180..5d60ed3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: link:../../packages/ui 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)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) motion: specifier: ^12.40.0 version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -74,7 +74,7 @@ importers: version: link:../../packages/shared 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)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@cloudflare/workers-types@4.20260502.1)(kysely@0.28.17) @@ -82,12 +82,18 @@ importers: specifier: ^4.12.16 version: 4.12.16 devDependencies: + '@cloudflare/vitest-plugin': + specifier: 1.2.8 + version: 1.2.8(@cloudflare/workers-types@4.20260502.1)(@types/node@20.19.39)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) '@cloudflare/workers-types': specifier: ^4.20260502.1 version: 4.20260502.1 typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) wrangler: specifier: ^4.87.0 version: 4.87.0(@cloudflare/workers-types@4.20260502.1) @@ -108,7 +114,7 @@ importers: version: 0.2.0(@zxing/library@0.22.0) 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)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) motion: specifier: ^12.40.0 version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -154,7 +160,7 @@ importers: version: link:../../packages/ui 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)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + 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)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))) motion: specifier: ^12.40.0 version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -438,36 +444,82 @@ packages: workerd: optional: true + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-plugin@1.2.8': + resolution: {integrity: sha512-D2AWDniKa9FRxX91U1AaskzLZRY1s4n16f0I5l2T1mHqwXmGWwbxxw+kDBnKa2Q2os5Dk9Aud9CQxLpQo7fNmA==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + '@cloudflare/workerd-darwin-64@1.20260430.1': resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260925.1': + resolution: {integrity: sha512-GFDjFo5jfmg98iiy73DOZjHRV+lPWaeGeJ6MbtUIVyostkh9L69KliaAWVytBHCSp36o3NNZOQ0CN8VrqO3+8A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260430.1': resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260925.1': + resolution: {integrity: sha512-b3BLoP9NkF6b2QOQFs8wFfOt9XSH3jVtacdGn9lcvkUCdPq7F6CfDlleHqyQEnDfZ32lH5jgMRTdaMHUPf/XbA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20260430.1': resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260925.1': + resolution: {integrity: sha512-LczXd6uEMqEmBxBJPFwplRIwKmUHqj5t8hilIWOtbX5i0TFp7/iMYBXbAiIhvZnev+8yiTyA7uWBBjDvfPh5Ug==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260430.1': resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260925.1': + resolution: {integrity: sha512-hziOugQbLuva85w8z9bGJbEtABKzSip653x0NgvxgP/Q3n1TpRqRyIJRBufbSUZRkJlfH2se2nQaTx2OU0TT3g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20260430.1': resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20260925.1': + resolution: {integrity: sha512-mG0JYuF6HVCgXSsNeB5os8Vtdprk5jHPysviKNNMHJSLA3Q1djFjSmK4+pnQR/zOAgzNo0HJ+Tpcz+tZj1Q7fQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20260502.1': resolution: {integrity: sha512-gttFwGL0pYBF5nA2GIazKTVjDqXLnqWa/Mstd5aGTZyzkhmPy0ej3L2sIn2h8kAbF6I+XGK0P4UXvlmnuxefYg==} @@ -481,13 +533,16 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -507,6 +562,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -531,6 +592,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -555,6 +622,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -579,6 +652,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -603,6 +682,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -627,6 +712,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -651,6 +742,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -675,6 +772,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -699,6 +802,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -723,6 +832,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -747,6 +862,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -771,6 +892,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -795,6 +922,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -819,6 +952,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -843,6 +982,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -867,6 +1012,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -891,6 +1042,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -909,6 +1066,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -933,6 +1096,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -951,6 +1120,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -975,6 +1150,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -993,6 +1174,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -1017,6 +1204,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -1041,6 +1234,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -1065,6 +1264,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -1089,6 +1294,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1114,70 +1325,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1185,6 +1471,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1192,6 +1485,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1199,6 +1499,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1206,6 +1513,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1213,6 +1527,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1220,6 +1541,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1227,6 +1555,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1234,29 +1569,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2352,6 +2721,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -2363,21 +2735,47 @@ packages: vite: optional: true + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@zxing/browser@0.2.0': resolution: {integrity: sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==} peerDependencies: @@ -2490,10 +2888,17 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2504,6 +2909,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -2640,6 +3048,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2660,6 +3071,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2812,6 +3228,10 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + miniflare@5.20260925.0-alpha: + resolution: {integrity: sha512-ly2ygNOfuouMiPcyTNEUW6f3+zgndfC45e1nQZ2LSciOSFfrih8V7ciaZ01I/64Ccp3F6fWraxh5VuHPsP3AAw==} + engines: {node: '>=22.0.0'} + motion-dom@12.40.0: resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} @@ -2871,6 +3291,10 @@ packages: sass: optional: true + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -2972,6 +3396,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -2979,6 +3408,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3005,6 +3443,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -3041,6 +3482,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -3053,6 +3498,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -3093,6 +3542,10 @@ packages: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -3166,26 +3619,67 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -3204,6 +3698,21 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20260925.1: + resolution: {integrity: sha512-cl7Mrvhm+xEC9brMBIWIHyfYl7y5dGH3CjQog6CglWhgRwAq+/b+2gHBXNQb4hsjLXQVCuCYExJRYCjEaZmAlA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.141.0: + resolution: {integrity: sha512-8+LRZEcynBMfVYgO4N378z5XyuajaILt2IzsdWRLCHrRWz7mmGWv4igdVjqbRQMJtE0S5bSmyz0W2XpA6UM/ow==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260925.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrangler@4.87.0: resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==} engines: {node: '>=22.0.0'} @@ -3226,6 +3735,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -3235,6 +3756,9 @@ packages: zod@4.4.2: resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3337,21 +3861,58 @@ snapshots: optionalDependencies: workerd: 1.20260430.1 + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260925.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260925.1 + + '@cloudflare/vitest-plugin@1.2.8(@cloudflare/workers-types@4.20260502.1)(@types/node@20.19.39)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)))': + dependencies: + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260925.0-alpha(@types/node@20.19.39) + vitest: 4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + wrangler: 4.141.0(@cloudflare/workers-types@4.20260502.1)(@types/node@20.19.39) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@types/node' + - bufferutil + - utf-8-validate + '@cloudflare/workerd-darwin-64@1.20260430.1': optional: true + '@cloudflare/workerd-darwin-64@1.20260925.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260430.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260925.1': + optional: true + '@cloudflare/workerd-linux-64@1.20260430.1': optional: true + '@cloudflare/workerd-linux-64@1.20260925.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260430.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260925.1': + optional: true + '@cloudflare/workerd-windows-64@1.20260430.1': optional: true + '@cloudflare/workerd-windows-64@1.20260925.1': + optional: true + '@cloudflare/workers-types@4.20260502.1': {} '@cspotcode/source-map-support@0.8.1': @@ -3365,6 +3926,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -3384,6 +3950,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -3396,6 +3965,9 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -3408,6 +3980,9 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -3420,6 +3995,9 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -3432,6 +4010,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -3444,6 +4025,9 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -3456,6 +4040,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -3468,6 +4055,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -3480,6 +4070,9 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -3492,6 +4085,9 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -3504,6 +4100,9 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -3516,6 +4115,9 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -3528,6 +4130,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -3540,6 +4145,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -3552,6 +4160,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -3564,6 +4175,9 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -3576,6 +4190,9 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -3585,6 +4202,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -3597,6 +4217,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -3606,6 +4229,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -3618,6 +4244,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -3627,6 +4256,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -3639,6 +4271,9 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -3651,6 +4286,9 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -3663,6 +4301,9 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -3675,6 +4316,9 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3699,95 +4343,199 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.10.0 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.4': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.4': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4815,6 +5563,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -4823,32 +5580,64 @@ snapshots: optionalDependencies: vite: 7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + '@vitest/mocker@4.1.11(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.11': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + '@zxing/browser@0.2.0(@zxing/library@0.22.0)': dependencies: '@zxing/library': 0.22.0 @@ -4872,7 +5661,7 @@ snapshots: baseline-browser-mapping@2.10.25: {} - better-auth@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)(vitest@3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): + better-auth@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)(vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))): dependencies: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260502.1)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260502.1)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260502.1)(kysely@0.28.17)) @@ -4897,7 +5686,7 @@ snapshots: next: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vitest: 4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -4927,8 +5716,12 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.3.0: {} + check-error@2.1.3: {} + cjs-module-lexer@1.2.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4937,6 +5730,8 @@ snapshots: clsx@2.1.1: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} csstype@3.2.3: {} @@ -4974,6 +5769,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -5086,6 +5883,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -5195,6 +6021,19 @@ snapshots: - bufferutil - utf-8-validate + miniflare@5.20260925.0-alpha(@types/node@20.19.39): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@20.19.39) + undici: 7.29.0 + workerd: 1.20260925.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + motion-dom@12.40.0: dependencies: motion-utils: 12.39.0 @@ -5244,6 +6083,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + obug@2.2.1: {} + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -5406,6 +6247,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + set-cookie-parser@3.1.0: {} sharp@0.34.5: @@ -5439,6 +6282,39 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.4(@types/node@20.19.39): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.39 + siginfo@2.0.0: {} sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -5459,6 +6335,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.3.0: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -5480,6 +6358,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5489,6 +6369,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.2.0: {} + tinyspy@4.0.4: {} ts-custom-error@3.3.1: {} @@ -5521,6 +6403,8 @@ snapshots: undici@7.24.8: {} + undici@7.29.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -5621,6 +6505,33 @@ snapshots: - tsx - yaml + vitest@4.1.11(@types/node@20.19.39)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.2.0 + vite: 7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + transitivePeerDependencies: + - msw + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5634,6 +6545,32 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260430.1 '@cloudflare/workerd-windows-64': 1.20260430.1 + workerd@1.20260925.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260925.1 + '@cloudflare/workerd-darwin-arm64': 1.20260925.1 + '@cloudflare/workerd-linux-64': 1.20260925.1 + '@cloudflare/workerd-linux-arm64': 1.20260925.1 + '@cloudflare/workerd-windows-64': 1.20260925.1 + + wrangler@4.141.0(@cloudflare/workers-types@4.20260502.1)(@types/node@20.19.39): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260925.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260925.0-alpha(@types/node@20.19.39) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260925.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260502.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + wrangler@4.87.0(@cloudflare/workers-types@4.20260502.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 @@ -5653,6 +6590,8 @@ snapshots: ws@8.18.0: {} + ws@8.21.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -5667,3 +6606,5 @@ snapshots: youch-core: 0.3.3 zod@4.4.2: {} + + zod@4.4.3: {}