mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
068b90ed72
## Collab — Complete Redesign - iMessage-style live chat with blue bubbles, grouped messages, date separators - Emoji reactions via right-click (desktop) or double-tap (mobile) - Twemoji (Apple-style) emoji picker with categories - Link previews with OG image/title/description - Soft-delete messages with "deleted a message" placeholder - Message reactions with real-time WebSocket sync - Chat timestamps respect 12h/24h setting and timezone ## Collab Notes - Redesigned note cards with colored header bar (booking-card style) - 2-column grid layout (desktop), 1-column (mobile) - Category settings modal for managing categories with colors - File/image attachments on notes with mini-preview thumbnails - Website links with OG image preview on note cards - File preview portal (lightbox for images, inline viewer for PDF/TXT) - Note files appear in Files tab with "From Collab Notes" badge - Pin highlighting with tinted background - Author avatar chip in header bar with custom tooltip ## Collab Polls - Complete rewrite — clean Apple-style poll cards - Animated progress bars with vote percentages - Blue check circles for own votes, voter avatars - Create poll modal with multi-choice toggle - Active/closed poll sections - Custom tooltips on voter chips ## What's Next Widget - New widget showing upcoming trip activities - Time display with "until" separator - Participant chips per activity - Day grouping (Today, Tomorrow, dates) - Respects 12h/24h and locale settings ## Route Travel Times - Auto-calculated walking + driving times via OSRM (free, no API key) - Floating badge on each route segment between places - Walking person icon + car icon with times - Hides when zoomed out (< zoom 16) - Toggle in Settings > Display to enable/disable ## Other Improvements - Collab addon enabled by default for new installations - Coming Soon removed from Collab in admin settings - Tab state persisted across page reloads (sessionStorage) - Day sidebar expanded/collapsed state persisted - File preview with extension badges (PDF, TXT, etc.) in Files tab - Collab Notes filter tab in Files - Reservations section in Day Detail view - Dark mode fix for invite button text color - Chat scroll hidden (no visible scrollbar) - Mobile: tab icons removed for space, touch-friendly UI - Fixed 6 backend data structure bugs in Collab (polls, chat, notes) - Soft-delete for chat messages (persists in history) - Message reactions table (migration 28) - Note attachments via trip_files with note_id (migration 30) ## Database Migrations - Migration 27: budget_item_members table - Migration 28: collab_message_reactions table - Migration 29: soft-delete column on collab_messages - Migration 30: note_id on trip_files, website on collab_notes
118 lines
3.5 KiB
JavaScript
118 lines
3.5 KiB
JavaScript
// OSRM routing utility - free, no API key required
|
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
|
|
|
/**
|
|
* Calculate a route between multiple waypoints using OSRM
|
|
* @param {Array<{lat: number, lng: number}>} waypoints
|
|
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
|
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
|
*/
|
|
export async function calculateRoute(waypoints, profile = 'driving') {
|
|
if (!waypoints || waypoints.length < 2) {
|
|
throw new Error('At least 2 waypoints required')
|
|
}
|
|
|
|
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
|
// OSRM public API only supports driving; we override duration for other modes
|
|
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
|
|
|
const response = await fetch(url)
|
|
if (!response.ok) {
|
|
throw new Error('Route could not be calculated')
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
|
throw new Error('No route found')
|
|
}
|
|
|
|
const route = data.routes[0]
|
|
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
|
|
|
|
const distance = route.distance // meters
|
|
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
|
|
let duration
|
|
if (profile === 'walking') {
|
|
duration = distance / (5000 / 3600)
|
|
} else if (profile === 'cycling') {
|
|
duration = distance / (15000 / 3600)
|
|
} else {
|
|
duration = route.duration // driving: use OSRM value
|
|
}
|
|
|
|
const walkingDuration = distance / (5000 / 3600) // 5 km/h
|
|
const drivingDuration = route.duration // OSRM driving value
|
|
|
|
return {
|
|
coordinates,
|
|
distance,
|
|
duration,
|
|
distanceText: formatDistance(distance),
|
|
durationText: formatDuration(duration),
|
|
walkingText: formatDuration(walkingDuration),
|
|
drivingText: formatDuration(drivingDuration),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a Google Maps directions URL for the given places
|
|
*/
|
|
export function generateGoogleMapsUrl(places) {
|
|
const valid = places.filter(p => p.lat && p.lng)
|
|
if (valid.length === 0) return null
|
|
if (valid.length === 1) {
|
|
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
|
|
}
|
|
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
|
|
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
|
|
return `https://www.google.com/maps/dir/${stops}`
|
|
}
|
|
|
|
/**
|
|
* Simple nearest-neighbor route optimization
|
|
*/
|
|
export function optimizeRoute(places) {
|
|
const valid = places.filter(p => p.lat && p.lng)
|
|
if (valid.length <= 2) return places
|
|
|
|
const visited = new Set()
|
|
const result = []
|
|
let current = valid[0]
|
|
visited.add(0)
|
|
result.push(current)
|
|
|
|
while (result.length < valid.length) {
|
|
let nearestIdx = -1
|
|
let minDist = Infinity
|
|
for (let i = 0; i < valid.length; i++) {
|
|
if (visited.has(i)) continue
|
|
const d = Math.sqrt(
|
|
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
|
|
)
|
|
if (d < minDist) { minDist = d; nearestIdx = i }
|
|
}
|
|
if (nearestIdx === -1) break
|
|
visited.add(nearestIdx)
|
|
current = valid[nearestIdx]
|
|
result.push(current)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function formatDistance(meters) {
|
|
if (meters < 1000) {
|
|
return `${Math.round(meters)} m`
|
|
}
|
|
return `${(meters / 1000).toFixed(1)} km`
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0) {
|
|
return `${h} h ${m} min`
|
|
}
|
|
return `${m} min`
|
|
}
|