feat: Initial release of LOADOUT - Travel Manifest & Bag Allocation System
This commit is contained in:
commit
805f3fe97a
26 files changed
+7618
No files matched your search
+270
@@ -0,0 +1,270 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 80;
|
||||
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data');
|
||||
|
||||
// Storage file paths
|
||||
const TRIPS_FILE = path.join(DATA_DIR, 'trips.json');
|
||||
const MEMORY_FILE = path.join(DATA_DIR, 'memory.json');
|
||||
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
||||
|
||||
// Ensure storage directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Helpers for safe file I/O
|
||||
function readJson(file, defaultVal) {
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const data = fs.readFileSync(file, 'utf8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error reading ${file}:`, err);
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
function writeJson(file, data) {
|
||||
try {
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||
} catch (err) {
|
||||
console.error(`Error writing ${file}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default memory if empty
|
||||
if (!fs.existsSync(MEMORY_FILE)) {
|
||||
writeJson(MEMORY_FILE, {
|
||||
exclusions: [
|
||||
{ itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' },
|
||||
{ itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' },
|
||||
{ itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' },
|
||||
],
|
||||
inclusions: [
|
||||
{
|
||||
itemName: 'Packable laundry detergent sheets',
|
||||
category: 'clothing',
|
||||
defaultBag: 'carry_on',
|
||||
tripType: 'international_urban',
|
||||
count: 2,
|
||||
lastDate: '2026-06-20',
|
||||
reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts',
|
||||
},
|
||||
{
|
||||
itemName: 'Compact 3-prong outlet splitter',
|
||||
category: 'tech',
|
||||
defaultBag: 'personal_item',
|
||||
tripType: 'business',
|
||||
count: 2,
|
||||
lastDate: '2026-09-02',
|
||||
reason: 'Airport and hotel outlets are always limited or loose',
|
||||
},
|
||||
],
|
||||
totalTripsDebriefed: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Healthcheck endpoint for Docker & Traefik
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
app: 'loadout',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/trips
|
||||
app.get('/api/trips', (_req, res) => {
|
||||
const trips = readJson(TRIPS_FILE, []);
|
||||
res.json(trips);
|
||||
});
|
||||
|
||||
// POST /api/trips
|
||||
app.post('/api/trips', (req, res) => {
|
||||
const trip = req.body;
|
||||
if (!trip || !trip.id) {
|
||||
return res.status(400).json({ error: 'Invalid trip data' });
|
||||
}
|
||||
|
||||
const trips = readJson(TRIPS_FILE, []);
|
||||
const index = trips.findIndex((t) => t.id === trip.id);
|
||||
if (index >= 0) {
|
||||
trips[index] = trip;
|
||||
} else {
|
||||
trips.unshift(trip);
|
||||
}
|
||||
|
||||
writeJson(TRIPS_FILE, trips);
|
||||
res.json({ success: true, trip });
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:id
|
||||
app.delete('/api/trips/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
let trips = readJson(TRIPS_FILE, []);
|
||||
trips = trips.filter((t) => t.id !== id);
|
||||
writeJson(TRIPS_FILE, trips);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/memory
|
||||
app.get('/api/memory', (_req, res) => {
|
||||
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
|
||||
res.json(memory);
|
||||
});
|
||||
|
||||
// POST /api/memory/debrief
|
||||
app.post('/api/memory/debrief', (req, res) => {
|
||||
const { tripId, debrief } = req.body;
|
||||
if (!tripId || !debrief) {
|
||||
return res.status(400).json({ error: 'Missing tripId or debrief data' });
|
||||
}
|
||||
|
||||
const trips = readJson(TRIPS_FILE, []);
|
||||
const trip = trips.find((t) => t.id === tripId);
|
||||
if (!trip) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
trip.debrief = debrief;
|
||||
writeJson(TRIPS_FILE, trips);
|
||||
|
||||
// Update memory
|
||||
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
|
||||
memory.totalTripsDebriefed = (memory.totalTripsDebriefed || 0) + 1;
|
||||
|
||||
// Record deadweight exclusions
|
||||
if (Array.isArray(debrief.excludedItemNames)) {
|
||||
for (const itemName of debrief.excludedItemNames) {
|
||||
const existing = memory.exclusions.find(
|
||||
(e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.lastDate = new Date().toISOString();
|
||||
} else {
|
||||
memory.exclusions.push({
|
||||
itemName,
|
||||
tripType: trip.tripType,
|
||||
count: 1,
|
||||
lastDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record wishlist inclusions
|
||||
if (Array.isArray(debrief.includedItemNames)) {
|
||||
for (const itemName of debrief.includedItemNames) {
|
||||
const existing = memory.inclusions.find(
|
||||
(i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.lastDate = new Date().toISOString();
|
||||
} else {
|
||||
memory.inclusions.push({
|
||||
itemName,
|
||||
category: 'gear',
|
||||
defaultBag: 'carry_on',
|
||||
tripType: trip.tripType,
|
||||
count: 1,
|
||||
lastDate: new Date().toISOString(),
|
||||
reason: `Wished for after trip: ${trip.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(MEMORY_FILE, memory);
|
||||
res.json({ success: true, memory });
|
||||
});
|
||||
|
||||
// GET /api/settings
|
||||
app.get('/api/settings', (_req, res) => {
|
||||
const settings = readJson(SETTINGS_FILE, {
|
||||
soundEnabled: true,
|
||||
aiEnabled: false,
|
||||
aiProvider: 'ollama',
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
ollamaModel: 'llama3.2',
|
||||
apiKey: '',
|
||||
});
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
// POST /api/settings
|
||||
app.post('/api/settings', (req, res) => {
|
||||
const settings = req.body;
|
||||
writeJson(SETTINGS_FILE, settings);
|
||||
res.json({ success: true, settings });
|
||||
});
|
||||
|
||||
// POST /api/ai/suggest
|
||||
app.post('/api/ai/suggest', async (req, res) => {
|
||||
const { prompt, tripDetails } = req.body;
|
||||
const settings = readJson(SETTINGS_FILE, {});
|
||||
|
||||
if (!settings.aiEnabled) {
|
||||
return res.status(400).json({ error: 'AI features are disabled in settings' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (settings.aiProvider === 'ollama') {
|
||||
const ollamaUrl = (settings.ollamaUrl || 'http://localhost:11434').replace(/\/$/, '');
|
||||
const response = await fetch(`${ollamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: settings.ollamaModel || 'llama3.2',
|
||||
prompt: `You are a travel packing specialist. Based on this trip: ${JSON.stringify(tripDetails)}, suggest 3 to 5 hyper-specific, high-value packing items or travel wisdom tips. Respond in clear bullet points with concise reasoning.`,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama returned status ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return res.json({ suggestion: data.response });
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Configured AI provider not supported directly yet' });
|
||||
} catch (err) {
|
||||
console.error('AI suggest error:', err);
|
||||
return res.status(500).json({ error: 'Failed to communicate with AI provider: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve frontend static build in production
|
||||
const DIST_DIR = path.resolve(__dirname, '../dist');
|
||||
if (fs.existsSync(DIST_DIR)) {
|
||||
app.use(express.static(DIST_DIR));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/api')) {
|
||||
return res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[LOADOUT] Server listening on port ${PORT}`);
|
||||
console.log(`[LOADOUT] Data directory: ${DATA_DIR}`);
|
||||
});
|
||||
Reference in new issue
Block a user