Meal Tracker v1 - complete
This commit is contained in:
3
server/.env
Normal file
3
server/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
UPLOAD_DIR=../uploads
|
||||
DB_PATH=../data/meal-tracker.db
|
||||
47
server/index.js
Normal file
47
server/index.js
Normal 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;
|
||||
42
server/middleware/upload.js
Normal file
42
server/middleware/upload.js
Normal 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
119
server/models/db.js
Normal 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
17
server/package.json
Normal 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
120
server/routes/entries.js
Normal 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
53
server/routes/foods.js
Normal 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
26
server/routes/summary.js
Normal 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;
|
||||
Reference in New Issue
Block a user