GitHub風投稿カレンダー機能の実装:Astroで作るcontribution graph

GitHub風のcontribution graphライクな投稿カレンダー機能をAstroで実装。データ処理、UI設計、レスポンシブ対応まで、段階的な開発プロセスと技術的判断を詳細記録。

約166分で読めます
#Astro#JavaScript#Calendar#GitHub#Data Visualization#Component Design

GitHub風投稿カレンダー機能の実装記録

今回は、ブログサイトにGitHub風のcontribution graphライクな投稿カレンダー機能を実装しました。投稿頻度を視覚的に表現し、学習の継続性を一目で確認できる機能です。実装の全プロセスと技術的判断を詳しく記録します。

実装動機:なぜ投稿カレンダーが必要だったか

課題の認識

ブログを継続的に書いていく中で、以下の課題を感じていました:

  1. 投稿頻度の可視化不足: どれくらいの頻度で投稿しているかが分からない
  2. 継続性の把握困難: 学習の継続性を客観的に評価できない
  3. モチベーション維持: 視覚的なフィードバックがない

GitHub contribution graphからの着想

GitHubのcontribution graphは、開発者の活動を視覚的に表現する優れたUIです:

この仕組みをブログ投稿に応用することで、学習の継続性を可視化できると考えました。

設計フェーズ:データ構造とUI設計

データ構造の設計

まず、投稿データをカレンダー表示用に変換する構造を設計しました:

// 基本的なデータフロー
投稿データ(Markdown) → 日付別集計 → カレンダー表示データ → UI表示

データ変換の考え方:

  1. 入力: Astroのimport.meta.glob()で取得した投稿配列
  2. 中間: 日付をキーとした投稿数の集計
  3. 出力: カレンダー表示用の日付データ配列

カレンダー表示の技術仕様

// カレンダーデータの構造設計
const calendarData = {
  postsByDate: {
    "2025-06-29": {
      count: 2,
      posts: [post1, post2]
    }
  },
  totalPosts: 6,
  activeDates: ["2025-06-27", "2025-06-29"]
};

設計判断のポイント:

実装フェーズ1:データ処理ユーティリティの構築

CalendarUtils.jsの実装

/**
 * ブログ投稿データからカレンダー表示用のデータを生成
 */
export function generateCalendarData(posts) {
  const postsByDate = {};
  
  posts.forEach(post => {
    if (!post.frontmatter || !post.frontmatter.pubDate) return;
    
    // ISO 8601形式の日時を日付のみに変換 (YYYY-MM-DD)
    const pubDate = new Date(post.frontmatter.pubDate);
    const dateKey = pubDate.toISOString().split('T')[0];
    
    if (!postsByDate[dateKey]) {
      postsByDate[dateKey] = {
        count: 0,
        posts: []
      };
    }
    
    postsByDate[dateKey].count++;
    postsByDate[dateKey].posts.push(post);
  });
  
  return {
    postsByDate,
    totalPosts: posts.length,
    activeDates: Object.keys(postsByDate).sort()
  };
}

実装のポイント:

  1. 日付正規化: toISOString().split('T')[0]でYYYY-MM-DD形式に統一
  2. 集計最適化: forEach一回での完了(O(n)時間計算量)
  3. データ構造: 日付別グループ化で効率的なアクセス

月別カレンダーデータの生成

/**
 * 指定した年・月のカレンダー表示用データを生成
 */
export function generateMonthData(calendarData, year, month) {
  const firstDay = new Date(year, month, 1);
  const lastDay = new Date(year, month + 1, 0);
  const daysInMonth = lastDay.getDate();
  const startWeekday = firstDay.getDay(); // 0 = 日曜日
  
  const monthData = [];
  
  // 月の最初の週の空白セルを追加
  for (let i = 0; i < startWeekday; i++) {
    monthData.push(null);
  }
  
  // 月の各日を追加
  for (let day = 1; day <= daysInMonth; day++) {
    const date = new Date(year, month, day);
    const dateKey = date.toISOString().split('T')[0];
    const dayData = calendarData.postsByDate[dateKey];
    
    monthData.push({
      date: day,
      dateKey,
      posts: dayData ? dayData.posts : [],
      count: dayData ? dayData.count : 0,
      intensity: getIntensityLevel(dayData ? dayData.count : 0)
    });
  }
  
  return monthData;
}

カレンダーロジックの実装判断:

  1. 週開始日: 日曜日スタート(getDay()の0)
  2. 空白セル: 月初の週の調整
  3. intensity計算: 投稿数に基づく色濃度レベル

投稿数に基づく濃淡計算

function getIntensityLevel(count) {
  if (count === 0) return 0;  // 白色(投稿なし)
  if (count === 1) return 1;  // 薄いグレー
  if (count === 2) return 2;  // 中間グレー
  if (count === 3) return 3;  // 濃いグレー
  return 4;                   // 最濃グレー(4件以上)
}

色階層の設計理念:

実装フェーズ2:Astroコンポーネントの構築

CalendarComponentの設計

---
import { generateCalendarData, generateMonthData, getMonthName, getCurrentYearMonth } from '../utils/CalendarUtils.js';

interface Props {
  posts: any[];
}

const { posts } = Astro.props;

// カレンダーデータを生成
const calendarData = generateCalendarData(posts);
const { year, month } = getCurrentYearMonth();
const monthData = generateMonthData(calendarData, year, month);
const monthName = getMonthName(month);

// 週の表示用
const weekDays = ['日', '月', '火', '水', '木', '金', '土'];
---

Astroコンポーネント設計の方針:

  1. 関心の分離: ユーティリティ関数とUI表示を分離
  2. Props型定義: TypeScript準拠の型安全性
  3. データ変換: フロントマター内での完結

カレンダーUI構造の実装

<div class="learning-calendar">
  <div class="calendar-header">
    <h2>{year}年{monthName} 投稿カレンダー</h2>
    <div class="calendar-stats">
      <span class="stat">総投稿数: {calendarData.totalPosts}</span>
      <span class="stat">投稿日数: {calendarData.activeDates.length}</span>
    </div>
  </div>

  <div class="calendar-grid">
    <div class="weekdays">
      {weekDays.map(day => (
        <div class="weekday">{day}</div>
      ))}
    </div>
    
    <div class="days">
      {monthData.map(dayData => (
        dayData ? (
          <div 
            class={`day intensity-${dayData.intensity}`}
            data-date={dayData.dateKey}
            data-count={dayData.count}
            title={dayData.count > 0 ? `${dayData.count}件の投稿` : '投稿なし'}
          >
            <span class="day-number">{dayData.date}</span>
            {dayData.count > 0 && (
              <div class="post-indicator">
                <span class="post-count">{dayData.count}</span>
              </div>
            )}
          </div>
        ) : (
          <div class="day empty"></div>
        )
      ))}
    </div>
  </div>
</div>

UIコンポーネント設計の特徴:

  1. セマンティック構造: header、grid、statsの明確な分離
  2. データ属性: data-*でJavaScript連携に備える
  3. アクセシビリティ: title属性でツールチップ対応
  4. 条件付きレンダリング: 投稿がある日のみインジケーター表示

実装フェーズ3:CSS設計とミニマルデザイン

カレンダーグリッドのCSS実装

.calendar-grid {
  border: 1px solid var(--color-gray-200);
  border-radius: 4px;
  margin-bottom: 2rem;
  overflow: hidden;
}

.weekdays {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  background-color: var(--color-gray-50);
  border-bottom: 1px solid var(--color-gray-200);
}

.days {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
}

.day {
  position: relative;
  aspect-ratio: 1;
  border: 1px solid var(--color-gray-200);
  margin: -1px -1px 0 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  cursor: pointer;
  transition: all 0.2s ease;
}

CSS設計の重要判断:

  1. CSS Grid: 7列固定のカレンダーレイアウト
  2. aspect-ratio: 正方形セルの維持
  3. マイナスマージン: ボーダーの重複回避
  4. transition: ホバー効果の滑らかさ

投稿頻度による色階層実装

/* 投稿頻度による濃淡表示 */
.day.intensity-0 {
  background-color: var(--color-white);
}

.day.intensity-1 {
  background-color: #f0f0f0;
}

.day.intensity-2 {
  background-color: #d0d0d0;
}

.day.intensity-3 {
  background-color: #a0a0a0;
}

.day.intensity-4 {
  background-color: #707070;
}

.day.intensity-4 .day-number {
  color: var(--color-white);
}

色彩設計の考慮点:

  1. ミニマル準拠: モノクロ基調の維持
  2. 階調バランス: 視覚的に分かりやすい段階差
  3. 可読性: 濃い背景での文字色対応

レスポンシブ対応の実装

@media (max-width: 768px) {
  .calendar-stats {
    flex-direction: column;
    gap: 0.5rem;
  }

  .day {
    font-size: 0.625rem;
  }

  .day-number {
    font-size: 0.625rem;
  }

  .post-count {
    min-width: 14px;
    height: 14px;
    line-height: 14px;
    font-size: 0.5rem;
  }
}

レスポンシブ設計方針:

実装フェーズ4:ページ統合と機能追加

最近の投稿セクションの実装

{calendarData.activeDates.length > 0 && (
  <div class="recent-posts">
    <h3>最近の投稿</h3>
    <div class="post-list">
      {calendarData.activeDates.slice(-3).reverse().map(dateKey => {
        const dayPosts = calendarData.postsByDate[dateKey].posts;
        const date = new Date(dateKey);
        return (
          <div class="recent-post-group">
            <div class="post-date">
              {date.toLocaleDateString('ja-JP', { 
                month: 'long', 
                day: 'numeric' 
              })}
            </div>
            {dayPosts.map(post => (
              <div class="recent-post">
                <a href={post.url}>{post.frontmatter.title}</a>
                <div class="post-tags">
                  {post.frontmatter.tags.slice(0, 2).map(tag => (
                    <span class="tag">#{tag}</span>
                  ))}
                </div>
              </div>
            ))}
          </div>
        );
      })}
    </div>
  </div>
)}

機能設計の判断:

  1. 表示件数制限: 直近3日分のみ(.slice(-3)
  2. 日付別グループ: 同日投稿をまとめて表示
  3. タグ制限: 主要タグ2つまで(slice(0, 2)
  4. 条件付き表示: 投稿がある場合のみセクション表示

ナビゲーション統合

<!-- Navigation.astro -->
<a href="/calendar" class={currentPath === '/calendar' ? 'active' : ''}>
  投稿カレンダー
</a>

統合設計の考慮:

技術的課題と解決方法

課題1: 日付処理の複雑性

問題: タイムゾーンや日付フォーマットの統一 解決: ISO 8601形式での統一とtoISOString()の活用

// 統一された日付処理
const dateKey = pubDate.toISOString().split('T')[0];

課題2: データ変換の効率性

問題: 大量投稿時のパフォーマンス 解決: ワンパス処理でO(n)時間計算量を実現

// 効率的な集計処理
posts.forEach(post => {
  // 一回のループで全ての集計を完了
});

課題3: レスポンシブカレンダーの表示

問題: 小画面でのカレンダー可読性 解決: CSS Gridとaspect-ratioの活用

.days {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
}

.day {
  aspect-ratio: 1;  /* 正方形維持 */
}

パフォーマンス最適化

静的サイト生成の活用

// ビルド時データ処理
const posts = import.meta.glob('./posts/*.md', { eager: true });
const allPosts = Object.values(posts);

最適化ポイント:

  1. ビルド時計算: ランタイム負荷ゼロ
  2. 静的HTML生成: 高速な初期表示
  3. 効率的データ構造: 最小限のDOM操作

CSS最適化

/* 効率的なCSS設計 */
.day {
  transition: all 0.2s ease;  /* スムーズな相互作用 */
}

.day:hover:not(.empty) {
  border-color: var(--color-black);
  z-index: 1;
}

実装の振り返りと学び

成功要因

  1. 段階的実装: データ処理 → UI → 統合の順序
  2. 関心の分離: ユーティリティとコンポーネントの明確な分割
  3. ミニマル準拠: 既存デザインシステムとの一貫性
  4. レスポンシブ対応: モバイルファーストではなく適応的対応

技術的改善点

  1. 型安全性: TypeScript化による開発効率向上
  2. エラーハンドリング: 無効データへの対応強化
  3. テスタビリティ: 単体テスト可能な構造への改善

設計判断の妥当性

良かった判断:

改善余地:

まとめ:GitHub風カレンダーの実装価値

今回の投稿カレンダー機能実装を通じて、以下の技術的価値を実現できました:

機能的価値

  1. 投稿頻度の可視化: 学習継続性の客観的評価
  2. モチベーション向上: 視覚的フィードバックによる動機付け
  3. ユーザビリティ: 直感的な投稿履歴確認

技術的価値

  1. Astroコンポーネント設計: 再利用可能な構造
  2. データ処理パターン: 効率的な集計とグループ化
  3. レスポンシブデザイン: 多デバイス対応のカレンダーUI

開発プロセス価値

  1. 段階的実装: リスクを抑えた開発アプローチ
  2. 設計思考: ユーザー体験を重視した機能設計
  3. 継続的改善: 初期実装から品質向上への道筋

この投稿カレンダー機能は、単なる視覚的な装飾を超えて、学習の継続性を支援する実用的なツールとして機能しています。今後もユーザーフィードバックを取り入れながら、継続的に改善していきます。


実装技術: Astro, JavaScript, CSS Grid, データ可視化
設計手法: コンポーネント設計, レスポンシブデザイン, ミニマルUI