Merge branch 'dev' into test

This commit is contained in:
Marek Maslowski
2026-04-03 22:28:29 +02:00
committed by GitHub
41 changed files with 340 additions and 139 deletions

View File

@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

108
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: Bug Report
description: Create a report to help us improve TREK
title: "[BUG] "
labels: []
body:
- type: checkboxes
id: preflight
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- type: input
id: version
attributes:
label: TREK version
description: Found in the Settings → About, or in the Docker image tag
placeholder: "e.g. 2.8.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: When I do X, Y happens instead of Z…
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to reliably trigger the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment method
options:
- Docker Compose
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Sources
- Other
validations:
required: true
- type: input
id: os
attributes:
label: Host OS
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
- type: dropdown
id: user_os
attributes:
label: Accessing TREK from
options:
- Desktop browser
- Mobile browser
- Mobile app (PWA)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
- type: textarea
id: logs
attributes:
label: Relevant logs or error output
description: Paste any relevant server or browser console output here.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Drag and drop screenshots here if applicable.
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might help us understand the issue.

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -19,7 +19,7 @@ jobs:
script: |
const title = context.payload.issue.title.trim();
const badTitles = [
"[BUG]",
"[bug]",
"bug report",
"bug",
"issue",
@@ -64,4 +64,4 @@ jobs:
state: "closed",
state_reason: "not_planned"
});
}
}

View File

@@ -11,6 +11,9 @@ spec:
app: {{ include "trek.name" . }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
app: {{ include "trek.name" . }}
spec:

View File

@@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: string): Promise<string>
return url
}
}
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
const MAX_CONCURRENT = 6
let active = 0
const queue: Array<() => void> = []
function dequeue() {
while (active < MAX_CONCURRENT && queue.length > 0) {
active++
queue.shift()!()
}
}
export function clearImageQueue() {
queue.length = 0
}
export async function fetchImageAsBlob(url: string): Promise<string> {
if (!url) return ''
return new Promise<string>((resolve) => {
const run = async () => {
try {
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) { resolve(''); return }
const blob = await resp.blob()
resolve(URL.createObjectURL(blob))
} catch {
resolve('')
} finally {
active--
dequeue()
}
}
if (active < MAX_CONCURRENT) {
active++
run()
} else {
queue.push(run)
}
})
}

View File

@@ -489,7 +489,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const d = currencyDecimals(currency)
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) }
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
const rows = [header.join(sep)]

View File

@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
interface TripMember {

View File

@@ -3,7 +3,7 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi
import apiClient, { addonsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
interface PhotoProvider {
@@ -16,8 +16,13 @@ interface PhotoProvider {
function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
const [src, setSrc] = useState('')
useEffect(() => {
getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {})
}, [baseUrl, provider])
let revoke = ''
fetchImageAsBlob(baseUrl).then(blobUrl => {
revoke = blobUrl
setSrc(blobUrl)
})
return () => { if (revoke) URL.revokeObjectURL(revoke) }
}, [baseUrl])
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
}
@@ -296,6 +301,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
shared: true,
})
setShowPicker(false)
clearImageQueue()
loadInitial()
} catch { toast.error(t('memories.error.addPhotos')) }
}
@@ -500,7 +506,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</h3>
<ProviderTabs />
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowPicker(false)}
<button onClick={() => { clearImageQueue(); setShowPicker(false) }}
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
@@ -769,9 +775,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {})
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`)
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
@@ -879,12 +886,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{/* Lightbox */}
{lightboxId && lightboxUserId && (
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',

View File

@@ -61,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
const f = new Date(dd[0].date + 'T00:00:00Z')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
}
function dayCost(assignments, dayId, locale) {

View File

@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}

View File

@@ -154,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long' }
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
) : null
const placesWithCoords = places.filter(p => p.lat && p.lng)
@@ -445,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>
@@ -457,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>

View File

@@ -743,7 +743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' ')}
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
@@ -1671,7 +1671,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.reservation_time?.includes('T')
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
: res.reservation_time
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: ''
}
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}

View File

@@ -373,7 +373,7 @@ export default function PlaceInspector({
{res.reservation_time && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{res.reservation_time?.includes('T') && (

View File

@@ -424,7 +424,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>

View File

@@ -572,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}

View File

@@ -84,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
}
const fmtDate = (str) => {
const d = new Date(str)
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const dateOnly = str.includes('T') ? str.split('T')[0] : str
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const fmtTime = (str) => {
const d = new Date(str)

View File

@@ -197,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (!prev.end_date || prev.end_date < value) {
next.end_date = value
} else if (prev.start_date) {
const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00')
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
const duration = Math.round((oldEnd - oldStart) / 86400000)
const newEnd = new Date(value + 'T00:00:00')
const newEnd = new Date(value + 'T00:00:00Z')
newEnd.setDate(newEnd.getDate() + duration)
next.end_date = newEnd.toISOString().split('T')[0]
}

View File

@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
}
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00')
return weekendDays.includes(d.getDay())
const d = new Date(dateStr + 'T00:00:00Z')
return weekendDays.includes(d.getUTCDay())
}
export function getWeekday(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
}
export function getWeekdayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
}
export function daysInMonth(year: number, month: number): number {
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
}
export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
}
export { BUNDESLAENDER }

View File

@@ -21,9 +21,9 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
const parsed = value ? new Date(value + 'T00:00:00Z') : null
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -36,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
}, [open])
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
}, [open])
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
@@ -47,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }) : null
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
const selectDay = (day: number) => {
const y = String(viewYear)
@@ -57,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
setOpen(false)
}
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
const today = new Date()
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d

View File

@@ -598,7 +598,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
'vacay.settings': 'الإعدادات',
'vacay.year': 'السنة',
'vacay.addYear': 'إضافة سنة',
'vacay.addYear': 'إضافة السنة التالية',
'vacay.addPrevYear': 'إضافة السنة السابقة',
'vacay.removeYear': 'إزالة السنة',
'vacay.removeYearConfirm': 'إزالة {year}؟',
'vacay.removeYearHint': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.',

View File

@@ -579,7 +579,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Planeje e gerencie dias de férias',
'vacay.settings': 'Configurações',
'vacay.year': 'Ano',
'vacay.addYear': 'Adicionar ano',
'vacay.addYear': 'Adicionar próximo ano',
'vacay.addPrevYear': 'Adicionar ano anterior',
'vacay.removeYear': 'Remover ano',
'vacay.removeYearConfirm': 'Remover {year}?',
'vacay.removeYearHint': 'Todas as entradas de férias e feriados da empresa deste ano serão excluídas permanentemente.',

View File

@@ -597,7 +597,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení',
'vacay.year': 'Rok',
'vacay.addYear': 'Přidat rok',
'vacay.addYear': 'Přidat následující rok',
'vacay.addPrevYear': 'Přidat předchozí rok',
'vacay.removeYear': 'Odebrat rok',
'vacay.removeYearConfirm': 'Odebrat rok {year}?',
'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',

View File

@@ -595,7 +595,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr',
'vacay.addYear': 'Jahr hinzufügen',
'vacay.addYear': 'Nächstes Jahr hinzufügen',
'vacay.addPrevYear': 'Vorheriges Jahr hinzufügen',
'vacay.removeYear': 'Jahr entfernen',
'vacay.removeYearConfirm': '{year} entfernen?',
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',

View File

@@ -592,7 +592,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
'vacay.addYear': 'Add year',
'vacay.addYear': 'Add next year',
'vacay.addPrevYear': 'Add previous year',
'vacay.removeYear': 'Remove year',
'vacay.removeYearConfirm': 'Remove {year}?',
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',

View File

@@ -572,7 +572,8 @@ const es: Record<string, string> = {
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
'vacay.settings': 'Ajustes',
'vacay.year': 'Año',
'vacay.addYear': 'Añadir año',
'vacay.addYear': 'Añadir año siguiente',
'vacay.addPrevYear': 'Añadir año anterior',
'vacay.removeYear': 'Eliminar año',
'vacay.removeYearConfirm': '¿Eliminar {year}?',
'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',

View File

@@ -594,7 +594,8 @@ const fr: Record<string, string> = {
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
'vacay.addYear': 'Ajouter une année',
'vacay.addYear': 'Ajouter l\'année suivante',
'vacay.addPrevYear': 'Ajouter l\'année précédente',
'vacay.removeYear': 'Supprimer l\'année',
'vacay.removeYearConfirm': 'Supprimer {year} ?',
'vacay.removeYearHint': 'Toutes les entrées de vacances et jours fériés d\'entreprise de cette année seront définitivement supprimés.',

View File

@@ -595,7 +595,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
'vacay.settings': 'Beállítások',
'vacay.year': 'Év',
'vacay.addYear': 'Év hozzáadása',
'vacay.addYear': 'Következő év hozzáadása',
'vacay.addPrevYear': 'Előző év hozzáadása',
'vacay.removeYear': 'Év eltávolítása',
'vacay.removeYearConfirm': '{year} eltávolítása?',
'vacay.removeYearHint': 'Az adott év összes szabadság-bejegyzése és céges szabadnapja véglegesen törlődik.',

View File

@@ -595,7 +595,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno',
'vacay.addYear': 'Aggiungi anno',
'vacay.addYear': 'Aggiungi anno successivo',
'vacay.addPrevYear': 'Aggiungi anno precedente',
'vacay.removeYear': 'Rimuovi anno',
'vacay.removeYearConfirm': 'Rimuovere {year}?',
'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',

View File

@@ -594,7 +594,8 @@ const nl: Record<string, string> = {
'vacay.subtitle': 'Plan en beheer vakantiedagen',
'vacay.settings': 'Instellingen',
'vacay.year': 'Jaar',
'vacay.addYear': 'Jaar toevoegen',
'vacay.addYear': 'Volgend jaar toevoegen',
'vacay.addPrevYear': 'Vorig jaar toevoegen',
'vacay.removeYear': 'Jaar verwijderen',
'vacay.removeYearConfirm': '{year} verwijderen?',
'vacay.removeYearHint': 'Alle vakantie-invoeren en bedrijfsvakanties voor dit jaar worden permanent verwijderd.',

View File

@@ -558,7 +558,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Planuj i zarządzaj dniami urlopu',
'vacay.settings': 'Ustawienia',
'vacay.year': 'Rok',
'vacay.addYear': 'Dodaj rok',
'vacay.addYear': 'Dodaj następny rok',
'vacay.addPrevYear': 'Dodaj poprzedni rok',
'vacay.removeYear': 'Usuń rok',
'vacay.removeYearConfirm': 'Usunąć {year}?',
'vacay.removeYearHint': 'Wszystkie wpisy dotyczące urlopów oraz dni wolnych w tym roku zostaną trwale usunięte.',

View File

@@ -594,7 +594,8 @@ const ru: Record<string, string> = {
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
'vacay.settings': 'Настройки',
'vacay.year': 'Год',
'vacay.addYear': 'Добавить год',
'vacay.addYear': 'Добавить следующий год',
'vacay.addPrevYear': 'Добавить предыдущий год',
'vacay.removeYear': 'Удалить год',
'vacay.removeYearConfirm': 'Удалить {year}?',
'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.',

View File

@@ -594,7 +594,8 @@ const zh: Record<string, string> = {
'vacay.subtitle': '规划和管理假期',
'vacay.settings': '设置',
'vacay.year': '年份',
'vacay.addYear': '添加年',
'vacay.addYear': '添加下一年',
'vacay.addPrevYear': '添加上一年',
'vacay.removeYear': '移除年份',
'vacay.removeYearConfirm': '移除 {year}',
'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。',

View File

@@ -59,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
}
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {

View File

@@ -106,7 +106,7 @@ export default function SharedTripPage() {
{(trip.start_date || trip.end_date) && (
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).join(' — ')}
</span>
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
@@ -199,7 +199,7 @@ export default function SharedTripPage() {
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
</div>
{dayAccs.map((acc: any) => (
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
@@ -274,7 +274,7 @@ export default function SharedTripPage() {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>

View File

@@ -41,11 +41,16 @@ export default function VacayPage(): React.ReactElement {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
}, [selectedYear])
const handleAddYear = () => {
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
}
const handleAddPrevYear = () => {
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
addYear(prevYear)
}
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement {
<>
{/* Year Selector */}
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
<div className="flex items-center justify-between mb-2">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}>
<Plus size={14} />
</button>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-1">
{years.map(y => (

View File

@@ -222,7 +222,14 @@ export const useVacayStore = create<VacayState>((set, get) => ({
removeYear: async (year: number) => {
const data = await api.removeYear(year)
set({ years: data.years })
const updates: Partial<VacayState> = { years: data.years }
if (get().selectedYear === year) {
updates.selectedYear = data.years.length > 0
? data.years[data.years.length - 1]
: new Date().getFullYear()
}
set(updates)
await get().loadStats()
},
loadEntries: async (year?: number) => {
@@ -240,6 +247,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
toggleCompanyHoliday: async (date: string) => {
await api.toggleCompanyHoliday(date)
await get().loadEntries()
await get().loadStats()
},
loadStats: async (year?: number) => {

View File

@@ -10,9 +10,9 @@ export function formatDate(dateStr: string | null | undefined, locale: string, t
if (!dateStr) return null
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
timeZone: timeZone || 'UTC',
}
if (timeZone) opts.timeZone = timeZone
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {

View File

@@ -496,6 +496,30 @@ export function deleteYear(planId: number, year: number, socketId: string | unde
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare('DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?').run(planId, year);
// Recalculate carry-over for year+1 if it exists, since its previous year has changed
const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1);
if (nextYearExists) {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = getPlanUsers(planId);
const prevYear = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1').get(planId, year + 1) as { year: number } | undefined;
for (const u of users) {
let carry = 0;
if (carryOverEnabled && prevYear) {
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined;
if (prevConfig) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${prevYear.year}-%`) as { count: number }).count;
const total = prevConfig.vacation_days + prevConfig.carried_over;
carry = Math.max(0, total - used);
}
}
db.prepare('UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?').run(carry, u.id, planId, year + 1);
}
}
notifyPlanUsers(planId, socketId, 'vacay:settings');
return listYears(planId);
}

View File

@@ -21,10 +21,41 @@
<DonateText>Support TREK development</DonateText>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<Requires/>
<!-- Ports & Volumes -->
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="Port for the web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="Data" Target="/app/data" Default="/mnt/user/appdata/trek/data" Mode="rw" Description="Database and app data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/data</Config>
<Config Name="Uploads" Target="/app/uploads" Default="/mnt/user/appdata/trek/uploads" Mode="rw" Description="Uploaded files (photos, documents)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/trek/uploads</Config>
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment" Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
<Config Name="JWT_SECRET" Target="JWT_SECRET" Default="" Mode="" Description="JWT secret key (auto-generated if empty)" Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="PORT" Target="PORT" Default="3000" Mode="" Description="Internal port" Type="Variable" Display="advanced" Required="false" Mask="false">3000</Config>
<!-- Core -->
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs)." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="PORT" Target="PORT" Default="3000" Mode="" Description="Internal server port (must match the container port mapping above)." Type="Variable" Display="advanced" Required="false" Mask="false">3000</Config>
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment (production / development)." Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
<Config Name="TZ" Target="TZ" Default="UTC" Mode="" Description="Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)." Type="Variable" Display="always" Required="false" Mask="false">UTC</Config>
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Redirect HTTP to HTTPS when TREK is behind a TLS-terminating reverse proxy." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Set to false to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Not recommended to disable in production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Number of trusted reverse proxies for X-Forwarded-For IP detection." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<!-- Initial Setup -->
<Config Name="ADMIN_EMAIL" Target="ADMIN_EMAIL" Default="admin@trek.local" Mode="" Description="Email for the first admin account created on initial boot. Has no effect once any user exists." Type="Variable" Display="always" Required="false" Mask="false">admin@trek.local</Config>
<Config Name="ADMIN_PASSWORD" Target="ADMIN_PASSWORD" Default="" Mode="" Description="Password for the first admin account created on initial boot. If omitted a random password is generated and printed to the server log. Has no effect once any user exists." Type="Variable" Display="always" Required="false" Mask="true"/>
<!-- OIDC / SSO -->
<Config Name="OIDC_ISSUER" Target="OIDC_ISSUER" Default="" Mode="" Description="OpenID Connect provider URL (e.g. https://auth.example.com)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_CLIENT_ID" Target="OIDC_CLIENT_ID" Default="" Mode="" Description="OIDC client ID registered with your identity provider." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_CLIENT_SECRET" Target="OIDC_CLIENT_SECRET" Default="" Mode="" Description="OIDC client secret registered with your identity provider." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="OIDC_DISPLAY_NAME" Target="OIDC_DISPLAY_NAME" Default="SSO" Mode="" Description="Label shown on the SSO login button." Type="Variable" Display="advanced" Required="false" Mask="false">SSO</Config>
<Config Name="OIDC_ONLY" Target="OIDC_ONLY" Default="false" Mode="" Description="Set to true to disable local password auth entirely (SSO only). First SSO login becomes admin." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="OIDC_ADMIN_CLAIM" Target="OIDC_ADMIN_CLAIM" Default="" Mode="" Description="OIDC claim used to identify admin users (e.g. groups)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_ADMIN_VALUE" Target="OIDC_ADMIN_VALUE" Default="" Mode="" Description="Value of the OIDC claim that grants admin role (e.g. app-trek-admins)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_SCOPE" Target="OIDC_SCOPE" Default="openid email profile groups" Mode="" Description="Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM." Type="Variable" Display="advanced" Required="false" Mask="false">openid email profile groups</Config>
<Config Name="OIDC_DISCOVERY_URL" Target="OIDC_DISCOVERY_URL" Default="" Mode="" Description="Override the auto-constructed OIDC discovery endpoint. Useful for providers with a non-standard path (e.g. Authentik)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<!-- Other -->
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
</Container>