diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Change log
+
+## 0.1.0
+
+* Initial release: costPerDecade, costPerUse, purchasesOver, breakEvenYear, warrantyClass.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/cost-per-decade.cabal b/cost-per-decade.cabal
new file mode 100644
--- /dev/null
+++ b/cost-per-decade.cabal
@@ -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
diff --git a/src/CostPerDecade.hs b/src/CostPerDecade.hs
new file mode 100644
--- /dev/null
+++ b/src/CostPerDecade.hs
@@ -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)
