Skip to content

Commit 25c4aaa

Browse files
committed
chore: sync from nutritrace-dev (v1.0.0-rc.35)
1 parent 153666a commit 25c4aaa

18 files changed

Lines changed: 449 additions & 69 deletions

CHANGELOG.md

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

1010
---
1111

12+
## [1.0.0-rc.35] — 2026-05-22
13+
14+
### Added
15+
16+
- **Scan Label**. On the Add/Edit Food screen, take a photo of a nutrition label and the AI Assistant fills in the values. Requires AI Assistant to be configured.
17+
18+
### Changed
19+
20+
- **Custom nutriment order is reflected in the food and meal editors.** Reordering nutrients in Settings now changes their display order everywhere.
21+
- **Vitamin D, Calcium, Iron, and Potassium are visible by default** on new installs, matching the FDA's mandatory Nutrition Facts label fields.
22+
23+
### Fixed
24+
25+
- **kcal/kJ inconsistencies when energy unit was set to kJ.** Food editor, diary footer, meal totals, statistics charts, goals, weekly summary email, and push notifications all respect the chosen unit now. Internal storage stays kcal; only the display converts. (Issue #38)
26+
- **Single-user mode: only the first food item added each day was visible** in the UI. A SQLite quirk meant NULL user_id rows never collided on upsert, so each save inserted a new row instead of updating. A one-time migration on first boot recovers items already in the database. (Issue #37)
27+
28+
---
29+
1230
## [1.0.0-rc.34] — 2026-05-22
1331

1432
### Fixed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ NutriTrace runs as a single Docker container on your own hardware, with a PWA fo
4141
### Foods & Meals
4242
- Personal food database with photos, barcodes, categories, and custom labels
4343
- Favorite foods and meals (star them) with a configurable sort order — favorites first, alphabetical, recently used, or most used
44-
- Barcode scanner (camera) for quick food lookup via Open Food Facts
44+
- Barcode scanner (camera) for quick food lookup via Open Food Facts. Barcodes not in OFF open the food editor with the barcode prefilled so you can enter the food manually and contribute back.
45+
- **Scan Label** — when AI Assistant is enabled, a button in the Nutrition card sends a photo of the actual nutrition label to your configured AI provider, which extracts every value (calories, macros, micros, serving size) and fills the form in one tap. Works with cloud providers (Claude, OpenAI, Gemini) or a local model with vision support
46+
- **Share to Open Food Facts** — contribute foods you've added (with their picture) to the OFF community database. The button flips to "View on OFF" when the product already exists, opening the wiki page where edits properly track history and moderation
4547
- Meal and recipe builder with drag-to-reorder ingredients
4648
- Proportional nutrition scaling when editing serving size
49+
- Configurable nutrient order — drag to reorder in Settings → Nutrients; the food and meal editors honor your custom order
4750
- Import foods from Open Food Facts, USDA FoodData Central, or Mealie (recipe manager); Open Food Facts barcode imports let you pick per-serving or per-100g when both are published
4851
- **Bulk import** custom foods from JSON or CSV (Settings → Import & Export → Bulk Import)
4952
- **Mass-aware unit conversion** when scaling nutrition: switching g ↔ oz ↔ lb, ml ↔ cup, tsp ↔ tbsp (or any custom unit you define) actually converts the macros, not just relabels the unit

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 103
23-
versionName "1.0.0-rc.34"
22+
versionCode 104
23+
versionName "1.0.0-rc.35"
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.

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.34",
3+
"version": "1.0.0-rc.35",
44
"private": true,
55
"type": "module",
66
"scripts": {

server/db.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,54 @@ if (!columnExists('user_settings', 'deleted_at')) {
485485
db.exec(`ALTER TABLE user_settings ADD COLUMN deleted_at TEXT DEFAULT NULL`);
486486
}
487487

488+
// Issue #37: single-user mode (user_id IS NULL) accumulated duplicate diary
489+
// rows because SQLite UNIQUE(date, user_id) treats NULL as distinct, so the
490+
// PUT handler's UPSERT never fired. Each save inserted a new row and GET
491+
// returned the oldest, so the client's currentEntry never advanced past
492+
// [item1]. Subsequent saves sent [item1, itemN] (not the full cumulative
493+
// list), so no single row holds the user's full day — items are scattered.
494+
// Recovery: per date, union items across all rows (dedup by addedAt),
495+
// keep the latest-id row, replace its items with the merged set, delete
496+
// the older duplicates. Idempotent.
497+
try {
498+
const dupDates = db.prepare(
499+
`SELECT date FROM diary WHERE user_id IS NULL GROUP BY date HAVING COUNT(*) > 1`
500+
).all();
501+
if (dupDates.length) {
502+
let totalDeleted = 0;
503+
const tx = db.transaction(() => {
504+
for (const { date } of dupDates) {
505+
const rows = db.prepare(
506+
`SELECT id, items FROM diary WHERE user_id IS NULL AND date = ? ORDER BY id ASC`
507+
).all(date);
508+
const merged = [];
509+
const seen = new Set();
510+
for (const r of rows) {
511+
let items = [];
512+
try { items = JSON.parse(r.items || '[]'); } catch {}
513+
if (!Array.isArray(items)) continue;
514+
for (const it of items) {
515+
const key = it && it.addedAt ? `t:${it.addedAt}` : `f:${JSON.stringify(it)}`;
516+
if (seen.has(key)) continue;
517+
seen.add(key);
518+
merged.push(it);
519+
}
520+
}
521+
merged.sort((a, b) => String(a?.addedAt || '').localeCompare(String(b?.addedAt || '')));
522+
const keepId = rows[rows.length - 1].id;
523+
db.prepare(`UPDATE diary SET items=?, updated_at=datetime('now') WHERE id=?`)
524+
.run(JSON.stringify(merged), keepId);
525+
const r = db.prepare(`DELETE FROM diary WHERE user_id IS NULL AND date = ? AND id != ?`).run(date, keepId);
526+
totalDeleted += r.changes;
527+
}
528+
});
529+
tx();
530+
console.log(`[db] #37: merged single-user diary items across ${dupDates.length} dates, removed ${totalDeleted} duplicate rows`);
531+
}
532+
} catch (e) {
533+
console.warn(`[db] #37 diary consolidation failed:`, e.message || e);
534+
}
535+
488536
// ── Favorites + usage tracking (foods + meals) ─────────────────────────────
489537
// `favorite` pins items to the top of the picker; `usage_count` and
490538
// `last_used_at` drive the Most Used / Recently Used sort modes.

server/email.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,14 @@ export async function sendWeeklySummaryEmail(userId, origin) {
293293
calGoal = g.calories?.max ?? g.calories?.min ?? null;
294294
} catch {}
295295

296+
// Energy unit preference (display only; storage stays kcal)
297+
const euRow = db.prepare(`SELECT value FROM user_settings WHERE user_id=? AND key='energyUnit'`).get(userId);
298+
let energyUnit = 'kcal';
299+
try { energyUnit = JSON.parse(euRow?.value || '"kcal"') || 'kcal'; } catch {}
300+
const _isKj = energyUnit === 'kJ';
301+
const _eUnit = _isKj ? 'kJ' : 'kcal';
302+
const _toE = (kcal) => kcal == null ? null : (_isKj ? Math.round(kcal * 4.184) : Math.round(kcal));
303+
296304
for (const row of diaryRows) {
297305
try {
298306
const entry = JSON.parse(row.data);
@@ -362,11 +370,14 @@ export async function sendWeeklySummaryEmail(userId, origin) {
362370
const fmtDate = (d) => `${DAYS[d.getDay()]} ${d.toLocaleDateString('en-US', { month:'short', day:'numeric' })}`;
363371
const weekRange = `${fmtDate(fromDate)}${fmtDate(toDate)}`;
364372

373+
const _dispAvgCal = _toE(avgCal);
374+
const _dispDiff = (avgCal != null && calGoal != null) ? _toE(avgCal - calGoal) : null;
375+
const _dispDiffSigned = _dispDiff != null ? `${_dispDiff >= 0 ? '+' : ''}${_dispDiff}` : null;
365376
const nutritionSection = daysLogged > 0 ? `
366377
${_sectionHeader(`Nutrition (${daysLogged}/7 days logged)`)}
367378
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
368-
${_statRow('Avg Daily Calories', avgCal, 'kcal')}
369-
${calGoal != null ? _statRow('vs Goal', `${avgCal >= calGoal ? '+' : ''}${avgCal - calGoal}`, 'kcal') : ''}
379+
${_statRow(_isKj ? 'Avg Daily Energy' : 'Avg Daily Calories', _dispAvgCal, _eUnit)}
380+
${_dispDiffSigned != null ? _statRow('vs Goal', _dispDiffSigned, _eUnit) : ''}
370381
${goalHitPct != null ? _statRow('Goal Hit Rate', goalHitPct, '%') : ''}
371382
${_statRow('Avg Protein', avgProt, 'g')}
372383
${_statRow('Avg Carbs', avgCarb, 'g')}
@@ -380,7 +391,7 @@ export async function sendWeeklySummaryEmail(userId, origin) {
380391
${_sectionHeader('Activity & Wellness')}
381392
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
382393
${_statRow('Avg Daily Steps', avgSteps)}
383-
${w.calories_out ? _statRow('Avg Calories Burned', Math.round(w.calories_out), 'kcal') : ''}
394+
${w.calories_out ? _statRow(_isKj ? 'Avg Energy Burned' : 'Avg Calories Burned', _toE(w.calories_out), _eUnit) : ''}
384395
${_statRow('Avg Sleep', avgSleep)}
385396
${w.resting_hr ? _statRow('Avg Resting HR', Math.round(w.resting_hr), 'bpm') : ''}
386397
${w.readiness_score ? _statRow('Avg Readiness', Math.round(w.readiness_score), '/ 100') : ''}
@@ -411,9 +422,9 @@ export async function sendWeeklySummaryEmail(userId, origin) {
411422
await sendMail({
412423
to: toEmail,
413424
subject: `NutriTrace Weekly Summary — ${weekRange}`,
414-
html: emailWrapper(origin, body, 'To stop receiving these emails, turn off Weekly Summary in Settings &rarr; Notifications.', `Your NutriTrace week: ${avgCal ? avgCal + ' avg kcal' : ''}${avgSteps ? ', ' + avgSteps + ' avg steps' : ''}`),
425+
html: emailWrapper(origin, body, 'To stop receiving these emails, turn off Weekly Summary in Settings &rarr; Notifications.', `Your NutriTrace week: ${_dispAvgCal != null ? _dispAvgCal + ' avg ' + _eUnit : ''}${avgSteps ? ', ' + avgSteps + ' avg steps' : ''}`),
415426
text: `Weekly Summary (${weekRange})\n\n` +
416-
(avgCal ? `Avg calories: ${avgCal} kcal\n` : '') +
427+
(_dispAvgCal != null ? `Avg ${_isKj ? 'energy' : 'calories'}: ${_dispAvgCal} ${_eUnit}\n` : '') +
417428
(avgProt ? `Avg protein: ${avgProt}g\n` : '') +
418429
(avgSteps ? `Avg steps: ${avgSteps}\n` : '') +
419430
(avgSleep ? `Avg sleep: ${avgSleep}\n` : '') +

server/lib/push-notify.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,19 @@ export async function sendWeeklySummary(userId) {
186186
const m = {};
187187
for (const r of rows) m[r.metric_type] = r.avg;
188188

189+
let energyUnit = 'kcal';
190+
try {
191+
const euRow = db.prepare(`SELECT value FROM user_settings WHERE user_id=? AND key='energyUnit'`).get(userId);
192+
energyUnit = JSON.parse(euRow?.value || '"kcal"') || 'kcal';
193+
} catch {}
194+
const _isKj = energyUnit === 'kJ';
195+
189196
const parts = [];
190197
if (m.steps) parts.push(`Avg steps: ${Math.round(m.steps).toLocaleString()}`);
191-
if (m.calories_out) parts.push(`Avg cal burned: ${Math.round(m.calories_out).toLocaleString()}`);
198+
if (m.calories_out) {
199+
const v = _isKj ? Math.round(m.calories_out * 4.184) : Math.round(m.calories_out);
200+
parts.push(`Avg ${_isKj ? 'energy' : 'cal'} burned: ${v.toLocaleString()} ${_isKj ? 'kJ' : 'kcal'}`);
201+
}
192202
if (m.sleep_duration_min) {
193203
const h = Math.floor(m.sleep_duration_min / 60);
194204
const min = Math.round(m.sleep_duration_min % 60);

server/routes/diary.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,22 @@ router.put('/:date', wrap((req, res) => {
3333
const { items, body_stats, water, notes } = req.body;
3434
const notesVal = (typeof notes === 'string' && notes.trim()) ? notes : null;
3535
const u = uid(req);
36+
const itemsJson = JSON.stringify(items || []);
37+
const bsJson = JSON.stringify(body_stats || {});
38+
const waterJson = JSON.stringify(water || []);
3639
if (u == null) {
37-
db.prepare(
38-
`INSERT INTO diary (date, items, body_stats, water, notes, updated_at)
39-
VALUES (?, ?, ?, ?, ?, datetime('now'))
40-
ON CONFLICT(date, user_id) DO UPDATE SET
41-
items=excluded.items, body_stats=excluded.body_stats,
42-
water=excluded.water, notes=excluded.notes,
43-
updated_at=excluded.updated_at,
44-
deleted_at=NULL`
45-
).run(req.params.date, JSON.stringify(items || []), JSON.stringify(body_stats || {}), JSON.stringify(water || []), notesVal);
40+
// Single-user mode: SQLite UNIQUE(date, user_id) treats NULL user_id as
41+
// distinct per row, so the standard UPSERT never collides — each PUT
42+
// would insert a new row and GET would return the oldest (issue #37,
43+
// "only the first food item added each day saves"). Manual upsert:
44+
const existing = db.prepare(`SELECT id FROM diary WHERE date = ? AND user_id IS NULL`).get(req.params.date);
45+
if (existing) {
46+
db.prepare(`UPDATE diary SET items=?, body_stats=?, water=?, notes=?, updated_at=datetime('now'), deleted_at=NULL WHERE id=?`)
47+
.run(itemsJson, bsJson, waterJson, notesVal, existing.id);
48+
} else {
49+
db.prepare(`INSERT INTO diary (date, items, body_stats, water, notes, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`)
50+
.run(req.params.date, itemsJson, bsJson, waterJson, notesVal);
51+
}
4652
} else {
4753
db.prepare(
4854
`INSERT INTO diary (user_id, date, items, body_stats, water, notes, updated_at)
@@ -52,7 +58,7 @@ router.put('/:date', wrap((req, res) => {
5258
water=excluded.water, notes=excluded.notes,
5359
updated_at=excluded.updated_at,
5460
deleted_at=NULL`
55-
).run(u, req.params.date, JSON.stringify(items || []), JSON.stringify(body_stats || {}), JSON.stringify(water || []), notesVal);
61+
).run(u, req.params.date, itemsJson, bsJson, waterJson, notesVal);
5662
}
5763
const row = u == null
5864
? db.prepare('SELECT * FROM diary WHERE date = ? AND user_id IS NULL AND deleted_at IS NULL').get(req.params.date)

server/routes/fitbit.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,12 +876,18 @@ async function _syncWorkouts(userId, from, to) {
876876
if (synced > 0) {
877877
try {
878878
const { notifyWorkout } = await import('../lib/push-notify.js');
879+
let _isKj = false;
880+
try {
881+
const euRow = db.prepare(`SELECT value FROM user_settings WHERE user_id=? AND key='energyUnit'`).get(u);
882+
_isKj = (JSON.parse(euRow?.value || '"kcal"') || 'kcal') === 'kJ';
883+
} catch {}
879884
for (const a of activities.slice(-3)) { // last 3 max
880885
const dur = Math.round((a.activeDuration || 0) / 60000);
881886
const dist = a.distance != null ? `${(a.distance * (a.distanceUnit === 'Mile' ? 1.60934 : 1)).toFixed(1)} km` : '';
882887
const cal = a.calories || 0;
888+
const calStr = cal ? `${_isKj ? Math.round(cal * 4.184) : cal} ${_isKj ? 'kJ' : 'kcal'}` : '';
883889
const name = a.activityName || 'Workout';
884-
notifyWorkout(u, `${name}: ${dur} min${dist ? ', ' + dist : ''}${cal ? ', ' + cal + ' kcal' : ''}`);
890+
notifyWorkout(u, `${name}: ${dur} min${dist ? ', ' + dist : ''}${calStr ? ', ' + calStr : ''}`);
885891
}
886892
} catch {}
887893
}

server/routes/sync.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,30 @@ router.post('/push', wrap((req, res) => {
211211
.run(d.date, ...(u != null ? [u] : []));
212212
} else {
213213
const dNotes = (typeof d.notes === 'string' && d.notes.trim()) ? d.notes : null;
214-
db.prepare(
215-
`INSERT INTO diary (user_id, date, items, body_stats, water, notes, updated_at)
216-
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
217-
ON CONFLICT(date, user_id) DO UPDATE SET
218-
items = excluded.items, body_stats = excluded.body_stats, water = excluded.water,
219-
notes = excluded.notes,
220-
updated_at = datetime('now'), deleted_at = NULL`
221-
).run(u, d.date, JSON.stringify(d.items || []), JSON.stringify(d.body_stats || {}), JSON.stringify(d.water || []), dNotes);
214+
const itemsJson = JSON.stringify(d.items || []);
215+
const bsJson = JSON.stringify(d.body_stats || {});
216+
const waterJson = JSON.stringify(d.water || []);
217+
if (u == null) {
218+
// Single-user mode: NULL user_id never collides under SQLite UNIQUE
219+
// (see diary.js PUT for the same workaround, issue #37).
220+
const existing = db.prepare(`SELECT id FROM diary WHERE date = ? AND user_id IS NULL`).get(d.date);
221+
if (existing) {
222+
db.prepare(`UPDATE diary SET items=?, body_stats=?, water=?, notes=?, updated_at=datetime('now'), deleted_at=NULL WHERE id=?`)
223+
.run(itemsJson, bsJson, waterJson, dNotes, existing.id);
224+
} else {
225+
db.prepare(`INSERT INTO diary (date, items, body_stats, water, notes, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`)
226+
.run(d.date, itemsJson, bsJson, waterJson, dNotes);
227+
}
228+
} else {
229+
db.prepare(
230+
`INSERT INTO diary (user_id, date, items, body_stats, water, notes, updated_at)
231+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
232+
ON CONFLICT(date, user_id) DO UPDATE SET
233+
items = excluded.items, body_stats = excluded.body_stats, water = excluded.water,
234+
notes = excluded.notes,
235+
updated_at = datetime('now'), deleted_at = NULL`
236+
).run(u, d.date, itemsJson, bsJson, waterJson, dNotes);
237+
}
222238
}
223239
const row = db.prepare(`SELECT id FROM diary WHERE date = ? AND user_id ${u != null ? '= ?' : 'IS NULL'}`)
224240
.get(d.date, ...(u != null ? [u] : []));

0 commit comments

Comments
 (0)