From 217458da81b540651b6916e3ce8b4c5d5feea664 Mon Sep 17 00:00:00 2001 From: Dario Ackermann Date: Fri, 3 Apr 2026 17:34:13 +0200 Subject: [PATCH 1/7] chore(helm): add config/secret checksum to deployment --- chart/templates/deployment.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 6a5e355..0ab074b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -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: From f6faaa23b0f29176249003e37f6c58dcf076c845 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 3 Apr 2026 19:24:36 +0200 Subject: [PATCH 2/7] fix(vacay): reset selectedYear when the active year is deleted When deleting the currently selected year, selectedYear was never cleared, leaving the deleted year shown as active in the UI. Now resets to the latest remaining year, or the current calendar year if all years have been removed. Fixes #369 --- client/src/store/vacayStore.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index f1d7ef7..73fb0c6 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -222,7 +222,13 @@ export const useVacayStore = create((set, get) => ({ removeYear: async (year: number) => { const data = await api.removeYear(year) - set({ years: data.years }) + const updates: Partial = { years: data.years } + if (get().selectedYear === year) { + updates.selectedYear = data.years.length > 0 + ? data.years[data.years.length - 1] + : new Date().getFullYear() + } + set(updates) }, loadEntries: async (year?: number) => { From 6c7229542497fe5260ea71b3f9159d3e689269ae Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 3 Apr 2026 19:49:58 +0200 Subject: [PATCH 3/7] fix(vacay): fix entitlement counter, year deletion, and year creation bugs - toggleCompanyHoliday now calls loadStats() so the entitlement sidebar updates immediately when a vacation day is converted to a company holiday - deleteYear now deletes vacay_user_years rows for the removed year, preventing stale entitlement data from persisting and re-appearing when the year is re-created - deleteYear recalculates carry-over for year+1 when year N is deleted, using the new actual previous year as the source - removeYear store action now calls loadStats() so the sidebar reflects the recalculated carry-over without requiring a page refresh - Add prev-year button (+[<] 2026 [>]+) so users can add years going backwards after deleting a past year; add vacay.addPrevYear i18n key to all 13 supported languages Closes #371 --- client/src/i18n/translations/ar.ts | 3 ++- client/src/i18n/translations/br.ts | 3 ++- client/src/i18n/translations/cs.ts | 3 ++- client/src/i18n/translations/de.ts | 3 ++- client/src/i18n/translations/en.ts | 3 ++- client/src/i18n/translations/es.ts | 3 ++- client/src/i18n/translations/fr.ts | 3 ++- client/src/i18n/translations/hu.ts | 3 ++- client/src/i18n/translations/it.ts | 3 ++- client/src/i18n/translations/nl.ts | 3 ++- client/src/i18n/translations/pl.ts | 3 ++- client/src/i18n/translations/ru.ts | 3 ++- client/src/i18n/translations/zh.ts | 3 ++- client/src/pages/VacayPage.tsx | 34 +++++++++++++++++++---------- client/src/store/vacayStore.ts | 2 ++ server/src/services/vacayService.ts | 24 ++++++++++++++++++++ 16 files changed, 75 insertions(+), 24 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 4ba26be..a2523e7 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -598,7 +598,8 @@ const ar: Record = { 'vacay.subtitle': 'خطط وأدر أيام الإجازة', 'vacay.settings': 'الإعدادات', 'vacay.year': 'السنة', - 'vacay.addYear': 'إضافة سنة', + 'vacay.addYear': 'إضافة السنة التالية', + 'vacay.addPrevYear': 'إضافة السنة السابقة', 'vacay.removeYear': 'إزالة السنة', 'vacay.removeYearConfirm': 'إزالة {year}؟', 'vacay.removeYearHint': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index c51907d..4c2eb59 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -579,7 +579,8 @@ const br: Record = { '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.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 1bf8e1d..669d496 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -597,7 +597,8 @@ const cs: Record = { '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.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7ebb8c0..a2980ba 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -595,7 +595,8 @@ const de: Record = { '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.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 9f1c3f6..c2107e6 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -592,7 +592,8 @@ const en: Record = { '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.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index bafee34..b1142fd 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -572,7 +572,8 @@ const es: Record = { '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.', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 4727ff7..c37207d 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -594,7 +594,8 @@ const fr: Record = { '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.', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 91211d8..05c72e1 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -595,7 +595,8 @@ const hu: Record = { '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.', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index bebaf16..e1d76e2 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -595,7 +595,8 @@ const it: Record = { '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.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 814be9b..c40f87a 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -594,7 +594,8 @@ const nl: Record = { '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.', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 36d9e9f..ecd3b8a 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -558,7 +558,8 @@ const pl: Record = { '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.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 997e25b..518c5e9 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -594,7 +594,8 @@ const ru: Record = { 'vacay.subtitle': 'Планируйте и управляйте днями отпуска', 'vacay.settings': 'Настройки', 'vacay.year': 'Год', - 'vacay.addYear': 'Добавить год', + 'vacay.addYear': 'Добавить следующий год', + 'vacay.addPrevYear': 'Добавить предыдущий год', 'vacay.removeYear': 'Удалить год', 'vacay.removeYearConfirm': 'Удалить {year}?', 'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 5fd14e4..5a90e5b 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -594,7 +594,8 @@ const zh: Record = { 'vacay.subtitle': '规划和管理假期', 'vacay.settings': '设置', 'vacay.year': '年份', - 'vacay.addYear': '添加年份', + 'vacay.addYear': '添加下一年', + 'vacay.addPrevYear': '添加上一年', 'vacay.removeYear': '移除年份', 'vacay.removeYearConfirm': '移除 {year}?', 'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。', diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 68efd34..b3a524e 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -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 (
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement { <> {/* Year Selector */}
-
+
{t('vacay.year')} -
- +
+ + +
{selectedYear} - +
+ + +
{years.map(y => ( diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 73fb0c6..8e87664 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -229,6 +229,7 @@ export const useVacayStore = create((set, get) => ({ : new Date().getFullYear() } set(updates) + await get().loadStats() }, loadEntries: async (year?: number) => { @@ -246,6 +247,7 @@ export const useVacayStore = create((set, get) => ({ toggleCompanyHoliday: async (date: string) => { await api.toggleCompanyHoliday(date) await get().loadEntries() + await get().loadStats() }, loadStats: async (year?: number) => { diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts index ba5aa43..c1607dc 100644 --- a/server/src/services/vacayService.ts +++ b/server/src/services/vacayService.ts @@ -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); } From 897e1bff2664e23004287fe2dcddc8bd23688086 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 3 Apr 2026 21:18:56 +0200 Subject: [PATCH 4/7] fix(dates): use UTC parsing and display for date-only strings (#351) Date-only strings parsed with new Date(dateStr + 'T00:00:00') were interpreted relative to the local timezone, causing off-by-one day display for users west of UTC. Fixed across 16 files by parsing as UTC ('T00:00:00Z') and displaying with timeZone: 'UTC'. --- client/src/components/Budget/BudgetPanel.tsx | 2 +- client/src/components/Collab/WhatsNextWidget.tsx | 2 +- client/src/components/PDF/TripPDF.tsx | 8 ++++---- client/src/components/Photos/PhotoGallery.tsx | 2 +- client/src/components/Planner/DayDetailPanel.tsx | 8 ++++---- client/src/components/Planner/DayPlanSidebar.tsx | 4 ++-- client/src/components/Planner/PlaceInspector.tsx | 2 +- client/src/components/Planner/PlacesSidebar.tsx | 2 +- .../src/components/Planner/ReservationModal.tsx | 4 ++-- .../src/components/Planner/ReservationsPanel.tsx | 4 ++-- client/src/components/Trips/TripFormModal.tsx | 6 +++--- client/src/components/Vacay/holidays.ts | 16 ++++++++-------- .../components/shared/CustomDateTimePicker.tsx | 12 ++++++------ client/src/pages/DashboardPage.tsx | 4 ++-- client/src/pages/SharedTripPage.tsx | 6 +++--- client/src/utils/formatters.ts | 4 ++-- 16 files changed, 43 insertions(+), 43 deletions(-) diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 0f447bb..a0cbd3d 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -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)] diff --git a/client/src/components/Collab/WhatsNextWidget.tsx b/client/src/components/Collab/WhatsNextWidget.tsx index 6ad678d..c5fd11a 100644 --- a/client/src/components/Collab/WhatsNextWidget.tsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -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 { diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index c83cc4b..2a4c03a 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -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) { diff --git a/client/src/components/Photos/PhotoGallery.tsx b/client/src/components/Photos/PhotoGallery.tsx index fe64a19..4e7a90d 100644 --- a/client/src/components/Photos/PhotoGallery.tsx +++ b/client/src/components/Photos/PhotoGallery.tsx @@ -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' }) } diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 22cc76e..49cd6ff 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -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" /> diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index b9850f3..b83270e 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -743,7 +743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{trip?.title}
{(trip?.start_date || trip?.end_date) && (
- {[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')}`}
)} @@ -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' })}`} diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index f6c00a5..13deb63 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -373,7 +373,7 @@ export default function PlaceInspector({ {res.reservation_time && (
{t('reservations.date')}
-
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
+
{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' })}
)} {res.reservation_time?.includes('T') && ( diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 91e50a5..ce8c6c5 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -424,7 +424,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{i + 1}
{day.title || t('dayplan.dayN', { n: i + 1 })}
- {day.date &&
{new Date(day.date + 'T00:00:00').toLocaleDateString()}
} + {day.date &&
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}
}
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && } diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index dbc3ab3..8a376d7 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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' }) } diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index b6f9c55..4d37fab 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -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) diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 4e834b9..aff4012 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -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] } diff --git a/client/src/components/Vacay/holidays.ts b/client/src/components/Vacay/holidays.ts index fe5903b..61f92a9 100644 --- a/client/src/components/Vacay/holidays.ts +++ b/client/src/components/Vacay/holidays.ts @@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record(null) const dropRef = useRef(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 diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 16f36d0..a386379 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -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[] { diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index d8fb54a..09fa866 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -106,7 +106,7 @@ export default function SharedTripPage() { {(trip.start_date || trip.end_date) && (
- {[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(' — ')} {days?.length > 0 && ·} {days?.length > 0 && {days.length} {t('shared.days')}} @@ -199,7 +199,7 @@ export default function SharedTripPage() {
{di + 1}
{day.title || `Day ${day.day_number}`}
- {day.date &&
{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
} + {day.date &&
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
}
{dayAccs.map((acc: any) => ( @@ -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 (
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 7e7ebc0..980f85a 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -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 { From 6ba5df021506b07024e2651b009ec188e6ce36f5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 3 Apr 2026 21:41:05 +0200 Subject: [PATCH 5/7] fix(immich): replace ephemeral token auth with blob fetch for Safari compatibility (#381) Safari blocks SameSite=Lax cookies on subresource requests, causing 401 errors when loading Immich thumbnails and originals. Replaced the token-based approach with direct fetch() using credentials: 'include', which reliably sends cookies across all browsers. Images are now loaded as blobs with ObjectURLs. Added a concurrency limiter (max 6 parallel fetches) to prevent ERR_INSUFFICIENT_RESOURCES when many photos load simultaneously. Queue is cleared when the photo picker closes so gallery images load immediately. --- client/src/api/authUrl.ts | 42 +++++++++++++++++++ .../src/components/Memories/MemoriesPanel.tsx | 19 ++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 203ceb3..9cdc541 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): P 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 { + if (!url) return '' + return new Promise((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) + } + }) +} diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 9dd1ed4..13a8bab 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,13 +3,18 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi import apiClient 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' function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { - getAuthUrl(baseUrl, 'immich').then(setSrc) + let revoke = '' + fetchImageAsBlob(baseUrl).then(blobUrl => { + revoke = blobUrl + setSrc(blobUrl) + }) + return () => { if (revoke) URL.revokeObjectURL(revoke) } }, [baseUrl]) return src ? : null } @@ -208,6 +213,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa shared: true, }) setShowPicker(false) + clearImageQueue() loadInitial() } catch { toast.error(t('memories.error.addPhotos')) } } @@ -365,7 +371,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {t('memories.selectPhotos')}
- @@ -634,8 +640,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }} onClick={() => { setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') - getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) + fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) @@ -743,12 +750,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {/* Lightbox */} {lightboxId && lightboxUserId && ( -
{ setLightboxId(null); setLightboxUserId(null) }} +
{ 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', }}> -