Meal Tracker v1 - complete

This commit is contained in:
Otto
2026-03-30 13:34:53 -04:00
commit 139f756807
21 changed files with 938 additions and 0 deletions

3
server/.env Normal file
View File

@@ -0,0 +1,3 @@
PORT=3000
UPLOAD_DIR=../uploads
DB_PATH=../data/meal-tracker.db

47
server/index.js Normal file
View File

@@ -0,0 +1,47 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import db from './models/db.js';
import entriesRouter from './routes/entries.js';
import foodsRouter from './routes/foods.js';
import summaryRouter from './routes/summary.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Serve uploaded files statically
app.use('/uploads', express.static(path.join(__dirname, process.env.UPLOAD_DIR || '../uploads')));
// API Routes
app.use('/api/entries', entriesRouter);
app.use('/api/foods', foodsRouter);
app.use('/api/summary', summaryRouter);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Initialize database and start server
db.initialize()
.then(() => {
app.listen(PORT, () => {
console.log(`Meal Tracker API running on port ${PORT}`);
});
})
.catch(err => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
export default app;

View File

@@ -0,0 +1,42 @@
import path from 'path';
import { fileURLToPath } from 'url';
import multer from 'multer';
import 'dotenv/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `${timestamp}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mime = allowedTypes.test(file.mimetype);
if (ext && mime) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024
}
});
export default upload;

119
server/models/db.js Normal file
View File

@@ -0,0 +1,119 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import 'dotenv/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/meal-tracker.db');
let db;
export function getDb() {
if (!db) {
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
}
return db;
}
export function initialize() {
const database = getDb();
database.exec(`
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
image_url TEXT,
notes TEXT,
calories REAL DEFAULT 0,
protein REAL DEFAULT 0,
carbs REAL DEFAULT 0,
fat REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_entries_created_at ON entries(created_at)
`);
console.log('Database initialized');
return Promise.resolve();
}
export function getAll(dateFilter = null) {
const db = getDb();
let query = 'SELECT * FROM entries';
let params = [];
if (dateFilter) {
query += ' WHERE date(created_at) = date(?)';
params.push(dateFilter);
}
query += ' ORDER BY created_at DESC';
const stmt = db.prepare(query);
return params.length ? stmt.all(...params) : stmt.all();
}
export function getById(id) {
const db = getDb();
const stmt = db.prepare('SELECT * FROM entries WHERE id = ?');
return stmt.get(id);
}
export function create(entry) {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO entries (name, description, image_url, notes, calories, protein, carbs, fat)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
entry.name,
entry.description || null,
entry.image_url || null,
entry.notes || null,
entry.calories || 0,
entry.protein || 0,
entry.carbs || 0,
entry.fat || 0
);
return { id: result.lastInsertRowid, ...entry };
}
export function remove(id) {
const db = getDb();
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
export function getDailyTotals(date) {
const db = getDb();
const stmt = db.prepare(`
SELECT
SUM(calories) as total_calories,
SUM(protein) as total_protein,
SUM(carbs) as total_carbs,
SUM(fat) as total_fat,
COUNT(*) as entry_count
FROM entries
WHERE date(created_at) = date(?)
`);
const result = stmt.get(date);
return {
date,
total_calories: result.total_calories || 0,
total_protein: result.total_protein || 0,
total_carbs: result.total_carbs || 0,
total_fat: result.total_fat || 0,
entry_count: result.entry_count || 0
};
}

17
server/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "meal-tracker-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.18.0",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"better-sqlite3": "^9.2.0",
"axios": "^1.6.0",
"dotenv": "^16.3.0"
}
}

120
server/routes/entries.js Normal file
View File

@@ -0,0 +1,120 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import 'dotenv/config';
import db from '../models/db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `${timestamp}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mime = allowedTypes.test(file.mimetype);
if (ext && mime) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
},
limits: {
fileSize: 5 * 1024 * 1024
}
});
// GET /api/entries
router.get('/', (req, res) => {
try {
const { date } = req.query;
const entries = db.getAll(date);
res.json(entries);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/entries/:id
router.get('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/entries
router.post('/', upload.single('image'), (req, res) => {
try {
const { name, description, notes, calories, protein, carbs, fat } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const image_url = req.file ? `/uploads/${req.file.filename}` : null;
const entry = db.create({
name,
description,
image_url,
notes,
calories: calories ? parseFloat(calories) : 0,
protein: protein ? parseFloat(protein) : 0,
carbs: carbs ? parseFloat(carbs) : 0,
fat: fat ? parseFloat(fat) : 0
});
res.status(201).json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/entries/:id
router.delete('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
if (entry.image_url) {
const imagePath = path.join(__dirname, '../../', entry.image_url);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
db.remove(req.params.id);
res.json({ message: 'Entry deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

53
server/routes/foods.js Normal file
View File

@@ -0,0 +1,53 @@
import express from 'express';
const router = express.Router();
const OPENFOODFACTS_API = 'https://world.openfoodfacts.org/cgi/search.pl';
// GET /api/foods/search?q=
router.get('/search', async (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.status(400).json({ error: 'Search query must be at least 2 characters' });
}
const params = new URLSearchParams({
search_terms: q,
search_simple: 1,
action: 'process',
json: 1,
page_size: 20,
fields: 'product_name,brands,nutriments,serving_size'
});
const response = await fetch(`${OPENFOODFACTS_API}?${params}`, {
headers: {
'User-Agent': 'MealTracker/1.0'
}
});
if (!response.ok) {
throw new Error(`OpenFoodFacts API error: ${response.status}`);
}
const data = await response.json();
const foods = (data.products || []).map(product => ({
name: product.product_name || 'Unknown',
brand: product.brands || null,
calories: product.nutriments?.['energy-kcal_100g'] ?? product.nutriments?.['energy-kcal'] ?? 0,
protein: product.nutriments?.proteins_100g ?? product.nutriments?.proteins ?? 0,
carbs: product.nutriments?.carbohydrates_100g ?? product.nutriments?.carbohydrates ?? 0,
fat: product.nutriments?.fat_100g ?? product.nutriments?.fat ?? 0,
serving_size: product.serving_size || '100g'
})).filter(f => f.name !== 'Unknown');
res.json(foods);
} catch (err) {
console.error('Food search error:', err);
res.status(500).json({ error: err.message });
}
});
export default router;

26
server/routes/summary.js Normal file
View File

@@ -0,0 +1,26 @@
import express from 'express';
import db from '../models/db.js';
const router = express.Router();
// GET /api/summary/daily?date=YYYY-MM-DD
router.get('/daily', (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date parameter is required (YYYY-MM-DD)' });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' });
}
const summary = db.getDailyTotals(date);
res.json(summary);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;