一人暮らしの「掃除が続かない」を助ける Web アプリ「Cleeky」を、設計書から作って公開しました
まず、使ってみてください(登録1分・無料)
👉 https://cleeky.inori0218adt.workers.dev
部屋ごとに掃除を登録すると、「今日やること」だけを並べてくれる、一人暮らし向けの掃除習慣アプリです。掃除癖のない私が、「終えたときの達成感」で、面倒な掃除を楽しくしたくて作りました。
- 登録は メールアドレス or Google アカウントで1分
- 最初はタップするだけ:部屋を選ぶ → おすすめの掃除を選ぶ → 完了(約1分)
- 掃除が終わったら丸を押すだけ。続けた日数も数えてくれます
- パソコンでもスマホでも同じデータ(アプリのインストールは不要。ホーム画面に追加すれば、アプリのようにも開けます)
- 基本の機能はずっと無料です(任意の有料プラン「Cleeky Plus」もあります)
📷 (ここに、今日の画面のスクリーンショットを入れる。パソコン幅とスマホ幅の2枚があると伝わりやすいです)
使ってもらえたら、とても嬉しいです。 「ここが分かりにくかった」「こうだったら続けられそう」というご意見は、この記事のコメントでいただけると助かります。初めて触る人が迷わないように、アプリの中に「はじめてガイド」も用意しました(あとで詳しく書きます)。
この記事に書くこと
「作ったものの紹介」だけでなく、どう設計したかも、できるだけ具体的に書きます。
| 章 | 内容 |
|---|---|
| 1 | どんなアプリか(できること・画面) |
| 2 | なぜ作ったか・技術構成 |
| 3 | 設計書の進め方(設計 → 見本 → 実装 → 確認) |
| 4 | インフラ設計(構成図・環境・秘密の値・CI/CD・決済と通知の流れ) |
| 5 | データベース設計(ER 図・RLS・関数・日付の扱い・型の自動生成) |
| 6 | アプリの設計(フォルダ構成・層の依存ルール・テスト) |
| 7 | 作ってハマったこと・工夫 |
1. どんなアプリか
3ステップで使えます
- 部屋を選ぶ:キッチン・浴室・リビングなど、使っている部屋を選びます
- 掃除を選ぶ:部屋ごとの「おすすめ」から選ぶだけ。頻度と時間はおすすめのままでOK
- 今日やることをこなす:毎日、今日の掃除が並びます。終わったら丸を押すだけ
「掃除の習慣が無い人」が、入力をほとんどせずに始められることを、いちばん大事にしました。
続けるための仕掛け
- 連続記録:続けた日数と自己ベスト。途切れそうな日は「おやすみ券」で守れます
- お祝い:今日の掃除を全部終えた日、節目の日に、お祝いが出ます
- ごほうびシール:掃除の回数・続けた日数に応じて集まります
- 今日はしない/明日にする:できない日は、責められずに外せます
- 毎朝のお知らせ(Web Push):今日の掃除を、決めた時刻に知らせます
- はじめてガイド:初めての人に、4つの基本操作(丸を押す・行を押す・「…」・タスクを足す)を、操作しながら覚えてもらいます
有料プラン「Cleeky Plus」(任意)
月額 300円 / 年額 1,500円(最初の1か月は無料)。使える時間に合わせて今日の掃除を自動で決める「自動の毎日プラン」、部屋ごとの汚れ具合を見る「きれい度メーター」、旅行や体調不良の日に連続記録を守る「おやすみモード」、月ごとの「振り返り」、季節の掃除の提案、があります。無料の機能だけでも、掃除の習慣づくりには十分なように作っています。
2. なぜ作ったか・技術構成
なぜ作ったか
正直に言うと、私はもともと掃除癖がありません。掃除は面倒で、つい後回しにしてしまいます。
そこで思いついたのが、「アプリで達成感を味わえるようにすれば、面倒な作業が楽しくなり、家をきれいにできるのではないか」ということでした。掃除のやり方を教えてくれるアプリではなく、掃除を「やった!」と気持ちよく終えられる仕組みを作りたかったのです。
そのために、Cleeky には、達成感を味わうための仕掛けを入れています。
| 気持ち | 仕掛け |
|---|---|
| 「今日は何をすればいい?」で迷わない | 今日やることだけを並べる(頻度から自動で決まる) |
| 終えた手応えがほしい | 丸を押すだけで完了。押した瞬間に、丸が弾む |
| 続いていることを感じたい | 連続記録(続けた日数と自己ベスト) |
| 頑張りを認めてほしい | お祝い(全部終えた日・節目の日)と、ごほうびシール |
| できない日に挫折したくない | 今日はしない/明日にする、おやすみ券 |
「掃除をする」という面倒な作業の、終わったときの気持ちよさを、小さく何度も味わえるようにして、習慣にしていく。それが、このアプリの出発点です。
Web アプリにした理由
誰でも、アプリのインストールなしで、すぐに試せるように、Web アプリにしました。パソコンの広い画面でも、スマホのブラウザでも使えて、同じデータが見られます。ホーム画面に追加すれば、アプリのようにも開けます。
そのため、ログインとサーバーを前提にしました。
| 項目 | 方針 |
|---|---|
| データの置き場所 | サーバー(Supabase の PostgreSQL) |
| ログイン | あり(全員。メールアドレス、または Google アカウント) |
| 有料プラン | Stripe |
| 公開 | Cloudflare(Web サイト) |
なぜ全員ログインにしたか:有料プランの「買った」という記録を、ブラウザの中に置くと、利用者に書き換えられてしまいます。「誰が買ったか」を見分けるにはログインが必要で、ログインが無いと、ブラウザを変えたり履歴を消したりしたときに、データも消えてしまいます。保存の仕組みを1つ(サーバー)にして、パソコンとスマホで同じデータを見られるようにしました。
技術構成
| 区分 | 使っているもの |
|---|---|
| 画面 | React 19 / TypeScript / Vite / React Router / styled-components |
| ログイン・データ | Supabase(Auth・PostgreSQL・Edge Functions。東京リージョン) |
| 決済 | Stripe(Checkout・カスタマーポータル・Webhook) |
| 公開 | Cloudflare Workers(静的な資産)。GitHub の main に push すると自動で公開 |
| 自動の確認 | GitHub Actions(型チェック・lint・テスト・ビルド) |
| テスト | Vitest(計算・ユースケース)/ PGlite(データベースの決まり・RLS) |
3. 設計書の進め方
このアプリは、**「作る前に、設計書に書く」**を決まりにして進めました。個人開発だと、頭の中だけで進めて、あとから「なぜこうしたんだっけ」となりがちなので、決めたことを必ず文書に残しています。
設計書の置き場所
plan/
PROJECT_PLAN.md 目的・画面・仕様・決定事項・進捗(これが「正」)
docs/
architecture.md フォルダ構成と、層の依存ルール
database.md 表・RLS・データベースの関数
infrastructure.md 構成図・ER 図・シーケンス図(Mermaid)
testing.md テストの方針と書き方
workflow.md 作業の進め方(ブランチ・確認・コミット)
deploy.md 公開の手順(Cloudflare・Supabase)
stripe.md 決済の準備
notifications.md 通知の準備
design/mockups/ 画面の見本(HTML)
進め方(1つの機能の例)
-
PROJECT_PLAN.mdに仕様を書く。決まっていない点は、推測で決めず、選択肢を出して選ぶ - 決まったことは「決定事項」の表に書く(あとから理由を辿れるように)
- **画面の見本(HTML)**を先に作る(Figma の利用上限に当たったので、HTML で。パソコン幅とスマホ幅の両方)
- domain(計算)→ 保存 → 画面の順に、小さく作る。各段階でテストを書く
- ブラウザ(パソコン幅とスマホ幅)で確かめて、型チェック・lint・テストを通してからコミット
たとえば、前述の「はじめてガイド」は、こう進めました。
- 「初めて使う人が戸惑わないか」を調べて、気づいた課題を書き出す
- 「形」の選択肢を4つ(ツアー/チェックリストのカード/その場のヒント/使い方ページ)並べ、良い点・気になる点を比べて、カード+使い方ページに決める
- 「あとで設定する」を選んだ人(タスクが0件)は、最初のタスクを登録したときに始める、と仕様に書く
- 見本(HTML)→ domain とテスト → 保存 → 画面、の順に作る
設計書に「選んだ理由」まで残してあるので、半年後に自分が読み返しても迷いません。
4. インフラ設計
4.1 システム構成図
ポイントは3つです。
- 画面のファイルは Cloudflare から配るだけ。データの読み書きは、ブラウザから Supabase へ直接行います(自前のサーバーは持ちません)
- ブラウザに入る鍵は publishable key だけ。守りは データベースの RLS(他の人の行は見えない・触れない)で行います
- 決済・通知・アカウント削除は、ブラウザに置けない鍵(Stripe の秘密鍵・Supabase の secret key)が要るので、Edge Functions で行います
4.2 環境
| 開発 | 本番 | |
|---|---|---|
| 画面 | 自分のパソコン(npm run dev) |
Cloudflare |
| Supabase | 同じプロジェクト(東京)を共用 | 同じ |
| Stripe | テストモードで開発 | 本番モード |
| データベースのテスト | PGlite(パソコンの中だけで動く PostgreSQL) | — |
個人開発で規模が小さいので、Supabase のプロジェクトは開発と本番で共用しています。そのため、マイグレーションを db push した時点で、公開中のサイトにも効きます。「表・関数を足すときは、画面より先に db push」「消す・名前を変えるときは、公開中の画面が使っていないことを確かめる」を、手順書(deploy.md)に書いています。利用者が増えたら、本番用に分ける予定です。
4.3 秘密の値の置き場所
| 値 | 置き場所 | 画面のコードに入れてよいか |
|---|---|---|
| Supabase の URL・publishable key |
.env(VITE_ で始まる名前) |
よい(誰でも見られる前提) |
| Supabase の secret key | Edge Functions の設定だけ | だめ |
| Stripe の秘密鍵・Webhook の署名の鍵 | Edge Functions の設定だけ | だめ |
| 通知の秘密鍵(VAPID) | Edge Functions の設定だけ | だめ |
.env は Git に入れません(見本は .env.example)。秘密の値は、コード・文書・チャット(AI との会話も含む)に書かない決まりにして、各サービスの管理画面にだけ入れています。
4.4 CI/CD
-
CI(GitHub Actions):
npm ci→npm run check(型チェック・lint・テスト)→npm run build。Supabase には触らない(画面のビルドに必要な環境変数は、ダミーの値) -
CD(Cloudflare Workers Builds):
mainへの push でnpm run build→npx wrangler deploy。ビルドが失敗したら、前の公開がそのまま残るので、サイトは壊れません - ブランチを push すると、本番とは別の「プレビュー」に出ます(
wrangler.jsoncに"previews": {}が必要でした。7章で書きます)
4.5 処理の流れ(シーケンス図)
今日の画面を開いて、丸を押す。予定日の計算は**ブラウザ(domain)**で行い、データベースには「計算した結果」を渡します。
Cleeky Plus を買う。Stripe の Webhook を、署名を確かめてから受け取ります。
毎朝のお知らせ。データベースの時刻で動く仕組み(pg_cron)から、1時間ごとに Edge Function を呼びます。
5. データベース設計
5.1 ER 図
| 表 | 役割 |
|---|---|
rooms |
部屋(論理削除。同じ人の、削除していない部屋どうしで名前の重複を禁止) |
tasks |
掃除タスク(頻度は daily / weekdays / everyNDays) |
task_occurrences |
予定と実施の記録(同じタスク・同じ日は1件だけ) |
app_settings |
設定(user_id + key の「名前と値」。進み具合などの JSON もここ) |
push_subscriptions |
お知らせを受け取る端末(ブラウザごとに1行) |
stripe_customers / subscriptions
|
決済(画面からは読むだけ。書くのは Edge Function だけ) |
アカウントを消すと(on delete cascade)、その人の行はすべて消えます。
5.2 守り方(RLS)の設計
方針は4つです。
- すべての表で RLS を有効にする(RLS の無い表は作らない)
-
まず全部の権限を取り上げ、必要な操作だけを
authenticatedに渡す。anonには何も渡さない - 部屋・タスクは消さない(論理削除)ので、
deleteの権限を渡さない。決済の表はselectだけ - RLS と外部キーで二重に守る
-- まず全部の権限を取り上げ、ログインしている人だけに、必要なものを渡す
revoke all on table public.rooms, public.tasks, public.task_occurrences,
public.app_settings, public.stripe_customers, public.subscriptions
from anon, authenticated;
grant select, insert, update on table public.rooms to authenticated; -- delete は渡さない
grant select on table public.subscriptions to authenticated; -- Plus は読むだけ
alter table public.rooms enable row level security;
create policy "rooms: 自分の部屋を読む" on public.rooms
for select to authenticated using ((select auth.uid()) = user_id);
create policy "rooms: 自分の部屋を足す" on public.rooms
for insert to authenticated with check ((select auth.uid()) = user_id);
auth.uid() は (select auth.uid()) で包みます。行ごとに呼ばれず、1回だけ求められるので、速くなります(Supabase の公式ドキュメントの書き方)。
5.3 「他の人のデータを指せない」複合外部キー
RLS だけに頼らず、テーブルの定義でも、他の人のデータを指せないようにしています。
create table public.rooms (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
name text not null check (char_length(name) between 1 and 30),
-- タスクから「同じ持ち主の部屋」だけを参照するための組み合わせ
unique (id, user_id)
);
create table public.tasks (
-- ...省略...
room_id bigint not null,
-- 部屋は「同じ持ち主の部屋」だけ(他の人の部屋の id を指定しても登録できない)
foreign key (room_id, user_id) references public.rooms (id, user_id)
);
create table public.task_occurrences (
-- ...省略...
unique (task_id, scheduled_date), -- パソコンとスマホで同時に予定を作っても二重にならない
check ((status = 'completed') = (performed_date is not null)), -- 完了なら実施日あり
foreign key (task_id, user_id) references public.tasks (id, user_id)
);
-
user_idはdefault auth.uid()。画面から送らなくても、ログインしている人になります - 「同じタスク・同じ日は1件」を
uniqueで守ると、複数の端末から同時に予定を作っても、二重になりません - 「完了なら実施日あり、未完了なら実施日なし」を
checkで守り、あり得ない状態を作れなくしています
5.4 複数の書き込みは「データベースの関数」で1回にまとめる
画面から何度もやり取りして、途中で失敗すると、中途半端な状態が残ります。そこで、複数の書き込みが必要な操作は、**PostgreSQL の関数(RPC)**にまとめました。関数の中は1つのトランザクションなので、全部成功するか、全部取り消されるかです。
| 関数 | 内容 |
|---|---|
complete_onboarding |
選んだ部屋・タスクの登録、完了の印、今日までの予定の作成 |
generate_occurrences |
「前回作った日から今日まで」の予定をまとめて保存 |
add_task / edit_task
|
タスクの登録・編集と、今日の予定の判定し直し |
stop_task / delete_room
|
停止・削除と、今日以降の未完了の予定の削除 |
record_done_now / postpone_occurrence
|
「今やった」「明日にする」を1回で |
list_history |
履歴(予定・タスク・部屋をつないで読む) |
関数は、引数で user_id を受け取らず、auth.uid() を使います(なりすましを防ぐため)。
5.5 日付の扱い
- 「今日」はブラウザ(利用者の端末)の日付で決め、
'YYYY-MM-DD'の文字でデータベースに渡します(サーバーの時刻に依存しない) - 予定日の計算は TypeScript(domain)で行い、データベースには「計算した結果」を渡します。計算を TypeScript と SQL の2か所に書かないためです
5.6 supabase gen types で型を自動生成
表の型は、手で書かずに生成します。
npx supabase gen types typescript --linked --schema public > src/shared/infrastructure/supabase/database.types.ts
export const supabase = createClient<Database>(url, publishableKey);
生成した型には弱点があります。補い方も決めています。
- 文字の列(
statusなど)はstringになる → 値を確かめて、決まった値の型にする関数(想定外の値なら例外)を通す - データベースの関数の引数・戻り値は null を表せない → 必要な所だけ、理由のコメントを付けて型を補う
6. アプリの設計
6.1 フォルダ構成(機能単位 × 層 × Atomic Design)
src/
app/ ルーティングと、アプリの組み立て
features/ 機能ごとのまとまり(today・rooms・tasks・history・guide ...)
<機能名>/
ui/ 画面と、その機能だけの部品・フック
application/ ユースケース:1つの操作の手順
domain/ 型・入力ルール・計算(React にも Supabase にも依存しない純粋な TypeScript)
infrastructure/ データベースの読み書き
shared/ 2つ以上の機能で使うもの(ui の atoms/molecules/organisms など)
supabase/
migrations/ 表の作成・変更の SQL(公開済みのものは書き換えず、足していく)
functions/ Edge Functions
6.2 層の依存ルール
-
Supabase は
infrastructureだけで使う(画面から直接データベースを触らない) -
domainは React もライブラリも使わない。だから単体テストが書きやすい - 決まりは ESLint の
no-restricted-importsで自動的に確かめます。破ると lint が落ちます
6.3 domain の例:「今日は予定日か」
頻度は3種類(毎日/曜日指定/N日ごと)。'YYYY-MM-DD' の文字は、そのまま大小を比べられます。
export function isDueOn(rule: ScheduleRule, date: string): boolean {
if (date < rule.scheduleAnchorDate) return false; // 起点日より前は予定日にしない
const { frequency } = rule;
switch (frequency.type) {
case 'daily':
return true;
case 'weekdays':
return includesWeekday(frequency.days, weekdayOf(date));
case 'everyNDays':
// 起点日を1回目として、N日おきに予定日になる
return daysBetween(rule.scheduleAnchorDate, date) % frequency.days === 0;
}
}
日付の計算は UTC で行います(サマータイムのある地域で、1日が23時間・25時間になって、日数がずれるのを避けるため)。
6.4 テストの方針
| 対象 | テスト |
|---|---|
| domain(計算・入力ルール) | 必ず書く。境目(0件・上限ちょうど・上限+1)、月末・年末・うるう年 |
| application(ユースケース) | 必ず書く。infrastructure を代役に差し替え、手順と結果を確かめる |
| データベース(表・RLS・関数) | 必ず書く。PGlite に migration の SQL を流して、他の人のデータが見えない・書き換えられないことを確かめる |
| ui(画面) | 今は書かない。ブラウザ(パソコン幅とスマホ幅)で確かめる |
全体で 約600件(2026年10月27日時点で 594件)。RLS のテストは「守りを外すと失敗する」ことを一度確かめています。そうしないと、テストが何も守っていなくても、気づけないからです。
7. 作ってハマったこと・工夫
7.1 Google で検索しても出てこない(SPA の SEO)
公開してから、「Cleeky」で検索しても出てきませんでした。調べると、index.html に本文が無く、JavaScript が動いてから画面が描かれる作りでした。Google は JavaScript も読めますが、読む順番が後回しになり、登録が遅れやすいです。
そこで、ビルドの最後に、トップの画面を HTML の文字に書き出して dist/index.html に入れるようにしました(プリレンダー)。
- 新しいパッケージは入れず、
react-dom/serverと styled-components のServerStyleSheetで書き出す -
vite buildのあとに、Node のスクリプトが、画面を「サーバー側の形」に Vite で一度作り直して呼ぶ - 書き出したトップは
#rootの隣(#prerender)に置き、アプリが最初の中身を描いたら取り除く(#rootを書き換えると、「文章 → 白 → 文章」とちらつくため) -
/loginなど/以外では、アプリを読む前に、小さなインラインスクリプトで取り除く -
canonicalは足さない。どのアドレスにも同じindex.htmlが返るので、/loginなども「トップと同じ」と読まれる心配があるため
7.2 wrangler preview が「previews ブロックが無い」で失敗
プルリクエストのブランチをプレビュー用に公開するとき、Cloudflare のビルドが wrangler preview で失敗しました。
Your Wrangler configuration is missing a `previews` block to run this command.
wrangler.jsonc に、空の "previews": {} を足すと直りました(本番の wrangler deploy には影響しません)。
7.3 styled.div is not a function(SSR で styled-components が読めない)
プリレンダーのスクリプトで、styled-components を Node がそのまま読むと、**styled.div が使えない形(CommonJS)**で入ってきて落ちました。Vite の SSR ビルドの ssr.noExternal: ['styled-components'] で、一緒にまとめて作ると動きます。
7.4 保存を待たずに画面を進める(楽観的な更新)と、失敗の扱い
丸を押したら、保存を待たずに「完了」の見た目にして、保存に失敗したら元に戻して知らせる、としています。保存・削除などの「失敗したら押せる状態に戻す」流れが、画面ごとに14か所に散らばっていたので、1つのフック(useAction)にまとめました。例外を握りつぶさないよう、原因は console.error に残します。
7.5 as で型を上書きしない
Supabase の戻り値を as で型に合わせていた箇所が24か所ありました。型の自動生成に切り替えて、**文字の列は値を確かめる関数(想定外なら例外)**を通すようにしたので、「データがおかしいときに黙って壊れる」ことが減りました。
7.6 初めて使う人のための「はじめてガイド」
「ログイン後に、初めて使う人が戸惑わないか」を調べると、アプリの中の案内が何もありませんでした。説明を読ませるより、実際に操作して覚えてもらうように、今日の画面の上に4ステップのカードを出し、操作したら自動でチェックが付く形にしました。いま押してほしい所には、静かな輪を付けます(強制のツアーにはしません。「視差効果を減らす」の設定なら、動きは止めます)。
ガイドの状態は、新しい表を作らず、既存の app_settings(名前と値)に JSON で保存しました。表が増えないので、db push も要りません。
7.7 そのほか
- OneDrive の中のフォルダだと、開発用サーバーがファイルの変更に気づかないことがあり、画面が古いままに見えて、迷いました(サーバーを起動し直すと直ります)
-
画面幅の判定:「パソコン幅(900px 以上)か」は、見た目の違いだけなら CSS の
mediaで、入力欄の並べ方そのものが違う画面だけ、フックで切り替えました
まとめ・これから
- 設計書を先に書くと、決めた理由が残り、迷いが減りました。選択肢を出して選ぶ形にすると、「なんとなく」で決めずに済みます
-
RLS・複合外部キー・
check制約・データベースの関数の組み合わせで、データベースの側でも、あり得ない状態を作れないようにしました - 層の依存ルールを ESLint で自動的に確かめるようにすると、コードが育っても、構造が崩れにくいです
掃除癖のない自分のために作ったアプリが、同じように「掃除が続かない」人にも役立てば、とても嬉しいです。これからは、使ってくれた人の声をもとに、初回の流れやガイドを直していきたいです。
👉 https://cleeky.inori0218adt.workers.dev
登録は1分、基本の機能はずっと無料です。「今日の掃除、全部おわり!」のお祝いが出る日を、ぜひ作ってみてください。気づいたことは、コメントでお知らせいただけると、とても助かります。
この記事について
この記事の設計書とコードは、生成 AI(Claude Code)と一緒に作りました。設計の判断・選択肢の決定は、私が行い、内容は私が読んで確認しています。