デスクトップに常駐するTODOウィジェット「NoToDo」を個人開発しました。この記事では、なぜ作ろうと思ったのか、開発で大変だったこと、作ってみて感じたことをまとめます。

NoToDoはWindows / Mac対応のデスクトップTODOウィジェットで、GitHubで無料公開しています。
- デスクトップの隅に常駐する小さなTODOリスト
- ワンクリックで完了、完了タスクは翌日自動で消える
- Notionと双方向同期(オプション。未設定ならローカル単体で動作)
- 単一の実行ファイルでインストール不要
https://github.com/wadokon/notodo
なぜ作ろうと思ったのか
タスク管理アプリを「開く」のが面倒だった
世の中にはタスク管理アプリがたくさんありますが、どれも使うためにはまず「アプリを開く」必要があります。ブラウザでタブを探したり、アプリを起動したり……この一手間が地味に面倒で、気づけばTodoを見なくなっている。そんな経験はないでしょうか。
私は「開く」という動作自体をなくしたいと思いました。デスクトップに常駐していて、PCを使っていれば常に視界に入る。それなら見忘れることもありません。
自分に必要な機能だけがほしかった
既存のTodoアプリは多機能です。プロジェクト管理、タグ、リマインダー、サブタスク……。でも自分が毎日使うのは「追加する」「終わったらチェックする」だけ。高機能ゆえに動作が重かったり、画面がごちゃごちゃしたりするくらいなら、必要な機能だけを持った小さなツールがほしい。そう考えました。

「今日の成果」を見える化したかった
NoToDoのテーマは「今日のTodo」です。完了したタスクは打ち消し線付きでその日のうちは表示され、翌日には自動で消えます。
これは意図的な仕様で、その日のうちは「今日はこれだけやった」という達成感を見える化しつつ、翌日には気持ちをリセットして新しい一日を始められるようにしています。データ自体は残るので、あとから振り返ることもできます。
大変だったこと
Notionとの双方向同期
デスクトップ常駐だけだと、出先で思いついたTodoを追加できません。そこでNotionのデータベースと双方向同期する機能を入れました。スマホのNotionアプリからTodoを追加すれば、帰ってPCを開いたときにはウィジェットに反映されています。

ただ、この同期機能の実装が一番大変でした。ウィジェット側の変更は即座にNotionへプッシュし、Notion側の変更は定期的にポーリングして取り込む。どちらで編集されたかを突き合わせて整合を取る必要があり、たとえば「スマホでチェックだけ付けて完了日は入っていない」といったケースの補完処理など、細かい考慮がたくさんありました。

中でも時間を使ったのが、ローカルとリモートのどちらを正とするかの決め方です。実装前は「両方から読んで、新しいほうを採る」くらいに考えていましたが、実際に作ると、その「新しい」を判定できない場面のほうが多いと分かりました。Windows 版は C# の1ファイルで約2,000行あり、その中心がこの同期エンジンです。

ローカルの変更が残っている間は、リモートを反映しない
最初に決めたのがこれです。送信待ちの変更が1件でもあるなら、リモートの取り込みを丸ごと次回に回します。
void ApplyRemote(List<RemoteItem> remote)
{
lock (qLock)
{
// ローカル変更が滞留している間はリモート反映を見送る(次回に持ち越し)
if (ops.Count > 0 || archives.Count > 0) return;
}
...
}
そうしないと、PCで打った直後の内容が、送信される前にリモートの古い値で上書きされます。 手元で編集したものが目の前で元に戻る挙動は、同期ツールとしていちばんやってはいけないことでした。
厳密なマージではなく「手元の編集を最優先し、同期を1周遅らせる」という単純な優先順位にしたのは、個人用ツールで衝突がまず起きないからです。複雑な解決ロジックより、遅れても壊れないほうを選びました。
照合は2段階にする
ID で突き合わせるだけだと、初回同期で必ず重複します。 PC側に「牛乳を買う」があり、Notion側にも同じものがある場合、PC側にはまだ ID が無いので別物と判定されてしまうからです。
foreach (var r in remote)
{
// 1) IDで照合する
TodoItem local = rest.FirstOrDefault(x => x.NotionId == r.Id);
// 2) IDが無いものは、本文が一致すれば同じものとみなす(初回同期の重複防止)
if (local == null)
local = rest.FirstOrDefault(x => x.NotionId == null && x.Text == r.Text);
if (local != null) { /* リモートの値で更新 */ }
else { /* ローカルに新規追加 */ }
}
foreach (var l in rest)
{
if (l.NotionId == null)
NotifyChanged(l); // ローカルにしか無い → Notionへ送る
// NotionId有りでリモートに無い → Notion側で削除されたので破棄
}
ID が無いものに限り、本文の一致で同一とみなすという2段目を入れて解決しました。ID が付いたあとは1段目だけが効くので、同名の項目を後から作っても混ざりません。
あわせて、「ID があるのにリモートに無い」ものは削除として扱っています。 Notion 側で消したものが PC に残り続けるほうが困るので、そう決めました。
送信キューは項目ごとに1件だけ持つ
タイトルを編集するたびに API を叩くと、数文字打っただけで何回もリクエストが飛びます。送信待ちを項目をキーにした辞書で持つことで、同じ項目への連続した編集は自動的に最後の1件へ畳まれます。
// 送信待ちは「項目ごとに1件」だけ持つ。同じ項目を続けて編集すると後の内容で上書きされる
readonly Dictionary<TodoItem, PushOp> ops = new Dictionary<TodoItem, PushOp>();
public void NotifyChanged(TodoItem item)
{
lock (qLock)
{
var op = new PushOp();
op.Item = item; op.Id = item.NotionId;
op.Text = item.Text; op.Done = item.Done;
op.DoneAt = item.DoneAt; op.Hidden = item.Hidden;
ops[item] = op; // ← キーが項目なので、同じ項目は1件に畳まれる
}
signal.Set();
}
タイマーでの遅延処理を書かなくても、データ構造を辞書にするだけで実質的なデバウンスになるのは、作ってみて気づいた点でした。
Notionの仕様理解
そもそもNotion APIを使うには「インテグレーション」という連携用の仕組みを理解する必要がありました。インテグレーションを作成してトークンを発行し、対象のデータベースに接続を許可して、データベースIDを控えて……と、初見では戸惑うポイントが多かったです。データベースのプロパティ(タイトル、チェックボックス、日付)をAPIごしにどう読み書きするかも、ドキュメントと格闘しながら覚えました。
このあたりはREADMEにセットアップ手順としてまとめたので、同じことをやりたい人の参考になればうれしいです。
もう1つ厄介だったのが、API のドキュメントには書いていないスマホの Notion アプリの書き込み方です。
具体的には、スマホからチェックを付けても、完了日プロパティは空のままです。PC 側のアプリは自分でチェックと完了日をセットで更新するので、両者で持っているデータが食い違います。
// 「完了⇔完了日」の整合を取る。スマホのNotionアプリはチェックだけ変えて完了日を
// 触らないので、こちらで補完(完了日はページ最終編集時刻から推定)・クリアして書き戻す。
void FixDoneAt(TodoItem item, RemoteItem r)
{
if (item.Done && item.DoneAt == null)
{
item.DoneAt = LastEditedDate(r); // 最終編集時刻から推定
NotifyChanged(item); // 推定した値をNotionにも書き戻す
}
else if (!item.Done && item.DoneAt != null)
{
item.DoneAt = null;
NotifyChanged(item);
}
}
完了日が空ならページの最終編集時刻から推定して埋め、それを Notion にも書き戻します。 逆に、完了を外したのに完了日が残っていればクリアします。
推定なので厳密ではありません。ただ「今日いくつ終わったか」を見るのが目的なので、分単位の正確さより、空欄が無いことのほうが価値がありました。 用途を決めておくと、こういう妥協の線を引けます。
Mac版はほぼ作り直し
最初はWindows版(C# / WinForms)だけを作っていました。ただ、公開していろんな人に使ってもらいたかったので、Mac版も作ることにしました。
同期のロジックや仕様はそのまま流用できるのですが、MacはSwift / AppKitで言語もUIフレームワークも別物。「移植」というより、同じ仕様書からもう一本アプリを書き起こす感覚で、実質ほぼ作り直しでした。ウィンドウの挙動やメニューバーまわりなど、OSごとの流儀の違いを吸収するのも地味に大変でした。
行数で比べると、Windows 版が C#(WinForms)で約2,000行、Mac 版が Swift(AppKit)で約800行です。Windows 版には「毎日のTODO」や集中タイマーなど Mac 版にまだ無い機能も入っているので、この差の多くは機能の差です。そのうえで2つを見比べると、そのまま持っていけたものと、書き直すしかなかったものがはっきり分かれていました。
- 移植できたもの
上で書いた突き合わせの分岐、完了日の補完、送信キューの畳み方。これは言語に依存しない - 作り直したもの
常駐の形(タスクトレイ → メニューバー)、最前面ウィジェットの出し方、設定ファイルの置き場所(exe と同じフォルダ →~/Library/Application Support/)
UI の常駐まわりは、OS ごとに考え方そのものが違うので流用が効きません。 逆に言えば、同期のルールを UI から切り離して書いておいたおかげで、移植で悩んだのは見た目の部分だけで済みました。
2つ目のOS向けに作る予定が少しでもあるなら、ロジックとUIの境界を最初から意識しておくと後が楽です。これは今回の実感として、いちばん残ったことでした。
作ってみて感じたこと
「自分にピッタリ」は自分で作るのが早い
フリーのデスクトップウィジェットは探せば色々あります。でも実際に試すと、余計な機能があったり、逆に肝心な機能が足りなかったり。「自分にピッタリ」のものを探し続けるのは案外大変です。
自分で作れば、必要な機能だけを必要な形で実装できます。既製品を探す時間で自作してしまうという選択肢は、可能性が広がる感覚があってとても良いものでした。
ホスティング不要の気軽さ
WebアプリだとサーバーやDB、デプロイ環境の準備が必要ですが、デスクトップアプリは実行ファイルを配るだけ。ランニングコストもかかりません。「作って、置いて、使ってもらう」までの距離が短く、個人開発のとっかかりとしてかなり楽でした。
デスクトップウィジェットはブルーオーシャン
個人開発というとWebサービスやスマホアプリが主流で、デスクトップウィジェットを作っている人は少ない印象です。競合が少ないぶん、ちょっとしたアイデアでも「これ欲しかった」と言ってもらえる余地が大きい、隠れたブルーオーシャンだと感じました。
おわりに
NoToDoはGitHubで公開しています。Windows / Mac対応、Notion連携はオプション(設定しなければローカル単体でも動きます)。
https://github.com/wadokon/notodo
デスクトップに小さなTodoが常駐しているだけで、日々のタスク消化は意外と変わります。興味があればぜひ試してみてください。フィードバックやスターも歓迎です。
次に読む記事



