Files
backend_fiesta/models/nutrition.go
2026-09-29 17:27:48 +05:30

138 lines
5.5 KiB
Go

package models
import (
"regexp"
"strconv"
"strings"
)
/*
The nutrition panel, as the customer app renders it.
── Why this is a type and not a list of strings ────────────────────────────
The catalogue already carries `nutrients`, a text[] of display lines like
"Energy 350kcal". That is enough to print bullets, which is what the console
does with it today, and not enough for an app: it cannot sort by a value, show
a per-serving column beside a per-100g one, or put the unit in a different
style from the number. It also has nowhere to say what the figures are PER,
which is the one piece of context that makes the rest meaningful — 520 kcal is
a fact about a quantity, and without "per 100g" it is a fact about nothing.
So this is the shape the agent team fills and the app reads. See
docs/NUTRITION_DATA.md for the contract.
── Why the field names are ugly ────────────────────────────────────────────
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
developer asked for, and an API is a promise to a client that has already been
written against it. Consistency with the rest of Fiesta — which is itself
inconsistent, `productid` beside `image_id` beside `sku_source` — is worth less
than not breaking the caller.
*/
type NutritionPanel struct {
// What the figures are measured against: "100g", "100ml", "1 serving".
Per string `json:"per,omitempty"`
// What the pack calls one serving: "30g". Separate from `Per` because a
// label routinely states both, and the app shows them in different places.
Servingsize string `json:"servingsize,omitempty"`
// Never nil when this panel exists — see `HasValues`. An app that receives
// `items: null` has to branch; one that receives `[]` does not, and a panel
// with no rows should not have been sent at all.
Items []NutritionItem `json:"items"`
}
// NutritionItem is one line of the panel.
type NutritionItem struct {
Name string `json:"name"`
// The figure. A float because saturated fat is 11.5g as often as it is 11g,
// and rounding it to please a type would be changing a label.
Value float64 `json:"value"`
// "kcal", "g", "mg". Free text on purpose: the label is the authority and a
// closed list here would mean refusing to carry whatever it actually says.
Unit string `json:"unit,omitempty"`
}
// HasValues reports whether this panel is worth sending.
//
// A panel with no rows is not a panel — it is an empty box on the product page,
// which reads as "this product has no nutrition" rather than "we do not know
// yet". The endpoint omits it instead.
func (p *NutritionPanel) HasValues() bool {
return p != nil && len(p.Items) > 0
}
/*
nutrientLine pulls "Energy 350kcal" apart.
── Why parsing exists at all ────────────────────────────────────────────────
Every catalogue row on the platform today holds nutrition as those display
strings and nothing else. Waiting for the agent team to refill all of them
before the app can show anything would mean shipping a field that is null for
every product, for as long as that takes.
So a structured panel is used when one exists, and one is derived from the
strings when it does not. The derived panel is strictly worse — it has no `per`
and no serving size, because the strings never carried them — and it is still
the difference between an app screen with values on it and an empty one.
── What it refuses to do ───────────────────────────────────────────────────
A line it cannot read is kept WHOLE as the name, with no value and no unit,
rather than dropped or guessed at. "Contains permitted natural colour" is a
real nutrition line and it has no number in it; binning it would quietly lose
label text, and forcing a 0 into it would state something false about the food.
*/
var nutrientLine = regexp.MustCompile(`^(.*?)[\s:]*(-?\d+(?:\.\d+)?)\s*([a-zA-Zµ%]*)$`)
// NutritionFromLines derives a panel from the catalogue's display strings.
//
// Returns nil when nothing usable is found, so a caller can tell "no nutrition"
// from "a panel with no numbers in it".
func NutritionFromLines(lines []string) *NutritionPanel {
items := make([]NutritionItem, 0, len(lines))
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
match := nutrientLine.FindStringSubmatch(line)
if match == nil {
// No number anywhere. Kept as written — see above.
items = append(items, NutritionItem{Name: line})
continue
}
name := strings.TrimSpace(strings.Trim(match[1], "-–—:"))
if name == "" {
// The whole line was a number. Nothing sensible to label it with,
// and an unnamed row on a nutrition panel is noise.
continue
}
value, err := strconv.ParseFloat(match[2], 64)
if err != nil {
items = append(items, NutritionItem{Name: line})
continue
}
items = append(items, NutritionItem{
Name: name,
Value: value,
Unit: strings.TrimSpace(match[3]),
})
}
if len(items) == 0 {
return nil
}
// No `per` and no serving size, deliberately left empty rather than guessed
// at. "100g" is the common case and it is not the only one, and a wrong
// basis is worse than an absent one — it makes every figure beneath it a
// misstatement rather than an unknown.
return &NutritionPanel{Items: items}
}