- Go 88.2%
- HTML 5.8%
- Python 4.3%
- CSS 1.2%
- Dockerfile 0.4%
- Other 0.1%
| .claude/skills | ||
| .just | ||
| .woodpecker | ||
| cmd/spisd | ||
| data | ||
| docs | ||
| internal | ||
| off-db | ||
| resolver | ||
| slv-db | ||
| .gitignore | ||
| CHANGELOG.md | ||
| Containerfile | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
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 lintsays 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 garliccosting 80 g against2 klyftor vitlök's 6 g — the same lunch, thirteen times apart.cloveswas 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 hammerely 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: 388–416 g · molly: 310–351 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
ResolveForand neverResolve. 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 hamwants 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 barcode — prä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.pypauses 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
Serverheader 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 up — slv: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 sayingslv:349while 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.Sourcederives 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 reported — spis 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 dinneris 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
substitutesgraph, 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.AnswerPartsalready costs a list of parts into one reading with a compound citation, for a phrase somebody split by hand. What is missing isPartson 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 raguwherebologneseis 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.
AdultPortionGramsisBatchGramsover the shares, andBatchGramssums 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
Tillagadrow rather than applying a yield factor. Aprepon 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.