カレンダー機能の完全実装:Astroでブログ記事を日付から直接アクセス

作成日:
約12分で読めます

どーも、ちょりんだです。

ブログを運営していると、「あの記事、いつ書いたっけ?」ということがよくあります。記事一覧を見ても、どの日に何を書いたか直感的に分かりにくいですよね。

そこで、カレンダー機能を実装して、日付から直接記事にアクセスできるようにしてみました。今回はその実装の全過程を詳しく解説します。

カレンダー機能の概要

実現したかった機能

まず、実現したかった機能を整理してみましょう。

  • カレンダー表示: 月別のカレンダーを表示
  • 記事の可視化: 記事がある日をドットで表示
  • 記事数の表現: 記事数を視覚的に表現
  • 直接ジャンプ: 1記事ならクリックで直接ジャンプ
  • 選択機能: 複数記事なら選択モーダルを表示
  • ダークモード対応: 完全なダークモード対応

技術的な課題

実装にはいくつかの課題がありました。

  • Astroの静的生成: サーバーサイドとクライアントサイドの連携
  • JavaScriptのスコープ: Astroコンポーネント内の関数の扱い
  • URL生成: ファイル名から正しいURLを生成
  • レスポンシブデザイン: モバイルでも見やすいカレンダー

Astroコンポーネントの基本構造

データ取得と処理

まず、Astroのコンテンツコレクションから記事データを取得します。

---
import { getCollection } from 'astro:content';

const posts = await getCollection('blog');
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();

// 記事を日付ごとにグループ化
const postsByDate = new Map();
posts.forEach(post => {
  const date = new Date(post.data.pubDate);
  const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  
  // ファイル名からスラッグを生成
  const fileName = post.id.replace(/^.*\//, '').replace(/\.md$/, '');
  
  if (!postsByDate.has(dateKey)) {
    postsByDate.set(dateKey, []);
  }
  
  postsByDate.get(dateKey).push({
    ...post,
    fileSlug: fileName
  });
});
---

カレンダーの日付生成

カレンダーの日付を生成する関数を実装します。

function getCalendarDays(year, month) {
  const firstDay = new Date(year, month, 1);
  const lastDay = new Date(year, month + 1, 0);
  const startDate = new Date(firstDay);
  startDate.setDate(startDate.getDate() - firstDay.getDay());
  
  const days = [];
  const current = new Date(startDate);
  
  for (let i = 0; i < 42; i++) {
    days.push({
      date: new Date(current),
      isCurrentMonth: current.getMonth() === month,
      isToday: current.toDateString() === new Date().toDateString(),
      posts: postsByDate.get(`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')}`) || []
    });
    current.setDate(current.getDate() + 1);
  }
  
  return days;
}

記事数の視覚的表現

デザインの試行錯誤

記事数の表現にはいくつかの試行錯誤がありました。

1. 数字表示(却下)

最初は数字を表示しましたが、カレンダーがごちゃごちゃしてしまいました。

2. 色の濃淡(却下)

次に色の濃さで表現しましたが、直感的ではありませんでした。

3. サイズのバリエーション(却下)

サイズを変える方法も試しましたが、複雑すぎました。

4. ドットの数(採用)

最終的に、ドットの数で表現する方法に落ち着きました。

完璧なドット表現

{day.posts.length === 1 && (
  <div class="post-dots">
    <span class="dot dot-single" title={day.posts[0].data.title}></span>
  </div>
)}
{day.posts.length === 2 && (
  <div class="post-dots">
    <span class="dot dot-double" title={day.posts[0].data.title}></span>
    <span class="dot dot-double" title={day.posts[1].data.title}></span>
  </div>
)}
{day.posts.length >= 3 && (
  <div class="post-dots">
    {day.posts.map((post, i) => (
      <span class="dot dot-many" title={post.data.title}></span>
    ))}
  </div>
)}

CSSでの視覚的階層

.dot-single {
  width: 6px;
  height: 6px;
  background: #3b82f6;
}

.dot-double {
  width: 5px;
  height: 5px;
  background: #2563eb;
}

.dot-many {
  width: 3px;
  height: 3px;
  background: #1d4ed8;
}
  • 1記事: 大きいドット1つ(重要度高)
  • 2記事: 中くらいのドット2つ(重要度中)
  • 3記事以上: 小さいドットの数(重要度低だが数が多い)

JavaScriptのスコープ問題と解決

Astro特有の課題

AstroコンポーネントでJavaScriptを使うと、スコープ問題に直面しました。

<!-- これだとエラーになる -->
<div onclick="handleDayClick(posts)">クリック</div>

<script>
  function handleDayClick(posts) {
    // ReferenceError: handleDayClick is not defined
  }
</script>

解決策:イベントリスナーとdata属性

最終的に、イベントリスナーとdata属性を使う方法にしました。

<div 
  class="day-cell has-posts"
  data-posts={JSON.stringify(posts.map(p => ({slug: p.fileSlug, title: p.data.title})))}
>
  <!-- カレンダーの内容 -->
</div>
document.addEventListener('DOMContentLoaded', function() {
  const dayCells = document.querySelectorAll('.day-cell.has-posts');
  
  dayCells.forEach(cell => {
    cell.addEventListener('click', function() {
      const postsData = this.getAttribute('data-posts');
      const posts = JSON.parse(postsData);
      handleDayClick(posts);
    });
  });
});

URL生成の課題と解決

ファイル名ベースのURL生成

Astroの自動スラッグ生成が不安定だったため、ファイル名から直接URLを生成しました。

// ファイル名からスラッグを生成
const fileName = post.id.replace(/^.*\//, '').replace(/\.md$/, '');
// 例: "blog:portfolio-visualization-complete.md" → "portfolio-visualization-complete"
// URL: "/blog/portfolio-visualization-complete"

クリック機能の実装

1記事の場合:直接ジャンプ

if (posts.length === 1) {
  const slug = posts[0].slug;
  const url = `/blog/${slug}`;
  window.location.href = url;
}

複数記事の場合:選択モーダル

if (posts.length > 1) {
  showPostSelection(posts);
}

function showPostSelection(posts) {
  const modal = document.createElement('div');
  modal.innerHTML = `
    <div class="modal-backdrop"></div>
    <div class="modal-content">
      <div class="modal-header">
        <h3>記事を選択してください</h3>
        <button class="close-button">×</button>
      </div>
      <div class="modal-body">
        ${posts.map(post => `
          <a href="/blog/${post.slug}" class="post-option">
            <div class="post-title">${post.title}</div>
          </a>
        `).join('')}
      </div>
    </div>
  `;
  
  document.body.appendChild(modal);
}

ダークモード対応

完全なダークモード対応

カレンダーは完全なダークモード対応を実装しました。

.dark .article-calendar {
  background: #0f172a;
  border: 1px solid #334155;
}

.dark .post-visual-count {
  background: #1e3a8a;
  border: 1px solid #60a5fa;
}

.dark .dot-single {
  background: #60a5fa;
}

.dark .dot-double {
  background: #93c5fd;
}

.dark .dot-many {
  background: #bfdbfe;
}

レスポンシブデザイン

モバイル対応

スマートフォンでも見やすいようにレスポンシブデザインを実装しました。

@media (max-width: 640px) {
  .article-calendar {
    padding: 10px;
  }

  .day-cell {
    min-height: 60px;
    padding: 4px;
  }

  .date-number {
    font-size: 12px;
  }
}

PC向けホバー機能

記事タイトルのプレビュー

PCユーザー向けに、マウスホバーで記事タイトルを表示する機能を実装しました。

<!-- PC用ホバーツールチップ -->
<div class="post-tooltip">
  <div class="tooltip-content">
    {day.posts.map((post, i) => (
      <div class="tooltip-item">
        <span class="tooltip-title">{post.data.title}</span>
      </div>
    ))}
  </div>
</div>

スマートな位置調整

カレンダーの端に近い場合は、ツールチップの位置を自動調整します。

/* カレンダーの右端(土日)は左寄せ */
.day-cell:nth-child(7n) .post-tooltip,
.day-cell:nth-child(7n-1) .post-tooltip {
  left: auto;
  right: 0;
  transform: none;
}

/* その他の日は中央寄せ */
.day-cell:not(:nth-child(7n)):not(:nth-child(7n-1)) .post-tooltip {
  left: 50%;
  transform: translateX(-50%);
}

視覚的な連結

ツールチップとドットを矢印で視覚的に連結します。

.tooltip-content::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 6px solid transparent;
  border-top-color: white;
  margin-top: -1px;
}

ユーザー体験の向上

このホバー機能によって、以下のような体験向上が期待できます。

  • 事前確認: クリック前に記事タイトルを確認可能
  • 効率的な操作: 目的の記事を素早く特定
  • 視覚的ガイド: 矢印でどの記事のツールチップか分かりやすい
  • ストレスフリー: 重ならない配置で見やすい

完成したカレンダー機能

機能のまとめ

最終的に完成したカレンダー機能は以下の通りです。

  • 月別表示: 現在の月をカレンダーで表示
  • 記事可視化: 記事がある日をドットで表示
  • 記事数表現: ドットの数と大きさで記事数を表現
  • 直接ジャンプ: 1記事ならクリックで直接ジャンプ
  • 選択モーダル: 複数記事なら選択肢を表示
  • ホバー表示: PCで記事タイトルをプレビュー
  • スマート配置: 端に近い場合は位置を自動調整
  • ダークモード: 完全なダークモード対応
  • レスポンシブ: モバイルでも見やすいデザイン

ユーザー体験の向上

このカレンダー機能によって、以下のようなユーザー体験の向上が期待できます。

  • 直感的なアクセス: 日付から直接記事にアクセス可能
  • 視覚的な理解: 記事の投稿状況が一目で分かる
  • 効率的な操作: 目的の記事に素早くアクセスできる
  • 事前確認: ホバーで記事タイトルをプレビュー可能

技術的な学び

Astroの理解が深まる

この実装を通して、Astroについて多くのことを学びました。

  • 静的生成と動的機能: どう連携させるか
  • JavaScriptのスコープ: Astroコンポーネント内での扱い
  • コンテンツコレクション: データの取得と処理方法
  • CSSのスコープ: コンポーネント内でのスタイル管理
  • CSSポジショニング: ツールチップのスマートな位置調整
  • z-index管理: レイヤーの重なり制御

UI/UXの重要性

視覚的表現の試行錯誤を通して、UI/UXの重要性を再認識しました。

  • 直感性: ユーザーが直感的に理解できるデザイン
  • 一貫性: サイト全体とのデザインの一貫性
  • アクセシビリティ: すべてのユーザーが使える機能

今後の改善点

考えられる改善

今後の改善点として、以下のようなものが考えられます。

  • 年間カレンダー: 年単位での表示
  • 記事検索: カレンダーからの記事検索機能
  • タグフィルター: タグでの絞り込み機能
  • 統計表示: 投稿頻度などの統計情報
  • ホバー機能拡張: タグや概要文のプレビュー表示

継続的な改善

ブログは継続的な改善が重要です。読者のフィードバックを元に、さらに使いやすい機能を追加していきたいと思います。

まとめ

カレンダー機能の実装は、単なる機能追加だけでなく、Astroへの理解を深め、UI/UXについて考える良い機会となりました。

特に、視覚的表現の試行錯誤は「どうすればユーザーに直感的に伝わるか」を考える上で非常に勉強になりました。

このカレンダー機能が、読者の皆様のブログ閲覧体験を少しでも向上できれば幸いです。


この記事が役に立った場合は、ぜひシェアやコメントをお願いします!

他のAstro関連の記事もぜひご覧ください!

Share