• #UI設計
  • #Vue.js
  • #Nuxt
  • #カレンダー
  • #ブログ
  • #CSS Grid
  • #実装報告
開発blog-platform完了

ブログ記事カレンダービュー実装報告

実装ステータス: ✅ 完了 実装日: 2025年11月28日 ファイル: apps/web/app/components/BlogCalendar.vue

実装サマリー

Googleカレンダー風のUIでブログ記事を月次カレンダー表示する機能を実装しました。当初の計画から一部仕様を変更し、以下の特徴を持つカレンダービューが完成しました:

  • ✅ 全記事を表示(制限なし)
  • ✅ 記事タイトルの折り返し表示
  • ✅ シンプルなデザイン(装飾なし)
  • ✅ CSSグリッドレイアウトの最適化

1. プロジェクト概要

ブログインデックスページに、Googleカレンダー風のUIで記事を日付ごとに表示する新機能を追加する。

目的

  • 時系列でブログ記事を視覚的に把握しやすくする
  • 執筆活動の頻度を一目で確認できるようにする
  • ユーザーが興味のある日付の記事を素早く見つけられるようにする

2. 現状分析

既存のブログインデックスページ

  • 場所: apps/web/app/pages/blog/index.vue
  • 表示形式: テーブル一覧 + ページネーション
  • データソース: queryContent() APIで記事を取得

記事データ構造

interface BlogArticle {
  title: string
  description: string
  path: string
  tags: string[]
  publishedAt: string  // YYYY-MM-DD形式
}

3. 提案機能

3.1 カレンダービュー

  • 表示形式: 月次カレンダーグリッド(7列 × 5-6行)
  • 日付セルの内容:
    • 日付番号
    • その日に公開された記事のタイトル(複数ある場合は全て表示)
    • 記事タイトルはクリック可能で、記事ページへ遷移
  • 現在の月: デフォルトで今月(2025年11月)を表示

3.2 ナビゲーション

  • 前月/次月ボタン: < > ボタンで月を切り替え
  • 月表示: 2025年 12月 のような形式でヘッダーに表示

3.3 UI要素

  • 今日の日付: 背景色やボーダーで強調表示
  • 記事のある日: 記事タイトルを表示(複数ある場合はリスト形式)
  • 記事のない日: 日付のみ表示
  • 曜日ヘッダー: 月, 火, 水, 木, 金, 土, 日

4. 技術仕様

4.1 使用技術

  • Vue 3 Composition API
  • Nuxt Content API: queryContent() で記事データ取得
  • TypeScript: 型安全性の確保
  • CSS Grid: カレンダーグリッドレイアウト

4.2 新規コンポーネント

BlogCalendar.vue

カレンダービュー本体のコンポーネント

Props:

interface Props {
  articles: BlogArticle[]  // 全記事データ
}

State:

const currentYear = ref<number>(new Date().getFullYear())
const currentMonth = ref<number>(new Date().getMonth()) // 0-11

interface CalendarDay {
  date: number
  isCurrentMonth: boolean
  isToday: boolean
  articles: BlogArticle[]
}

const calendarDays = computed<CalendarDay[]>(() => {
  // カレンダーグリッド生成ロジック
})

Methods:

const goToPreviousMonth = () => {
  if (currentMonth.value === 0) {
    currentMonth.value = 11
    currentYear.value--
  } else {
    currentMonth.value--
  }
}

const goToNextMonth = () => {
  if (currentMonth.value === 11) {
    currentMonth.value = 0
    currentYear.value++
  } else {
    currentMonth.value++
  }
}

const getArticlesForDate = (year: number, month: number, date: number): BlogArticle[] => {
  const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(date).padStart(2, '0')}`
  return articles.filter(article => article.publishedAt === dateStr)
}

5. 実装手順

Step 1: BlogCalendarコンポーネントの作成

  • apps/web/app/components/BlogCalendar.vue を作成
  • カレンダーグリッドの基本構造を実装
  • 月の日数計算ロジックを実装
  • 前月/次月の空白セルを含む42セル(6週間分)のグリッド生成

Step 2: データ連携

  • 記事データを日付ごとにグループ化
  • 各日付セルに記事タイトルを表示
  • 記事タイトルのクリックで記事ページへ遷移

Step 3: ナビゲーション機能

  • 前月/次月ボタンの実装
  • 月・年の表示
  • 今日の日付へジャンプする機能(実装済み)

Step 4: スタイリング

  • Googleカレンダー風のデザイン
  • レスポンシブ対応(モバイル・タブレット)
  • 今日の日付の強調表示
  • 記事のある日のホバーエフェクト

Step 5: ブログインデックスページへの統合

  • apps/web/app/pages/blog/index.vue にカレンダービューを追加
  • テーブルビューとカレンダービューの切り替え機能(実装済み)
  • デフォルトの表示モードを設定

Step 6: テストと最適化

  • 各月の日数が正しく表示されるか確認
  • うるう年の2月が正しく処理されるか確認
  • 記事の多い日の表示を確認(UI崩れを修正済み)
  • パフォーマンスの確認

6. コンポーネント設計

ファイル構成

apps/web/app/
├── components/
│   └── BlogCalendar.vue       # 新規作成
├── pages/
│   └── blog/
│       └── index.vue          # 既存ファイル(修正)

BlogCalendar.vueの構造

<template>
  <div class="blog-calendar">
    <!-- ヘッダー: 前月/次月ボタン + 年月表示 -->
    <div class="calendar-header">
      <button @click="goToPreviousMonth">&lt;</button>
      <h2>{{ currentYear }}年 {{ currentMonth + 1 }}月</h2>
      <button @click="goToNextMonth">&gt;</button>
    </div>

    <!-- 曜日ヘッダー -->
    <div class="calendar-weekdays">
      <div v-for="day in weekdays" :key="day">{{ day }}</div>
    </div>

    <!-- カレンダーグリッド -->
    <div class="calendar-grid">
      <div
        v-for="day in calendarDays"
        :key="`${day.date}-${day.isCurrentMonth}`"
        class="calendar-day"
        :class="{
          'is-today': day.isToday,
          'has-articles': day.articles.length > 0,
          'other-month': !day.isCurrentMonth
        }"
      >
        <div class="date-number">{{ day.date }}</div>
        <div class="articles-list">
          <NuxtLink
            v-for="article in day.articles"
            :key="article.path"
            :to="article.path"
            class="article-link"
          >
            {{ article.title }}
          </NuxtLink>
        </div>
      </div>
    </div>
  </div>
</template>

7. データフロー

blog/index.vue
  ↓ queryContent().find()
  ↓ (全記事データ取得)
  ↓
BlogCalendar.vue (props: articles)
  ↓ computed: calendarDays
  ↓ (年月に基づいてカレンダーグリッド生成)
  ↓ getArticlesForDate()
  ↓ (日付ごとに記事をフィルタリング)
  ↓
カレンダーセル表示
  ↓ クリック
  ↓
記事ページへ遷移

8. UI/UX詳細

8.1 レイアウト

  • カレンダーグリッド: CSS Grid(7列 × 6行)
  • 最大幅: 1400px
  • セルの最小高さ: 120px(記事タイトルが複数入る余裕を持たせる)

8.2 色とスタイル

  • 今日の日付: 背景色を薄い青に設定(例: #e3f2fd
  • 記事のある日: 記事タイトルを青いテキストで表示
  • 記事のない日: グレーの日付のみ
  • 他月の日付: 薄いグレーで表示
  • ホバーエフェクト: 記事タイトルにアンダーライン

8.3 レスポンシブ対応

  • デスクトップ(>1024px): 7列グリッド
  • タブレット(768px-1024px): 7列グリッド(フォントサイズ調整)
  • モバイル(<768px): 簡易リスト表示、または横スクロール

9. 追加機能(オプション)

9.1 ビュー切り替え

  • テーブルビューとカレンダービューの切り替えボタン
  • ユーザーの好みを localStorage に保存

9.2 フィルタリング

  • タグでフィルタリング
  • カレンダー上で特定のタグの記事のみを表示

9.3 記事数バッジ

  • 日付の隣に記事数を表示(例: 15 (3) → 15日に3記事)

9.4 今日にジャンプ

  • 「今日」ボタンで現在の月に戻る機能

10. 実装上の注意点

  1. 日付計算: JavaScriptのDateオブジェクトは月が0始まり(0-11)なので注意
  2. タイムゾーン: 記事のpublishedAtがUTC/JSTのどちらかを確認
  3. パフォーマンス: 記事数が多い場合、computedで効率的にフィルタリング
  4. アクセシビリティ: キーボードナビゲーション、スクリーンリーダー対応
  5. 記事が多い日の表示: 1日に10記事以上ある場合のUI(スクロール or 省略表示)

11. 参考実装

カレンダー生成の基本ロジック:

function generateCalendarDays(year: number, month: number): CalendarDay[] {
  const firstDay = new Date(year, month, 1)
  const lastDay = new Date(year, month + 1, 0)
  const daysInMonth = lastDay.getDate()
  const startDayOfWeek = firstDay.getDay() // 0=日曜, 1=月曜, ...

  const days: CalendarDay[] = []

  // 前月の日付で埋める
  const prevMonthLastDay = new Date(year, month, 0).getDate()
  for (let i = startDayOfWeek - 1; i >= 0; i--) {
    days.push({
      date: prevMonthLastDay - i,
      isCurrentMonth: false,
      isToday: false,
      articles: []
    })
  }

  // 当月の日付
  const today = new Date()
  for (let i = 1; i <= daysInMonth; i++) {
    const isToday = year === today.getFullYear() &&
                    month === today.getMonth() &&
                    i === today.getDate()
    days.push({
      date: i,
      isCurrentMonth: true,
      isToday,
      articles: getArticlesForDate(year, month, i)
    })
  }

  // 次月の日付で埋める(42セルになるまで)
  const remainingCells = 42 - days.length
  for (let i = 1; i <= remainingCells; i++) {
    days.push({
      date: i,
      isCurrentMonth: false,
      isToday: false,
      articles: []
    })
  }

  return days
}

12. スケジュール

  • Phase 1: BlogCalendarコンポーネント基本実装(2-3時間)
  • Phase 2: データ連携とナビゲーション(1-2時間)
  • Phase 3: スタイリングとレスポンシブ対応(2-3時間)
  • Phase 4: テストと調整(1時間)

合計: 6-9時間

13. 成功基準

  • カレンダーが正しく月次で表示される
  • 前月/次月ナビゲーションが動作する
  • 記事タイトルが正しい日付に表示される
  • 記事タイトルクリックで記事ページに遷移する
  • 今日の日付が強調表示される
  • モバイルでも快適に閲覧できる
  • パフォーマンスの問題がない(1000記事以上でも快適)

14. 実装結果と変更点

14.1 当初計画からの変更

実装中にユーザー要求により、以下の仕様変更を行いました:

記事表示数の制限撤廃

当初計画:

  • 1日あたり3記事まで表示
  • 4記事以上ある場合は「+N more」と表示

変更後:

  • 全記事を表示(制限なし)
  • セルの高さを自動調整(grid-auto-rows: minmax(120px, auto)

記事タイトルの表示方法

当初計画:

  • 1行に省略表示(text-overflow: ellipsis
  • ホバーで完全なタイトルを表示

変更後:

  • 複数行に折り返し表示word-wrap: break-word
  • 長いタイトルも全文読める

デザインのシンプル化

当初計画:

  • 記事タイトルの前に青いドット(●)を表示
  • リッチな装飾

変更後:

  • ドットを削除してシンプルに
  • 記事タイトルのみをブロック表示

14.2 技術的課題と解決策

課題1: CSSグリッド列幅の不均等問題

問題:

  • 長い記事タイトルによってグリッド列が拡大
  • repeat(7, 1fr) を指定しても、一部の列幅が2000px以上に
  • 結果、画面右側の日付(30日、31日など)が表示されない

原因:

  • CSSグリッドのデフォルト動作: グリッドアイテムは内容に応じて拡大される
  • min-width: auto(デフォルト)により、長いテキストが列を押し広げる

解決策:

/* グリッドアイテムがコンテンツで拡大されるのを防ぐ */
.calendar-day {
  min-width: 0;  /* 重要: グリッドアイテムの縮小を許可 */
}

.articles-list {
  min-width: 0;
}

.article-link {
  min-width: 0;
  word-wrap: break-word;
  overflow-wrap: break-word;
}

.article-title {
  word-wrap: break-word;
  overflow-wrap: break-word;
}

技術的解説:

  • min-width: 0 はCSS Gridの重要なテクニック
  • デフォルトの min-width: auto はコンテンツの最小幅を保証するが、グリッドレイアウトを崩す
  • min-width: 0 により、グリッドアイテムは親の制約内で縮小可能になる
  • 参考: CSS Grid Layout and The New min-width: auto

課題2: 日付番号の視認性

問題:

  • 初期実装で日付番号が小さく見づらい
  • 記事リストに埋もれて視認性が低い

解決策:

.date-number {
  font-size: 1.1rem;      /* 0.85rem から拡大 */
  font-weight: 600;       /* セミボールド */
  min-width: 30px;
  min-height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* 今日の日付はさらに大きく */
.calendar-day.is-today .date-number {
  min-width: 34px;
  min-height: 34px;
  background-color: #1a73e8;
  color: #fff;
  border-radius: 50%;
  font-weight: 700;
}

課題3: 記事タイトルの折り返し

問題:

  • 当初の white-space: nowrap により1行表示
  • 長いタイトルが省略され、内容が分かりづらい

解決策:

.article-link {
  display: block;          /* flex から block に変更 */
  word-wrap: break-word;   /* 長い単語を折り返す */
  overflow-wrap: break-word;
  line-height: 1.3;        /* 行間を調整 */
}

14.3 最終的な実装の特徴

  1. レスポンシブグリッド: 7列均等配置(repeat(7, 1fr)
  2. 高さの自動調整: 記事数に応じてセルが伸縮
  3. 視認性の向上: 日付番号を大きく太く表示
  4. 情報量の最大化: 全記事を折り返し表示
  5. シンプルなデザイン: 不要な装飾を削除

14.4 パフォーマンス

  • 記事総数: 100+ 記事
  • 最大記事数/日: 12記事(2025年11月27日)
  • レンダリング: 問題なし
  • グリッド計算: computed による効率的なフィルタリング

14.5 今後の改善案

  1. 仮想スクロール: 記事数が1000件を超えた場合の最適化
  2. タグフィルター: カレンダー上で特定タグの記事のみ表示
  3. 年ビュー: 12ヶ月分のミニカレンダー表示
  4. 記事プレビュー: ホバーで記事の説明を表示
  5. エクスポート機能: カレンダーをPDF/画像でエクスポート

15. 学んだこと

  1. CSS Gridのmin-width: 0の重要性: グリッドアイテムの縮小を許可する必須テクニック
  2. コンテンツ駆動のレイアウト: 固定高さより自動調整の方が柔軟
  3. 段階的な要件変更: 実装中のフィードバックで仕様が進化
  4. Chrome DevToolsの活用: 実際の表示を確認しながらデバッグ

16. 参考資料


実装完了: 2025年11月28日 作成者: Claude Code with AI Assistant