想定している読者:業務でExcelスケジュールを使っているが、時間の経過を見失いがちな人/会社のIT環境に制約があって「自動化」がなかなか通らない人
はじめに
私はUI/UXデザイナーとして働いていて、日々のスケジュールを 15分刻みのExcel で管理しています。
この管理方法自体はとても気に入っているのですが、ひとつだけどうしても解決できない問題がありました。それを解決するために作ったのが、画面の隅に浮かべておく「やること付箋」です。
技術的には Document Picture-in-Picture API を使って、ブラウザだけで常に最前面に浮くウィンドウを作っています。
この記事では、完成品の紹介よりもむしろ たどり着くまでにやめた案とその理由 に多くを割きました。同じような環境の人にとっては、そちらのほうが役に立つと思ったからです。
1. 課題:一覧としては、これで足りていた
まず、私が使っているスケジュール表を紹介します。
1日を15分ずつに区切って、その枠に何をやるかを書いていきます。横方向に日付が並んでいるので、前日や翌日との比較もできます。
一覧として見るには、これで足りていました。 15分の升目に何でも書けて、前後の日と見比べられる。ここは後で重要になります。
一方で、使っていて困っていたのは次の2点でした。
現在時刻が更新されない
セルに NOW() を入れて現時刻を表示していたのですが、これが更新されません。
NOW() は揮発性関数なので再計算の対象ではあるものの、再計算は「セルを編集した」などのイベントが起きたときにしか走りません。開いたまま放置していると、表示は開いた瞬間の時刻のまま止まります。
結果、「今どのコマにいるのか」を知るために、いちいちExcelを触って更新する必要がありました。
15分の経過に気づかない
これが本命の問題です。作業に集中していると、15分どころか1時間くらい平気で溶けます。Excelは黙っているので、こちらが見に行かない限り何も教えてくれません。
「一覧が見たい」のではなく「経過に気づきたい」。 最終的にはここが分かれ道でした。
2. 制約:普通の自動化が使えない環境だった
解決策を考えるにあたって、以下の制約がありました。
| 制約 | 内容 |
|---|---|
| Excelがオンライン | 会社のOfficeアカウントでOneDrive上のブックをブラウザで開いている |
| VBAが使えない | ブラウザ版Excelはマクロを実行できない |
| 権限が自由にならない | Entra ID(Azure AD)のアプリ登録は情シスの承認が要る |
| 追加ライセンスなし | Power Automateは標準コネクタの範囲のみ |
| 都度アップロードは不可 | データを手作業でサーバーに上げる運用は現実的でない |
| 複数端末で見たい | 会社PC・自宅PC・スマホ |
事業会社でよくある条件だと思います。「技術的にできる」と「自分の環境で通せる」は別問題 で、今回はほぼ後者との戦いでした。
3. 試したけどやめた3つの案
ここが本題です。
案A:VBAでJSONを書き出す
最初に考えたのは、ブックに Application.OnTime で1分ごとのマクロを仕込み、予定を data.js として書き出して、HTMLがそれを読み直す方式でした。
やめた理由:ブラウザ版のExcelではVBAが動かない。
そもそもの前提を間違えていました。デスクトップ版で開けば動きますが、普段の運用はブラウザ版なので、実質使えません。
案B:Microsoft Graph APIでExcelを直接読む
HTMLにMSAL.jsを載せてOfficeアカウントでサインインし、Graph APIでセル範囲を直接読む方式です。中間ファイルが要らず、常に最新で、どの端末でも同じURLで動く。作りとしては一番きれいでした。
やめた理由:Entra IDのアプリ登録が必要で、テナントの設定次第では自分で完結できない。
加えて、スマホのブラウザだとCookieの制限でトークンの自動更新が効かず、開くたびにサインインを求められる可能性がありました。「さっと見たい」道具としては相性が良くありません。
承認が下りるまで何も動かないのも、個人的にはつらいところでした。
案C:Office Script + Power Automate → Outlookの予定表
Excelの今日の列をOffice Scriptで読み、Power AutomateでOutlookの専用カレンダーに予定として書き込む方式です。カレンダーには現在時刻の横線が引かれますし、通知も来ます。スマホにも最初から入っています。
かなり良い線までいきましたが、2つの壁に当たりました。
壁1:実行回数の上限
Microsoft 365に付いてくるライセンスの範囲では、1日6,000リクエスト が上限です。フローの実行回数ではなくアクション数で数えるので、思ったより早く減ります。
「今日の予定を全部消して全部作り直す」設計だと、ブロック8個で1回あたり20リクエストほど。1分間隔で12時間回すと約14,000となり、あっさり超えます。
差分更新(変わったものだけ触る)にすれば3分間隔でも収まる計算でしたが、実装が一気に重くなります。
壁2:そもそも目的から外れていた
そして決定的だったのがこちらです。作りながら、こう思いました。
一覧で見るなら、Excelのほうが優れている。
15分の升目、横に並ぶ前日と翌日、メモ欄。あの密度をOutlookのカレンダーは再現できません。移行すると、むしろ劣化します。
私が欲しかったのは一覧ではなく「気づき」でした。ここに気づいて、方針を戻しました。
4. 採用した構成:Excelはそのまま、表示だけ外に出す
最終的にこうなりました。
設計の要点は3つです。
- Excelは触らない。 予定を立てる場所としては完成しているので、そのまま使う
- 表示だけを外に出す。 常に最前面に浮く小さな付箋にする
- 予定の置き場所は1か所にする。 自分のサーバーにJSONを1個置いて、全端末がそこを見る
一見すると「コピペするだけ」で後退したように見えますが、予定を組み替える瞬間、自分はすでにExcelを開いている ので、その場で貼るのが一番速いという結論になりました。自動化のための自動化をやめた形です。
5. 実装
5-1. Document Picture-in-Picture で最前面に浮かせる
今回の肝です。このAPIを使うと、任意のHTMLを入れた常に最前面のウィンドウ を開けます。動画用のPiPを拡張したもので、macOSなら他のアプリの上にも乗ります。
const pip = await documentPictureInPicture.requestWindow({
width: 330,
height: 290
});
// PiPウィンドウはスタイルを引き継がないので、自分で移植する
document.querySelectorAll("style, link[rel=stylesheet]").forEach(node => {
pip.document.head.appendChild(node.cloneNode(true));
});
// 付箋のDOMごと引っ越しさせる
pip.document.body.appendChild(note.root);
// 閉じたら元の画面に戻す
pip.addEventListener("pagehide", () => {
slot.appendChild(note.root);
});
ポイントは appendChild でDOMごと移動させている ところです。要素の参照は生きたままなので、元のページで回している setInterval がそのまま浮いている付箋を更新し続けます。状態を同期する仕組みを別途作る必要がありません。
制約もあります。
- ユーザー操作が必要(ボタンのクリックなどがないと開けない。自動起動はできない)
- HTTPSが前提(secure context)
- 開いた側のウィンドウより長生きできない(元のタブを閉じると付箋も消える)
- ChromeとEdgeのみ
5-2. Excelからの貼り付けを解釈する
Excelから列を選択してコピーすると、タブ区切りのテキストになります。これを寛容に読み取ります。
const TIME = /^(\d{1,2}):(\d{2})$/;
const HOURLAB = /^\d{1,2}\s*時$/; // 「13時」のような見出し列
const SEP = /^[〜~\-–—]$/; // 「〜」だけの列
// 各セルを、時刻・区切り・予定名に振り分ける
cells.forEach(raw => {
const c = raw.trim();
if (!c || SEP.test(c) || HOURLAB.test(c)) return; // 無視
const m = c.match(TIME);
if (m) { times.push(+m[1] * 60 + +m[2]); return; }
texts.push(c);
});
余計な列が混ざっても落ちないようにしてあります。実際の運用では、選択範囲がぴったりになることのほうが少ないためです。
さらに、連続する同じ予定はひとつのブロックにまとめます。 15分の行が4つ続いていたら「13:00–14:00」の1件として扱う、という処理です。
5-3. 15分ごとに回り続けるタイマー
ブロックにまとめた結果、今度は「15分の刻み」が見えなくなるという副作用が出ました。1時間の塊に見えてしまい、その中で何コマ進んだか分からない。
そこで、時計に同期した15分カウントダウン を足しました。
const sec = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
const rem = 900 - (sec % 900); // 00/15/30/45分ちょうどでゼロになる
qTime.textContent = pad(Math.floor(rem / 60)) + ":" + pad(rem % 60);
自分でタイマーを開始・リセットするのではなく、現在時刻から毎秒計算し直している のがポイントです。こうすると、ページを開いた時刻に関係なくExcelの行と必ず一致しますし、リセット処理も要りません。ゼロになれば自然と15:00に戻ります。
あわせて、ブロックを15分ごとのコマに分割して表示し、今いるコマだけ色を変えるようにしました。
5-4. 端末間の同期:schedule.json は何をしているのか
ここが今回いちばん悩んだところなので、少し丁寧に書きます。
登場人物は3つだけ
| ファイル | 置き場所 | 役割 |
|---|---|---|
sticky.html |
サーバー | 画面。予定を解釈して表示する |
save.php |
サーバー | 受付。読み書きの窓口 |
schedule.json |
サーバー | 保管庫。予定そのもの |
データベースは使っていません。保存したいのは「今日の予定」ひとつだけ なので、テキストファイルが1個あれば足ります。
schedule.json の中身
実際に保存されているのは、こういう内容です。
{
"text": "13:00\t〜\t13:15\t研修 スライド作成\n13:15\t〜\t13:30\t研修 スライド作成\n14:00\t〜\t14:15\t書き物",
"updated": "2026-08-09T11:14:32+09:00"
}
たった2つのキーしかありません。
-
text… Excelから貼り付けた文字列を、そのまま入れたもの -
updated… 最後に保存された時刻
なぜ「解析済みのデータ」を保存しないのか
最初は、解析後の構造をそのまま保存しようと考えていました。こういう形です。
{ "blocks": [ { "start": "13:00", "end": "14:00", "title": "研修 スライド作成" } ] }
一見きれいですが、やめました。理由は2つあります。
1. サーバーが予定の中身を知らなくて済む
解析(タブ区切りを読む、連続する予定をまとめる)は、すべてブラウザ側の処理です。生のテキストのまま保存すれば、save.php は 文字列を預かって返すだけ の存在でいられます。実際、PHP側のコードは30行ほどしかありません。
2. 解析ロジックを直しても、過去のデータが壊れない
これが決め手でした。開発中、貼り付けの読み取り方を何度も変えています。「13時」のような見出し列を無視する、連続する予定をまとめる、といった調整です。
解析結果を保存していたら、ロジックを変えるたびに保存済みのデータと食い違い ます。生のテキストを保存していれば、読み込むたびに最新のロジックで解釈し直されるので、この問題自体が起きません。
言い換えると、保存しているのは「解釈」ではなく「元の入力」 です。
updated は何のためにあるのか
保存時刻は、表示のためだけではありません。取りに行ったデータが前回と同じかどうかを判断する ために使っています。
if (d.updated === lastUpdated) return; // 変わっていないので何もしない
30秒ごとに取りに行きますが、ほとんどの場合、内容は変わっていません。毎回画面を組み立て直すと、入力中のスクロール位置が飛んだり、表示が一瞬ちらついたり します。時刻を1つ持っておくだけで、それを避けられます。
画面右下に出している「同期 11:14 更新」も、この値です。いつ時点の予定を見ているのかが分かる のは、複数の端末で開くときに地味に効きます。
書き込みと読み込みの流れ
$TOKEN = '(長いランダム文字列)';
$FILE = __DIR__ . '/schedule.json';
// 合言葉が違えば何もしない
if (!hash_equals($TOKEN, $_GET['token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
// POST:貼り付けられた文字列を保存する
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
file_put_contents($FILE, json_encode([
'text' => (string)$data['text'],
'updated' => date('c'),
], JSON_UNESCAPED_UNICODE), LOCK_EX);
echo json_encode(['ok' => true]);
exit;
}
// GET:保存されているものをそのまま返す
readfile($FILE);
LOCK_EX は、書き込み中に別のリクエストが割り込むのを防ぐためのものです。2台から同時に保存すると、ファイルが半分だけ書き換わった状態で読まれる可能性があります。実際に起きる確率は低いのですが、1語足すだけで防げるので入れています。
JSON_UNESCAPED_UNICODE がないと、日本語が \u7814\u4fee のようなエスケープ表記で保存されます。動作に支障はありませんが、中身を目で確認したいときに困ります。
ブラウザ側は30秒ごとに見に行く
function pull() {
if (!editor.hidden) return; // 編集中はさわらない
fetch(endpoint(), { cache: "no-store" })
.then(r => r.json())
.then(d => {
if (d.updated === lastUpdated) return; // 変化なし
lastUpdated = d.updated;
build(d.text); // ここで初めて解析する
});
}
ポイントが2つあります。
編集中は取りに行った結果を反映しません。 貼り付けている最中に他の端末の内容で置き換わると、書きかけのものが消えます。
cache: "no-store" は必須です。 これがないと、ブラウザが以前の応答を使い回して、いつまでも古い予定が表示され続けます。最初これで「同期していない」と勘違いして、しばらく原因を探しました。
競合はどうするか
2台で同時に貼り替えた場合、後から保存したほうが勝ちます。 差分を統合するような処理は入れていません。
使うのが自分ひとりで、予定を編集するのは基本的に1台だけなので、ここに手をかける価値はないと判断しました。作らなかったものを書いておくのも、あとで自分が読み返すときに役立つと思っています。
注意:この方式は、会社で使っている予定の内容が自分のサーバーに置かれることを意味します。トークンによる保護はしていますが、扱う情報の性質によっては適さない場合があります。導入前に社内のルールを確認してください。
6. つまずいたところ
file:// でもDocument PiPが動いた
ドキュメント上はHTTPS(secure context)が前提なので、ローカルファイルを直接開いた状態では動かないと思っていました。ところが、Macのローカルファイルのまま試したところ普通に浮きました。
とはいえ挙動が保証されているわけではないので、最終的にはサーバーに置いています。通知APIを使うにもHTTPSが必要 だったので、結果的にはそれで正解でした。
PiPウィンドウはスタイルを引き継がない
requestWindow() で開いたウィンドウは、真っさらな別ドキュメントです。CSSを自分で流し込むまで、当然ながら素のHTMLが表示されます。最初にこれで「デザインが全部消えた」と焦りました。
トークンの不一致で403
PHP側とHTML側に同じ文字列を書く必要があるのですが、1文字違うだけで 403 になります。手打ちせず、コピペで揃えるのが確実です。
エラーにならない間違いがあった
これが一番厄介でした。
Excelから予定をコピーするとき、日付の列は横に何十日分も並んでいます。 時刻の列(左端)と今日の列(右のほう)は離れているので、ドラッグで一気に選択すると、間にある過去の日付の列もまるごと入ってきます。
貼り付けを解釈する処理は、各行の最初に見つかった予定名を採用します。つまり、間の列が混ざると 今日ではなく何日も前の予定が表示されます。
厄介なのは、エラーが出ないこと です。時刻の形式は正しいので処理は通り、それらしい画面が表示されます。壊れて止まるなら気づけますが、静かに間違ったものが出ると見過ごします。
対策として、貼り付け欄の上に注意書きを出すようにしました。
今日の列だけを選んでコピーしてください。ドラッグで一気に選ぶと間の日付列が混ざり、別の日の予定が表示されます(エラーにはなりません)。
正しくは、時刻の列と今日の列を、⌘またはCtrlを押しながら 飛び飛びで選択 します。行が揃っていれば、離れた列同士でもコピーは正しく効きます。
技術で防ぐこともできたはずです。たとえば列の位置を覚えておくとか、日付の行を見て今日の列を特定するとか。ただ、そこまで作り込むより 注意書き1行で足りる と判断しました。使うのが自分ひとりなので、この判断でよかったと思っています。
なお、6時 7時 のような1時間ごとの見出し列については、混ざっても無視するようにしてあります。こちらは選択範囲に入れても問題ありません。
使わない入力欄を残してしまった
これは技術というより設計の反省です。「予定の列だけを貼る場合」に備えて開始時刻の入力欄を付けたのですが、実際の運用では時刻列も一緒にコピーするため、一度も使いませんでした。
使わない機能が画面に出ていると、それだけで「これは何だっけ」と考えさせてしまいます。 最終的に削除しました。読み取り側の処理は残してあるので、必要になれば戻せます。
7. 使ってみて変わったこと
| 以前 | 現在 | |
|---|---|---|
| 現在地の確認 | Excelを触って再計算させる | 見れば書いてある |
| 15分の経過 | 気づかない | カウントダウンが視界にある |
| 端末をまたぐ | できない | 同じURLを開くだけ |
| 予定の作成 | Excel | Excel(変わらず) |
一番の変化は、Excelを開き直す動機がなくなったこと です。今どのコマにいるかを知るためにExcelに戻る必要がなくなったので、作業画面を離れる回数が減りました。
15分のカウントダウンが減っていくのが常に見えるので、「あと3分でこのコマが終わる」という感覚を持ったまま作業できます。これは通知よりも効きました。通知は鳴った瞬間しか効きませんが、カウントダウンはずっとそこにあります。
8. まとめ
- 一覧はExcelに勝てない。 移行ではなく役割分担にしたのが正解でした
- やめた案のほうが学びが多かった。 VBA・Graph API・Power Automateは、それぞれ別の理由で環境に合いませんでした
- 制約は先に洗い出したほうがいい。 「技術的にできるか」より「自分の権限で通せるか」で選択肢はかなり絞られます
- 自動化は目的ではない。 予定を組み替える瞬間はすでにExcelを開いているので、コピペが最速でした
遠回りをしましたが、遠回りしたぶん「なぜこの形なのか」を自分の言葉で説明できるようになりました。同じような環境で悩んでいる方の参考になれば嬉しいです。



