きっかけ
飲み会の会計で「1 人 3,487 円です」と言われると、幹事は大抵 3,500 円か 4,000 円で切ります。現実の割り勘は**「端数をどうするか」と「いくら単位で揃えるか」の組み合わせ**で決まっていて、単純に「合計 ÷ 人数」では終わらない。
さらに、「男女で 1.5:1」とか「後輩は少なめ」みたいな重み付きシナリオもよくあります。これら全部を 1 つのツールに収めたかった。
作ったもの
割り勘計算機 — https://sen.ltd/portfolio/warikan/
3 モード搭載:
- 均等: 合計を人数で単純に割る
- 男女比: 「A 人と B 人を 1.5:1 で」みたいな比率
-
重み付き:
1, 1, 1.5, 2のようにカンマ区切りで個別指定
オプション:
- 端数処理: 切り上げ / 切り捨て / 四捨五入
- 単位揃え: 1 円 / 10 円 / 100 円 / 1000 円
- 徴収合計と差額: 「全員分で合計 14,000 円、実際より 13 円多い」みたいに表示
vanilla JS + HTML + CSS、ゼロ依存、ビルド不要。ロジックは 70 行、node --test で 12 ケース。
roundTo(value, unit, mode) の 3 引数が全て
割り勘計算で一番考えないといけないのは、「いくら単位で揃えるか」と「端数をどう処理するか」は直交だということ。100 円単位で「切り上げ」もあれば、1 円単位で「四捨五入」もある。両軸で組み合わせなのでヘルパー 1 つに集約:
function roundTo(value, unit, mode) {
const n = value / unit
let rounded
if (mode === 'floor') rounded = Math.floor(n)
else if (mode === 'round') rounded = Math.round(n)
else rounded = Math.ceil(n) // default ceil
return rounded * unit
}
ポイントは value / unit してから丸めて、また * unit で戻すこと。3487 円を 100 円単位で切り上げたいなら 3487 / 100 = 34.87 → Math.ceil(34.87) = 35 → 35 * 100 = 3500。単純な発想ですが、Math.ceil(3487 / 100) * 100 という 1 行で表現するより、単位を 1 つの変数にしたほうがテストしやすいし UI の input も楽です。
デフォルトを切り上げにしたのは、「足りない」より「多い」ほうが幹事の心理的負担が少ないから。opts.rounding = 'ceil' を省略してもちゃんと切り上げで動く。
均等割は diff を一緒に返すのが UX
単純な均等割の結果を「1 人 3,500 円」とだけ返しても、ユーザーは心の中で「4 人だから 14,000 円…あれ、元は 13,948 円だったから 52 円多い…」と計算し直します。これを事前に計算して一緒に返す:
export function splitEvenly(total, count, opts = {}) {
if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }
const rounding = opts.rounding ?? 'ceil'
const unit = opts.unit ?? 1
const per = roundTo(total / count, unit, rounding)
const totalCollected = per * count
const diff = totalCollected - total
return { per, count, total, totalCollected, diff, rounding, unit }
}
UI には「1 人 3,500 円 / 徴収合計 14,000 円 / 差額 +52 円」という 3 つの数字を同時に出します。幹事が「あ、+52 円は私が飲み会に貢献した分」と理解できる表示にしたかった(あるいは「-50 円足りないから自分が多めに出す」)。
差額のサイン(正 / 負)も意味が違うので、UI 側で正なら緑(多めに集まった)、負なら赤(足りない)で着色しています。
重み付き: 比率を正規化して各人に配分
weights = [1, 1, 1.5, 2] みたいな配列を受け取ったら、合計で正規化して各人の取り分を出す:
export function splitWeighted(total, weights, opts = {}) {
const sum = weights.reduce((a, b) => a + b, 0)
const shares = weights.map((w) => roundTo((total * w) / sum, unit, rounding))
const totalCollected = shares.reduce((a, b) => a + b, 0)
const diff = totalCollected - total
return { shares, weights: weights.slice(), total, totalCollected, diff, rounding, unit }
}
sum で正規化するので、[1, 1, 1.5, 2] と [2, 2, 3, 4] は同じ結果を返します。ユーザーが「1 倍 / 1 倍 / 1.5 倍 / 2 倍」で入力しても「10% / 10% / 15% / 20%」みたいに入力してもいい。
男女比モードはこの関数の薄いラッパー:
export function splitByRatio(total, countA, countB, ratio, opts = {}) {
const weights = [
...Array(countA).fill(ratio),
...Array(countB).fill(1),
]
return splitWeighted(total, weights, opts)
}
「男 3 人 女 2 人 比率 1.5:1」なら weights = [1.5, 1.5, 1.5, 1, 1] を組み立てて重み付き関数に投げるだけ。モードを 3 つに見せていますが、裏では splitWeighted が 1 つあれば全部まかなえるという構造です。
NaN と 0 除算は早めに弾く
ユーザー入力は何が飛んでくるか分からないので、全関数の冒頭でガードを入れます:
if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }
Number.isFinite は NaN と Infinity を両方弾きます。Number.isInteger はさらに小数も弾く。count <= 0 をチェックしないと 0 除算で Infinity が下流に流れて UI が壊れるので、早めにキャッチ。
重み付き関数では「負の重み」もダメなのでチェック:
if (weights.some((w) => !Number.isFinite(w) || w <= 0)) {
return { error: 'weights must be positive numbers' }
}
some 1 発で配列を全走査、1 つでも悪いのがあればエラー。エラーを早く返して UI に回すのが、純粋関数 + { error } フィールドの気持ちよさ。例外を投げて catch する流れより、データフローとして素直。
テスト
node --test で 12 ケース。期待動作と、よくある typo を捕まえる型のテスト:
test('evenly splits 10000 between 4', () => {
const r = splitEvenly(10000, 4)
assert.equal(r.per, 2500)
assert.equal(r.diff, 0)
})
test('ceil rounding when 10000 split by 3', () => {
const r = splitEvenly(10000, 3, { rounding: 'ceil' })
assert.equal(r.per, 3334)
assert.equal(r.diff, 2) // 3334 * 3 = 10002
})
test('unit 100 rounds up to nearest 100', () => {
const r = splitEvenly(14200, 4, { rounding: 'ceil', unit: 100 })
assert.equal(r.per, 3600)
})
test('weighted 1:1:1.5:2 sums to total', () => {
const r = splitWeighted(10000, [1, 1, 1.5, 2])
assert.equal(r.shares.length, 4)
assert.equal(r.shares.reduce((a, b) => a + b, 0), r.totalCollected)
})
test('count=0 returns error', () => {
assert.ok(splitEvenly(1000, 0).error)
})
「合計が変わらない」系のテスト(重みを 2 倍しても結果が同じ)も入れると正規化の実装ミスが検出できます。
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 11 件目です。
- 📦 レポジトリ: https://github.com/sen-ltd/warikan
- 🌐 ライブデモ: https://sen.ltd/portfolio/warikan/
- 🏢 会社: https://sen.ltd/
「こういう割り方がある」系のアイデア、Issue で歓迎です。
