GitHub風投稿カレンダー機能の実装:Astroで作るcontribution graph
GitHub風のcontribution graphライクな投稿カレンダー機能をAstroで実装。データ処理、UI設計、レスポンシブ対応まで、段階的な開発プロセスと技術的判断を詳細記録。
GitHub風投稿カレンダー機能の実装記録
今回は、ブログサイトにGitHub風のcontribution graphライクな投稿カレンダー機能を実装しました。投稿頻度を視覚的に表現し、学習の継続性を一目で確認できる機能です。実装の全プロセスと技術的判断を詳しく記録します。
実装動機:なぜ投稿カレンダーが必要だったか
課題の認識
ブログを継続的に書いていく中で、以下の課題を感じていました:
- 投稿頻度の可視化不足: どれくらいの頻度で投稿しているかが分からない
- 継続性の把握困難: 学習の継続性を客観的に評価できない
- モチベーション維持: 視覚的なフィードバックがない
GitHub contribution graphからの着想
GitHubのcontribution graphは、開発者の活動を視覚的に表現する優れたUIです:
- 色の濃淡: 活動量を直感的に表現
- 継続性の可視化: 日々の積み重ねが一目で分かる
- モチベーション向上: 「緑を絶やさない」という動機付け
この仕組みをブログ投稿に応用することで、学習の継続性を可視化できると考えました。
設計フェーズ:データ構造とUI設計
データ構造の設計
まず、投稿データをカレンダー表示用に変換する構造を設計しました:
// 基本的なデータフロー
投稿データ(Markdown) → 日付別集計 → カレンダー表示データ → UI表示
データ変換の考え方:
- 入力: Astroの
import.meta.glob()で取得した投稿配列 - 中間: 日付をキーとした投稿数の集計
- 出力: カレンダー表示用の日付データ配列
カレンダー表示の技術仕様
// カレンダーデータの構造設計
const calendarData = {
postsByDate: {
"2025-06-29": {
count: 2,
posts: [post1, post2]
}
},
totalPosts: 6,
activeDates: ["2025-06-27", "2025-06-29"]
};
設計判断のポイント:
- Record型: 日付キーでの高速アクセス
- 投稿配列保持: 将来の機能拡張に備える
- 統計情報: 総投稿数、投稿日数をワンパスで計算
実装フェーズ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()
};
}
実装のポイント:
- 日付正規化:
toISOString().split('T')[0]でYYYY-MM-DD形式に統一 - 集計最適化: forEach一回での完了(O(n)時間計算量)
- データ構造: 日付別グループ化で効率的なアクセス
月別カレンダーデータの生成
/**
* 指定した年・月のカレンダー表示用データを生成
*/
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;
}
カレンダーロジックの実装判断:
- 週開始日: 日曜日スタート(
getDay()の0) - 空白セル: 月初の週の調整
- 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件以上)
}
色階層の設計理念:
- GitHub contribution graphと同様の5段階
- ミニマルデザインに合わせたモノクロ表現
- 投稿数の感覚的理解を重視
実装フェーズ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コンポーネント設計の方針:
- 関心の分離: ユーティリティ関数とUI表示を分離
- Props型定義: TypeScript準拠の型安全性
- データ変換: フロントマター内での完結
カレンダー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コンポーネント設計の特徴:
- セマンティック構造: header、grid、statsの明確な分離
- データ属性:
data-*でJavaScript連携に備える - アクセシビリティ:
title属性でツールチップ対応 - 条件付きレンダリング: 投稿がある日のみインジケーター表示
実装フェーズ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設計の重要判断:
- CSS Grid: 7列固定のカレンダーレイアウト
- aspect-ratio: 正方形セルの維持
- マイナスマージン: ボーダーの重複回避
- 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);
}
色彩設計の考慮点:
- ミニマル準拠: モノクロ基調の維持
- 階調バランス: 視覚的に分かりやすい段階差
- 可読性: 濃い背景での文字色対応
レスポンシブ対応の実装
@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;
}
}
レスポンシブ設計方針:
- 768px単一ブレークポイント(既存デザインと統一)
- モバイルでのフォントサイズ調整
- タッチデバイスでの操作性確保
実装フェーズ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>
)}
機能設計の判断:
- 表示件数制限: 直近3日分のみ(
.slice(-3)) - 日付別グループ: 同日投稿をまとめて表示
- タグ制限: 主要タグ2つまで(
slice(0, 2)) - 条件付き表示: 投稿がある場合のみセクション表示
ナビゲーション統合
<!-- 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);
最適化ポイント:
- ビルド時計算: ランタイム負荷ゼロ
- 静的HTML生成: 高速な初期表示
- 効率的データ構造: 最小限のDOM操作
CSS最適化
/* 効率的なCSS設計 */
.day {
transition: all 0.2s ease; /* スムーズな相互作用 */
}
.day:hover:not(.empty) {
border-color: var(--color-black);
z-index: 1;
}
実装の振り返りと学び
成功要因
- 段階的実装: データ処理 → UI → 統合の順序
- 関心の分離: ユーティリティとコンポーネントの明確な分割
- ミニマル準拠: 既存デザインシステムとの一貫性
- レスポンシブ対応: モバイルファーストではなく適応的対応
技術的改善点
- 型安全性: TypeScript化による開発効率向上
- エラーハンドリング: 無効データへの対応強化
- テスタビリティ: 単体テスト可能な構造への改善
設計判断の妥当性
良かった判断:
- データ構造: Record型による高速アクセス
- UI構造: CSS Gridによる柔軟なレイアウト
- 色彩設計: ミニマルデザインと機能性の両立
改善余地:
- 国際化: 月名や曜日の多言語対応
- カスタマイズ: 色テーマや表示期間の設定
- インタラクション: 日付クリック時の詳細表示
まとめ:GitHub風カレンダーの実装価値
今回の投稿カレンダー機能実装を通じて、以下の技術的価値を実現できました:
機能的価値
- 投稿頻度の可視化: 学習継続性の客観的評価
- モチベーション向上: 視覚的フィードバックによる動機付け
- ユーザビリティ: 直感的な投稿履歴確認
技術的価値
- Astroコンポーネント設計: 再利用可能な構造
- データ処理パターン: 効率的な集計とグループ化
- レスポンシブデザイン: 多デバイス対応のカレンダーUI
開発プロセス価値
- 段階的実装: リスクを抑えた開発アプローチ
- 設計思考: ユーザー体験を重視した機能設計
- 継続的改善: 初期実装から品質向上への道筋
この投稿カレンダー機能は、単なる視覚的な装飾を超えて、学習の継続性を支援する実用的なツールとして機能しています。今後もユーザーフィードバックを取り入れながら、継続的に改善していきます。
実装技術: Astro, JavaScript, CSS Grid, データ可視化
設計手法: コンポーネント設計, レスポンシブデザイン, ミニマルUI