mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
247433fb2a
* fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml.
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
|
import { fetchWeather } from '../../services/weatherQueue'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
|
|
const WEATHER_ICON_MAP = {
|
|
Clear: Sun,
|
|
Clouds: Cloud,
|
|
Rain: CloudRain,
|
|
Drizzle: CloudDrizzle,
|
|
Thunderstorm: CloudLightning,
|
|
Snow: CloudSnow,
|
|
Mist: Wind,
|
|
Fog: Wind,
|
|
Haze: Wind,
|
|
}
|
|
|
|
interface WeatherIconProps {
|
|
main: string
|
|
size?: number
|
|
}
|
|
|
|
function WeatherIcon({ main, size = 13 }: WeatherIconProps) {
|
|
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
|
return <Icon size={size} strokeWidth={1.8} />
|
|
}
|
|
|
|
function getWeatherCache(key) {
|
|
try {
|
|
const raw = sessionStorage.getItem(key)
|
|
if (raw === null) return undefined
|
|
return JSON.parse(raw)
|
|
} catch { return undefined }
|
|
}
|
|
|
|
function setWeatherCache(key, value) {
|
|
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
|
}
|
|
|
|
interface WeatherWidgetProps {
|
|
lat: number | null
|
|
lng: number | null
|
|
date: string
|
|
compact?: boolean
|
|
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
|
|
stacked?: boolean
|
|
}
|
|
|
|
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
|
|
const [weather, setWeather] = useState(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [failed, setFailed] = useState(false)
|
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
|
|
|
useEffect(() => {
|
|
if (!lat || !lng || !date) return
|
|
const rLat = Math.round(lat * 100) / 100
|
|
const rLng = Math.round(lng * 100) / 100
|
|
const cacheKey = `weather_${rLat}_${rLng}_${date}`
|
|
const cached = getWeatherCache(cacheKey)
|
|
if (cached !== undefined) {
|
|
if (cached === null) setFailed(true)
|
|
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
|
else if (cached.type === 'climate') {
|
|
setWeather(cached)
|
|
fetchWeather(lat, lng, date)
|
|
.then(data => {
|
|
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
|
setWeatherCache(cacheKey, data)
|
|
setWeather(data)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
return
|
|
} else {
|
|
setWeather(cached)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
setLoading(true)
|
|
fetchWeather(lat, lng, date)
|
|
.then(data => {
|
|
if (data.error || data.temp === undefined) {
|
|
setFailed(true)
|
|
} else {
|
|
setWeatherCache(cacheKey, data)
|
|
setWeather(data)
|
|
}
|
|
})
|
|
.catch(() => { setFailed(true) })
|
|
.finally(() => setLoading(false))
|
|
}, [lat, lng, date])
|
|
|
|
if (!lat || !lng) return null
|
|
|
|
const fontStyle = { fontFamily: "var(--font-system)" }
|
|
|
|
if (loading) {
|
|
return (
|
|
<span style={{ fontSize: 11, color: '#d1d5db', ...fontStyle }}>…</span>
|
|
)
|
|
}
|
|
|
|
if (failed || !weather) {
|
|
return (
|
|
<span style={{ fontSize: 11, color: '#9ca3af', ...fontStyle }}>—</span>
|
|
)
|
|
}
|
|
|
|
const rawTemp = weather.temp
|
|
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
|
const unit = isFahrenheit ? '°F' : '°C'
|
|
const isClimate = weather.type === 'climate'
|
|
|
|
if (stacked) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
|
<WeatherIcon main={weather.main} size={13} />
|
|
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (compact) {
|
|
return (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
|
<WeatherIcon main={weather.main} size={12} />
|
|
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
|
<WeatherIcon main={weather.main} size={15} />
|
|
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
|
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
|
</div>
|
|
)
|
|
}
|