---
title: "events テーブル定義書"
---

最終更新: 2026-08-30
関連 Issue: #926（eventsテーブル実装）, #989（カラム名修正）, #997・#998（タイムラインUI表示仕様追加）, #1070（imageUrl管理方針追加）【v5.0.0 タイムライン機能】 / #1108（V2設計書整備）, #1109（festival統合）, #1110（タグシステム）【v7.0.0 タイムライン機能拡張(V2)】 / #1381（イベントテーブル拡張・time カラム追加） / #1472（タイムライン改善: 同日順序・プレースホルダー全文表示・ツアー時刻二重表示解消） / #1551（TO_CHAR()日付比較クエリのunstable_cache化） / MorningStatusApp #1528（イベント登録における属性拡張）, morning-status-blume#67（手動イベント登録の会場・楽曲属性設計。`venueId`カラム・`event_songs`/`event_song_tracks`テーブル追加）

---

## 1. 目的

既存データ（Releases / Members / Lives / Radio / Manual Events）を時系列で横断的に扱うための統合イベントレイヤーを構築する。

- 既存データは変更しない
- events は「派生データ」として生成する
- タイムライン UI は events のみを参照する

---

## 2. テーブル定義

### テーブル名

`events`

### スキーマ

| カラム | 型 | NOT NULL | デフォルト | 説明 |
|---|---|---|---|---|
| `id` | UUID | ✅ | —（Prisma がアプリ層で UUID 生成） | PK |
| `date` | DATE | ✅ | — | イベント発生日 |
| `time` | TIME(0) | — | NULL | イベント開始時刻。`lives.time` と同じ表現方式（秒精度なし）。時刻不明・未登録時は NULL |
| `type` | TEXT | ✅ | — | イベント種別（後述） |
| `title` | TEXT | ✅ | — | イベントタイトル |
| `description` | TEXT | — | NULL | 補足説明 |
| `members` | TEXT[] | — | `'{}'` | 関連メンバー ID 配列（members.json の id と対応） |
| `tags` | TEXT[] | — | `'{}'` | タグ配列（後述） |
| `imageUrl` | TEXT | — | NULL | 表示画像の URL（Vercel Blob URL 等） |
| `link` | TEXT | — | NULL | 関連リンク（MV / ニュース / radiko 等） |
| `venueId` | VARCHAR(8) | — | NULL | 会場ID（`venues.venue_id` 参照、FK制約あり）。手動イベント（`source='manual'`）で `venueName` を指定・解決できた場合のみ設定する（§5-5参照）。`live` 型等、他の source では現状未使用（NULL のまま）。オンライン開催等、物理会場を持たないイベントもあるため任意項目 |
| `source` | TEXT | ✅ | — | 元データ種別（後述） |
| `sourceId` | TEXT | ✅ | — | 元データの ID |
| `createdAt` | TIMESTAMPTZ | ✅ | `CURRENT_TIMESTAMP` | 作成日時 |
| `updatedAt` | TIMESTAMPTZ | ✅ | —（Prisma の `@updatedAt` で管理） | 更新日時（更新時に自動更新） |

---

## 2a. `event_songs`（イベント楽曲）

手動イベントの `songs` 入力（`seq`・`songTitle`）を保持する。`radio_onair_songs`・`setlists` と同じ「曲エンティティ自体のテーブル + トラック紐付け中間テーブル」の2階層構成を踏襲する（詳細は[ラジオオンエア データ設計書 §4-0](/design/radio/radio-onair-data-design)参照）。イベント（`Live`・`Festival`）と同格に、曲情報を独立したレコードとして持たせる方針とする。

```sql
CREATE TABLE event_songs (
  event_song_id SERIAL        PRIMARY KEY,
  event_id      UUID          NOT NULL REFERENCES events(id) ON DELETE CASCADE,
  seq           INTEGER       NOT NULL,
  song_title    TEXT          NOT NULL,
  UNIQUE (event_id, seq)
);
```

| カラム名 | 型 | NOT NULL | 説明 |
| --- | --- | --- | --- |
| `event_song_id` | SERIAL | ✅ | PK。`radio_onair_songs.onairId` と同じ代理キー方式（`events.id` が UUID のため、`setlists.setlistId` のような文字列組み立て PK ではなく独立した連番キーを持たせる） |
| `event_id` | UUID | ✅ | FK → events(id)。イベント削除時に連動削除（CASCADE） |
| `seq` | INTEGER | ✅ | イベント内での曲順（1始まり）。入力 JSON の `seq` をそのまま格納 |
| `song_title` | TEXT | ✅ | 曲名。入力 JSON の `songTitle` をそのまま格納 |

**インデックス:**
```sql
CREATE INDEX idx_event_songs_event_id ON event_songs (event_id);
```

**備考:**
- `(event_id, seq)` の一意制約により、同一イベント内での `seq` 重複を防ぐ
- トラックID への紐付けは本テーブルの列では持たず、§2b「event_song_tracks」の中間テーブルで管理する（1曲が複数トラック（CD盤・配信版シングル等）に正しく該当しうるため。ラジオオンエア・セットリストと同じ方針）
- 曲名からトラックが1件も解決できなかった場合も、`event_songs` には曲名・`seq` が残る（`event_song_tracks` への行のみ作成されない）。これはラジオオンエア・セットリストの「不明な曲」パターンと同じ考え方であり、将来イベントタイムライン UI に曲情報を表示する際の受け皿になる。ただし本書の時点ではイベントタイムライン UI への曲情報表示自体は対象外（画面設計は別途検討）

---

## 2b. `event_song_tracks`（イベント楽曲 × トラック紐付け）

```sql
CREATE TABLE event_song_tracks (
  event_song_id INTEGER       NOT NULL REFERENCES event_songs(event_song_id) ON DELETE CASCADE,
  track_id      VARCHAR(255)  NOT NULL REFERENCES tracks(track_id),
  PRIMARY KEY (event_song_id, track_id)
);
CREATE INDEX idx_event_song_tracks_track_id ON event_song_tracks (track_id);
```

| カラム名 | 型 | NOT NULL | 説明 |
| --- | --- | --- | --- |
| `event_song_id` | INTEGER | ✅ | FK → event_songs(event_song_id)。複合 PK の一部。曲削除時に連動削除（CASCADE） |
| `track_id` | VARCHAR(255) | ✅ | FK → tracks(track_id)。複合 PK の一部 |

セットリスト側にも同じパターンの中間テーブル（`setlist_tracks`）があり、詳細は[ライブ・フェス機能 テーブル設計書 §2-9](/design/live/live-data-design)を参照。

---

## 3. type（イベント種別）

| 値 | 説明 | 元データ | V1/V2 |
|---|---|---|---|
| `release` | シングル・アルバム発売 | releases | V1 |
| `live` | ライブ・ツアー | lives | V1 |
| `join` | メンバー加入 | members.json | V1 |
| `graduate` | メンバー卒業 | members.json | V1 |
| `media` | ラジオ・TV 出演 | radio_episodes | V1 |
| `topic` | その他の話題・発表 | manual | V1 |
| `festival` | 外部フェス出演 | festivals | V2追加予定 |

### festival type の仕様（V2）

- `festivals` テーブルからの派生データ
- `source = 'festival'`、`sourceId = festival.festivalId`
- `members` フィールドは空配列（メンバー×フェス紐づけテーブルが存在しないため）
- フェスが複数日にまたがる場合も `dateEnd` は持たない（セクション 3b 参照）。V2 時点では開始日のみ表示とし、期間表示が必要になった際に改めて検討する

---

## 3a. tags（タグ）仕様

`tags` フィールドは `type` とは別軸のイベント性質を表すラベル配列。フィルタリングやUIでの表示に使用する。

| タグ値 | 説明 | 設定対象 type の例 | 付与方法 |
|---|---|---|---|
| `OG` | OG メンバーが関与するイベント | 全 type | 自動（`assignTags`） |
| `Birthday` | 誕生日関連イベント | 全 type | 自動（`assignTags`） |
| `Solo` | メンバーのソロ活動 | `topic`, `live`, `media` | 手動（manual-events.json） |
| `Unit` | ユニット活動 | `release`, `live`, `topic` | 手動（manual-events.json） |
| `Special` | 記念・特別イベント | `live`, `topic` | 手動（manual-events.json） |
| `Collaboration` | 他アーティストとのコラボ | `release`, `topic` | 手動（manual-events.json） |

- タグは任意付与。付与なし（空配列）も有効
- 1イベントに複数タグを付与可能
- 自動付与タグ（`OG`・`Birthday`）は `sync-events.ts` の `assignTags()` 関数で付与される
  - `OG`: イベントに関与するメンバーのうち1名以上が `status='OG'`
  - `Birthday`: イベント日の UTC 月/日がいずれかの関与メンバーの `profile.dob` と一致
  - 全データソース（Releases / Members / Lives / Radio / Festivals / **Manual**）に適用される
- `data/inputs/manual-events.json` の `tags` フィールドで任意タグを明示指定できる
  - `tags` を**省略**した場合 → `assignTags()` が OG・Birthday を自動付与
  - `tags` を**明示指定**した場合（`[]` を含む）→ 明示値を優先し、`assignTags()` は適用されない

---

## 3b. dateEnd フィールドの要否

`dateEnd`（終了日）フィールドは当初 V2 設計として検討されたが、**追加しない**と判断した。

理由：
- `live` 型はツアーの各公演を1レコードずつ持つ設計のため、期間を持つ概念がない
- `festival` 型の元データ（`festivals` テーブル）は単日記録であり、終了日のデータソースがない
- タイムライン UI での期間表示が必要になった時点で、データ供給方法と合わせて改めて検討する

---

## 4. source / source_id の意味

events は既存データの派生であるため、どのデータから生成されたかを保持する。

| source 値 | sourceId の意味 | 例 |
|---|---|---|
| `release` | Release.releaseId | `"release_001"` |
| `member` | Member.memberId（members.json の id） | `"nakazawa-yuko"` |
| `live` | Live.liveId | `"2026B001"` |
| `radio` | RadioEpisode.episodeId | `"morning-musume-radio-20240517"` |
| `manual` | 任意（手動入力時に付与） | `"topic-2024-01"` |
| `festival` | Festival.festivalId | `"summer-sonic-2025"` |

---

## 5. 変換ロジック仕様

変換レイヤーの実装は #927 で行う。本書には入力元フィールドのマッピング仕様のみを記載する。

### 5-1. Releases → events(release)

| events カラム | 元フィールド |
|---|---|
| `date` | `release.releaseDate` |
| `type` | `"release"` |
| `title` | `release.title` |
| `members` | releases.json のメンバー紐づけ |
| `imageUrl` | Cover Art Archive から取得したジャケット画像 URL（後述）|
| `link` | releases.json の MV URL |
| `source` | `"release"` |
| `sourceId` | `release.releaseId`（= MusicBrainz リリースグループ MBID） |

### 5-2. Members → events(join / graduate)

#### 加入（join）

| events カラム | 元フィールド |
|---|---|
| `date` | `member.joined`（members.json） |
| `type` | `"join"` |
| `title` | `"{期}期加入"` |
| `members` | `[member.id]` |
| `imageUrl` | `member.image`（members.json） |
| `source` | `"member"` |
| `sourceId` | `member.id` |

#### 卒業（graduate）

| events カラム | 元フィールド |
|---|---|
| `date` | `member.graduated`（members.json） |
| `type` | `"graduate"` |
| `title` | `"{name} 卒業"` |
| `members` | `[member.id]` |
| `imageUrl` | `member.image`（members.json） |
| `source` | `"member"` |
| `sourceId` | `member.id` |

### 5-3. Lives → events(live)

| events カラム | 元フィールド |
|---|---|
| `date` | `live.date` |
| `time` | `live.time`（そのままコピー） |
| `type` | `"live"` |
| `title` | `live.title`（または `tour.title`） |
| `members` | `MemberLive` 経由の `memberId` 配列 |
| `source` | `"live"` |
| `sourceId` | `live.liveId` |

### 5-4. Radio → events(media)

| events カラム | 元フィールド |
|---|---|
| `date` | `radioEpisode.date` |
| `type` | `"media"` |
| `title` | `radioShow.name` |
| `members` | `RadioMember` 経由の `memberId` 配列 |
| `source` | `"radio"` |
| `sourceId` | `radioEpisode.episodeId` |

### 5-5. Manual → events(topic)

全フィールド手動入力。`source = "manual"`、`source_id` は任意の識別子を付与する。

`data/inputs/manual-events.json` の `time` フィールド（`"HH:MM"` 形式の文字列、任意）を指定すると、`time` カラムに変換される。省略時は既存値を保持する（`imageUrl` と同じ undefined/null の使い分け。#1381）。

#### 会場（`venueName` → `venueId`）

`data/inputs/manual-events.json` に任意項目 `venueName`（会場名の文字列）を指定すると、`events.venueId` に変換される。物理会場を持たないイベント（オンライン開催等）も扱えるよう `venueName` は省略可能とする。`venueName` を省略した場合、既存の `venueId` を保持する（`time` と同じ undefined 時保持パターン）。

解決手順は `register-tour-festival.ts`（[ライブ・フェス機能 テーブル設計書](/design/live/live-data-design)）の会場登録ロジックをそのまま流用する。

1. 既存 `venues.name` と完全一致する会場があれば、その `venueId` を再利用する
2. 一致しない場合、Google Maps Geocoding API（環境変数 `GEOCODING_API_KEY`）で `venueName` をそのままクエリとして緯度経度・都道府県名を取得する（`region=jp` に加え `language=ja` を指定し、都道府県名の表記言語を日本語に固定する。#1520）
3. 都道府県コードへの変換に失敗した場合、または API 呼び出し自体が失敗した場合は、当該会場名を警告ログに出力し、そのイベントの `venueId` は更新しない（既存値があればそれを保持、なければ NULL のまま。他のイベントの処理は継続する。グレースフルな失敗処理）
4. 新規解決できた会場は都道府県コード + 連番3桁で `venueId` を新規採番し、`venues` へ登録する

`GEOCODING_API_KEY` が未設定の場合、会場解決自体をスキップする（`venueName` 指定イベントは `venueId` を更新せず同期される。Releases / Lives / Radio 等、会場を伴わない他のイベント同期には影響しない）。

**本仕様は会場の「登録・変更」のみを扱う。**`venueName` に明示的な「未設定に戻す」値（`null` 等）は用意しない。一度 `venueId` が設定されたイベントについて、`venueName` を省略しても `venueId` は変更されない（前述の undefined 時保持）ため、会場を後から解除したい場合は本仕様の対象外（必要になった時点で別途検討する）。

#### 楽曲（`songs` → `event_songs` / `event_song_tracks`）

`data/inputs/manual-events.json` に任意項目 `songs`（`SongEntrySchema` の配列。`scripts/lib/song-entry-schema.ts`、`seq`・`songTitle` を持つ。ラジオオンエア・セットリストの入力と共通のスキーマ。詳細は[ラジオオンエア データ設計書 §4-0](/design/radio/radio-onair-data-design)参照）を指定すると、§2a「event_songs」・§2b「event_song_tracks」に変換される。

**同期は `seq` をキーにした差分方式。** `events` 本体が `source`＋`sourceId` をキーに差分upsert（`upsertEvent()`。既存レコードは `id` を保持したままUPDATE、新規のみINSERT）する既存設計と対称に揃える。`event_songs.event_id` は `events(id)` への FK のため、`event_songs`／`event_song_tracks` への書き込みは `upsertEvent()` で対象イベントの `id` が確定した**後**に行う（新規の手動イベントは `events` 行の INSERT より前に曲を書き込むことはできない。処理順序の詳細は[バッチ設計書 W6](/design/common/batch-design)参照）。`songs` を省略した場合、既存の `event_songs`／`event_song_tracks` を保持する（変更しない）。`songs` を明示指定した場合（`[]` を含む）は、そのイベントの既存 `event_songs`（`seq` で識別）と入力 `songs` を突き合わせ、以下のように処理を分ける。

| ケース | 処理 |
|---|---|
| 既存に同じ `seq` があり `songTitle` も同じ | `event_songs` 行は変更しない（`event_song_id` を保持） |
| 既存に同じ `seq` があるが `songTitle` が異なる | 該当行の `songTitle` のみ UPDATE（`event_song_id` は保持） |
| 既存にない `seq` | 新規 INSERT（新しい `event_song_id` を採番） |
| 入力 `songs` に含まれない既存 `seq` | 該当行を DELETE（`event_song_tracks` は CASCADE で連動削除） |

- **本仕様は「楽曲の登録・追加・削除・全削除」のいずれも扱う。** 入力 `songs` 配列に曲を足せば追加、配列から曲を除けば削除、配列を `[]` にすれば全削除になる
- `event_song_id` は、対応する `seq` の曲が存在し続ける限り再同期をまたいで保持される。内容に変更がない曲について `event_songs` 行を毎回 delete→re-create することはしない
- 一方 `event_song_tracks`（トラック紐付け）は、`songs` に含まれる**全曲**について毎回 `buildTrackCandidatesMapFromPrisma` で再解決し、該当 `event_song_id` の既存リンクを削除してから書き直す。`event_song_tracks` は複合PK（`event_song_id`・`track_id`）のみで独自の代理キーを持たないため、`event_songs` と異なり洗い替えても ID の再採番は発生しない。この再解決を毎回行う理由は、曲名からtrackIdが1件も解決できなかった曲（発表当初はTrack未登録の新曲等）が、後日 Track が正式登録された時点で自動的に紐付くようにするため（追加の再解決バッチを別途用意せずに済む）

1. `songs` 内の各曲について、`lib/track-match.ts` の `buildTrackCandidatesMapFromPrisma` で曲名から該当する全 `trackId` 候補を解決する（1曲が複数トラック（CD盤・配信版シングル等）に正しく該当しうるケースへの対応。ラジオオンエア・セットリストと同じロジックを再利用する）
2. 上記の差分ルールに従って `event_songs` を INSERT／UPDATE／DELETE する
3. 各曲（`event_song_id` が確定したもの全て）について、既存の `event_song_tracks` を削除し、解決できた `trackId` を全件登録し直す（`lib/track-match.ts` の `buildTrackJunctionRows` を再利用）
4. 該当トラックが1件も見つからない曲は、`event_songs` には登録するが（曲名・`seq` は残る）、`event_song_tracks` への登録は行わない。該当曲名を警告ログに出力する（`register-tour-festival.ts` の `unresolvedSongTitles` と同じ扱い）

### 5-6. Festivals → events(festival)（V2追加予定）

| events カラム | 元フィールド |
|---|---|
| `date` | `festival.date` |
| `type` | `"festival"` |
| `title` | `festival.name` |
| `members` | `[]`（メンバー×フェス紐づけなし） |
| `source` | `"festival"` |
| `sourceId` | `festival.festivalId` |

---

## 6. imageUrl 管理方針

### 6-1. release イベントのジャケット画像（#1070）

`source = 'release'` のイベントは **Cover Art Archive（CAA）** からジャケット画像を取得して `imageUrl` に保存する。

#### データフロー

```
releases.releaseId
  = MusicBrainz リリースグループ MBID
    └─ https://coverartarchive.org/release-group/{MBID}/front
         → 307 リダイレクト → archive.org 上の実画像 URL
              → events.imageUrl に保存
```

#### 取得タイミング

`imageUrl` の自動設定は行わず、以下のタイミングで手動実行する。

| タイミング | 手順 |
|---|---|
| 初回一括バックフィル | `bun run scripts/patch/fetch-release-images.ts` を実行 |
| 新シングル発売後 | MusicBrainz 登録・sync-discography・sync-events 完了後に同スクリプトを実行 |

> 詳細な手順は `docs/operations/discography-operations.md` の「新シングル発売時のカバーアート取得手順」を参照。

#### 取得対象外

- `source = 'manual'`（`manual-` プレフィックス ID）の手動登録リリース→ MusicBrainz MBID を持たないため CAA 取得不可

### 6-2. release 以外のイベントの画像表示（#1070、プレースホルダー方式見直し #1472）

`imageUrl = NULL` かつ `type ≠ release` のイベントは、EventCard コンポーネントが種別に応じたプレースホルダーを動的生成する。DB への保存は不要。プレースホルダーは表示内容によって2方式に分かれる。

**方式A: SVG テキストアート**（`live`・`join`・`graduate`・`leader`）

タイトル文字列そのものではなく、種別ごとに合成した短い定型テキスト（開催年・`JOIN`/`GRAD` ラベル・メンバー名等）を固定サイズの `viewBox` に描画する。表示内容が短く定型のため、折り返しや省略は発生しない。

| type | SVG に表示する内容 |
|---|---|
| `live` | 開催年 + 季節（UTC 月から算出: 3〜5月→春、6〜8月→夏、9〜11月→秋、12〜2月→冬） |
| `join` | 1行目: `JOIN` / 2行目: メンバー名（1行に収まる範囲、超過は省略） |
| `graduate` | 1行目: `GRAD` / 2行目: メンバー名（1行に収まる範囲、超過は省略） |
| `leader` | 1行目: `第X代` / 2行目: `リーダー就任` |

**方式B: HTML テキストブロック**（`media`・`topic`・`festival`、#1472）

`events.title` をそのまま全文表示する種別。SVG の `<text>` は CSS の折り返し（`white-space`/`overflow-wrap`）に対応しておらず、行数を固定して溢れた分を `…` で省略する方式だと、タイトル長が可変な限り必ず一部のケースで全文を表示できなくなる問題があった（#1472で発覚）。そのため、これらの type は `<div>` 等の HTML ブロック（`overflow-wrap: break-word`）で描画し、コンテナは固定の縦横比をやめて `min-height` のみを指定し、コンテンツ量に応じて高さが伸びる可変レイアウトにする。行数の上限は設けず、`events.title` を省略なく全文表示する。

| type | HTML ブロックに表示する内容 |
|---|---|
| `media` | 番組名（`events.title`、全文） |
| `topic` | タイトル（`events.title`、全文） |
| `festival` | フェス名（`events.title`、全文） |

### 6-3. タイトル表示の重複回避（#1472）

EventCard は画像・プレースホルダー領域の下に `<h3>` でタイトルを別途表示するが、6-2 の方式Bはプレースホルダー自体に `events.title` の全文を表示するため、`imageUrl` 未設定時は `<h3>` と内容が完全に重複する。

- `imageUrl = NULL` かつ 6-2 方式B対象の type（`media`・`topic`・`festival`）の場合のみ、`<h3>` タイトルを非表示にする
- 上記以外（`imageUrl` が設定されている場合、または方式A対象の type）は、プレースホルダー側がタイトル文字列自体を表示しないため、従来通り `<h3>` を表示する

---

## 7. インデックス

| インデックス対象 | インデックス種別 | 理由 |
|---|---|---|
| `date` | B-tree | タイムライン表示（`ORDER BY date`）の主要クエリキー |
| `type` | B-tree | イベント種別フィルタリング |
| `source, sourceId` | B-tree | 元データからのルックアップ・重複排除 |
| `tags` | GIN | タグ配列フィルタリング（`@> ARRAY[...]`）（フィルタ機能実装時に追加予定） |

### 7-1. `TO_CHAR(date, ...)` による日付比較クエリのキャッシュ方針（#1551）

「過去のこの日」（トップページ）・タイムラインの年一覧取得は、`date` の月日・年だけを取り出して比較するため `TO_CHAR()` を使った生SQLクエリになっている。`TO_CHAR()` は `date` カラムに関数をかけてから比較する形になり、7. の `date` への B-tree インデックスが使えず全件スキャンになる。

| 関数 | 用途 | クエリ内容 |
| --- | --- | --- |
| `lib/on-this-day.ts` `getOnThisDayEvents(monthDay)` | トップページ「過去のこの日」セクション | `WHERE TO_CHAR(date, 'MM-DD') = ${monthDay}` |
| `lib/timeline.ts` `getTimelineYears()` | タイムラインページの年タブ一覧 | `SELECT DISTINCT TO_CHAR(date, 'YYYY') AS year FROM events` |

いずれも `unstable_cache`（revalidate: 3600秒）でラップし、DBへのクエリ発行自体を最大1時間に1回へ抑える（`lib/youtube.ts` と同パターン）。全件スキャンそのものを解消する関数インデックスの追加は本番DBへのマイグレーションを伴うため別Issue（#1554）で扱う。

備考: 以前は `getOnThisDayEvents()` にキャッシュがなく、アクセスの度にNeon DBへ生クエリを発行していた。同じトップページ内の「新着投稿」セクション（キャッシュ経由でほぼ即時に返る）とは別々の `Suspense` で並行ストリーミングされるため、データ取得が先に完了した方から画面に反映される仕様と相まって、`app/page.tsx` 上でのセクション配置順（過去のこの日が先）と実際の表示順が逆転する不具合が発生していた（#1551で修正）。

---

## 8. タイムライン UI 表示仕様

### 8-1. 年別表示（#998）

- タイムラインページは **1年分のイベントのみ** を表示する
- URL クエリパラメータ `year`（例: `/timeline?year=2024`）で表示年を指定する
- 未指定・無効値の場合は最新年を表示する
- ページ上部にすべての年のタブリンクを表示し、選択中の年をハイライトする

### 8-2. ライブイベントのグループ表示（#997）

同一ツアー（`title` が同じ `type=live` イベント群）は1グループとして折りたたんで表示する。

| グループの公演数 | 表示方法 |
|---|---|
| 1件 | 通常のフルサイズカード（会場名を日付の後に表示） |
| 2件 | 2枚のフルサイズカードを並列表示（会場名あり） |
| 3件以上 | 初日・千秋楽をフルサイズカード、中間日をコンパクトリスト（日付 / 会場 / 開演時刻）で表示 |

- 会場名・開演時刻は `lives` テーブルを `sourceId`（liveId）経由でジョインして取得する
- 同日に複数公演がある場合は1行にまとめ、時刻を `"14:00 / 18:00"` 形式で列挙する
- 開演時刻が `00:00`（未設定）の場合は時刻を表示しない

### 8-3. EventCard の時刻表示（#1381）

ツアーグループ化されない単発イベント（`EventCard` で直接描画される全 type）は、`events.time` を日付の直後に表示する。

- `time` が NULL または `00:00` の場合は表示しない（8-2 の Live 表示ルールと同じ判定関数 `shouldShowTime`/`formatLiveTime` を共通利用）
- `type=live` のツアーグループ表示（8-2）は `lives` テーブルの時刻を別途取得して表示するため、`events.time` の表示とは独立している
- 8-2 のツアーグループで会場欄に複数公演の時刻一覧（`"14:00 / 18:00"`）が含まれる代表カード（初日・千秋楽、または同日2件時の1枚カード）は、時刻が会場欄と二重表示にならないよう `events.time` 自体の表示を抑制する（#1472）

### 8-4. イベントの並び順（#1472）

タイムライン表示（年・週・日・年表の各ビュー）で `events` を取得するクエリは、`date` 昇順に加えて **`time` を副次ソートキー** として指定する（`orderBy: [{ date: "asc" }, { time: "asc" }]`）。

- `date` のみで `ORDER BY` すると、同日内のタイ（同値）の並び順は Postgres が保証しないため、物理的な行順（挿入順等）が表示順になり不安定になる（#1472で発覚）
- `time` が NULL のイベントは Postgres の `ASC` デフォルト挙動（`NULLS LAST`）により末尾に並ぶ

---

## 改訂履歴

| 版 | 更新日 | 変更内容 |
| --- | --- | --- |
| 2.5 | 2026-08-30 | 手動イベント登録に「会場」「楽曲」属性を追加する設計を追記。`events.venueId` カラム、`event_songs`・`event_song_tracks` テーブル（§2a・§2b）を新設。§5-5 に `venueName` → `venueId` の解決手順（`register-tour-festival.ts` の会場登録ロジックを流用）と `songs` → `event_songs`/`event_song_tracks` の変換手順（ラジオオンエア・セットリストと共通の `SongEntrySchema`・`buildTrackCandidatesMapFromPrisma` を再利用）を追加。実装は別Issueで対応（MorningStatusApp #1528, morning-status-blume#67） |
| 2.4 | 2026-08-23 | 7-1. `TO_CHAR(date, ...)` を使う日付比較クエリ（`getOnThisDayEvents`・`getTimelineYears`）の `unstable_cache` 化を追記。全件スキャン解消（関数インデックス追加）は別Issue（#1554）に切り出したことを明記（#1551） |
| 2.3 | 2026-08-03 | タイムライン改善（#1472）: 同日イベントの並び順仕様（8-4）を追加、ツアーグループ代表カードの時刻二重表示回避仕様（8-3）を追加、`media`/`topic`/`festival` のプレースホルダーをSVG2行折り返しからHTML全文表示（可変高さ）に変更し6-2を更新、タイトル重複回避仕様（6-3）を追加 |
| 2.2 | 2026-07-20 | `time` カラムを追加。Lives → events(live) の変換仕様・Manual の time 入力仕様・EventCard の時刻表示仕様（8-3）を追記（#1381） |
| 2.1 | 2026-06-14 | tags フィールドを実装済みに更新。OG・Birthday 自動タグ仕様を追記。手動タグ付与フローを追記（#1110） |
| 2.0 | 2026-06-13 | V2 拡張設計を追記（festival type・tags フィールド・dateEnd フィールド・GIN インデックス）。セクション番号重複を修正（#1108） |
| 1.3 | 2026-06-05 | imageUrl 管理方針を追加（CAA によるジャケット画像取得・SVG テキストアート）（#1070） |
| 1.2 | 2026-05-29 | タイムライン UI 表示仕様を追加（年別表示・ライブグループ表示）（#997, #998） |
| 1.1 | 2026-05-24 | カラム名を実際の DB カラム名（camelCase）に修正。id・updatedAt のデフォルト値記述を修正（#989） |
| 1.0 | 2026-05-24 | 初版作成（#926） |
