cost-per-decade (empty) → 0.1.0
raw patch · 4 files changed
+135/−0 lines, 4 filesdep +base
Dependencies added: base
Files
- CHANGELOG.md +5/−0
- README.md +28/−0
- cost-per-decade.cabal +35/−0
- src/CostPerDecade.hs +67/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Change log++## 0.1.0++* Initial release: costPerDecade, costPerUse, purchasesOver, breakEvenYear, warrantyClass.
+ README.md view
@@ -0,0 +1,28 @@+# cost-per-decade++Buy-it-for-life arithmetic: cost per decade, cost per use, and the+year the cheap option stops being the bargain.++Sticker price is a bad comparator when lifespans differ. A $40 item+replaced every two years outruns a $280 item that lasts twenty: $140+per decade against $200, and the forty-dollar stack needs fifteen+units to cover thirty years.++The package follows the cost-per-decade method used on+[Durable Picks](https://www.durablepicks.com). It depends only on base.++## Example++A $280 durable item with a 20-year expected life, a $40 alternative+that lasts 2 years, a 25-year warranty, and 150 uses a year:++* cost per decade: `costPerDecade 280 20` = $140.00+* cost per use: `costPerUse 280 20 150` = $0.0933+* warranty: `warrantyClass 25 20` = covered for life (25y warranty on 20y expected life)+* 30-year horizon: durable = 2 purchases, $560 total; cheap = 15 purchases, $600 total+* break-even: `breakEvenYear 280 20 40 2 30` = year 12++The cheap stack hits $280 at year 12, the same year it takes its+seventh unit. From there the durable option saves $40 across the+thirty-year window, and the warranty outlives the item instead of+covering a fraction of it.
+ cost-per-decade.cabal view
@@ -0,0 +1,35 @@+cabal-version: 2.2+name: cost-per-decade+version: 0.1.0+synopsis: Buy-it-for-life math: cost per decade and break-even years+description:+ Sticker price compares badly across lifespans: a cheap item replaced+ every couple of years outruns a durable one that lasts decades. These+ functions spread each price over the decades it buys, price a single+ use, count the units a horizon needs, and find the first year the+ cheap stack has spent as much as the durable one. A warranty-coverage+ verdict rounds it out.+ .+ Companion to Durable Picks, which ranks gear by cost-per-decade+ math. Five pure functions, no dependencies beyond base.+license: MIT+author: Poket Dev+maintainer: hello@poketdev.com+homepage: https://www.durablepicks.com+bug-reports: https://github.com/scrapertweeter3-prog/cost-per-decade-hs/issues+copyright: 2026 Poket Dev LLC+category: Math+build-type: Simple+extra-doc-files: README.md+ , CHANGELOG.md++source-repository head+ type: git+ location: https://github.com/scrapertweeter3-prog/cost-per-decade-hs++library+ exposed-modules: CostPerDecade+ hs-source-dirs: src+ build-depends: base >=4 && <5+ default-language: Haskell2010+ ghc-options: -Wall
+ src/CostPerDecade.hs view
@@ -0,0 +1,67 @@+-- | Buy-it-for-life arithmetic: cost per decade, cost per use, and the+-- year the cheap option stops being the bargain.+module CostPerDecade+ ( costPerDecade+ , costPerUse+ , purchasesOver+ , breakEvenYear+ , warrantyClass+ ) where++-- | Price spread over a decade of expected life.+costPerDecade :: Double -> Double -> Double+costPerDecade price lifeYears = price / lifeYears * 10.0++-- | Price of a single use, given uses per year. 'Nothing' when the item+-- never gets used.+costPerUse :: Double -> Double -> Double -> Maybe Double+costPerUse price lifeYears usesPerYear+ | usesPerYear <= 0 = Nothing+ | otherwise = Just (price / (lifeYears * usesPerYear))++-- | Units owned across a horizon: ceiling of horizon over life,+-- minimum one. Replacements land when a unit dies.+purchasesOver :: Double -> Double -> Int+purchasesOver horizonYears lifeYears =+ max 1 (ceiling (horizonYears / lifeYears - 1e-9))++-- | First year the cheap stack has spent at least as much as the+-- durable one. Both buyers pay for their first unit at year 0, and+-- replacements land when a unit dies. 'Just' 0 when the durable is+-- cheaper up front, 'Nothing' when the cheap stack never catches up+-- inside the horizon.+breakEvenYear :: Double -> Double -> Double -> Double -> Double -> Maybe Int+breakEvenYear priceDurable lifeDurable priceCheap lifeCheap horizonYears+ | priceCheap >= priceDurable = Just 0+ | otherwise = search 1 priceDurable lifeDurable priceCheap lifeCheap+ where+ horizon = floor horizonYears :: Int+ search year dSpend dNext cSpend cNext+ | year >= horizon = Nothing+ | otherwise =+ let dHit = fromIntegral year >= dNext+ cHit = fromIntegral year >= cNext+ dSpend' = if dHit then dSpend + priceDurable else dSpend+ dNext' = if dHit then dNext + lifeDurable else dNext+ cSpend' = if cHit then cSpend + priceCheap else cSpend+ cNext' = if cHit then cNext + lifeCheap else cNext+ in if cSpend' >= dSpend'+ then Just year+ else search (year + 1) dSpend' dNext' cSpend' cNext'++-- | How much of the expected life the warranty actually covers.+warrantyClass :: Double -> Double -> String+warrantyClass warrantyYears lifeYears+ | warrantyYears <= 0 = "no warranty stated"+ | warrantyYears >= lifeYears =+ "covered for life (" ++ whole warrantyYears ++ "y warranty on "+ ++ whole lifeYears ++ "y expected life)"+ | warrantyYears / lifeYears >= 0.5 =+ "solid (" ++ whole warrantyYears ++ "y warranty covers "+ ++ pct (warrantyYears / lifeYears) ++ "% of expected life)"+ | otherwise =+ "thin (" ++ whole warrantyYears ++ "y warranty covers only "+ ++ pct (warrantyYears / lifeYears) ++ "% of expected life)"+ where+ whole v = show (floor v :: Int)+ pct r = show (round (100 * r) :: Int)