Skip to content

Commit d07abef

Browse files
committed
chore: sync from nutritrace-dev (v1.0.0-rc.40)
1 parent 124a75f commit d07abef

11 files changed

Lines changed: 1174 additions & 1092 deletions

File tree

.env.example

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@ JWT_SECRET=change-me-to-a-long-random-secret
5151
# .duckdb — Legacy native DuckDB file (pre-rc.39 setups still work).
5252
#
5353
# Setup:
54-
# 1. Uncomment the OFF mirror volume mount in docker-compose.yml
55-
# 2. Set OFF_LOCAL_DB below to the in-container path (default /data/off.parquet).
56-
# NutriTrace downloads the initial snapshot (~7-8 GB) automatically on
57-
# first boot if the file is missing — no manual wget required.
54+
# 1. Uncomment the OFF mirror volume mount in docker-compose.yml. IMPORTANT:
55+
# the mount points at a parent DIRECTORY (e.g. ./off-mirror:/data/off-mirror),
56+
# not the file itself. Docker auto-creates missing single-file bind-mount
57+
# sources as directories on the host, which breaks the atomic-swap rename
58+
# during refresh with EISDIR. The file lives inside the mounted directory.
59+
# 2. Set OFF_LOCAL_DB below to the in-container path to the file
60+
# (e.g. /data/off-mirror/off.parquet). NutriTrace downloads the initial
61+
# snapshot (~7-8 GB) automatically on first boot if the file is missing.
5862
# 3. (Optional) Set OFF_LOCAL_ONLY=1 to refuse any fallback to the remote
5963
# OFF API. True air-gap mode: lookups for products not in the mirror
6064
# return "not found" instead of querying the public API.
@@ -66,7 +70,7 @@ JWT_SECRET=change-me-to-a-long-random-secret
6670
# atomic swap; no container restart needed. See DEPLOY.md → "Local Open Food
6771
# Facts mirror" for the full guide.
6872
#
69-
# OFF_LOCAL_DB=/data/off.parquet
73+
# OFF_LOCAL_DB=/data/off-mirror/off.parquet
7074
# OFF_LOCAL_ONLY=1
7175
# Optional — pin the download source. Defaults to the maintained Hugging Face
7276
# Parquet dump. To use a legacy / self-hosted DuckDB file instead, set this to

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,41 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
99

1010
---
1111

12+
## [1.0.0-rc.40] — 2026-05-28
13+
14+
### Fixed
15+
16+
- **Diary: meal kcal footer truncated meals over 1000 kcal.** The per-meal
17+
subtotal at the bottom of each diary card was dropping digits after the
18+
thousands comma — a 1,234 kcal lunch displayed as "1 kcal".
19+
(Issue #51, reported by @LoveHonorGirth)
20+
- **OFF local mirror: barcode scans threw a JS error on the client.** The
21+
Parquet schema adapter assumed `product_name` lists were always real
22+
Arrays, but the DuckDB Node API can return list-like iterables that
23+
broke the assumption and leaked through as a non-string field, crashing
24+
the client's `.trim()` call. Now coerces to a real Array up front and
25+
guarantees a string output in both schema branches.
26+
(Issue #22 followup, reported by @duplaja)
27+
28+
### Changed
29+
30+
- **OFF local mirror Docker example now uses a parent-directory bind
31+
mount** (`./off-mirror:/data/off-mirror` with
32+
`OFF_LOCAL_DB=/data/off-mirror/off.parquet`) across `docker-compose.yml`,
33+
`DEPLOY.md`, and `.env.example`. The previous single-file bind-mount
34+
example tripped Docker's auto-create-as-directory behavior on a fresh
35+
host path, which then broke the atomic-swap refresh with `EISDIR`.
36+
Existing setups that already work are unaffected. (Issue #22 followup)
37+
38+
### Added
39+
40+
- **Per-query debug logs for the OFF local mirror.** With
41+
`LOG_LEVEL=debug`, every barcode and name-search lookup now prints
42+
whether it was served from the local mirror or fell through to the
43+
remote OFF API, with hit counts for searches.
44+
45+
---
46+
1247
## [1.0.0-rc.39] — 2026-05-27
1348

1449
### Added

DEPLOY.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,16 +143,16 @@ Self-hosters on air-gapped networks, in strict-egress environments, or just want
143143

144144
### Setup
145145

146-
1. Uncomment the OFF mirror volume mount in `docker-compose.yml`:
146+
1. Uncomment the OFF mirror volume mount in `docker-compose.yml`. **Bind-mount a parent directory, not the file itself** — Docker auto-creates missing single-file bind-mount sources as directories on the host, which then surfaces inside the container as a directory at the mount point and breaks atomic-swap refresh with `EISDIR` (issue #22 followup):
147147
```yaml
148-
- ${OFF_LOCAL_DB_HOST_PATH:-./off.parquet}:/data/off.parquet
148+
- ${OFF_LOCAL_DB_HOST_PATH:-./off-mirror}:/data/off-mirror
149149
```
150-
Note: the mount is read-write so NutriTrace can perform in-place refresh via atomic swap. Earlier docs suggested `:ro`; if you previously set that, change it to writable so scheduled and manual refreshes work.
150+
The mount is read-write so NutriTrace can perform in-place refresh via atomic swap. Earlier docs suggested `:ro`; if you previously set that, change it to writable so scheduled and manual refreshes work.
151151

152152
2. Set the env vars in `.env`:
153153
```bash
154-
OFF_LOCAL_DB_HOST_PATH=/path/on/host/off.parquet # the host path (will be created if missing)
155-
OFF_LOCAL_DB=/data/off.parquet # the in-container path
154+
OFF_LOCAL_DB_HOST_PATH=/path/on/host/off-mirror # parent directory on host
155+
OFF_LOCAL_DB=/data/off-mirror/off.parquet # in-container path to the file
156156
# Optional — pin the download URL (defaults to the maintained Hugging Face Parquet dump).
157157
# OFF_LOCAL_URL=https://huggingface.co/datasets/openfoodfacts/product-database/resolve/main/food.parquet?download=true
158158
# Optional — full air-gap mode (never call api.openfoodfacts.org)
@@ -176,8 +176,8 @@ You can also click **Refresh Now** in the same panel to force an immediate refre
176176
Prefer the command line? You can still drop a fresh file in place manually:
177177

178178
```bash
179-
wget 'https://huggingface.co/datasets/openfoodfacts/product-database/resolve/main/food.parquet?download=true' -O /path/on/host/off.parquet.new
180-
mv /path/on/host/off.parquet.new /path/on/host/off.parquet
179+
wget 'https://huggingface.co/datasets/openfoodfacts/product-database/resolve/main/food.parquet?download=true' -O /path/on/host/off-mirror/off.parquet.new
180+
mv /path/on/host/off-mirror/off.parquet.new /path/on/host/off-mirror/off.parquet
181181
# Optional — trigger reopen without waiting for the next lookup:
182182
docker compose restart
183183
```

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ android {
1919
applicationId "com.nutritrace.app"
2020
minSdkVersion rootProject.ext.minSdkVersion
2121
targetSdkVersion rootProject.ext.targetSdkVersion
22-
versionCode 108
23-
versionName "1.0.0-rc.39"
22+
versionCode 109
23+
versionName "1.0.0-rc.40"
2424
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
2525
aaptOptions {
2626
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

docker-compose.yml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ services:
1212
# OFF_LOCAL_DB in .env. NutriTrace downloads the initial ~7-8 GB
1313
# Parquet snapshot from Hugging Face automatically on first boot if
1414
# the file is missing; subsequent refreshes happen on a schedule from
15-
# the Settings UI. Mount is read-write so atomic swap during refresh
16-
# succeeds. File extension is auto-detected — Parquet (default) or
17-
# legacy .duckdb both supported. See DEPLOY.md → "Local Open Food
15+
# the Settings UI. File extension is auto-detected — Parquet (default)
16+
# or legacy .duckdb both supported. See DEPLOY.md → "Local Open Food
1817
# Facts mirror" for the full recipe.
19-
# - ${OFF_LOCAL_DB_HOST_PATH:-./off.parquet}:/data/off.parquet
18+
#
19+
# IMPORTANT: bind-mount a parent DIRECTORY, not the file itself.
20+
# Docker auto-creates missing single-file bind-mount sources as
21+
# directories on the host, which then surfaces inside the container
22+
# as a directory at the mount point and breaks the atomic-swap
23+
# rename during refresh with EISDIR (issue #22 followup, reported
24+
# by @duplaja). The file lives inside the mounted directory; set
25+
# OFF_LOCAL_DB to the in-container path of the file below.
26+
# - ${OFF_LOCAL_DB_HOST_PATH:-./off-mirror}:/data/off-mirror
2027
# Forward every variable from .env into the container. Without this, only
2128
# variables explicitly named in the `environment:` block below reach the
2229
# app, so .env settings like INSECURE_COOKIES were silently ignored (#41).
@@ -80,11 +87,12 @@ services:
8087
# and are read-only there — edit via .env / docker-compose / k8s secrets.
8188
# Optional — Local Open Food Facts mirror (Issue #22). When set,
8289
# barcode + name lookups try the local mirror before reaching out
83-
# to api.openfoodfacts.org. Path is the in-container path matching
84-
# the volume mount above. File extension is auto-detected — Parquet
85-
# (default since rc.39) or legacy .duckdb both supported. See
86-
# DEPLOY.md for download / refresh.
87-
# - OFF_LOCAL_DB=${OFF_LOCAL_DB:-} # e.g. /data/off.parquet
90+
# to api.openfoodfacts.org. Path is the in-container path to the
91+
# mirror file, which lives inside the directory bind-mount above.
92+
# File extension is auto-detected (Parquet default since rc.39, or
93+
# legacy .duckdb still supported). See DEPLOY.md for download /
94+
# refresh.
95+
# - OFF_LOCAL_DB=${OFF_LOCAL_DB:-} # e.g. /data/off-mirror/off.parquet
8896
# - OFF_LOCAL_ONLY=${OFF_LOCAL_ONLY:-} # 1 = air-gap mode, never call remote OFF
8997
# - OFF_LOCAL_URL=${OFF_LOCAL_URL:-} # override download source (defaults to HF Parquet)
9098
# Any server env var may also be supplied via <NAME>_FILE, for example:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nutritrace",
3-
"version": "1.0.0-rc.39",
3+
"version": "1.0.0-rc.40",
44
"private": true,
55
"type": "module",
66
"scripts": {

server/lib/off-local.js

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -463,12 +463,26 @@ function _sniffParquet(filePath) {
463463
// which file format was used.
464464

465465
function _toOffProduct(row) {
466-
if (Array.isArray(row.product_name)) {
467-
// Parquet shape
468-
const name = _extractLocalized(row.product_name) || _extractLocalized(row.generic_name);
466+
// Normalize LIST<STRUCT> columns up front: DuckDB Node API can return
467+
// LIST values as either a real Array (typical) or a list-like object
468+
// (iterable but not Array.isArray) depending on version. The previous
469+
// Array.isArray check missed the latter case and dropped through to the
470+
// legacy branch, which then leaked the raw list object out as
471+
// product_name and broke the client's .trim() call (issue #22 followup
472+
// from @duplaja). Coerce to a real Array here so both branches see the
473+
// shape they expect.
474+
const _asArray = v => Array.isArray(v)
475+
? v
476+
: (v != null && typeof v !== 'string' && typeof v[Symbol.iterator] === 'function')
477+
? Array.from(v)
478+
: null;
479+
const pnList = _asArray(row.product_name);
480+
if (pnList) {
481+
// Parquet shape: product_name is LIST<{lang, text}>
482+
const name = _extractLocalized(pnList) || _extractLocalized(_asArray(row.generic_name));
469483
return {
470484
code: row.code,
471-
product_name: name,
485+
product_name: typeof name === 'string' ? name : '',
472486
brands: row.brands || '',
473487
brands_tags: row.brands_tags || [],
474488
categories: row.categories || '',
@@ -480,11 +494,11 @@ function _toOffProduct(row) {
480494
nutriments: _unfoldNutrimentsList(row.nutriments),
481495
};
482496
}
483-
// Legacy native DuckDB shape
497+
// Legacy native DuckDB shape (product_name is a plain string column)
484498
const nutriments = _flattenNutrimentsStruct(row.nutriments) || _pickNutrimentColumns(row);
485499
return {
486500
code: row.code,
487-
product_name: row.product_name || '',
501+
product_name: typeof row.product_name === 'string' ? row.product_name : '',
488502
brands: row.brands || '',
489503
brands_tags: row.brands_tags || [],
490504
categories: row.categories || '',

server/routes/proxy.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,15 @@ async function _tryLocalOff(parsedUrl) {
110110
if (!m) return undefined;
111111
const code = m[1];
112112
const result = await lookupByBarcode(code);
113-
if (result == null) return undefined; // mirror errored — fall through (or 503 in air-gap)
114-
if (result.status === 0 && !airGap) return undefined; // mirror miss in non-air-gap — try live
113+
if (result == null) {
114+
logger.debug(`[off-local] barcode ${code} → mirror error, falling through to remote OFF`);
115+
return undefined;
116+
}
117+
if (result.status === 0 && !airGap) {
118+
logger.debug(`[off-local] barcode ${code} → miss, falling through to remote OFF`);
119+
return undefined;
120+
}
121+
logger.debug(`[off-local] barcode ${code} → hit from local mirror${airGap && result.status === 0 ? ' (air-gap, returning empty)' : ''}`);
115122
return result;
116123
}
117124
// Name search: /search?q=...&page=...&page_size=...
@@ -120,8 +127,16 @@ async function _tryLocalOff(parsedUrl) {
120127
const page = parseInt(parsedUrl.searchParams.get('page') || '1', 10);
121128
const pageSize = parseInt(parsedUrl.searchParams.get('page_size') || '20', 10);
122129
const result = await searchByName(q, { page, pageSize });
123-
if (result == null) return undefined;
124-
if ((result.hits?.length ?? 0) === 0 && !airGap) return undefined; // empty search in non-air-gap — try live
130+
if (result == null) {
131+
logger.debug(`[off-local] search "${q}" → mirror error, falling through to remote OFF`);
132+
return undefined;
133+
}
134+
const hitCount = result.hits?.length ?? 0;
135+
if (hitCount === 0 && !airGap) {
136+
logger.debug(`[off-local] search "${q}" → 0 hits, falling through to remote OFF`);
137+
return undefined;
138+
}
139+
logger.debug(`[off-local] search "${q}" → ${hitCount} hits from local mirror${airGap && hitCount === 0 ? ' (air-gap, returning empty)' : ''}`);
125140
return result;
126141
}
127142
// Any other OFF endpoint (CGI scripts for product upload, images, etc.):

src/lib/version.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const APP_VERSION = 'v1.0.0-rc.39';
1+
export const APP_VERSION = 'v1.0.0-rc.40';

src/routes/Diary.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,13 @@
581581
const p = Math.round((t.proteins || 0) * 4 / cal * 100);
582582
const c = Math.round((t.carbohydrates || 0) * 4 / cal * 100);
583583
const f = Math.round((t.fat || 0) * 9 / cal * 100);
584-
return { cal: Math.round(cal).toLocaleString(), p, c, f };
584+
// cal stays as a number — the meal-macro-footer passes it through
585+
// Nutrition.displayEnergy() which does parseFloat() internally. Returning
586+
// a locale-formatted string like "29,710" used to break that parseFloat
587+
// (it'd stop at the comma and yield 29), truncating meal kcal totals
588+
// above 1000 to the leading digit(s). The template handles display-side
589+
// commas via _mtEnergy.value.toLocaleString() below. (Issue #51)
590+
return { cal: Math.round(cal), p, c, f };
585591
}
586592
587593
function getMealItems(entryItems, mealIdx) {

0 commit comments

Comments
 (0)