時刻表
https://docs.google.com/spreadsheets/d/1rc22YcvpJLZSAQ9Uq4ken8M5tr9V5M237831FitHcqw/edit?usp=sharing
クイズゲーム
https://docs.google.com/spreadsheets/d/1OxDrQqjk-h2o4S0xEXuTegtohuwycm84ELpLFjxENBs/edit?usp=sharing
# クイズゲーム API リファレンス
> **Base URL**: Next.js デプロイ先のルート(例 `https://iot.ichika.net`)を基準にしてください。
---
## 1. GET `/api/quiz/random`
プレイヤーが **まだ回答していない** クイズを 1 問だけ取得します。
| クエリ | 型 | 必須 | 説明 |
| ------------ | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userId` | `string` | **必須** | プレイヤー ID。存在しなければ自動で `User` 行が作成されます。 |
| `spotCode` | `string` | 任意 | 物理スポットのコード(例 `"Tokyo-A"`)<br> - 指定あり → **本番モード**。スポットに合致し、かつ現在時刻(JST)が期間内の問題のみ対象。<br> - 指定なし → **練習モード**。全体共通問題(`areaCode NULL & spotCode NULL`)のみ対象。 |
| `difficulty` | `string` | 任意 | `"簡単" / "普通" / "難しい"` のいずれか。指定しない場合はランダム。 |
### 200 OK 例
```jsonc
{
"id": 42,
"difficulty": "普通",
"question": "キャベツがモチーフの銚子市のマスコットキャラクターの名前は?",
"choiceA": "ジオっちょ",
"choiceB": "ちょーぴー",
"choiceC": "カミスココくん",
"answer": 2,
"explanation": "ちょーぴー…",
"quizSet": {
"period": {
"id": 3,
"name": "2025-Summer",
"startAt": "2025-07-01T00:00:00.000Z",
"endAt": "2025-07-31T14:59:59.999Z"
}
}
}
```
`answer` はクライアント側で正誤判定に使うために返しています。
### エラー
| ステータス | `error` メッセージ | 意味 |
| ----- | ------------------ | ----------------- |
| 400 | `userId required` | `userId` が無い |
| 400 | `invalid spotCode` | `spotCode` が存在しない |
| 404 | `no quiz` | 未回答の問題が見つからない |
---
## 2. POST `/api/quiz/answer`
本番モードでは回答を保存し、練習モードでは正誤だけ返します。
### JSON Body
```jsonc
{
"userId" : "alice01", // 必須
"quizId" : 42, // 必須
"spotCode": "Tokyo-A", // 本番モード必須(練習時は空文字または省略)
"selected": 2 // 必須 (1=A, 2=B, 3=C)
}
```
### レスポンス
| モード | ステータス | 例 |
| --- | -------- | --------------------------------- |
| 本番 | `200 OK` | `{ "ok": true, "correct": true }` |
| 練習 | `200 OK` | 同上(DB には保存されない) |
### エラー
| ステータス | メッセージ | 条件 |
| ----- | -------------------------------- | -------------------------------------- |
| 400 | `bad params` | 必須フィールド不足 |
| 400 | `spot not allowed for this quiz` | スポットと問題のスコープ不整合 |
| 404 | `quiz not found` | `quizId` が存在しない |
| 409 | `already answered this period` | 同期間で既に回答済み(`userId + periodId` ユニーク制約) |
---
## 3. データモデルと制約
| テーブル | 役割 | 主な制約 |
| ----------------- | ----- | ------------------------------------------- |
| `User` | プレイヤー | `id` は自由入力。初アクセス時に自動作成。 |
| `QuizResult` | 回答ログ | `@@unique([userId, quizId])` → 同じ問題は 1 度だけ。 |
| `UserPeriodScore` | 期間別集計 | API で回答を登録するたびに更新。 |
---
## 4. cURL サンプル
```bash
# クイズ取得(本番モード)
curl "https://example.com/api/quiz/random?userId=alice01&spotCode=Tokyo-A&difficulty=普通"
# 回答登録
curl -X POST https://example.com/api/quiz/answer \
-H "Content-Type: application/json" \
-d '{"userId":"alice01","quizId":42,"spotCode":"Tokyo-A","selected":2}'
```
## API 仕様(バス)
GET https://iot.ichika.net/api/timetable
直近のバス便(3件)と各乗り継ぎ便(電車、高速バス各2件)を返却
```
{
"buses": [
{
"departure": "23:02:00",
"arrival": "23:10:00"
},
{
"departure": "23:22:00",
"arrival": "23:40:00"
}
],
"transfer": {
"naritasen": [
{
"departure": "23:40",
"remark": ""
}
],
"sobuhonsen": [
{
"departure": "23:45",
"remark": ""
}
]
}
}
```
時刻表
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>時刻表</title>
<style>
:root {
--border: 3px solid #000;
--thin-border: 2px solid #000;
--next-color: #c00;
--font-main: "Noto Sans JP", system-ui, sans-serif;
}
body{
margin:0;
padding:20px;
font-family:var(--font-main);
background:#fff;
}
/* ─── ヘッダ(現在時刻 & タイトル) ─── */
#header{
display:flex;
justify-content:space-between;
align-items:flex-start;
margin-bottom:20px;
}
#nowTime{
font-size:2.4rem;
font-weight:700;
}
#title{
font-size:1.8rem;
font-weight:700;
}
/* ─── 共通ボックス ─── */
.box{
border:var(--border);
padding:12px 16px;
box-sizing:border-box;
}
.box + .box{ margin-top:18px; } /* 隣接ボックスの間隔 */
/* ─── バス ─── */
#bus-box{
position:relative;
}
#bus-header{
font-size:0.9rem;
font-weight:600;
margin-bottom:6px;
}
#bus-current{
font-size:2.2rem;
font-weight:700;
margin-bottom:8px;
line-height:1.2;
}
#bus-split{
border-top:var(--thin-border);
margin:-4px -16px 8px; /* 全幅で線を引くためマイナスマージン */
}
#bus-next{
font-size:1.2rem;
font-weight:600;
}
#bus-next .label{ color:var(--next-color); }
/* ─── 電車 2 路線横並び ─── */
#train-area{
display:flex;
gap:18px;
flex-wrap:wrap;
}
.train-box{
flex:1 1 260px; /* 最低幅 260px で折り返し */
}
.train-header{
font-size:1rem;
font-weight:600;
margin-bottom:6px;
}
.train-split{
border-top:var(--thin-border);
margin:-4px -16px 8px;
}
.train-next,
.train-second{
font-size:1.4rem;
font-weight:700;
line-height:1.3;
}
.train-next .label{ color:var(--next-color); }
</style>
</head>
<body>
<!-- ▽ ヘッダ ▽ -->
<header id="header">
<div id="nowTime">--:--</div>
<div id="title">時刻表</div>
</header>
<!-- ▽ バス ▽ -->
<section id="bus-box" class="box">
<div id="bus-header">市立銚子発</div>
<div id="bus-current">(loading)</div>
<div id="bus-split"></div>
<div id="bus-next">
<span class="label">NEXT </span><span id="bus-next-time"></span>
→ <span id="bus-next-arr"></span>
</div>
</section>
<!-- ▽ 電車 ▽ -->
<section id="train-area">
<div id="sobuhonsen" class="train-box box">
<div class="train-header">総武本線</div>
<div class="train-next">
<span class="label">NEXT </span><span class="next-time"></span>
</div>
<div class="train-split"></div>
<div class="train-second second-time"></div>
</div>
<div id="naritasen" class="train-box box">
<div class="train-header">成田線</div>
<div class="train-next">
<span class="label">NEXT </span><span class="next-time"></span>
</div>
<div class="train-split"></div>
<div class="train-second second-time"></div>
</div>
</section>
<script>
/* ─── ヘルパー ─── */
const pad = n => String(n).padStart(2,'0');
const toSec = t => {
const [h,m,s='0'] = t.split(':').map(Number);
return h*3600 + m*60 + s;
};
/* ─── 現在時刻表示 ─── */
function updateClock(){
const now = new Date();
document.getElementById('nowTime').textContent =
`${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
setInterval(updateClock,1000);
updateClock();
/* ─── API 取得 & 描画 ─── */
async function loadTimetable(){
try{
const res = await fetch('https://iot.ichika.net/api/timetable');
const json = await res.json();
const nowS = toSec(`${pad(new Date().getHours())}:${pad(new Date().getMinutes())}:00`);
/* バス */
const upcomingBus = json.buses
.map(b => ({...b, sec: toSec(b.departure)}))
.filter(b=>b.sec>=nowS)
.slice(0,2);
if(upcomingBus.length){
const cur = upcomingBus[0];
document.getElementById('bus-current').textContent =
`${cur.departure}(${cur.arrival}銚子駅着)`;
if(upcomingBus[1]){
const nxt = upcomingBus[1];
document.getElementById('bus-next-time').textContent = nxt.departure;
document.getElementById('bus-next-arr').textContent = nxt.arrival;
}else{
document.getElementById('bus-next').textContent = '本日はこれ以降の便はありません';
}
}
/* 路線名日本語テーブル */
const jpNames = { sobuhonsen:'総武本線', naritasen:'成田線' };
/* 電車各路線 */
Object.entries(json.transfer).forEach(([key,arr])=>{
const box = document.getElementById(key);
if(!box) return;
const upcoming = arr
.map(t=>({...t, sec: toSec(t.departure+':00')}))
.filter(t=>t.sec>=nowS)
.slice(0,2);
box.querySelector('.train-header').textContent = jpNames[key] || key;
if(upcoming[0]){
box.querySelector('.next-time').textContent = upcoming[0].departure;
box.querySelector('.second-time').textContent = upcoming[1] ? upcoming[1].departure : '';
}else{
box.querySelector('.next-time').textContent = '-';
box.querySelector('.second-time').textContent = '';
}
});
}catch(e){
console.error(e);
}
}
/* 初回 & 60 秒毎に更新 */
loadTimetable();
setInterval(loadTimetable,60_000);
</script>
</body>
</html>