
はじめに
買ったまま手を付けていない本、作っていないプラモデル、遊んでいないゲーム。いわゆる「積み」を、罪悪感なく楽しく崩すためのWebアプリ「TsumiBreak(ツミブレイク)」を作りました。
積みを「コレクション」として眺めるアプリはたくさんあります。TsumiBreakはその逆で、積みを減らす行動を後押しすることだけに絞っています。
この記事では、どんな考えでこの形にしたのかと、主な機能の作りを紹介します。
積みが崩れない理由は3つある
自分の積みを振り返ると、崩れない理由は次の3つでした。
- 何から手を付けるか迷う:積みが多いほど選べなくなり、結局どれにも手を付けない
- 始めるハードルが高い:「ちゃんと時間を取ってから」と思っているうちに日が過ぎる
- 崩しても達成感がない:1つ終わっても、山がまだ高いので減った気がしない
TsumiBreakでは、それぞれに1つずつ機能を当てています。
理由 | 機能 |
|---|---|
何から手を付けるか迷う | 崩しガチャ(次に手を付けるものをランダムで決める) |
始めるハードルが高い | 15分タイマー(「15分だけ」と決めて始める) |
崩しても達成感がない | 資産額メーターとXシェア(減った金額を見える化する) |
機能1:崩しガチャ
ダッシュボードのガチャを回すと、積んでいるアイテムの中から1つがランダムに選ばれます。「自分で選ばない」ことで、迷う時間をゼロにするのが狙いです。
抽選そのものは、とても単純です。
/** 均等確率で 1 件選ぶ。rand は [0, 1) を返す関数(テスト用に差し替え可能) */
export function pickRandom<T>(items: readonly T[], rand: () => number = Math.random): T | null {
if (items.length === 0) return null
const idx = Math.min(Math.floor(rand() * items.length), items.length - 1)
return items[idx]
}
/** 引き直し用。候補が 2 件以上あれば直前の結果を除外して選ぶ */
export function pickNext<T extends { id: string }>(
items: readonly T[],
previousId: string | null,
rand: () => number = Math.random,
): T | null {
const pool = previousId && items.length > 1 ? items.filter((i) => i.id !== previousId) : items
return pickRandom(pool, rand)
}
工夫したのは2点です。
- 乱数を引数で差し替えられるようにした:
randを渡せるので、テストでは決まった値を返す関数を渡して「どれが選ばれるか」を確かめられます。 - 引き直しで同じものが出ないようにした:純粋にランダムだと、引き直しても同じものが出ることがあります。体験としては「壊れている」と感じるので、候補が2件以上あるときは直前の結果を外しています。
抽選の候補は、カテゴリ(プラモ・本・ゲーム)や「途中のものも含めるか」で絞り込めます。
演出はSVGとCSSアニメーションだけ
ガチャの演出は、カプセルトイ風にしました。ハンドルが回り、カプセルが落ちてきて、揺れてからパカッと開きます。全体で約2.5秒です。
画像や動画は使わず、マシンもカプセルもSVGで描いて、CSSのkeyframesで動かしています。最後の光のアニメーションが終わったとき(onAnimationEnd)に結果を表示します。
結果が出たら紙吹雪(canvas-confetti)を飛ばします。紙吹雪は使うときだけ import() で読み込むので、最初のページ表示を重くしません。
void import('canvas-confetti').then(({ default: confetti }) =>
confetti({ particleCount: 80, spread: 70, origin: { y: 0.55 }, zIndex: 1000, disableForReducedMotion: true }),
)
OSで「視差効果を減らす」を設定している人には、disableForReducedMotion で紙吹雪を出しません。ガチャの演出も飛ばして、すぐに結果を表示します。
機能2:15分タイマー
ガチャで選ばれたアイテムは、そのまま15分タイマーを始められます。「全部やる」ではなく「15分だけ」と決めると、始めるハードルがぐっと下がります。
タイマーは画面を移動しても、ブラウザを再読み込みしても続くようにしました。開始時刻と長さを localStorage に保存して、残り時間は毎回「開始時刻+長さ−今」で計算します。
export type TimerState = { itemId: string; itemTitle: string; startedAt: number; durationMs: number }
export function remainingMs(s: TimerState, now: number): number {
return Math.max(0, s.startedAt + s.durationMs - now)
}
/** 記録用の経過分。タイマー終了後に時間が経っていても durationMs を上限にする */
export function elapsedMinutes(s: TimerState, now: number): number {
const elapsed = Math.min(now, s.startedAt + s.durationMs) - s.startedAt
return Math.max(0, Math.round(elapsed / 60_000))
}
「1秒ごとに残り時間を1減らす」作りにすると、スマホでタブが裏に回ったときにタイマーが止まってずれます。時刻から毎回計算すれば、裏に回っていても正しい残り時間が出ます。
localStorage から読んだ値は、型と範囲(長さは4時間まで、など)を確かめてから使います。プライベートブラウズなどで localStorage が使えない環境でも、例外で落ちずに画面上ではタイマーが動くようにしています。
機能3:資産額メーターとXシェア
アイテムには価格を登録できます。ダッシュボードでは「まだ積んでいる金額」と「今月崩した金額」を出しています。
積みの山は減った気がしにくいのですが、「今月 6,200円分を消化した」と金額で見ると、崩した実感が湧きます。
崩し完了にすると、紙吹雪と一緒に「MISSION COMPLETE!」の画面が出て、そのままXにシェアできます。シェアには、件数と金額が入った画像付きのカードが付きます。この画像はDBに保存せず、URLのパラメータから動的に作っています(作り方は別の記事にまとめます)。
使っている技術
分類 | 技術 |
|---|---|
フレームワーク | Next.js 16(App Router、Server Actions) |
UI | Tailwind CSS v4、shadcn/ui、Lucide |
認証・DB | Supabase(Auth、PostgreSQL、RLS) |
バーコード読み取り | barcode-detector(ZXingのWebAssembly版) |
PWA | Serwist |
テスト | Vitest、Testing Library、Playwright |
ホスティング | Vercel |
ホーム画面に追加すればアプリのように使えるよう、PWAにしています。ストアに出すネイティブアプリにしなかったのは、URLを開けばすぐ試せることを優先したからです。
本屋や模型店で「これ、もう持ってたっけ?」を確かめられるバーコード読み取りも入れました。仕組みは次の記事で詳しく書きます。
おわりに
積みを崩すのに必要なのは、立派な管理機能より「とりあえず15分やってみる」きっかけでした。ガチャ、タイマー、金額の見える化は、どれもそのきっかけを作るための機能です。
無料で使えて、登録はメールアドレスだけです。積みに心当たりのある方は、ぜひ試してみてください。


