mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beb48af8ed | |||
| e2be3ec191 | |||
| 68a1f9683e | |||
| 5c57116a68 | |||
| 48508b9df4 | |||
| c8250256a7 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -341,14 +341,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
initTransportPositions(dayId)
|
initTransportPositions(dayId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
|
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||||
// Places keep their order_index ordering — only transports are inserted based on time.
|
|
||||||
const baseItems = [
|
const baseItems = [
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
// Only transports are inserted among base items based on time/position
|
// Transports are inserted among places based on time
|
||||||
const timedTransports = transport.map(r => ({
|
const timedTransports = transport.map(r => ({
|
||||||
type: 'transport' as const,
|
type: 'transport' as const,
|
||||||
data: r,
|
data: r,
|
||||||
@@ -360,22 +359,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert transports among base items using persisted position or time-to-position mapping.
|
// Insert transports among places based on per-day position or time
|
||||||
const result = [...baseItems]
|
const result = [...baseItems]
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
const timed = timedTransports[ti]
|
const timed = timedTransports[ti]
|
||||||
const minutes = timed.minutes
|
const minutes = timed.minutes
|
||||||
|
|
||||||
// Use per-day position if available, fallback to global position
|
// Use per-day position if explicitly set by user reorder
|
||||||
const dayObj = days.find(d => d.id === dayId)
|
|
||||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
const effectivePos = perDayPos ?? timed.data.day_plan_position
|
if (perDayPos != null) {
|
||||||
if (effectivePos != null) {
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
result.push({ type: timed.type, sortKey: effectivePos, data: timed.data })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find insertion position: after the last base item with time <= this transport's time
|
// Find insertion position: after the last place with time <= this transport's time
|
||||||
let insertAfterKey = -Infinity
|
let insertAfterKey = -Infinity
|
||||||
for (const item of result) {
|
for (const item of result) {
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
|
|||||||
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match feature by ISO code OR region name
|
// Match feature by ISO code OR region name (native or English)
|
||||||
const isVisitedFeature = (f: any) => {
|
const isVisitedFeature = (f: any) => {
|
||||||
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||||
const name = (f.properties?.name || '').toLowerCase()
|
const name = (f.properties?.name || '').toLowerCase()
|
||||||
if (visitedRegionNames.has(name)) return true
|
if (visitedRegionNames.has(name)) return true
|
||||||
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
const nameEn = (f.properties?.name_en || '').toLowerCase()
|
||||||
for (const vn of visitedRegionNames) {
|
if (nameEn && visitedRegionNames.has(nameEn)) return true
|
||||||
if (name.includes(vn) || vn.includes(name)) return true
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
},
|
},
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
const regionName = feature?.properties?.name || ''
|
const regionName = feature?.properties?.name || ''
|
||||||
|
const regionNameEn = feature?.properties?.name_en || ''
|
||||||
const countryName = feature?.properties?.admin || ''
|
const countryName = feature?.properties?.admin || ''
|
||||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
const regionCode = feature?.properties?.iso_3166_2 || ''
|
||||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||||
const visited = isVisitedFeature(feature)
|
const visited = isVisitedFeature(feature)
|
||||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
|
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
|
||||||
layer.on('click', () => {
|
layer.on('click', () => {
|
||||||
if (!countryA2) return
|
if (!countryA2) return
|
||||||
if (visited) {
|
if (visited) {
|
||||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
|
||||||
if (regionEntry?.manuallyMarked) {
|
if (regionEntry?.manuallyMarked) {
|
||||||
setConfirmActionRef.current({
|
setConfirmActionRef.current({
|
||||||
type: 'unmark-region',
|
type: 'unmark-region',
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.4",
|
"version": "2.9.7",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
|
|||||||
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
|
|||||||
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
||||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||||
.run(placeTime ?? null, endTime ?? null, id);
|
.run(placeTime ?? null, endTime ?? null, id);
|
||||||
|
|
||||||
|
// Auto-sort: reorder timed assignments chronologically within the day
|
||||||
|
if (placeTime) {
|
||||||
|
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
|
||||||
|
if (assignment) {
|
||||||
|
const dayAssignments = db.prepare(`
|
||||||
|
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
|
||||||
|
FROM day_assignments da
|
||||||
|
JOIN places p ON da.place_id = p.id
|
||||||
|
WHERE da.day_id = ?
|
||||||
|
ORDER BY da.order_index ASC
|
||||||
|
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
|
||||||
|
|
||||||
|
// Separate timed and untimed, sort timed by time
|
||||||
|
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
|
||||||
|
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
|
||||||
|
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
|
||||||
|
return ta.localeCompare(tb);
|
||||||
|
});
|
||||||
|
const untimed = dayAssignments.filter(a => !a.effective_time);
|
||||||
|
|
||||||
|
// Interleave: timed in chronological order, untimed keep relative position
|
||||||
|
const reordered = [...timed, ...untimed];
|
||||||
|
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||||
|
reordered.forEach((a, i) => update.run(i, a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return getAssignmentWithPlace(Number(id));
|
return getAssignmentWithPlace(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
|
|||||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||||
}
|
}
|
||||||
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
|
||||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||||
const info: RegionInfo = {
|
const info: RegionInfo = {
|
||||||
country_code: countryCode,
|
country_code: countryCode,
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
|
|||||||
await pipeline(Readable.fromWeb(resp.body as any), response);
|
await pipeline(Readable.fromWeb(resp.body as any), response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (response.headersSent) return;
|
if (response.headersSent) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error instanceof SsrfBlockedError) {
|
if (error instanceof SsrfBlockedError) {
|
||||||
response.status(400).json({ error: error.message });
|
response.status(400).json({ error: error.message });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Mark/unmark region', () => {
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Bayern', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('regions');
|
||||||
|
const deRegions = res.body.regions['DE'] as any[];
|
||||||
|
expect(deRegions).toBeDefined();
|
||||||
|
const codes = deRegions.map((r: any) => r.code);
|
||||||
|
expect(codes).toContain('DE-NW');
|
||||||
|
expect(codes).toContain('DE-BY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const del = await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
expect(del.body.success).toBe(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||||
|
const codes = (deRegions || []).map((r: any) => r.code);
|
||||||
|
expect(codes).not.toContain('DE-NW');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).not.toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ name: 'Bayern', country_code: 'DE' });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const stats = await request(app)
|
||||||
|
.get('/api/addons/atlas/stats')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||||
|
expect(codes).toContain('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ATLAS-011 — regions are isolated between users', async () => {
|
||||||
|
const { user: user1 } = createUser(testDb);
|
||||||
|
const { user: user2 } = createUser(testDb);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||||
|
.set('Cookie', authCookie(user1.id))
|
||||||
|
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/addons/atlas/regions')
|
||||||
|
.set('Cookie', authCookie(user2.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||||
|
expect(deRegions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user