feat: atlas country marking, bucket list, trip creation UX — closes #49

Atlas:
- Click any country to mark as visited or add to bucket list
- Bucket list with country flags, planned month/year, horizontal layout
- Confirm popup with two options (mark visited / bucket list)
- Full A2/A3 country code mapping for all countries

Trip creation:
- Drag & drop cover image support
- Add travel buddies via CustomSelect dropdown when creating a trip
- Manual date entry via double-click on date picker (supports DD.MM.YYYY, ISO, etc.)
This commit is contained in:
Maurice
2026-03-29 16:51:35 +02:00
parent 808b7f7a72
commit 8458481950
7 changed files with 582 additions and 23 deletions

View File

@@ -264,6 +264,27 @@ function runMigrations(db: Database.Database): void {
try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {}
try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS visited_countries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, country_code)
)`);
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS bucket_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
lat REAL,
lng REAL,
country_code TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -153,6 +153,14 @@ router.get('/stats', (req: Request, res: Response) => {
}
const totalCities = citySet.size;
// Merge manually marked countries
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
for (const mc of manualCountries) {
if (!countries.find(c => c.code === mc.country_code)) {
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
}
}
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
const continents: Record<string, number> = {};
@@ -239,7 +247,57 @@ router.get('/country/:code', (req: Request, res: Response) => {
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
res.json({ places: matchingPlaces, trips: matchingTrips });
const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code));
res.json({ places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked });
});
// Mark/unmark country as visited
router.post('/country/:code/mark', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(authReq.user.id, req.params.code.toUpperCase());
res.json({ success: true });
});
router.delete('/country/:code/mark', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(authReq.user.id, req.params.code.toUpperCase());
res.json({ success: true });
});
// ── Bucket List ─────────────────────────────────────────────────────────────
router.get('/bucket-list', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const items = db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(authReq.user.id);
res.json({ items });
});
router.post('/bucket-list', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, lat, lng, country_code, notes } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
);
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
});
router.put('/bucket-list/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, notes } = req.body;
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
});
router.delete('/bucket-list/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
export default router;