packages feed

cost-per-decade (empty) → 0.1.0

raw patch · 4 files changed

+135/−0 lines, 4 filesdep +base

Dependencies added: base

Files

+ 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)