Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c9655a9
docs(signage): revise spec for YouTube IFrame video + OS Spotify audio
ut42tech May 30, 2026
f6063d1
docs(signage): revise implementation plan for YouTube video layer
ut42tech May 30, 2026
9068306
feat(shared): add activity-cycle (50/10 schedule + chime events)
ut42tech May 30, 2026
64d89c9
feat(shared): add YouTube Data API wrapper and signage playlist schema
ut42tech May 30, 2026
b0b58ba
feat(api): add GET /api/signage/playlist (YouTube Data API, cached)
ut42tech May 30, 2026
e620aef
feat(signage): scaffold Next.js app on port 3002
ut42tech May 30, 2026
c1b6937
feat(signage): add mentor-whitelist auth (auth-client, MeProvider she…
ut42tech May 30, 2026
017b59e
feat(signage): add time utils (now override, useNow, jst/mmss) and wa…
ut42tech May 30, 2026
4d20a60
feat(signage): synthesize chime tones via Web Audio
ut42tech May 30, 2026
abcf43e
feat(signage): drift-free chime scheduler with per-boundary dedupe
ut42tech May 30, 2026
ca92f29
feat(signage): poll /api/sessions/today and derive per-term counts
ut42tech May 30, 2026
d271612
feat(signage): YouTube IFrame self-queue player, playlist fetch, mute…
ut42tech May 30, 2026
0aae018
feat(signage): info-bar, break/idle screens, tap-to-start overlay
ut42tech May 30, 2026
ece4737
feat(signage): wire phase state machine (YouTube/break/idle + chimes …
ut42tech May 30, 2026
1b62414
style(signage): apply biome formatting
ut42tech May 30, 2026
e549253
docs(signage): add app CLAUDE.md/AGENTS.md and architecture entry
ut42tech May 30, 2026
1bda4de
fix(signage): rescue YouTube queue when playlist arrives before onReady
ut42tech May 30, 2026
7940974
feat(signage): add ?debug=1 preview mode (pseudo-clock, force-active,…
ut42tech May 30, 2026
590646b
feat(shared): add attendance liveliness classifier
ut42tech May 30, 2026
8325fd9
feat(signage): carry video titles and current-track through playlist/…
ut42tech May 30, 2026
c766a48
feat(signage): redesign viewer screens as a broadcast layout
ut42tech May 30, 2026
49c0379
feat(signage): align login + shell with broadcast theme; document layout
ut42tech May 30, 2026
9840b84
feat(shared): calibrate liveliness to a 25-person same-time capacity
ut42tech May 30, 2026
aded081
feat(signage): animate countdown digits with an odometer roll
ut42tech May 30, 2026
192202f
feat(signage): modernize the info ticker (stories progress, clipped s…
ut42tech May 30, 2026
3e05d2f
feat(signage): enrich the chime rail and refine the video panel
ut42tech May 30, 2026
09ff83b
refactor(signage): reword status, soften copy, add entrance motion
ut42tech May 30, 2026
4fa641e
feat(shared): summarize previous-event stays by unique participant
ut42tech May 30, 2026
5e709b7
feat(api): add signage previous-summary and health endpoints
ut42tech May 30, 2026
3bcc4ca
feat(signage): ticker shows previous-event stats, system health, and …
ut42tech May 30, 2026
70890f9
refactor(signage): drop the status badge ripple (sonar) animation
ut42tech May 30, 2026
1a9bfe5
feat(signage): style the follow call-out as an Instagram card with av…
ut42tech May 30, 2026
c31db78
fix(signage): widen Instagram follow card so the handle no longer clips
ut42tech May 30, 2026
6123156
revert(signage): restore the simple Instagram follow card
ut42tech May 30, 2026
e4d2e59
chore(signage): add comment
ut42tech May 30, 2026
adeded1
fix(signage): address code-review findings
ut42tech May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,21 @@ BETTER_AUTH_SECRET=
# 本番: https://tecnova-api.<your-subdomain>.workers.dev
BETTER_AUTH_URL=

# 管理画面・受付アプリなど、API に cross-origin で来るフロントのオリジン許可リスト。
# 管理画面・受付アプリ・サイネージなど、API に cross-origin で来るフロントのオリジン許可リスト。
# カンマ区切り文字列。CORS と Better Auth の trustedOrigins の両方で参照される。
# 開発: http://localhost:3000,http://localhost:3001
# 本番: https://<checkin-domain>,https://<admin-domain>
# 開発: http://localhost:3000,http://localhost:3001,http://localhost:3002
# 本番: https://<checkin-domain>,https://<admin-domain>,https://<signage-domain>
TRUSTED_ORIGINS=

# -----------------------------------------------------------------------------
# YouTube Data API (apps/signage 動画プレイリスト)
# -----------------------------------------------------------------------------
# サイネージの GET /api/signage/playlist が playlistItems.list を叩くための APIキーと
# プレイリストID。APIキーは「YouTube Data API v3 限定」の制限を付与する(リファラ制限は
# 付けない=サーバ側呼び出し)。本番は Wrangler Secrets、ローカルは apps/api/.dev.vars。
YOUTUBE_API_KEY=
YOUTUBE_PLAYLIST_ID=

# -----------------------------------------------------------------------------
# Frontend (Next.js apps)
# -----------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { authRoute } from './routes/auth';
import { checkinRoute } from './routes/checkin';
import { healthRoute } from './routes/health';
import { preRegistrationsRoute } from './routes/pre-registrations';
import { signageRoute } from './routes/signage';
import type { AppEnv } from './types';

const app = new Hono<AppEnv>();
Expand All @@ -26,6 +27,7 @@ app.use('/checkin/*', requireAuthenticatedMentor);

app.route('/api', adminRoute);
app.route('/api/pre-registrations', preRegistrationsRoute);
app.route('/api/signage', signageRoute);
app.route('/checkin', checkinRoute);
app.route('/', healthRoute);

Expand Down
70 changes: 70 additions & 0 deletions apps/api/src/lib/signage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type * as schema from '@tecnova/db';
import { events, sessions } from '@tecnova/db';
import type {
SignagePlaylistResponse,
SignagePreviousSummaryResponse,
} from '@tecnova/shared/schemas';
import { toJstDateString } from '@tecnova/shared/venue-schedule';
import { summarizeStays } from '@tecnova/shared/visit-summary';
import { fetchPlaylistVideos, type YouTubePlaylistVideo } from '@tecnova/shared/youtube';
import { desc, eq, lt } from 'drizzle-orm';
import type { DrizzleD1Database } from 'drizzle-orm/d1';
import type { Bindings } from '../types';

type Db = DrizzleD1Database<typeof schema>;

// プレイリストはサーバ側で数分キャッシュする。Data API のクォータ節約と、プレイリスト
// 更新の反映遅延(数分)は許容(spec §5.1)。Workers がリサイクルされたら自然に再取得。
const CACHE_TTL_MS = 5 * 60_000;

let cache: { items: YouTubePlaylistVideo[]; expiresAt: number } | null = null;

export const fetchSignagePlaylist = async (env: Bindings): Promise<SignagePlaylistResponse> => {
const now = Date.now();
if (!cache || cache.expiresAt <= now) {
const items = await fetchPlaylistVideos(env.YOUTUBE_API_KEY, env.YOUTUBE_PLAYLIST_ID);
cache = { items, expiresAt: now + CACHE_TTL_MS };
}
return { items: cache.items, refreshAt: new Date(cache.expiresAt).toISOString() };
};

// 前回開催日(JST の今日より前で最新の event)の来場・滞在データを集計して返す。
// events.date は TEXT 'YYYY-MM-DD' なので辞書順比較=日付順で「今日より前の最新」を取れる。
// 返すのは集計値のみ(PII なし)。前回開催が無ければ previous=null。
export const fetchPreviousEventSummary = async (
db: Db,
): Promise<SignagePreviousSummaryResponse> => {
const today = toJstDateString(new Date());
const [event] = await db
.select({ id: events.id, date: events.date })
.from(events)
.where(lt(events.date, today))
.orderBy(desc(events.date))
.limit(1);
if (!event) return { previous: null };

const rows = await db
.select({
participantId: sessions.participantId,
checkedInAt: sessions.checkedInAt,
checkedOutAt: sessions.checkedOutAt,
})
.from(sessions)
.where(eq(sessions.eventId, event.id));

// 退館→再入館で同一人物が複数行になるため、participantId を渡してユニーク人数で集計する。
const summary = summarizeStays(
rows.map((r) => ({
participantId: r.participantId,
checkedInAt: r.checkedInAt.getTime(),
checkedOutAt: r.checkedOutAt ? r.checkedOutAt.getTime() : null,
})),
);
return {
previous: {
date: event.date,
participantCount: summary.count,
averageStayMinutes: summary.averageStayMinutes,
},
};
};
30 changes: 30 additions & 0 deletions apps/api/src/routes/signage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { events } from '@tecnova/db';
import { count } from 'drizzle-orm';
import { Hono } from 'hono';
import { fetchPreviousEventSummary, fetchSignagePlaylist } from '../lib/signage';
import { createDb } from '../middleware/auth';
import type { AppEnv } from '../types';

// サイネージ用エンドポイント。requireAuthenticatedMentor は index.ts で /api/* に適用済み
// なので、ここでは付けない(メンター認証済みの信頼端末からのみ叩かれる)。
export const signageRoute = new Hono<AppEnv>();

// 動画プレイリスト(順序付き videoId 列)。取得失敗(APIキー未設定・YouTube エラー等)は
// throw され apiErrorHandler が 500 化し、クライアントは §5.4 のフォールバック videoId に倒れる。
signageRoute.get('/playlist', async (c) => c.json(await fetchSignagePlaylist(c.env)));

// 前回開催日の来場・滞在データ(集計のみ・PII なし)。
signageRoute.get('/previous-summary', async (c) =>
c.json(await fetchPreviousEventSummary(createDb(c.env))),
);

// 基盤システムの稼働確認。軽量な DB 到達クエリを1本投げ、成功なら ok を返す
// (失敗時は apiErrorHandler が 500 化し、サイネージ側で「障害」表示に倒れる)。
// /health(公開・root マウント)は CORS 対象外で別オリジンのサイネージから叩けないため、
// CORS 済みの /api 配下に置く。
signageRoute.get('/health', async (c) => {
const db = createDb(c.env);
// count() は1行に畳まれるので limit は不要。到達できれば ok。
await db.select({ n: count() }).from(events);
return c.json({ status: 'ok', time: new Date().toISOString() });
});
3 changes: 3 additions & 0 deletions apps/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type Bindings = {
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
TRUSTED_ORIGINS: string;
// サイネージの動画プレイリスト取得(YouTube Data API v3・APIキーのみ)。
YOUTUBE_API_KEY: string;
YOUTUBE_PLAYLIST_ID: string;
};

export interface AuthUser {
Expand Down
41 changes: 41 additions & 0 deletions apps/signage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
3 changes: 3 additions & 0 deletions apps/signage/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
16 changes: 16 additions & 0 deletions apps/signage/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@AGENTS.md

# signage(会場サイネージ / 大型モニター・キオスク)

- **Next.js 16 / React 19**。App Router の API がトレーニングデータと乖離しているため、実装前に `node_modules/next/dist/docs/` を確認すること。
- **dev ポート**: `3002`(`next dev --port 3002`)。api は `8787`、checkin は `3000`、admin は `3001`。
- **認証あり**: checkin/admin と同じメンター・ホワイトリスト(`MeProvider`/`auth-client`)。運用は**テクノバ共有の管理用 Google アカウント**で1回ログイン(セッション既定7日)。`useMe` はツリー内に `MeProvider` 必須。
- **データ**: 認証付き `GET /api/sessions/today` を再利用(稼働判定・在館数)。ターム別チェックイン数は `sessions[].term` から算出し、**現タームに当日チェックインが入った時点で稼働開始**(ターム終了まで sticky)。
- **レイアウト**: 配信(ブロードキャスト)風(`BroadcastFrame`)。上=`StageHeader`(ワードマーク・ステータス・JST時計)/中=`VideoStage`(縮小動画パネル。休憩・待機は `BreakOverlay`/`IdleOverlay` をパネル上にクロスフェード)+右レーン `ChimeRail`(**チャイムの役割**=次チャイムまでのカウントダウンリング・ターム・サイクル、idle 時は本日のスケジュール)/下=`InfoTicker`(動画タイトル・来場・にぎわい・主催/共催・tec-nova/tecnova-platform 紹介を巡回)。状態機械(activity/break/idle)と稼働判定は従来どおり `page.tsx` で算出し frame に渡すだけ。世界観は checkin に統一(`from-sky-50 to-white`・`motion` + `useReducedMotion`・`rounded-2xl`/`shadow-sm` カード)。にぎわいは `@tecnova/shared/attendance-level` の純粋ロジック由来。
- **時刻ロジック**: `@tecnova/shared/activity-cycle`(50分活動/10分休憩・チャイム時刻)。時刻ソースは `src/lib/now.ts` の `getNow()`(優先順位: デバッグ擬似時計 → `?now=ISO` アンカー → 実時刻)。
- **プレビュー/デバッグ**: `?debug=1` を付けたときだけ画面下部に操作バー(`DebugPanel`)が出る。本番壁面は `?debug=1` 無しで起動するので影響ゼロ(`debugEnabled=false` で全分岐が短絡=従来挙動と完全同値)。擬似時計(ジャンプ/一時停止/×1×30×120)・稼働強制(チェックインデータ無しで活動/休憩を再現)・手動チャイムで、実時刻を待たずに全状態と各遷移+チャイムを検証できる。下部インフォメーション(`InfoTicker`)には `?debug=1` のとき ◀▶ の手動送りが出る。ストアは `now.ts` に同居し `useSyncExternalStore` で購読。チャイムスケジューラは `jumpEpoch` で前方ジャンプ時の過去チャイム一斉発火を抑止する。
- **動画**: 縮小した動画パネル(`VideoStage`)に YouTube IFrame Player API の自前キューを**常時マウント**(`ENDED`/`onError` で次 videoId へ `loadVideoById`、活動フェーズのみ再生・他は pause)。再生順は YouTube のプレイリストを `GET /api/signage/playlist`(YouTube Data API・Worker キャッシュ)が videoId+タイトル列にして返し、`usePlaylist` が保持/`useYoutubePlayer` の `onVideoChange` で現在トラックを通知(下部インフォの動画タイトル用)。フォールバックは `src/config/playlist.ts` の `FALLBACK_VIDEO_IDS`。**広告は埋め込み側で消せない**(仕様 §5.3)。
- **音声**: 無音/音ありのグローバルトグルのみ(既定=無音・localStorage)。BGM は **OS側 Spotify**(アプリ非統合)。チャイムは Web Audio 合成で独立。
- **キオスク**: 横向き・フルスクリーン。起動「タップして開始」で**チャイム解放・全画面・wake lock**(+音ありモード時のみ動画 unMute)。ミュート動画はタップ前から再生。本番は Chromium を `--kiosk` 等で起動。
- **必須 env**: `NEXT_PUBLIC_API_URL`(未設定時 `http://localhost:8787`)。API 側 `TRUSTED_ORIGINS` にサイネージ origin(dev: `http://localhost:3002`、本番ドメイン)、`YOUTUBE_API_KEY`/`YOUTUBE_PLAYLIST_ID` を登録すること。
- 新しい `@tecnova/*` パッケージを使うときは `next.config.ts` の `transpilePackages` に追加。
23 changes: 23 additions & 0 deletions apps/signage/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "tabler",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@tecnova/ui/lib/utils",
"ui": "@tecnova/ui/components"
},
"rtl": false,
"menuColor": "default-translucent",
"menuAccent": "subtle"
}
8 changes: 8 additions & 0 deletions apps/signage/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// モノレポ内 workspace パッケージを Next の transpile 対象にする
transpilePackages: ['@tecnova/shared', '@tecnova/ui'],
};

export default nextConfig;
31 changes: 31 additions & 0 deletions apps/signage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "signage",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tabler/icons-react": "^3.42.0",
"@tecnova/shared": "workspace:*",
"@tecnova/ui": "workspace:*",
"better-auth": "^1.6.9",
"motion": "^12.40.0",
"next": "16.2.4",
"qrcode.react": "^4.2.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/youtube": "^0.1.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
1 change: 1 addition & 0 deletions apps/signage/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@tecnova/ui/postcss.config';
Binary file added apps/signage/public/logo_tecnova.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions apps/signage/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from 'next';
import { LINE_Seed_JP } from 'next/font/google';
import '@tecnova/ui/globals.css';
import { cn } from '@tecnova/ui/lib/utils';
import { AppShell } from '@/components/app-shell';

const fontSans = LINE_Seed_JP({
variable: '--font-sans',
weight: ['100', '400', '700', '800'],
subsets: ['latin'],
});

export const metadata: Metadata = {
title: 'テクノバ サイネージ',
// iOS Safari に PWA 起動を伝える。Android/Chromium 用の Web マニフェスト
// (app/manifest.ts)と両方必要。
appleWebApp: { capable: true, title: 'サイネージ', statusBarStyle: 'black-translucent' },
};

export const viewport: Viewport = {
// 配信レイアウトの地(sky-50)に合わせる。
themeColor: '#f0f9ff',
// 大型モニターのキオスク表示。ピンチズーム無効で誤操作を防ぐ。
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ja" className={cn('h-full antialiased font-sans', fontSans.variable)}>
<body className="min-h-full bg-sky-50">
<AppShell>{children}</AppShell>
</body>
</html>
);
}
69 changes: 69 additions & 0 deletions apps/signage/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card';
import Image from 'next/image';
import { useState } from 'react';
import { Reveal } from '@/components/reveal';
import { authClient } from '@/lib/auth-client';

export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

const signIn = async () => {
setBusy(true);
setError(null);
try {
// callbackURL は絶対URL(フロントのオリジンに戻す)。相対だと API オリジンに着地する。
const redirect = `${window.location.origin}/`;
await authClient.signIn.social({
provider: 'google',
callbackURL: redirect,
errorCallbackURL: redirect,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setBusy(false);
}
};

return (
<main className="flex min-h-svh items-center justify-center bg-gradient-to-b from-sky-50 to-white p-8">
<Reveal className="w-full max-w-md">
<Card>
<CardHeader>
<div className="flex flex-col items-center gap-3 text-center">
<Image
src="/logo_tecnova.png"
alt="tec-nova ながさき"
width={153}
height={40}
priority
className="h-10 w-auto"
/>
<CardTitle className="text-2xl">サイネージ表示</CardTitle>
</div>
</CardHeader>
{error && (
<CardContent>
<Alert variant="destructive">
<AlertTitle>ログインエラー</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
)}
<CardFooter className="flex-col gap-3">
<Button type="button" size="lg" onClick={signIn} disabled={busy} className="w-full">
{busy ? 'リダイレクト中...' : 'Google でログイン(共有アカウント)'}
</Button>
<p className="text-center text-sm text-muted-foreground">
許可リストに登録されたアカウントのみ利用できます
</p>
</CardFooter>
</Card>
</Reveal>
</main>
);
}
Loading
Loading