Model

How the Wardrobe Diary data is structured and how every figure is calculated, traceable down to the code and tests.

Semantic layer version 0.2.1 (2026-10-16) · JSON-LD · source (YAML) · CC BY 4.0

Conceptual model

The things the data is about and how they relate. Arrows are relations; the attributes are stored fields or derived values.

classDiagram
  class Person {
  }
  class Garment {
    text item
    text details
    money-eur price
    date purchased
    date divested
    integer timesUsedInit
    text materials
    mass-g weight
    image photo
  }
  class Category {
  }
  class OwnershipPeriod {
  }
  class WearDay {
    date date
  }
  class Wear {
  }
  class Tag {
  }
  class MaterialComposition {
    share fibreShare
  }
  class Fibre {
  }
  class FibreClass {
  }
  class ComparisonItem {
    money-eur monthlyCost
    share useFrequency
  }
  Garment --> Category : inCategory
  Garment --> Tag : hasTag
  Garment --> OwnershipPeriod : hasOwnershipPeriod
  Garment --> MaterialComposition : hasComposition
  Wear --> WearDay : onDay
  Wear --> Garment : ofGarment
  WearDay --> Garment : worn
  Fibre --> FibreClass : inFibreClass
ClassDefinitionAligned withStored as
PersonThe wardrobe's owner, who logs what they wear. In this data set there is exactly one.schema:Person
GarmentOne physical piece of clothing, footwear or accessory owned at some point since tracking began. Two identical items bought together are separate garments (e.g. "Diesel gray 01" and "Diesel gray 02").schema:ProductGarments
CategoryA kind of clothing that groups garments, from a fixed list in a standard order.schema:DefinedTermgarments.category
Ownership period derivedThe days a garment counts as owned: from its purchase date (at the earliest the start of tracking) to its divest date, inclusive; open-ended while it is still active.
Wear dayOne calendar day of the log and the set of garments worn on it.Wear log
Wear derivedOne garment worn on one day. Wearing a garment twice on the same day is one wear.prov:Activity
TagA descriptive label from a fixed vocabulary, e.g. second hand or Hanko. Tags never change a calculation.schema:DefinedTermgarments.tags
Material composition derivedThe fibres a garment's main part is made of, with their shares (summing to 100 %), read from its free-text materials label.
FibreA textile or material, e.g. cotton, wool, polyester, leather.
Fibre classOne of four groups of fibres: natural, regenerated cellulose, synthetic or other.
Comparison itemAn item of other everyday spending (transport, subscriptions, tools…) with a monthly cost and a frequency of use, shown next to the clothing categories for comparison.Comparison items

Properties

PropertyFrom → toDefinitionAligned with
itemGarment → textThe garment's name, e.g. brand, model and colour.schema:name
inCategoryGarment → CategoryThe garment's category.schema:category
detailsGarment → textFree-text details such as size or model code.schema:description
priceGarment → money-eurWhat was paid, in euros, rounded to whole cents.schema:price
purchasedGarment → dateThe date the garment was bought.schema:purchaseDate
divestedGarment → dateThe last day the garment was owned; empty while active.
timesUsedInitGarment → integerWears before tracking began on 1 January 2018.
materialsGarment → textThe materials label as written (free text).
weightGarment → mass-gThe garment's weight in grams.schema:weight
hasTagGarment → TagThe garment's descriptive tags.
photoGarment → imageThe garment's standard photo (228 × 228 PNG).schema:image
hasOwnershipPeriodGarment → OwnershipPeriodDerived from purchased and divested.
hasCompositionGarment → MaterialCompositionDerived from materials.
onDayWear → WearDayThe day of the wear.
ofGarmentWear → GarmentThe garment worn.prov:used
dateWearDay → dateThe calendar date (no time zone).
wornWearDay → GarmentThe garments worn that day.
fibreShareMaterialComposition → shareEach fibre's share of the main part, 0–1, summing to 1.
inFibreClassFibre → FibreClassThe fibre's class.
monthlyCostComparisonItem → money-eurWhat the item costs per month, in euros (entered).
useFrequencyComparisonItem → shareShare of days on which it is used, 0–1 (entered).

Metrics (semantic model)

Every figure the app and the MCP server compute: its definition, formula, grain, unit, how the chosen date range applies, the decisions behind it, and the code and tests that implement it. Shared rules: wears outside a garment's ownership period never count; dates are inclusive; one month is 30.5 days.

Times worn times-worn

Wears of the garment within the range (and within its ownership period), plus pre-tracking wears when the range starts on the start of tracking.

|{wear days d : range.from ≤ d ≤ range.to, d in ownership}| + (range.from ≤ 2018-01-01 ? timesUsedInit : 0)
Grain
garment
Unit
count
Date range
range
Term
Times worn
Inputs
days.worn, days.date, garments.timesUsedInit, garments.purchased, garments.divested
Code
web/src/lib/metrics.ts#timesWorn
Tests
counts times worn including pre-tracking wears, ignoring wears outside ownership
counts pre-tracking wears only when the range starts at the start of tracking

Lifetime wears lifetime-wears

All wears over the garment's whole ownership period, including pre-tracking wears.

times-worn over {from: 2018-01-01, to: open}
Grain
garment
Unit
count
Date range
lifetime
Term
Lifetime wears
Uses
Times worn
Code
web/src/lib/metrics.ts#lifetimeWears

Cost per wear (garment) garment-cost-per-wear

Price divided by lifetime wears, independent of any chosen range; a garment never worn costs its full price.

lifetime-wears = 0 ? price : price / lifetime-wears
Grain
garment
Unit
EUR per wear
Date range
lifetime
Empty when
the price is unknown
Term
Cost per wear
Inputs
garments.price
Uses
Lifetime wears
Decisions
D-2
Code
web/src/lib/metrics.ts#costPerWear
Tests
computes cost per wear over the whole ownership; unworn garments cost their full price
computes cost per wear independently of any range, including wears after it (D-2)

Days owned days-owned

Days owned within the range up to its end, frozen at the divest date. When the range starts on the start of tracking, counted from the purchase date even before tracking (since pre-tracking wears are included too).

max(0, days(start .. min(range.to, divested))) with start = range.from ≤ 2018-01-01 ? purchased : max(range.from, ownedFrom)
Grain
garment
Unit
days
Date range
range
Inputs
garments.purchased, garments.divested
Code
web/src/lib/metrics.ts#daysOwned
Tests
counts days owned from purchase, frozen at the divest date

Wears per month wears-per-month

Times worn divided by months owned in the range (30.5 days per month); 0 if not owned in the range.

days-owned > 0 ? times-worn / (days-owned / 30.5) : 0
Grain
garment
Unit
wears per month
Date range
range
Term
Wears per month
Uses
Times worn, Days owned
Code
web/src/lib/metrics.ts#itemListing
Tests
lists items with wears per month

Active items active-items

Number of the category's garments active on the range's end date. Total row - the sum over categories.

|{garments g in category : active(g, range.to)}|
Grain
category
Unit
count
Date range
end-date
Term
Active items
Inputs
garments.category, garments.purchased, garments.divested
Code
web/src/lib/metrics.ts#categoryListing
Tests
summarises a category

Inventory value inventory-value

Sum of the prices of the category's garments active on the range's end date (unknown prices count as 0). Total row - the sum.

Σ price over active garments on range.to
Grain
category
Unit
EUR
Date range
end-date
Term
Inventory value
Inputs
garments.price
Uses
Active items
Code
web/src/lib/metrics.ts#categoryListing
Tests
summarises a category

Cost per wear (category) category-cost-per-wear

The sum of the prices of the category's garments owned at any point in the range, divided by the sum of their lifetime wears. Unworn garments count with their full price and no wears; if none of them has ever been worn, the sum of prices is divided by 1 (as an unworn garment costs its full price). Garments without a price are left out. No Total row value.

Σ price / max(1, Σ lifetime-wears), over the category's garments owned during the range with a price
Grain
category
Unit
EUR per wear
Date range
range (which garments) + lifetime (their wears)
Empty when
no garment of the category with a price was owned in the range
Term
Cost per wear
Inputs
garments.price
Uses
Lifetime wears
Decisions
D-2
Code
web/src/lib/metrics.ts#categoryListing
Tests
pools prices over lifetime wears of all garments owned in the range, unworn ones included (D-2)
weights garments by lifetime wears, not by wears in the range (D-2)
divides by 1 when no garment of the category has ever been worn, and skips unknown prices

Equivalent to the average of the garments' cost per wear weighted by lifetime wears, with unworn garments' prices included. Until 2026-10-16 it was weighted by wears in the range and left unworn garments out. The original R pipeline used Σ price of all owned garments / Σ wears in the range.

Days used days-used

Share of days with at least one wear of a category garment, from the later of the range start and the day the category's first garment was owned, to the range end. No Total row value.

|{days d in span : some category garment worn on d}| / |span|, span = max(range.from, min ownedFrom) .. range.to
Grain
category
Unit
share
Date range
range
Term
Days used
Uses
Times worn
Code
web/src/lib/metrics.ts#categoryListing
Tests
summarises a category

Yearly cost yearly-cost

Category cost per wear × 365 × days used. Total row - the sum over categories.

category-cost-per-wear × 365 × days-used
Grain
category
Unit
EUR per year
Date range
range
Empty when
cost per wear or days used is missing
Term
Yearly cost
Uses
Cost per wear (category), Days used
Code
web/src/lib/metrics.ts#categoryListing
Tests
adds a total row

Monthly cost monthly-cost

Yearly cost divided by 12, as in the original R comparison chart.

yearly-cost / 12
Grain
category
Unit
EUR per month
Date range
range
Term
Monthly cost
Uses
Yearly cost
Code
web/src/pages/Dashboard.tsx#Dashboard

Inventory turnover turnover

Yearly cost divided by inventory value. Total row - Σ yearly cost / Σ inventory value.

yearly-cost / inventory-value
Grain
category
Unit
share per year
Date range
range
Empty when
yearly cost is missing or inventory value is 0
Term
Inventory turnover
Uses
Yearly cost, Inventory value
Code
web/src/lib/metrics.ts#categoryListing
Tests
adds a total row

Second hand share second-hand-share

Share of the category's garments active on the range's end date that carry the second-hand tag (by count). Total row - over all active garments.

|{active garments tagged second-hand}| / |{active garments}|
Grain
category
Unit
share
Date range
end-date
Empty when
no garment is active
Term
Second hand share
Inputs
garments.tags
Code
web/src/lib/metrics.ts#categoryListing
Tests
gives the second-hand share of active garments, per category and in total

Divested items' averages divested-averages

Over divested garments owned at least 30 days: wears per month (Σ times worn / Σ days owned × 30.5) and the use-weighted cost per wear. Reference lines on category pages.

(Σ times-worn / Σ days-owned) × 30.5; Σ (cost per wear × times-worn) / Σ times-worn
Grain
category
Unit
wears per month; EUR per wear
Date range
range
Uses
Times worn, Days owned, Cost per wear (garment)
Code
web/src/lib/metrics.ts#divestedAverages
Tests
averages divested items owned at least 30 days

Active items over time active-items-over-time

Active items per category on every day of the range, as step series (Sportswear left out on the Dashboard).

active-items(category, d) for each day d in range
Grain
category × day
Unit
count
Date range
each day
Uses
Active items
Code
web/src/lib/metrics.ts#activeItemsOverTime
Tests
steps up on purchase and down the day after divesting
agrees with isActive on every day

Inventory value over time inventory-value-over-time

Inventory value per category (and in total) on every day of the range, as step series.

inventory-value(category, d) for each day d in range
Grain
category × day
Unit
EUR
Date range
each day
Uses
Inventory value
Code
web/src/lib/metrics.ts#inventoryValueOverTime
Tests
sums prices of active garments per category, in whole cents
sums all categories for the total

Daily cost daily-cost

For each logged day in the range with wears: the sum of the lifetime cost per wear of the garments worn and owned that day (unknown prices count 0); "final" when all of them are divested. Average - the mean of 30 consecutive logged days centred on the day (days i−14 … i+15), so days without wears are skipped.

Σ garment-cost-per-wear over garments worn on d; average(i) = mean(cost[i−14 .. i+15])
Grain
day
Unit
EUR per day
Date range
each day + lifetime (cost per wear)
Term
Daily cost
Uses
Cost per wear (garment)
Decisions
D-2, Q-1
Code
web/src/lib/metrics.ts#dailyCost
Tests
sums lifetime cost per wear of the garments worn each day (D-2)
centres a 30-value window on values i-14 … i+15

With "as of" a date (animations), cost per wear counts only wears up to that date.

Fibre mass fibre-mass

Weight × main-part composition, summed per fibre, with recycled and organic kg. Owned basis - garments active on the range's end date. Worn basis - each wear in the range counts the garment's full weight. Only garments with a weight and readable materials are included; coverage says how many.

Σ weightG / 1000 × fibreShare
Grain
fibre
Unit
kg
Date range
end-date (owned) or range (worn)
Term
Fibre mass
Inputs
garments.weightG, garments.materials
Code
web/src/lib/materials.ts#materialBreakdown
Tests
adds up owned fibre mass from weight × composition
counts each wear for the worn basis, with the average outfit weight

Average outfit weight average-outfit-weight

Mean total weight of the garments worn per logged day (garments with a known weight).

Σ weight of garments worn / number of logged days
Grain
range or month
Unit
kg per day
Date range
range
Term
Average outfit weight
Inputs
garments.weightG, days.worn
Code
web/src/lib/materials.ts#materialsByMonth
Tests
computes outfit weight and synthetic share per month

Synthetic share (worn) synthetic-share

Share of the worn fibre mass in a month that is synthetic.

synthetic kg / total kg (worn basis)
Grain
month
Unit
share
Date range
range (by month)
Uses
Fibre mass
Code
web/src/lib/materials.ts#materialsByMonth
Tests
computes outfit weight and synthetic share per month

Yearly cost (comparison item) comparison-yearly-cost

Monthly cost × 12.

monthlyCost × 12
Grain
comparison item
Unit
EUR per year
Date range
none (fixed figures)
Inputs
comparison.monthlyCost
Code
web/src/lib/comparison.ts#yearlyCost
Tests
derives yearly cost and cost per use as the Sheet did

Cost per use (comparison item) comparison-cost-per-use

Monthly cost divided by (30.5 × use frequency); none if never used.

useFrequency > 0 ? monthlyCost / (30.5 × useFrequency) : null
Grain
comparison item
Unit
EUR per use
Date range
none (fixed figures)
Term
Cost per use (comparison)
Inputs
comparison.monthlyCost, comparison.useFrequency
Code
web/src/lib/comparison.ts#costPerUse
Tests
derives yearly cost and cost per use as the Sheet did

Decisions

D-1: Public pages get the full day-by-day log 2026-10-09

Public pages and the MCP server read one world-readable document with all garments and the full wear log, and compute every metric from it, rather than precomputed summaries.

Why: Any metric for any date range can be computed in the browser with one document read.

D-2: Cost per wear is always lifetime 2026-10-09

A garment's cost per wear is price / all its wears over its whole ownership, regardless of the chosen date range, even when wears after the range lower it. A category's cost per wear for a range is Σ price / Σ lifetime wears over its garments owned at any point in the range, unworn garments included (divided by 1 if none has ever been worn).

Why: The concept must be treated consistently - a garment has one cost per wear, and unused purchases must not disappear from a category's cost.

Amended: 2026-10-16 (Olof): the category figure was the average of the garments' cost per wear weighted by wears in the range, over garments worn in the range, which left unworn garments out entirely. Now pooled over lifetime wears with unworn garments included. Raises categories with unused garments (e.g. Socks +43 % in 2025) and changes Yearly cost, Turnover and the monthly cost comparison with it.

D-3: Small multiples for per-category charts 2026-10-09

Charts that show several categories use one small chart per category on a shared scale.

Why: The original 12-colour palette fails colour-blind and contrast checks.

D-4: Garment details are edited in the app 2026-10-09

Garment details are edited in the app and stored in Firestore, which is their source of truth.

Q-1: Daily cost definition 2026-10-09

Daily cost = Σ over garments worn that day (and owned then) of their lifetime cost per wear; "final" when every one of them is divested; plus a rolling mean centred on the day.

R-15: Cut-over to logging in the app 2026-10-09

The app is the source of truth for wears from 2026-10-10; the Sheet holds the log through 2026-10-09 and is retired.

R-28: Nightly backups 2026-10-10

A nightly job exports all owner data to a private repository with a read-only account.

2026-10-08-ranges: Metrics work for any date range 2026-10-08

All metric functions take a date range; the per-day data stays available rather than being pre-aggregated.