choshi0524

https://ai.ichika.net/

時刻表

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>