📌 AI関連

【実録】Chrome拡張で「祝日つきカレンダー時計」を自作した|日本の祝日は一覧表を持たずに計算できる

完成したChrome拡張。左に月カレンダー、右に今日の日付・時刻・メモ欄が並ぶ

ブラウザの右上に、いつでも今日の日付と祝日が見えるカレンダーが欲しい。ついでに、その日のメモも残せたら最高。

そう思って探すと、カレンダー系のChrome拡張はたくさん見つかります。でも「日本の祝日が正しく入っていて」「余計な権限を要求せず」「文字が読みやすい」ものとなると、意外と見つかりません。

そこで、AIコーディングツールに手伝ってもらって自作してみました。

完成したChrome拡張。左に月カレンダー、右に今日の日付・時刻・メモ欄が並ぶ

この記事は、その制作記です。ただの日記で終わらせるのはもったいないので、そのまま他の人が使える情報を軸に構成しました。持ち帰っていただけるのは主にこの2つです。

  • 日本の祝日は「一覧表」を用意しなくても計算で出せる(コードあり・2099年まで対応)
  • Chrome拡張づくりで実際に詰まった5つの罠と、その回避方法

筆者はプログラムを自分で書ける人間ではありません。それでも動くものができたので、「AIと一緒にどこまでいけるか」の実例としても読んでいただければと思います。

目次

  1. 何を作ったのか
  2. 【本題1】日本の祝日は一覧表を持たなくていい
  3. 【本題2】実際に詰まった5つの罠
  4. 「あとで機能を足せる」形にしておくと後が楽
  5. 見た目の調整は「数字ひとつ」に寄せる
  6. AIに作らせるときに効いたこと
  7. まとめ

何を作ったのか

ツールバーのアイコンを押すと出てくる、横長のカレンダー時計です。

  • 月カレンダー(日本の祝日・振替休日・国民の休日が赤く表示される)
  • 今日の日付と時刻(和暦つき)
  • 日付ごとのメモ(打ち込むと自動保存)

「今日」はマスの上に赤いマーカーが引かれます。別の日を選んでいても今日の位置が分かるようにするためです。

Chromeが暗い配色なら、表示も自動で暗くなります。これは prefers-color-scheme というCSSの仕組みを使っているだけで、拡張機能側で切り替えボタンを作る必要はありません。

もうひとつ、サイドパネルにも対応させました。ブラウザの右端にドッキングして、ページを見ている間もずっと表示されたままになる領域です。ポップアップと違ってどこかをクリックしても閉じません。

サイドパネルにダークモードで表示した状態。縦長に合わせて自動で1列に組み替わる

細長い形になるので、幅に応じて2列→1列に自動で組み替わるようにしています。

使っている権限は「保存」と「サイドパネルを開く」の2つだけ。閲覧履歴もページの中身も読みません。カレンダー拡張は権限を広く要求するものが多いので、ここは自作の一番の利点かもしれません。

【追記】この記述は公開時点(v1.0)のものです。その後の版で権限が増えています。 予定のお知らせを出すために alarms と notifications を、 クイックリンクに「よく見るサイト」を並べるために topSites を追加しました。 topSites で読めるのは新しいタブに出るのと同じ「サイトの名前とURLの一覧」だけで、 閲覧履歴(いつ・どのページを見たか)やページの中身を読まない点は変わっていません。 足したものと、その過程で踏んだ罠は続編にまとめています。


【本題1】日本の祝日は一覧表を持たなくていい

ここが、この記事でいちばん持ち帰っていただきたいところです。

日本の祝日をプログラムで扱うとき、多くの人がまず考えるのは「祝日の一覧表をどこかから取ってくる」方法だと思います。内閣府がCSVを公開していますし、祝日APIもあります。

ただ、この方法には弱点があります。

  • 毎年、新しいデータに更新しないといけない
  • 通信できない環境では動かない
  • 外部サービスが止まったら壊れる

そこで今回は、「祝日法」のルールをそのまま計算する方式にしました。一覧表は一切持ちません。結果として、オフラインでも動き、来年になってもファイルを更新する必要がなく、2099年まで正しく出ます。

祝日は4種類のルールでできている

日本の祝日は、ぜんぶで4種類のルールの組み合わせです。

① 日付が固定されているもの

元日(1/1)、建国記念の日(2/11)、天皇誕生日(2/23)、昭和の日(4/29)、憲法記念日(5/3)、みどりの日(5/4)、こどもの日(5/5)、山の日(8/11)、文化の日(11/3)、勤労感謝の日(11/23)。これは単純です。

② ハッピーマンデー(何番目の月曜か)

成人の日=1月の第2月曜、海の日=7月の第3月曜、敬老の日=9月の第3月曜、スポーツの日=10月の第2月曜。

「その月の何番目の月曜日か」は、こう計算できます。

/** その月の n 回目の月曜日が何日かを返す */
function nthMonday(year, month, nth) {
  const first = new Date(year, month - 1, 1);
  const offsetToMonday = (1 - first.getDay() + 7) % 7;
  return 1 + offsetToMonday + (nth - 1) * 7;
}

③ 春分の日・秋分の日(天文計算の近似式)

この2つは年によって20日だったり21日だったりします。厳密には国立天文台が毎年2月に翌年ぶんを官報で発表するのですが、近似式で求められることが知られています。

/** 春分の日(1980〜2099年) */
function shunbun(year) {
  return Math.floor(20.8431 + 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4));
}

/** 秋分の日(1980〜2099年) */
function shubun(year) {
  return Math.floor(23.2488 + 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4));
}

0.242194 は1年が365日ちょうどではなく約365.242194日であること(=うるう年が必要な理由)から来ています。この式で1980年から2099年まで正しく出ます。

④ 後から足されるルール(振替休日と国民の休日)

ここが見落とされやすいところです。①〜③を並べたあとで、次の2つを追加します。

  • 振替休日:祝日が日曜にあたったら、その後いちばん近い「祝日でない日」を休みにする
  • 国民の休日:祝日と祝日に挟まれた平日も休みにする

「国民の休日」はレアなので忘れがちですが、実際に発生します。たとえば2026年9月は、21日(敬老の日)と23日(秋分の日)に挟まれた22日が国民の休日になります。

順番も大事です。振替休日を先に決めてから国民の休日を判定しないと、結果が変わってしまいます。

// 振替休日:祝日が日曜なら、次の「祝日でない日」を休みにする
for (const key of [...map.keys()].sort()) {
  const date = fromKey(key);
  if (date.getDay() !== 0) continue;
  const next = new Date(date);
  do {
    next.setDate(next.getDate() + 1);
  } while (map.has(toKey(next)));
  substitutes.push(toKey(next));
}

法律が変わった年の特例も要る

さらに、過去には例外的な年がありました。ここを入れておかないと、過去の月を表示したときにズレます。

年 何が起きたか
2019年 改元にともない「天皇の即位の日」(5/1)「即位礼正殿の儀の行われる日」(10/22)が祝日に。この年だけ天皇誕生日が無い
2020年 天皇誕生日が12/23から2/23へ。五輪のため海の日・スポーツの日・山の日が移動
2021年 五輪の延期にともない、3つの祝日がふたたび別の日へ移動

2020年と2021年は、海の日・山の日・スポーツの日が本来と違う日にあります。「去年のカレンダーが合わない」というときは、たいていここが原因です。

検算のやり方

計算方式の弱点は「合っているかどうか自分では分からない」ことです。そこで、内閣府が公開している祝日一覧と突き合わせました。

やり方は単純で、内閣府データの内容を期待値として書き出し、自分の計算結果と1日ずつ比べるだけです。特例が多い年を意図的に選ぶのがコツです。

検算に使ったのは、2019年(改元)・2020年・2021年(五輪移動)・2024年・2025年・2026年・2027年(振替あり)・2032年(国民の休日あり)の8年ぶん。すべて1日もズレずに一致しました。

「たぶん合っている」ではなく「8年分照合した」と言えるかどうかで、この方式を採用していいかの判断が変わります。ここは省略しないことをおすすめします。


【本題2】実際に詰まった5つの罠

ここからは、作る途中で実際に踏んだ落とし穴です。どれも「知っていれば5分、知らないと1時間」の類なので、同じものを作る方の参考になれば。

罠1:クリックしても色が変わらない(CSSの優先順位)

日付をクリックすると青くなる、という当たり前の動きが効きませんでした。マウスカーソルを外すと青くなるという奇妙な症状です。

原因はCSSの詳細度でした。

.day:hover      { background: グレー; }   /* クラス+擬似クラス = 強い */
.day--selected  { background: 青; }       /* クラスだけ    = 弱い */

CSSは「後に書いたほうが勝つ」と思われがちですが、詳細度(specificity)が違う場合はそちらが優先されます。.day:hover はクラス1つ+擬似クラス1つで、.day--selected のクラス1つより強い。だからカーソルが乗っている間はホバー色が選択色を上書きしてしまっていたわけです。

直し方は「選ばれていないときだけホバーする」と書くこと。

.day:not(.day--selected):hover { background: グレー; }

「クリックしたのに反映されない」系の不具合は、JavaScriptを疑う前にCSSの優先順位を疑うと早いです。

罠2:ポップアップは800×600pxが上限

「カレンダーをもっと大きく」という調整をしていて気づいたのですが、Chrome拡張のポップアップには大きさの上限があります。幅800px、高さ600pxです。

これは拡張機能側から変えられません。CSSで width: 1000px と書いても800pxで頭打ちになります。

大きく表示したい場合の選択肢は3つです。

  1. サイドパネルにする(幅はユーザーがドラッグで自由に変えられる)
  2. 新しいタブに置き換える(chrome_url_overrides)
  3. 上限内でレイアウトを工夫する

今回は1と3を採用しました。ポップアップは732×566px、それ以上欲しい人はサイドパネルへ、という形です。

罠3:サイドパネルで要素が「一番上に飛ぶ」

ポップアップ(横長2列)とサイドパネル(縦長1列)で、同じHTMLを使い回しています。切り替えには display: contents というCSSを使いました。入れ物のdivを「無かったこと」にして、中身だけを並べ替えられる便利な指定です。

ただしこれには落とし穴があって、並べ替えの指定(order)を1つでも書き忘れると、その要素だけ一番上に飛びます。orderの初期値が0で、他の要素に1、2、3…と振っていると、書き忘れた要素が最小値になってしまうためです。

実際、後から足した欄が時計より上に飛んでいて、しかもパネルがスクロールする状態だったので「パネルだと表示されない」という症状に見えていました。

display: contents を使うときは、中身ぜんぶにorderを振る。ひとつでも抜けると壊れます。

罠4:スプレッドシートは「文字列の日付」を勝手に日付型にする

ここから2つは、少し発展させた話です。

このカレンダーには後から、仕事で使っているGoogleスプレッドシートの予定を重ねて表示する機能を足しました。スプレッドシート側にGoogle Apps Script(GAS)を置いて「ウェブアプリ」として公開し、拡張機能からそこに問い合わせる、という構成です。

拡張機能の設定画面。スプレッドシート側で作った接続キーを1か所に貼るだけでつながる

この連携で踏んだ罠が、以下の2つです。GAS+スプレッドシートを使う方なら、拡張機能を作らなくても遭遇します。

まずは日付の話から。Google Apps Scriptで日付を文字列として書き込んだつもりでも、スプレッドシートが自動的に「日付型」に変換します。

sheet.getRange('A2').setValue('2026/08/20');   // 文字列のつもり
const v = sheet.getRange('A2').getValue();     // → Date型が返ってくる
String(v);  // "Wed Aug 20 2026 00:00:00 GMT+0900..." 英語表記になる

これに気づかず「2026-08 で始まる日付を探す」という処理を書いていたので、条件が全部外れて取得件数が0件になりました。しかも通信自体は成功しているので、原因がなかなか分かりません。

対策は3つセットで入れると確実です。

  1. 書き込む列を「書式なしテキスト」にする:range.setNumberFormat('@')
  2. 読むときは getValues() ではなくgetDisplayValues()(表示どおりの文字で受け取る)
  3. それでもDate型が来る可能性を考えて、読み取り側で両対応にしておく
function toDateKey(value) {
  if (value instanceof Date) {
    return Utilities.formatDate(value, 'Asia/Tokyo', 'yyyy-MM-dd');
  }
  const m = String(value || '').match(/(20\d{2})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/);
  if (!m) return '';
  return m[1] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[3]).slice(-2);
}

罠5:会社のGoogleアカウントはURLの形が違う

Apps Scriptを「ウェブアプリ」として公開すると、こういうURLが発行されます。

https://script.google.com/macros/s/AKfycb.../exec

ところが会社のGoogleアカウント(Google Workspace)だと、途中にドメイン名が挟まります。

https://script.google.com/a/macros/会社のドメイン/s/AKfycb.../exec

個人アカウントの形だけを想定して入力チェックを書いていたので、会社のPCで試したときに正しいURLなのに弾かれるという状態になりました。

チェックするなら、/macros/s/ を決め打ちせず「script.google.com で、末尾が /s/○○/exec」くらいの緩さにしておくのが無難です。

もうひとつ、会社アカウントでは公開範囲に「全員」が選べず、組織名しか出ないことがあります。この場合、アクセスするたびにGoogleが「誰か」を確認するため、そのアカウントでブラウザにサインインしている必要があります。

ちなみにこれは制限というより安全側に倒れている状態です。「全員」で公開するとURLを知っている人は誰でも中身を読めますが、組織内限定なら社外の人はそもそもアクセスできません。


「あとで機能を足せる」形にしておくと後が楽

最初にカレンダーと時計だけ作って、あとからメモ機能、さらにその後で外部データとの連携機能を足しました。

このとき効いたのが、最初から「機能を差し込む口」を作っておいたことです。カレンダー本体のコードは、機能を足すたびに書き換えなくて済みます。

具体的には、機能ひとつを次の形の「部品」として書くルールにしました。

export const memoFeature = {
  id: 'memo',                      // 他とかぶらない名前
  mount(root, ctx) { ... },        // 起動時に1回。rootの中に自由にHTMLを作る
  show(dateKey) { ... },           // 選んだ日が変わるたび呼ばれる
  marks(year, month) { ... },      // カレンダーに印を付けたい日を返す
};

そして、使う機能を並べたファイルを1つ用意します。

import { memoFeature } from './memo.js';
import { scheduleFeature } from './schedule.js';

export const features = [memoFeature, scheduleFeature];

機能を足す=ファイルを1つ作って、この配列に1行足すだけ。この形にしておくと、「あとで思いついたものを足す」がとても軽い作業になります。

規模の小さい個人用ツールでも、1つ目の機能を作る時点で「2つ目が来る前提」の形にしておくと、後の自分がラクをできます。


見た目の調整は「数字ひとつ」に寄せる

これは作ってみて実感したことですが、自分用のツールは使い始めてから見た目の要望が次々と出てきます。

「もう少し小さく」「日付の数字だけ大きく」「メモの文字を2倍に」——実際、今回もこの調整を何度も繰り返しました。

そのたびに個別の数値を書き換えていると、あちこちの比率が崩れていきます。そこで途中から、倍率を数か所にまとめて、他はすべてそこからの計算で書く形に変えました。

:root {
  --ui-scale:   .75;   /* 全体(幅・余白・文字ぜんぶ) */
  --day-scale:  1.5;   /* カレンダーの日付の数字だけ */
  --text-scale: 2;     /* 日付の数字以外の文字ぜんぶ */
  --memo-scale: 1;     /* メモの文字だけ */
  --memo-area:  1.5;   /* メモ欄の高さ */

  /* 以下はすべて上の倍率からの計算で書く */
  --fs-day:  calc(15px * var(--ui-scale) * var(--day-scale));
  --fs-memo: calc(12px * var(--ui-scale) * var(--text-scale) * var(--memo-scale));
}

こうしておくと、「全体を小さく」は --ui-scale の数字ひとつ、「メモだけ大きく」は --memo-scale ひとつで済みます。比率も崩れません。

CSSの変数(カスタムプロパティ)は calc() の中で掛け算できるので、倍率を掛け合わせる設計にすると調整のつまみを自由に増やせます。これは自分用ツールに限らず使える考え方だと思います。


AIに作らせるときに効いたこと

最後に、AIと一緒に作ってみて「これは効いた」と感じた点を3つ。

1. 「検算した」と言えるところまでやってもらう

祝日の計算は、AIに書いてもらった時点では正しいかどうか分かりませんでした。内閣府のデータと8年分突き合わせて初めて「使える」と判断できます。動いた=正しい、ではないところが、この手のロジックの怖さです。

出力そのものより、「どう確かめたか」を一緒に出してもらうと安心できます。

2. 詰まったら画面のスクリーンショットを見せる

「クリックしても色が変わらない」「サイドパネルだと出ない」といった症状は、言葉で説明するより画面を見せたほうが早く原因にたどり着きました。とくに後者は、実際には「表示されていない」のではなく「画面外に飛んでいた」ので、言葉だけだと誤診したままだったはずです。

3. コメントを日本語で書いてもらう

自分で書けないコードを持つ以上、半年後の自分が読めるかが生命線です。「なぜこう書いたか」を日本語のコメントで残してもらうようにしたら、後から見返したときの理解度がまったく違いました。

とくに罠にはまって直した箇所は、「これを外すとまた壊れる」と書いておくのがおすすめです。


まとめ

プログラムを書けなくても、AIと一緒なら実用に耐えるChrome拡張は作れます。ただし、丸投げでうまくいくわけではなく、「確かめる」「症状を正確に伝える」といった部分は人間の仕事として残ります。

この記事で持ち帰っていただきたいポイントを、あらためて整理します。

  • 日本の祝日は一覧表を持たなくていい。祝日法のルール(固定日・ハッピーマンデー・春分秋分の近似式・振替休日と国民の休日)を計算すれば、オフラインで2099年まで出せる
  • ただし2019年の改元、2020・2021年の五輪による移動という特例があるので、そこは個別に入れる
  • 計算方式を採用するなら内閣府データとの突き合わせを省略しない
  • Chrome拡張のポップアップは800×600pxが上限。それ以上はサイドパネルか新しいタブへ
  • 「クリックが反映されない」はCSSの詳細度を疑う
  • スプレッドシートは文字列の日付を勝手にDate型にする。書式を「書式なしテキスト」にし、読むときは getDisplayValues()
  • Google WorkspaceのウェブアプリはURLの形が違う(/a/macros/ドメイン/s/…)

同じような「あったら便利なのに見つからないツール」は、意外と自分で作れる範囲にあります。祝日カレンダーはその入口としてちょうどいい題材でした。

続編を書きました

この拡張機能はその後も手を入れていて、ドラッグでの伸縮、アイコンのバッジ、時刻の通知、営業日カウンターなどを足しました。その過程で踏んだ罠(Chrome 137以降でヘッドレステストが動かなくなった件、ウィンドウ自身の大きさをドラッグするときの座標の罠、Manifest V3のサービスワーカーの設計)を、続編にまとめています。

【続編】自作Chrome拡張を「毎日開く道具」に育てた|ポップアップの伸縮・通知・営業日計算で踏んだ罠

※当サイトはアフィリエイト広告を利用しています。商品の価格・仕様は変更になる場合があります。