138 lines
5.5 KiB
Go
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}
|
|
}
|