Design
Context¶
現在の MemberDetailPage は、メンバーのセッション出席履歴を勉強会別アコーディオンで1カラム表示している。データ取得フローは以下のとおり:
index.jsonを取得してメンバーのsessionIdsと勉強会名マップを取得- 各セッションJSONを
Promise.all()で並列取得 groupIdをキーにグルーピングし、各グループの参加回数・合計時間・セッション一覧を算出- アコーディオン展開で各グループのセッション履歴を表示
セッションデータには date フィールド(YYYY-MM-DD 形式)が存在しており、この日付から年度の上期・下期を算出できる。データモデル(index.json / sessions/*.json)の変更は不要。
Goals / Non-Goals¶
Goals:
- セッションを年度の上期(4〜9月)・下期(10〜3月)に分類する純関数を追加する
- 左列に期サマリーリスト、右列に選択した期の勉強会別アコーディオンを表示する2カラムレイアウトを実装する
- レスポンシブ対応(lg以上で2カラム、それ未満で縦積み)
- dev-fixtures に2025年度上期のセッションデータを追加し、期の切り替えをデモ可能にする
Non-Goals:
- DataFetcher やサービス層の変更
- データモデル(
index.json/sessions/*.json)のスキーマ変更 - ルーティング構造の変更(URLは
#/members/:memberIdのまま) - 期のフィルタ状態のURL永続化(URLパラメータ等)
- ページネーションやlazyロード
Decisions¶
D1: 期の算出ユーティリティを src/utils/ に配置する¶
選択: src/utils/fiscal-period.js に getFiscalPeriod(dateString) 関数を新規作成する。日付文字列(YYYY-MM-DD)を受け取り、{ fiscalYear: number, half: 'first' | 'second', label: string } を返す純関数とする。
理由: 期の算出ロジックはビジネスルール(上期=4〜9月、下期=10〜3月、年度は4月始まり)に基づく汎用的な変換であり、ページコンポーネントに閉じ込めるべきではない。純関数として分離することで単体テストが容易になり、将来的にグループ詳細ページ等でも再利用できる。
代替案: MemberDetailPage 内にインライン定義する → テスタビリティが低下するため不採用。
D2: 期のソートキー¶
選択: 期の降順ソートには fiscalYear * 10 + (half === 'second' ? 1 : 0) のスコア値を使用する。数値の降順ソートで「2025年度下期 → 2025年度上期 → 2024年度下期 → ...」の順序を保証する。
理由: 文字列ソート(ラベル)では年度は正しくソートされるが「上期」「下期」の順序が日本語五十音順に依存して不安定になる。数値スコアにより確実な降順を実現する。
D3: グルーピングの2段処理¶
選択: セッション取得後のグルーピングを2段階で行う。
- 第1段: セッションを期別にグルーピング(
getFiscalPeriod(date)のラベルをキーに) - 第2段: 選択した期内のセッションを既存のグループ別グルーピングロジックで処理
理由: 既存のグループ別グルーピングロジック(groupMap 構築)をそのまま活用できる。第1段の期別分類を追加するだけで、既存コードの変更を最小限に抑えられる。
代替案: 期とグループを同時にグルーピング(複合キー) → 既存のアコーディオン表示ロジックとの互換性が低下するため不採用。
D4: state 構造¶
選択: 既存の groupAttendances state を期別にネストした構造に変更する。
periodAttendances: [
{
label: "2025年度 下期",
fiscalYear: 2025,
half: "second",
sortKey: 20251,
totalSessions: 15,
totalDurationSeconds: 56983,
groupAttendances: [
{
groupId: string,
groupName: string,
sessionCount: number,
totalDurationSeconds: number,
sessions: [{ date, durationSeconds }]
}
]
}
]
新しい state:
- periodAttendances: 期別にグルーピングされた全データ(降順ソート済み)
- selectedPeriodLabel: 選択中の期のラベル(デフォルトは最新期)
既存の expandedGroups state はそのまま維持し、アコーディオンの展開/折りたたみに使用する。
D5: レスポンシブ2カラムレイアウト¶
選択: Tailwind CSS の lg:grid lg:grid-cols-[280px_1fr] を使用する。左列は固定幅280px、右列は残り幅を占める。lg 未満では grid-cols-1 で縦積みになる。
理由: Tailwind CSS 4 のグリッドレイアウトで簡潔に実装でき、左列のサマリーリストは短いテキスト(「2025年度 上期」+ 統計値)なので固定幅が適切。flex よりも grid のほうが列幅の制御が明示的。
D6: 期サマリーの選択UI¶
選択: 左列の期サマリーは button 要素のリストとして実装する。選択中の期には bg-primary-50 border-l-4 border-l-primary-500 のスタイルを適用し、非選択は hover:bg-surface-muted とする。
理由: 既存の MemberDetailPage のアコーディオンボタンと同様のインタラクションパターンに統一する。aria-pressed 属性で選択状態をアクセシブルに伝達する。
D7: dev-fixtures データの追加方針¶
選択: 2025年度上期のセッションを4件追加する。フロントエンド勉強会(b20ae593)に2件、TypeScript読書会(75aa4f8f)に2件。主要メンバー3名(d2ede157, 7c35db6e, 70c5a8d7)が参加するデータとする。
理由: 4件あれば期サマリーの参加回数・合計時間の集計と、勉強会別アコーディオンの2グループ表示がデモできる。過剰なデータ追加はフィクスチャの保守負荷を増やすため最小限にする。
Risks / Trade-offs¶
- [リスク] 期が1つしかない場合のUX: 現在のdev-fixturesデータのみの環境では全セッションが2025年度下期に集中し、左列に1期しか表示されない → 2カラムレイアウトの意味が薄くなるが、期の追加に伴い自然に解消される。デモ用に上期データを追加することで開発中の動作確認を担保する。
- [トレードオフ] グルーピング2段処理のメモリ使用量: 全セッションを期別・グループ別にグルーピングして保持するため、セッション数が多い場合にメモリ使用量が増加する → 現時点のデータ規模(数十件程度)では問題なし。将来的にデータ量が増えた場合は期ごとの遅延ロードを検討する。
- [トレードオフ] expandedGroups の期間横断: アコーディオンの展開状態は期をまたいで保持される(期Aで展開したグループは期Bに切り替えても展開状態が残る)→ 同じ
groupIdのアコーディオンが開いたままになるが、UXとしては「前回見ていたグループがまだ開いている」ので自然と判断。期切替時にリセットする方式は、ユーザーが期を行き来する際に毎回展開し直す手間が増えるため不採用。