No description
  • Go 88.2%
  • HTML 5.8%
  • Python 4.3%
  • CSS 1.2%
  • Dockerfile 0.4%
  • Other 0.1%
Find a file
Viktor Varland 38fe76a0c5
All checks were successful
ci/woodpecker/push/image Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v0.25.0
2026-08-30 11:41:32 +02:00
.claude/skills docs: add the htmx 4 skills shipped with the release 2026-08-29 07:18:59 +02:00
.just feat: the recipe format, the ingredient table and the checks 2026-08-17 22:31:57 +02:00
.woodpecker feat: publish spisd as an image 2026-08-18 14:30:50 +02:00
cmd/spisd feat: sweep on a timer, and redo one resolver's work 2026-08-29 10:45:42 +02:00
data fix: take Klar's strength from the can's totals 2026-08-28 12:29:05 +02:00
docs docs: the loose match is fixed, and recorded as a trap 2026-08-20 12:50:21 +02:00
internal test: pin that a bare mention with no weight goes on down the chain 2026-08-30 09:56:42 +02:00
off-db feat: make a real lunch resolve 2026-08-18 10:55:04 +02:00
resolver feat: offer a resolver the line a phrase came out of 2026-08-30 09:50:03 +02:00
slv-db feat: refresh the mirror from a full scan 2026-08-20 15:48:50 +02:00
.gitignore fix: an amount and its unit are one word 2026-08-18 14:55:35 +02:00
CHANGELOG.md docs: changelog for v0.25.0 2026-08-30 11:41:32 +02:00
Containerfile fix: say at boot whether the errand can run 2026-08-18 15:20:24 +02:00
go.mod feat: the library moves into a database 2026-08-18 13:33:29 +02:00
go.sum feat: the library moves into a database 2026-08-18 13:33:29 +02:00
justfile feat: one api layer, for the cli and the browser both 2026-08-18 14:16:42 +02:00
main.go feat: sweep on a timer, and redo one resolver's work 2026-08-29 10:45:42 +02:00
main_test.go fix: match a product against the food's name, not any word in it 2026-08-20 15:41:08 +02:00
README.md docs: note what a resolver still cannot say 2026-08-29 10:42:17 +02:00

spis

A week of family food, planned against real protein targets, real Swedish pack sizes and real spoilage — then shopped for.

The name is the argument. Swedish spis reaches us from Middle Low German spîse "food", from Medieval Latin spesa, a clipping of Latin expensa — money paid out, the same root as spend. In older Swedish it still meant the food itself; the stove is the late meaning. The word's own history runs expenditure → provisions → the place you cook, which is this tool's pipeline in order.

Picking this up fresh? Start with docs/handoff.md.

Status

Working end to end. spis plan produces a week and the shopping for it. Everything below is tested. See what is not built yet.

71 of 82 ingredients cite a source — 51 a Livsmedelsverket food number, 19 a product barcode, one a retailer's own declaration — so their figures can be checked rather than trusted. The corrections ran up to ±70%, about what the seed values were warned to be worth.

11 still carry seed figures, and spis lint says so on every clean run: eight spices and condiments that barely move a meal, two loose cabbages with no barcode and no Livsmedelsverket row, and wholegrain pasta, which no search reached.

Usage

spis lint                          # check the table, the household, every recipe
spis list                          # every component, with what an adult portion carries
spis list --weeknight              # only what fits a weekday
spis eat "100g kvarg, 50g granola" # read a typed food line
spis eat "1 portion bolognese"     # …or a portion of something you cooked
spis plan                          # the week, and the shopping for it
spis plan 5 --no-shop              # just five days
spis show chicken-kale-braise      # one component, resolved and costed
spis show bolognese                # named by enough words to pick it out
spis shop chicken-kale-braise salmon-root-tray
spis off 7318690146643             # look up a barcode — the verification pass
spis off prästost                  # …or search by name; a barcode is digits, a name is not
spis off --add --category dairy 7318690146643
spis off --update 7318690146643    # what upstream has changed since
spis off --update --apply 7318690146643
spis ingredients --category fish   # the table
spis add draft.toml                # take a recipe or ingredient into the library
spis add - < draft.toml            # …or pipe one in from wherever it was written
spis import data                   # replace the whole library with these files
spis export data                   # …and write it back out
spis resolve                       # what vitals is waiting to have read
spis resolve --apply               # …and file it

Every command takes --json. The CLI is a client: it reads nothing from disk and talks to spisd, which it finds in ~/.config/spis/config.toml ([server] url and token) or in $SPIS_URL and $SPIS_TOKEN. --data is now only what import reads and export writes.

The format is the product

Anything that can write the recipe TOML is a producer: a hand-typed file, an LLM draft, a photographed cookbook page, a scraped URL. Nothing downstream asks where a recipe came from except to record it, so the library grows through whichever route is cheapest that day without the planner ever learning a second shape.

id     = "chicken-kale-braise"
title  = "Chicken and kale braise"
status = "accepted"          # draft until somebody has cooked it
role   = "main"              # or side | dessert
origin = "hand"              # or llm | photo:<file> | url:<addr>
serves = { adult = 2, child = 2 }
time   = { active_min = 20, total_min = 40 }
tags   = ["chicken", "braise", "one-pot"]

[[ingredients]]
line = "700 g kycklinglårfilé"

[[ingredients]]
line = "2 dl matlagningsgrädde"

[[steps]]
text = "Cut the chicken into chunks and brown them hard."

Both languages, on purpose

Ingredient lines are Swedish; everything else is English. That split is functional, not stylistic: 700 g kycklinglårfilé is read in a Swedish shop, resolves against a table of Swedish names, and will eventually be typed into a Swedish search box by whatever fills the basket. Translating it produces a string that matches nothing in the physical world. Titles, steps, notes, tags and ids have to match nothing, so they are English.

The rule for any producer, an LLM drafting one of these included: the identifier of a thing may be Swedish; prose about a thing is English.

A portion unit is neither, so it reads as both. skiva answers to slice and slices, klyfta to clove, st to piece — one stored spelling, both languages. An ingredient line is Swedish because it has to match a shop; a portion unit matches nothing in the world, it is only how somebody happened to write "one of them", and a food log gets typed in whichever language comes out. Where a row carries both spellings, the one written wins.

This was found by 2 cloves garlic costing 80 g against 2 klyftor vitlök's 6 g — the same lunch, thirteen times apart. cloves was not a unit, so the grammar fell through to its last reading, where the head word is an adjective and the number counts the food itself: two whole bulbs. Neither reading raised an error. 2 slices ham merely failed, which is the harmless half of the same gap — a failure lands in the unread queue and gets dealt with.

What the file deliberately does not hold: grams, macros, pack sizes, shelf life. A line is written the way a person writes it and everything numeric is inferred from it against the ingredient table. That is lifted from vitals, which keeps a typed line verbatim so a better reading can be run over the whole history later — and it matters more here, because four producers of varying reliability write these files. Improve the table and every recipe ever ingested improves with it.

status is the review gate. An LLM drafts; you cook it; you accept it.

A recipe is a component, not a dinner

role is the field that says which part of a meal this is, and it exists because a dinner here is an assembly of recipes and almost never one of them. A roast gets a pie beside it; a lasagne gets a salad. So the library is components — meatballs, potatismos, smorartor — and the planner puts one main and one or two sides on each day.

The consequence worth understanding is where the protein floor lives. It is a rule about the plate, not about any file on it: spenatpaj carries 36 g and potatismos carries 7, and neither is a recipe that fails a rule. The version of this tool that asked the question of one recipe at a time could not plan a side dish at all — it rejected every one of them for being under a dinner floor no side was ever meant to clear on its own.

Two figures combine differently when a plate is costed, and the difference is not cosmetic:

how it combines why
protein, kcal sum a portion of the dinner is a portion of each thing on it
active_min sum there is one cook and one pair of hands
total_min max the hob and the oven run at the same time

dessert is a legal role that the weeknight planner does not assemble — it lints, resolves and shops like anything else, and spis plan says so rather than dropping it silently.

A surface renders; it never computes

There is one planner, one resolver and one lint. They live in internal/kitchen and every surface — the CLI, the JSON API, the web UI — decides only how to draw the answer. A phone and a terminal disagreeing about what is for dinner is not a state this design can reach, because there is nothing for them to disagree with.

The seam is a function call, not a transport. The web UI is server-rendered HTML with htmx, so it does not consume the JSON API: a server making HTTP requests to itself is a second failure mode for every page, to reach code already in the same binary. internal/api marshals the kitchen's answers into JSON; internal/web renders them into templates; neither owns a rule.

And no surface works anything out "just this once" — however small the subtotal. That is how a rule acquires a second implementation, and the one nobody is looking at is the one that goes wrong.

Five decisions worth knowing

A dinner is assembled, not chosen. A day is one main and one or two sides, and every rule about a meal — the protein floor, the time budget — is asked of the assembly. The cost of this is that the tool proposes the pairings, and it has no taste: it will put taco bowls next to a salmon tray bake if the numbers work. One rule keeps the worst of it out — at most one side tagged starch, because rice next to mash is not a plate anybody serves.

Active time is separate from total time. Twenty minutes of work and twenty in the oven is a Tuesday. Forty-five minutes of chopping is not. A single "45 min" would call them the same, and that is the field the whole weeknight constraint reads.

A dinner is a range per person, not a number. Two adults on different budgets eat from one pot, so a serving cannot be 570 kcal for one of them and 480 for the other — but a helping can. Taking less of the same food takes less of everything in it, so a dish works for somebody when some amount of it carries their protein floor without passing their energy ceiling, and spis show says which amounts: viktor: 388416 g · molly: 310351 g.

Rearranged, that is a condition on the food and not on the helping — protein / kcal >= floor / ceiling, a density. Which is why a rich dish fails: not for being big, since a big dish can be served smaller, but because no amount of it is both enough protein and few enough calories. It is the counterweight the protein floor never had, and once a dinner became several dishes it became load-bearing: a floor alone is satisfied by stacking dinners on top of each other.

Only adults carry a protein target. A child's portion is a share of the same pot, scaled by appetite. Nothing computes a macro goal for a seven-year-old, and the validator refuses one if it appears. The tool feeds the children and steers the adults.

A restriction can depend on preparation. Being unable to eat a raw peach says nothing about a baked one — the protein responsible does not survive heat. So allergens are refused however the thing is cooked, and labile classes only bite raw. A recipe using a labile ingredient must declare prep, and leaving it off is an error rather than a pass: silence is not consent.

The things that cannot be eaten are in the table on purpose. Shellfish and the tree nuts are listed so a recipe naming them is refused by name rather than failing as an unknown word. An unresolvable line is a typo; a refused line is a health rule, and collapsing the two reports the most important failure in the mildest available voice.

Reading what was eaten

spis eat "<line>" resolves a comma-separated food line — the resolver vitals is waiting on, usable on its own.

PHRASE              READ AS                 WEIGHT  BASIS    KCAL  PROTEIN
2 slices of bread   ragbrod                 70 g    portion  178   7.0
2 slices of cheese  hushallsost → prastost  30 g    portion  115   7.5
butter              smor                    7 g     assumed  51    0.0
an apple            apple                   180 g   portion  83    0.0
1 portion mystery   unread
total                                                        427   14.5
  1 of 5 phrases unread — the total is a floor

A food, or a dish

A phrase is one of two things, and the difference is why spis owns this at all.

2 skivor ost names a food. Any composition table can answer it, and the ingredient table does.

1 portion ragu bolognese names a dish — a pot somebody cooked, whose composition is the sum of what went into it divided by who ate it. No table of foods can answer that, which is exactly why the record could not resolve it before there was a recipe library to ask.

PHRASE                    READ AS          WEIGHT  BASIS    KCAL  PROTEIN
1 portion ragu bolognese  pasta-bolognese  578 g   portion  824   53.5

Four things that follow from a dish not being a food:

  • The dish is costed as this household makes it, through ResolveFor and never Resolve. The portion eaten contained prästost because that is what the pot was made with, so the figure is not a property of the recipe alone.
  • The citation is spis:<recipe-id> and carries no version. What protects a logged day from a later recipe edit is the record keeping its own copy of the figures, not a version pinned here.
  • The weight is what went into one portion, as bought. Cooking loses water, so it is not what landed on the plate, and it says so. It is the same rule the compositions follow: recipes are measured raw, so raw is the honest number.
  • A name is matched by word, not by string. Nobody logs Salmon tray bake with root vegetables and broccoli; they write salmon tray. A name matching two dinners is asked about, and a name matching none says so — and says recipe, because a phrase written in portions was never asking the table.

spis show takes the same names, so spis show bolognese is the recipe behind the line you just logged.

When a word means more than one thing — three loaves all answer to bröd — it asks, and offers to remember:

⚠ "2 skivor bröd" could be more than one thing
  1. polarbrod    Polarbröd   290 kcal · 8.0 g protein per 100 g
  2. ragbrod      rågbröd     235 kcal · 8.0 g protein per 100 g
  which one? [1-2, or enter to skip] 1
  always read "bröd" as ragbrod? [y/N] y
✔ "bröd" now means ragbrod

Saying yes takes the alias off the rows you did not mean, so the question stops being asked. Being asked which bread once is a clarification; being asked every morning is the tool failing to learn.

A name matching two dinners is asked the same way, and is not offered as a permanent answer. The asymmetry is honest rather than an omission: an ingredient's answer has somewhere to live — an alias on the row — and a recipe has no such field. Being asked twice means two dishes are named too alike to tell apart, and the fix for that is to rename one.

It only asks where somebody is there to answer. Piped, or under --json, an ambiguous phrase is recorded as unread with its candidates and the total says it is a floor. That is not a lesser path — it is the one the vitals resolver runs on, unattended, and the question it records is answered later from the record's own queue.

Three things it does that the record depends on. An unreadable phrase is kept and counted, because a total short by an unknown amount that does not say so is worse than one that does — anything eaten away from home stays unread, permanently, and that is normal. Household preferences apply, because the cheese eaten was the cheese bought. And the basis is reported in vitals' vocabulary — exact, portion, assumed — since a weighed amount and a default serving are the same number and not the same claim.

The comma rule is written here as well as in vitals, deliberately: spis cannot import vitals (the dependency runs the wrong way) and vitals must not need a meal planner to store a sentence. Four lines, one test each side.

Getting content in

spis add takes a file, a directory of them, or - for standard input, and adds or updates without touching anything it was not given. Dry by default: every recipe is costed against the live library first, so what you see before deciding is what review would say afterwards.

ID          KIND    STATE  WHAT IT COMES TO
kvarg-bowl  recipe  new    16 g protein · 90 kcal a portion
⚠ nothing was written — run with --apply

A file is read as a recipe unless it holds [[ingredient]] tables. Nothing has to say which, because the content already does — and guessing from a filename would be a rule somebody has to know.

spis import replaces, which is right for filling an empty server and wrong afterwards. A draft accepted in the browser and a row added from a barcode are in nobody's checkout, so it refuses when a replace would remove something and names what:

spis: http://spis.home.arpa:8080 holds 1 recipes these files do not — kvarg-bowl
       `spis add` takes files in without removing anything; --force replaces anyway

The web UI's intake page is the same door for a paste rather than a file.

Filing readings back to vitals

spis resolve is the errand between the two tools: pull the phrases the record has no reading for, say what each one means, post the answers back.

read (9 of 41)
DATE        READ AS               WEIGHT  BASIS             KCAL  PROTEIN
2026-08-18  kvarg                 100 g   specific/exact    60    11.0
2026-08-16  kycklingfilé          450 g   generic/portion   518   96.8
2026-08-12  Pasta with beef ragù  578 g   specific/portion  824   53.5
  2603 kcal and 228.7 g protein the record did not have

left unread (32)
  2026-08-17  no ingredient named "pad thai"
  2026-08-13  no ingredient named "oreo"
  left alone rather than filed — anything eaten away from home stays unread

⚠ nothing was filed — run with --apply

Dry by default, like every other command here that writes. --apply posts.

A phrase spis cannot place is left alone, not filed as unresolved. It is already unread in the record, and a restaurant meal stays that way permanently — that is the normal state of a food log, not a backlog to burn down.

A row reached by dropping words is generic, whatever it cites. "2 slices home-made bread" finds the bread row, and that row's barcode is not the loaf somebody baked. Calling it specific would put a firmer figure into a health record than anybody has — and vitals scores a day's confidence off exactly that field.

What it could not read is a page. /unread in the web UI is the recovery path: the record's outstanding phrases, grouped by what would fix each one rather than by date, because that is the only grouping that changes what you do.

  • answer these — a name more than one row answers to. Picking a candidate files the reading against that entry immediately.
  • the table cannot weigh these — the food is in the table and its row has no portion: 2 slices ham wants a slice weight on skinka. Give it one on the page and the phrase that needed it is filed in the same request.
  • these name a dish that will not resolve — the fix is in the recipe.
  • nothing in the table names these — mostly food eaten away from home, which stays unread. That is normal, not a backlog, and the page says so. Where it is worth reading, name the nearest row and a weight: a charkbricka is skinka to within a rounding error. Tick always and the phrase becomes an alias, so the next one reads without asking.

One at a time, not in bulk. The list is a card per phrase, and each one opens a page of its own. Settling a phrase means searching the table, reading what a row already answers to, and sometimes fetching the food out of Livsmedelsverket before there is a row to point at — none of which fits on a line, and all of which used to be squeezed into a row of narrow inputs beside the phrase. So: pick one, settle it, come back for the next.

The picker is the field. Choosing a row used to mean typing its id into a text box, with the search a section further down the page — which is backwards: you had to know what the row was called before you could look it up, and typing an id from memory is how you file a reading against a typo. The search is now inside the form it fills, the id it submits is hidden, and what is chosen is shown as a name.

It proposes before it is asked. The search runs against the library in this process — no network, no rate limit, nothing to be careful with — so it fires on every keystroke and has already run once before anybody types: the phrase's own words are the first query, so the page opens with what it thinks you mean.

A phrase that matches nothing whole is tried by its words, longest first, since that is the most specific: 2 skivor ost finds nothing and ost finds two, and rågbröd med ost leads with the bread. Words of two letters are skipped — they match half the table and help nobody.

The picker says what a row answers to. A phrase is filed against a row by name, and the names a row answers to are invisible in a list of ids — choosing between hushallsost and prastost meant remembering which one "ost" reaches. The search shows the aliases, what the row weighs by, and where its composition came from, so a seed guess is visible as one at the moment somebody files a reading against it. Dishes are in the same search as ingredients, because a phrase does not know the difference: 1 portion bolognese names one and 100 g keso names the other.

A phrase that named two foods can be filed as both. The record splits a typed line on commas, so 1 skiva rågbröd, 1 skiva ost arrives as two items and reads as two foods. Forget the comma and rågbröd med ost arrives as one, which could only be filed against one row — dropping the cheese out of the day without saying so. Now each food gets a row and a weight, and the phrase is settled with both.

It goes back as one reading, because that is what the record holds: a reading is keyed by the item it belongs to, and vitals applies it by matching that key, so a second reading at the same address overwrites the first. The figures are the sum, the food is named after its parts, the note carries the breakdown, and the citation lists every source that went in — which is also what stops it reading as a specific product, since a compound matches none of the prefixes that mean one.

True separate entries would need the record to be able to split an item, and it has no endpoint for that. That is a decision rather than a gap. The food log is deliberately the lowest-friction thing in either tool — a line typed one-handed while eating beats a line typed correctly, because the one that never gets typed is the one that costs a day's total. So the friction lands here, where somebody is already sitting down to settle a queue. Two workarounds cost nothing and belong to the person rather than the tool: log each food as its own item, or remember the comma.

What would change it is this path being used often. Once is a forgotten comma; every week is evidence the log is too low-friction to be read back, and then the split belongs in the record where the line is.

And a food that is in neither can be found. Livsmedelsverket by name, Open Food Facts by the digits off the packet — which of the two is decided by the query rather than by a toggle, since a barcode is all digits and a food is not. The composition is shown before anything is written, because OFF is a wiki and has been wrong; a food already cited by some row says so instead of offering itself again. Taking one writes the row and hands the picker straight back with it in.

Open Food Facts takes a product name as well as a barcodeprästost returns the Allerum and the Skånemejerier with their codes, figures and pack sizes, which is the half Livsmedelsverket never has. It sits behind a button rather than firing as you type, and that is a rule about the service:

Searching OFF can fail in ways that look like an empty shelf. A rate limit arrives as a 429; a busy service answers with an HTML holding page and a 200 — no status code, no error body, a page. A client not looking for that reads it as "no such product" and tells somebody their food does not exist. off-db/byname.py pauses fourteen seconds between calls for the same reason, and the button here exists rather than a keystroke.

Only a 429 is reported as "slow down". A 503 or a page is reported as what arrived, with the Server header and the first line of the body, because a proxy, a block page and an egress filter all look identical from here — and telling somebody to wait out a captive portal sends them to do nothing about a problem that is not going away.

Livsmedelsverket is compiled into the binary, so that half searches as you type and works on a server with no route out at all.

And when neither has it, type it in. A food can be missing from both for opposite reasons — loose produce has no barcode, and a national table of foods has no brands — so the unread page takes a name and a composition off the packet and writes the row there and then. Before this the way through was to leave the browser, edit TOML and import, for a packet somebody was holding.

Those rows cite label, and spis lint goes on counting them among the unsourced. That is the point of the citation rather than a shortcoming of it: a figure one person read off a packet now in the bin is checkable by nobody, which is worth being reminded of. A citation counts as sourced when it names a food somebody else can look upslv:349, off:73187… — and not when it only says how the number was arrived at, which is seed and label both.

A reading that comes to nothing is refused. Linking a bare phrase to a row with no default portion would mark the item read, take it out of the queue and leave the day quietly short — worse than leaving it unread, so it asks for a weight instead.

A phrase that reads fine never appears: the timer will file it within the quarter hour, and showing it would turn a work list into a status board.

It runs on a timer, in spisd. Every 15 minutes by default (-resolve-every, 0 to turn it off), so a meal logged at dinner carries its figures before bedtime and nobody has to remember anything. spis resolve is the same errand on demand.

That is safe because the record does not store a reading identical to the one an entry already carries, and never overwrites an item a person linked by hand — guarantees written for exactly this case. The timer is quiet when it read nothing, which is most runs, and reports a record that is down once rather than once per interval.

A run that failed is tried again within seconds, and then progressively later up to the interval. The usual reason the first run fails is the boot it happened during — a container is a process with no init, so its address and its resolver file land a moment after the server — and noticing that should cost seconds rather than the whole quarter hour.

Two things it does not do, both on purpose. It does not ask which of two dishes you meant — an unattended resolver has nobody to ask, so the phrase stays unread and the question waits. And it does not compute a total for the day: the record sums its own items, from the figures each was read with.

Where the tokens come from

One file, two servers — ~/.config/spis/config.toml:

[server]                                   # spisd itself
url   = "http://spis.home.arpa:8080"
token = "…"

[vitals]                                   # the record `spis resolve` files with
url   = "http://vitals.home.arpa:8080"
token = "…"

The environment wins over it$SPIS_URL, $SPIS_TOKEN, $VITALS_URL, $VITALS_TOKEN — and is the only route the server's container has, since scratch holds no config file. The vitals CLI's own config is the fallback, so a laptop that already has that tool set up needs no copying at all.

The vitals half used to be read only from that other file, on the argument that a copied credential is a second place to rotate it. That argument is spent: the errand moved into spisd, which takes those credentials from its environment, so the copy exists anyway in the infrastructure that starts the container. What reading another tool's file still bought was a coupling to another tool's layout.

In a browser

spisd serves the same answers as HTML. Server-rendered, htmx for the swaps, no build step: the templates and one vendored script are embedded in the binary, so deploying the server deploys the UI.

Two surfaces, chosen because they are the two a terminal is worse at.

The week and tonight — one card per day, tonight first, with what an adult portion carries and why the planner put that meal there. An empty day says which constraint bound. Both shopping trips are on the same page, because that is the page a phone has open in the shop.

Recipe intake and review — a draft arrives as a wall of TOML from an LLM, and what you want is to see it resolved, costed and checked against the household beside what it says. Paste it in; it is stored as written and shown read. The same unknown-key check a file gets catches notes written after [[steps]], which binds to the step rather than the recipe, silently.

Accepting is refused while a recipe has a blocking problem. Only an accepted recipe is plannable, so accepting a broken one puts a dish that cannot resolve into the rotation and the failure surfaces days later as an empty day. What the button does not check is whether anybody has cooked it — that is what the status means, and the one thing a server cannot know.

Buying for the table, not for the recipe

A recipe states what it yields — two adult portions and two child ones — and that is a fact about the dish, true wherever it is shared. What this table eats is a different fact, and it lives in household.toml:

portion = 1.0     # viktor
portion = 0.85    # molly, on 1600 kcal against 1900
portion = 0.5     # levi
portion = 0.35    # axel

Those sum to 2.70 adult servings against a recipe's 3.00, so the shopping list buys nine tenths of it. Every portion is already a share of an adult serving, so the table's appetite is their sum and a child's is not converted again — doing that twice is a nineteen per cent error in everything bought.

The scaling happens in the shopping and never in the resolver. A recipe's adult portion has to stay one adult portion whoever is eating, or every figure downstream — what a person is served, whether a plate clears a floor — starts meaning something different per household.

It moves the need and not yet the basket. Ten per cent off every line did not cross a pack boundary anywhere in a fortnight of planning: 42 lines, none of them buying less. What it does show is the surplus, which went up by 1.6 kg — that is the number that was always there and never counted.

Pairings

A main can say what it is served with, and the week editor writes them:

serve_with = ["potatismos", "smorartor"]

A pool, not a plate. Meatballs listing mash, peas and a salad is not a three-side dinner — it is three sides any of which counts as written down, and the planner scores a plate by how many of them are on it. So remember this pairing adds to the list; recording meatballs-with-salad does not unsay meatballs-with-mash. A list that only grows is one nobody can correct, so each entry can be taken back out from the recipe page.

It is a preference and never a constraint. Freshness still wins: a main is planned with something else on a day its usual side would be five days old, because a plan that let a written pairing win would be planning food into the bin.

How this kitchen cooks

household.toml says who eats here — and, in a [kitchen] table, how the kitchen runs:

[kitchen]
weekday_active_min = 30   # how long a weeknight is, in work
weekday_total_min  = 45
weekend_active_min = 60
weekend_total_min  = 90
repeat_gap_days    = 3    # before the same main comes round again
min_sides          = 1    # a dinner here is rarely one recipe
max_sides          = 2

Every field is optional and zero means the default, so a file written before this existed plans exactly as it did.

These were constants in the planner, and moving them is a line worth being explicit about. min_sides described itself in its own comment as "the household's own account of how it eats" while being compiled into the binary — another household would have had to fork plan.go to say its Tuesday runs to forty-five minutes.

The rule for which numbers moved: a number somebody could argue with over dinner belongs in configuration; a number that only means anything inside the search belongs in the search. How long a weeknight is, and whether a plate has a side on it, are the first kind. The scoring weights — a sharing reward of 6.0 against a repeat penalty of 30.0 — are the second, and stayed put, because nobody can hold an opinion about them without reading the beam search they feed.

The table, in the browser

/ingredients is the table: every row, searchable by name, alias or id, with the ones carrying a TODO first — those are the ones with a job attached. A food with no shelf life cannot be planned around and one with no portions cannot weigh 2 skivor, and both are exactly what a row added from a barcode is missing, because neither source knows them.

A new row can be looked up before it is written. Livsmedelsverket as you type, Open Food Facts on the button, and taking one writes the row and opens it to be finished. Typing a composition twice — once into a search to check it, once into a form — was the friction; the figures are also the half a person is least able to invent.

Editing a composition drops the citation to label. A citation claims the numbers came from somewhere checkable, so editing them under one is how a row ends up saying slv:349 while holding figures Livsmedelsverket never published. The page says so when it happens, and lint goes on counting it.

Removing a row is refused while anything still points at it — a recipe line that resolves to it, another row listing it as a substitute, a household preference naming it. The page names them. Removing one a recipe uses does not fail loudly; it makes that recipe stop resolving days later, as a fault that looks like something else.

What a category is for

Two things, which is why a row left on TODO is worth finishing:

  • The shopping list is grouped by it. That is the order you walk a shop in.
  • The planner reads it as a dinner's protein source — the category of whichever ingredient carries the most protein — which is what stops chicken landing three nights running. plan.Source derives it rather than reading a tag, because a tag has to be remembered and the protein is already in the numbers.

There is no fixed list and there should not be one: a household that starts buying tofu needs a category for it and should not have to change the code. The edit form offers what the table already uses, which is the thing worth matching — diary beside dairy would be two names for one shelf, and nothing downstream would notice.

What belongs in the table

Both halves of what spis is for. Recipes are made of it, and a food log names it — so an oreo is a row even though nobody plans with one, because 1 oreo has to read as something. A row nothing cooks with is invisible to the planner, which reads recipes, so it costs nothing to have.

What is still true is how a row arrives: nothing is created because somebody typed a word. Every row was put there by a person, which is what makes a plan built from the table trustworthy and what makes strict matching the right rule.

Nicotine, which is not food at all

A pouch is in the table for the reason an oreo is: 1 pouch klar has to read as something. It is a nutritional nothing — no calories, no macros — so the only figure worth carrying off it is the dose, and nicotine_mg_per_g is where the row says how strong it is.

Per gram, unlike everything in per_100g, because that is how a Swedish can is labelled and because a per-100 g figure for something weighing 0.7 g is a four-digit number nothing prints. A can giving mg per pouch is one division from it. Same trade as abv: store the strength, derive the dose, so improving the conversion improves every pouch ever logged.

Two rows, meaning their grams differently, and both are honest.

[[ingredient]]
id = "klar"                       # a product: its grams are grams of pouch
nicotine_mg_per_g = 14.4          # 180 mg in the can's 12.5 g
portions = { pouch = 0.625 }      # what 12.5 g divides into, 20 ways

[[ingredient]]
id = "nikotin"                    # the substance: its grams ARE nicotine
nicotine_mg_per_g = 1000
portions = { pouch = 0.009 }      # what a pouch of the usual strength delivers
default_portion = "pouch"

That is what lets a dose be written either way round — as the packet you used, or as the amount you know you took:

A can states more than it needs to, and the figures do not always agree. Take the strength from the totals where they differ: Klar's can says 20 pouches, 0.6 g / pouch, total 12,5 g, Nicotine (180mg), 9mg/pouch — and 20 × 0.6 is 12 g rather than 12.5, while 9 mg in 0.6 g would be 15 mg/g rather than the 14.4 the totals give. The printed pouch weight is the rounded one; 180 mg in 12.5 g closes exactly, and puts a pouch at 0.625 g carrying its 9 mg.

spis eat "1 pouch klar"       # 625 mg of pouch  → 9 mg nicotine
spis eat "1 pouch nicotine"   # one, unspecified → 9 mg nicotine
spis eat "9mg nicotine"       # the dose outright → 9 mg nicotine

Neither row carries packs, so the planner cannot reach them. The reading is filed to vitals as nicotine_mg beside the macros — never among them, for the reason ethanol is not among them — and the record totals a day of it.

Overriding an ingredient

household.toml can say what this kitchen buys, without touching a recipe:

[[prefer]]
instead_of = "hushallsost"
use = "prastost"
why = "what we actually buy"

Every recipe still says 100 g hushållsost — that describes the dish and stays true wherever it is shared — and the shopping list buys prästost, costed at prästost's figures. The placement is the point:

  • Editing the recipe makes it wrong for anybody else, and has to be repeated in every recipe using the ingredient.
  • Editing the ingredient is worse: it leaves a row called hushållsost carrying prästost's numbers, which is a lie the tool then repeats everywhere.
  • The household is where the other facts about this kitchen already live.

Three rules it follows. The swap is always reportedspis show prints hushallsost → prastost on the line and lists it under your preferences — because a preference silently changing what a meal contains is the same class of fault as a wrong composition. The substitute is checked against the allergy rules exactly as the original would have been, so a preference can never be a way round them. And preferences do not chain: A-for-B plus B-for-C is refused rather than resolved to whichever end the rule happened to reach.

Adding a product you actually buy

spis off <barcode> fetches one product from Open Food Facts and shows it. --add writes it into data/ingredients.toml.

spis off <name> searches instead, and which you meant is read off the argument rather than a flag — a barcode is digits and a product name is not, and somebody holding a packet types what is on it. Searching is for what Livsmedelsverket structurally cannot carry: it is a table of foods, so prästost is in it and Allerum's prästost is not.

BARCODE        PRODUCT                     BRAND / SIZE   KCAL  PROTEIN
7393720092428  Prästost 17%                Allerum        280   30.0 g
7393720092794  Prästost                    Skånemejerier  400   22.0 g
2340415206481  Prästost                    Arla           384   25.0 g   already prastost

A search prints codes and does not offer to add one: --add takes a barcode, and picking a product out of ten is a decision worth making with the figures in front of you — the same reason the bare lookup does not write either.

The bare command is the verification pass, and the split is the point. Open Food Facts is a wiki — vitals' client documented a Polarbröd claiming 720 kcal per 100 g — so the figures are printed beside the packet you are holding before anything is written.

Three things it does that matter:

It refuses a duplicate twice over — once on the id, once on the barcode. The second is the one that catches you after --id has been used to work around the first.

It never leaves the table broken. The row is appended, the table reloaded, and anything short of a clean load puts the file back as it was. A failed add costs nothing; a half-written ingredients.toml takes down lint, plan and shop together.

It writes TODO for what a barcode cannot say — the category, what one of them weighs, how long it keeps. spis lint reports those as things to tidy rather than as failures, because a lint that cries wolf about tidying stops being run.

It warns when Livsmedelsverket already has the food. A bag of plain cashews is a bag of plain cashews, and a national composition table beats a scanned label for it — adding the branded row instead is a downgrade dressed as precision. A warning and never a refusal, because the opposite case is just as real: a specific loaf is not the generic one, and things SLV has no row for are the whole reason the barcode lookup exists. The preparation is printed with it, since Tranbär — Rå is not what you meant by dried cranberries.

Several brands, one ingredient

A household buys whichever kvarg the shop has. Lindahls against Arla is 60 kcal against 65 — under ten per cent, well inside the noise of everything else — so they are one ingredient with several packets, not several ingredients. A row per brand would make every recipe naming it ambiguous about which one it meant.

spis off --as kvarg 5711953153846    # another packet of a food you have

The figures stay the row's own; an alternate says which packets satisfy this food, not where the composition came from. A barcode already recorded is recognised rather than offered again, and a name that reads as a flavoured variant is warned about — a vanilla kvarg carries sugar the plain one does not.

Removing a row

spis rm naturella_cashewnotter           # what points at it
spis rm --apply naturella_cashewnotter

Refuses while anything still names it: a recipe line, another ingredient's substitutes, or a household preference. That is the whole reason it is a command rather than an edit — removing an ingredient a recipe uses does not fail as a missing ingredient, the recipe just stops resolving, and lint then reports a broken recipe, which sends you looking in the wrong file.

Refreshing a row

A wiki changes, so a row is a snapshot of what it said the day it was read. --update re-reads it, and two rules make that safe.

It says what would move before it writes. Dry by default, --apply to commit — because an upstream anyone can edit can as easily make the table worse as better.

hushallsost — off:7318690146643
FIELD      WAS  NOW  MOVE
kcal       300  330  +10%
protein_g  20   24   +20%
  upstream pack is "150 g" (150 g) — not written, check it yourself

It changes the composition and nothing else. Category, portions, shelf life, packs, aliases and substitutes are what a person added because a barcode cannot know them. Note the pack above: upstream says 150 g, the table says a 700 g block, and the table wins — a refresh that helpfully reset that would cost more than the figures are worth.

How a line is read

Three readings of what follows the number, tried in order of how much of the phrase each accounts for:

example reading
1 3 morötter all of it names the ingredient
2 500 g nötfärs, 1 huvud broccoli first word is a unit
3 2 gula lökar all of it, past an adjective

Two and three cannot be swapped: an adjective and a unit occupy the same slot and look alike, so dropping leading words before trying them as units reads g nötfärs as nötfärs and loses the gram.

Matching is strict after folding å/ä/ö to ASCII — the table is curated, so a name that does not match is a typo or a missing row, and both want reporting rather than a near-enough guess that silently plans the wrong food. An ingredient's own declared portion beats the global volume table, because a teaspoon of dried thyme is a spoonful of leaves rather than five millilitres of a liquid.

Where this sits next to vitals

vitals holds the record — what happened, as an append-only log. spis produces intent — what to cook and what to buy. Different jobs, so different tools.

The seam is the one vitals already draws in invariant 8 of its architecture: reference data is not the log. Composition is a fact about the world and true whoever eats it; the event log, the targets and the check-in describe a person. spis needs the first and must never own the second.

Nothing is shared in code yet, and that is deliberate — extracting a library before the second consumer exists produces an abstraction shaped like the first one. The likely end state inverts the obvious direction: a curated Livsmedelsverket table is better composition data than one grown reactively from what happened to be logged, so spis becomes the upstream and vitals imports it. Worth knowing now, because it means the table wants to stay a first-class exportable artifact rather than an internal detail.

The shopping list

spis shop <recipe>… aggregates the meals, rounds each ingredient up to what the shop actually sells, and reports what is left over.

The leftover is the output, not a rounding error. A recipe needing 600 g of something sold in 500 g packs cannot buy 600 g, and that gap is where household waste comes from. Fit minimises surplus first and pack count second — 600 g from packs of 500 and 800 is one 800 and 200 over, not two 500s and 400 over.

Three distinctions the list makes, each of which stopped it from lying:

Staples are checked, not bought. Without that it tells you to buy a kilo of salt every week, and a list that is wrong every week stops being read.

A surplus that keeps is stock; a surplus that does not is waste. 1200 g of potatoes over is a month's supply. 100 g of fresh fish over is a bin, on Wednesday. Both under one heading trains you to ignore the heading — so risk is measured against a PlanningWeek horizon rather than against whether the food is technically perishable. A 21-day carrot does not threaten a 7-day plan.

Buying frozen changes the shelf life. The fit is free to reach past a fresh pack to a frozen one when that wastes less — 700 g of salmon is better served by one 800 g frozen than two 600 g fresh — and when it does, the two-day clock never starts. That line drops out of cook these first rather than distorting the whole week's ordering.

Every surplus is offered to the recipes in the library that could absorb it and are not already being cooked. That resolves each candidate line through the table rather than matching text, because lök sits inside purjolök and a substring test would offer you a leek for a leftover onion.

The planner

spis plan [days] assigns meals to days, then shops for what it chose.

Three things decide a day, in that order:

What cannot go there. The weekday time budget, the dinner protein floor, and the date a perishable stops being food. These are filters and are never traded against a preference — a plan that puts salmon on Friday because it scored well elsewhere is not a good plan with one flaw, it is a wrong one.

What it costs to shop for. Two meals sharing an ingredient buy one pack instead of two, and sharing a perishable is worth several times sharing rice, because only one of them was ever going to be thrown away.

Whether it is the same dinner again. Chicken four nights running is correct by every measure above and nobody will cook it twice. The protein source is derived from whichever ingredient carries the most protein rather than from a tag — a tag has to be remembered, and a producer that forgets one silently defeats the rule.

The search is a deterministic beam, so the same library and the same week give the same plan every time. A plan you cannot reproduce is one you cannot argue with.

Two shops, not one

The default is a big shop and a top-up — Monday and Thursday — and it has to be. On a single weekly shop nothing perishable survives past midweek, which is not a limitation of the model; it is true, and it is why households already shop twice. A planner assuming one would either report four empty days or quietly plan food into the bin.

That makes the basket one list per trip, because a combined one would have you buying Thursday's fish on Monday. And what the first shop leaves over is subtracted from the second: without that, a two-shop week buys the two-kilo bag of potatoes twice. Only surplus that will still be food gets carried — something about to spoil is not stock you can plan against.

An empty day is an output

When nothing can fill a day, the plan says which constraint bound and on which recipe — laxfilé keeps 2 days and would be 3 old, not nothing fits. An empty day means the library cannot feed you, and that is the single most useful thing the tool can report; making you search forty recipes for a fault it already knew the name of would waste it.

Not built yet

  • A calorie ceiling. The dinner protein floor is a filter and there is no counterpart above: a recipe can clear 45 g by simply being a bigger meal, and three of the first twelve came out over 1000 kcal an adult portion — half a day's maintenance in one dinner. Trimmed by hand for now, which is exactly the kind of thing that should not need a human.

  • Feedback. What got cooked, how long it really took, whether the children ate it. That is what makes week 20 better than week 1.

  • A portion phrase answered from the plan. The planner knows which day held which meal, and the unread queue carries the date, so 1 portion dinner is answerable without naming the dish. Naming it works today; this is the version that does not need you to.

  • The ICA cart agent. Fills a basket and stops; the pick-up button stays yours. Needs the substitutes graph, or it stalls on the first stock-out.

  • A resolver answering with several foods. A reading names one food today — resolver.Reading.Food — and a dish nothing has a row for is several. "1 portion of ragu" is mince, tomato, onion and carrot in some proportion, and a resolver that can guess the proportion still cannot say so. The machinery for the sum exists: kitchen.AnswerParts already costs a list of parts into one reading with a compound citation, for a phrase somebody split by hand. What is missing is Parts on the wire and the same arithmetic behind it, so the decomposition is the resolver's judgement and every figure is still this kitchen's.

  • Recipes offered to a resolver as context. A phrase carries candidate foods today. It does not carry the dish this library already has a recipe for, which is the case worth the most: 1 portion of ragu where bolognese is right there and only its portion could not be worked out. The ingredient list is the answer to what a portion contains — divide the pot by its shares — and a resolver handed the lines could produce it from a recipe this side declined as unweighable. Note that a recipe that will not resolve is still useful here, which is why this is context rather than another candidate.

  • A cooked weight for a portion. AdultPortionGrams is BatchGrams over the shares, and BatchGrams sums the ingredients as bought. So a portion of anything that loses water reports a raw mass for a cooked plate. The macros are unaffected — boiling removes water, not protein — so this is a wrong number beside right ones, which is the kind that survives longest.

    Livsmedelsverket already carries the fix for the component case, and it is not arithmetic: pasta is 358 kcal raw and 128 cooked under two numbers, so a resolver costing a component picks the Tillagad row rather than applying a yield factor. A prep on a recipe line will not do it — that field is household.Raw/Cooked and it is about allergens surviving the pan, not about mass. A yield needs somewhere of its own before a recipe can state one.

Swedish composition data

slv-db/ holds a local copy of Livsmedelsverket's Livsmedelsdatabasen — 2763 foods, CC BY 4.0, with the scripts that produced it and the ones that mapped it onto the ingredient table. spis slv <query> searches it and spis slv <number> prints one food.

An ingredient's source = "slv:1174" is a citation: that is chicken thigh, boneless, skinless, raw, and anyone can check it.

off-db/ is the second source, and not a fallback. Livsmedelsdatabasen is a table of foods — things that grow, and things butchered from them. Kvarg, keso and crème fraîche are things a dairy makes, so they were never going to be in it. Open Food Facts is packaged goods by barcode, which is exactly the missing half, and the citations stay distinct: slv: is a national composition table, off: is one company's label. Different trust, so a different prefix.

There is one place OFF is not merely complementary but better. SLV holds only cooked rice and a recipe measures rice dry — taking its row would have been a threefold error. OFF lists rice as sold.

The API is not empty, only its index is. GET /livsmedel answers 200 with totalRecords: 0 and GET /livsmedel/{n} answers 404, so the master record — the one carrying a food's namn — is unreachable. Every child endpoint works. An earlier check read the empty listing as an empty database and fell back to USDA; the nutrition data was there the whole time, one URL over.

There is no bulk download, so the mirror is a scan of the food numbers keeping whatever answers. See slv-db/README.md for what is broken, how to refresh, and why the scan must not stop at the first gap.

Two things the data forces:

Preparation is part of the identity. Raw carrot is 36 kcal and cooked 23, under different numbers with the same name. Picking the wrong one is silent.

The name alone cannot identify a food. Every chicken cut is called Kyckling; breast, thigh and wing are told apart only by the LanguaL facets, which the mirror keeps and the search reads.