feat(vacay): add school holiday overlays (#1633)

* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
This commit is contained in:
Elmer
2026-07-23 22:48:42 +02:00
committed by GitHub
parent 4d3d16389e
commit 7bd9e08016
47 changed files with 854 additions and 119 deletions
+1 -2
View File
@@ -133,7 +133,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
@@ -472,4 +472,3 @@ for full third-party attributions.
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+21 -1
View File
@@ -105,6 +105,8 @@ export default function VacayCalendar() {
const selectedUser = users.find(u => u.id === selectedUserId)
const tipEntries = tip ? entryMap[tip.date] : undefined
const tipShared = tip ? sharedMap[tip.date] : undefined
const tipHolidayRaw = tip ? holidays[tip.date] : undefined
const tipSchool = (Array.isArray(tipHolidayRaw) ? tipHolidayRaw : tipHolidayRaw ? [tipHolidayRaw] : []).filter(h => h.type === 'school_holiday')
const tipDate = tip ? new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(new Date(tip.date + 'T00:00:00')) : ''
return (
@@ -135,7 +137,7 @@ export default function VacayCalendar() {
with half days, plus shared read-only calendars). Rendered fixed at the
root (not inside a month card) so backdrop-filter stacking contexts
can't clip or occlude it. */}
{tip && ((tipEntries && tipEntries.length > 0) || (tipShared && tipShared.length > 0)) && (
{tip && ((tipEntries && tipEntries.length > 0) || (tipShared && tipShared.length > 0) || tipSchool.length > 0) && (
<div
className="vg-card rounded-xl"
style={{ position: 'fixed', top: tip.top - 9, left: tip.left, transform: 'translate(-50%, -100%)', zIndex: 80, pointerEvents: 'none' }}
@@ -164,6 +166,24 @@ export default function VacayCalendar() {
</span>
</div>
))}
{/* School holidays fold into this tooltip under a divider instead of a
separate native title, so a half/full day and the school break read together. */}
{tipSchool.length > 0 && (
<>
{((tipEntries?.length ?? 0) > 0 || (tipShared?.length ?? 0) > 0) && (
<div style={{ height: 1, background: 'var(--vg-line)', margin: '7px 0 6px' }} />
)}
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--vg-ink3)', marginBottom: 4 }}>
{t('vacay.schoolHolidays')}
</div>
{tipSchool.map((h, i) => (
<div key={`sch${i}`} className="flex items-center gap-2" style={{ marginTop: i ? 3 : 0 }}>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: h.color }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--vg-ink)' }}>{h.label ? `${h.label}: ${h.localName}` : h.localName}</span>
</div>
))}
</>
)}
</div>
</div>
)}
@@ -67,6 +67,16 @@ describe('VacayMonthCard', () => {
expect(cell).toBeInTheDocument()
})
it('FE-COMP-VACAYMONTHCARD-014: School holiday markers do not recolor the day number', () => {
const props = {
...baseProps,
holidays: { '2025-01-02': { name: 'School holiday', localName: 'School holiday', label: 'NL', color: '#22c55e', type: 'school_holiday' as const } },
}
render(<VacayMonthCard {...props} />)
const daySpan = screen.getByText('2')
expect(daySpan.style.color).toBe('var(--vg-ink2)')
})
it('FE-COMP-VACAYMONTHCARD-006: Weekend cell has default cursor (blocked)', () => {
render(<VacayMonthCard {...baseProps} />)
// January 5, 2025 is a Sunday (getDay() === 0), which is in weekendDays [0, 6]
+28 -7
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { useTranslation } from '../../i18n'
import type { HolidaysMap, VacayEntry } from '../../types'
import { schoolHolidayBand, schoolHolidayWash } from './holidayVisual'
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
@@ -96,13 +97,16 @@ export default function VacayMonthCard({
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
const dayOfWeek = new Date(year, month, day).getDay()
const weekend = weekendDays.includes(dayOfWeek)
const holiday = holidays[dateStr]
const rawHolidayMarkers = holidays[dateStr]
const holidayMarkers = Array.isArray(rawHolidayMarkers) ? rawHolidayMarkers : rawHolidayMarkers ? [rawHolidayMarkers] : []
const publicHoliday = holidayMarkers.find(h => (h.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayMarkers = holidayMarkers.filter(h => h.type === 'school_holiday')
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || []
const hasEntries = dayEntries.length > 0
const isBlocked = (weekend && blockWeekends) || (isCompany && !companyMode)
const isToday = dateStr === todayStr
const plain = !hasEntries && !holiday && !isCompany
const plain = !hasEntries && holidayMarkers.length === 0 && !isCompany
// The fill always shows WHO is off (person colour, split for several) — half
// days keep that fill and get a small corner ½ badge instead, so a half day
@@ -119,7 +123,10 @@ export default function VacayMonthCard({
if (dayEntries.length === 1) background = fill(dayEntries[0].person_color)
else if (dayEntries.length === 2) background = `linear-gradient(135deg, ${fill(dayEntries[0].person_color)} 50%, ${fill(dayEntries[1].person_color)} 50%)`
else if (dayEntries.length === 0 && isCompany) background = 'rgba(245,158,11,0.22)'
else if (dayEntries.length === 0 && holiday) background = `color-mix(in srgb, ${holiday.color} 22%, transparent)`
else if (dayEntries.length === 0 && publicHoliday) background = `color-mix(in srgb, ${publicHoliday.color} 22%, transparent)`
// A plain school-break day gets a soft wash of its calendar colour so a run of
// days reads as one gentle stretch; the rounded band below names the calendar.
else if (dayEntries.length === 0 && schoolHolidayMarkers.length > 0) background = schoolHolidayWash(schoolHolidayMarkers[0].color)
// Weekend / settings-blocked days read as inactive: subtle grey fill, like before the facelift.
else if (weekend && blockWeekends) background = 'color-mix(in srgb, var(--vg-ink3) 7%, transparent)'
@@ -133,13 +140,13 @@ export default function VacayMonthCard({
let numColor = 'var(--vg-ink2)'
if (hasEntries) numColor = '#fff'
else if (holiday) numColor = holiday.color
else if (publicHoliday) numColor = publicHoliday.color
else if (weekend) numColor = 'var(--vg-ink3)'
return (
<div
key={di}
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
title={publicHoliday ? (publicHoliday.label ? `${publicHoliday.label}: ${publicHoliday.localName}` : publicHoliday.localName) : undefined}
className="relative flex items-center justify-center transition-colors"
style={{
height: 28,
@@ -151,11 +158,11 @@ export default function VacayMonthCard({
onClick={() => onCellClick(dateStr)}
onMouseEnter={e => {
if (!isBlocked && plain) e.currentTarget.style.background = 'var(--vg-surf2)'
if (anyHalf || sharedColors.length > 0) onCellHover?.(dateStr, e.currentTarget)
if (hasEntries || sharedColors.length > 0 || schoolHolidayMarkers.length > 0) onCellHover?.(dateStr, e.currentTarget)
}}
onMouseLeave={e => {
if (!isBlocked && plain) e.currentTarget.style.background = background
if (anyHalf || sharedColors.length > 0) onCellHover?.(null, null)
if (hasEntries || sharedColors.length > 0 || schoolHolidayMarkers.length > 0) onCellHover?.(null, null)
}}
>
{/* 3+ people: quadrant overlay at full colour (1 & 2 use the cell background). */}
@@ -185,6 +192,20 @@ export default function VacayMonthCard({
<span className="absolute bottom-1 right-1 w-[5px] h-[5px] rounded-full z-[3] bg-[#f97316]" style={{ boxShadow: '0 0 0 1.5px var(--vg-surf)' }} aria-hidden />
)}
{schoolHolidayMarkers.length > 0 && (
<span
className="absolute rounded-full z-[2]"
style={{
left: 4,
right: 4,
bottom: 3,
height: 3,
background: schoolHolidayBand(schoolHolidayMarkers.map(h => h.color)),
}}
aria-hidden
/>
)}
<span className="relative z-[1]" style={{
fontFamily: 'var(--font-subtext)',
fontSize: 12,
@@ -7,7 +7,8 @@ import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import { useVacayStore } from '../../store/vacayStore'
import VacaySettings from './VacaySettings'
import { fetchRegionOptions } from './holidayRegions'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
const basePlan = {
id: 1,
@@ -445,6 +446,53 @@ describe('VacaySettings', () => {
expect(await fetchRegionOptions('JP')).toEqual([])
})
it('FE-COMP-VACAYSETTINGS-021: school holiday whitelist contains only approved green/yellow countries', () => {
expect(Object.values(SCHOOL_HOLIDAY_COUNTRY_CONFIG).every(config => ['green', 'yellow'].includes(config.status))).toBe(true)
expect(SCHOOL_HOLIDAY_COUNTRY_CONFIG.SE).toBeUndefined()
})
it('FE-COMP-VACAYSETTINGS-022: German school holidays use subdivisions even when OpenHolidays exposes groups', async () => {
server.use(
http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({
groups: [
{ code: 'DE-MV-ABS', name: [{ language: 'EN', text: 'General Schools' }] },
],
subdivisions: [
{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] },
{ code: 'DE-NW', name: [{ language: 'EN', text: 'North Rhine-Westphalia' }] },
],
})
),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([
{ value: 'DE-BY', label: 'Bavaria' },
{ value: 'DE-NW', label: 'North Rhine-Westphalia' },
])
})
it('FE-COMP-VACAYSETTINGS-023: Dutch school holidays use OpenHolidays groups', async () => {
server.use(
http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({
groups: [
{ code: 'NL-NO', name: [{ language: 'EN', text: 'North Region' }] },
{ code: 'NL-MI', name: [{ language: 'EN', text: 'Central Region' }] },
],
subdivisions: [
{ code: 'NL-DR', name: [{ language: 'EN', text: 'Drenthe' }] },
],
})
),
)
expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([
{ value: 'NL|group:NL-MI', label: 'Central Region' },
{ value: 'NL|group:NL-NO', label: 'North Region' },
])
})
it('FE-COMP-VACAYSETTINGS-018: adding weekend day calls updatePlan with day added', async () => {
const user = userEvent.setup()
const updatePlan = vi.fn().mockResolvedValue(undefined)
+77 -16
View File
@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, GraduationCap } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
import { fetchRegionOptions } from './holidayRegions'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
import type { VacayHolidayCalendar } from '../../types'
interface VacaySettingsProps {
@@ -18,6 +19,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
const [showAddForm, setShowAddForm] = useState(false)
const [showAddSchoolForm, setShowAddSchoolForm] = useState(false)
const { language } = useTranslation()
@@ -38,6 +40,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
if (!plan) return null
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
return (
<div className="space-y-4">
@@ -162,15 +167,16 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/>
{plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
{(plan.holiday_calendars ?? []).length === 0 && (
{publicHolidayCalendars.length === 0 && (
<p className="text-xs text-content-faint">{t('vacay.noCalendars')}</p>
)}
{(plan.holiday_calendars ?? []).map(cal => (
{publicHolidayCalendars.map(cal => (
<CalendarRow
key={cal.id}
cal={cal}
countries={countries}
language={language}
calendarType="public_holiday"
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
@@ -179,7 +185,8 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
<AddCalendarForm
countries={countries}
language={language}
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
calendarType="public_holiday"
onAdd={async (data) => { await addHolidayCalendar({ ...data, type: 'public_holiday' }); setShowAddForm(false) }}
onCancel={() => setShowAddForm(false)}
/>
) : (
@@ -195,6 +202,53 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
</div>
)}
</div>
{/* School holidays — sits on the right, directly under Public holidays */}
<div>
<SettingToggle
icon={GraduationCap}
label={t('vacay.schoolHolidays')}
hint={t('vacay.schoolHolidaysHint')}
value={plan.school_holidays_enabled}
onChange={() => toggle('school_holidays_enabled')}
/>
{plan.school_holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
{schoolHolidayCalendars.length === 0 && (
<p className="text-xs text-content-faint">{t('vacay.noSchoolCalendars')}</p>
)}
{schoolHolidayCalendars.map(cal => (
<CalendarRow
key={cal.id}
cal={cal}
countries={schoolHolidayCountries}
language={language}
calendarType="school_holiday"
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
))}
{showAddSchoolForm ? (
<AddCalendarForm
countries={schoolHolidayCountries}
language={language}
calendarType="school_holiday"
defaultColor="#a5f3fc"
onAdd={async (data) => { await addHolidayCalendar({ ...data, type: 'school_holiday' }); setShowAddSchoolForm(false) }}
onCancel={() => setShowAddSchoolForm(false)}
/>
) : (
<button
onClick={() => setShowAddSchoolForm(true)}
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors text-content-muted bg-surface-secondary"
>
<Plus size={12} />
{t('vacay.addCalendar')}
</button>
)}
</div>
)}
</div>
</div>
</div>
@@ -266,10 +320,11 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
countries: { value: string; label: string }[]
language: string
calendarType: 'public_holiday' | 'school_holiday'
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
onDelete: () => void
}) {
@@ -278,16 +333,18 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
const [localLabel, setLocalLabel] = useState(cal.label || '')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const selectedCountry = cal.region.split('-')[0]
const selectedRegion = cal.region.includes('-') ? cal.region : ''
const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
fetchRegionOptions(selectedCountry).then(setRegions)
}, [selectedCountry])
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(selectedCountry).then(setRegions)
}, [calendarType, selectedCountry])
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -349,27 +406,31 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
}
// ── Add-new-calendar form ─────────────────────────────────────────────────────
function AddCalendarForm({ countries, onAdd, onCancel }: {
function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColor = '#fecaca' }: {
countries: { value: string; label: string }[]
language: string
calendarType: 'public_holiday' | 'school_holiday'
onAdd: (data: { region: string; color: string; label: string | null }) => void
onCancel: () => void
defaultColor?: string
}) {
const { t } = useTranslation()
const [region, setRegion] = useState('')
const [color, setColor] = useState('#fecaca')
const [color, setColor] = useState(defaultColor)
const [label, setLabel] = useState('')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const [loadingRegions, setLoadingRegions] = useState(false)
const selectedCountry = region.split('-')[0] || ''
const selectedRegion = region.includes('-') ? region : ''
const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
setLoadingRegions(true)
fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
}, [selectedCountry])
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
}, [calendarType, selectedCountry])
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
@@ -1,5 +1,6 @@
import apiClient from '../../api/client'
import iso31662 from 'iso-3166-2'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
// Loads the subdivision (state/region) options for a holiday-calendar country.
//
@@ -32,3 +33,51 @@ export async function fetchRegionOptions(country: string): Promise<{ value: stri
return []
}
}
type LocalizedName = { language: string; text: string }
type OpenHolidayOption = {
code: string
shortName?: string
name?: LocalizedName[]
children?: OpenHolidayOption[] | null
}
function localizedName(names: LocalizedName[] | undefined, fallback: string): string {
return names?.find(n => n.language?.toUpperCase() === 'DE')?.text
|| names?.find(n => n.language?.toUpperCase() === 'EN')?.text
|| names?.[0]?.text
|| fallback
}
function flattenOptions(items: OpenHolidayOption[] | undefined, prefix = ''): { value: string; label: string }[] {
const result: { value: string; label: string }[] = []
for (const item of items ?? []) {
const label = localizedName(item.name, item.shortName || item.code)
result.push({ value: item.code, label: prefix ? `${prefix} / ${label}` : label })
result.push(...flattenOptions(item.children ?? undefined, prefix ? `${prefix} / ${label}` : label))
}
return result
}
export async function fetchSchoolHolidayRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
if (!config || config.strategy === 'country') return []
try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') {
return flattenOptions(r.data.groups)
.map(opt => ({
value: `${country}|group:${opt.value}`,
label: opt.label,
}))
.sort((a, b) => a.label.localeCompare(b.label))
}
return flattenOptions(r.data.subdivisions)
.map(opt => ({ value: opt.value, label: opt.label }))
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
return []
}
}
@@ -0,0 +1,27 @@
// Shared visual treatment for school-holiday days, used by the desktop month card
// (VacayMonthCard) and the mobile month grid (vacayDayModel/MVacayMonth) so both
// surfaces render the layer identically.
/**
* Bottom accent band for a school-holiday day: a single rounded bar in the
* calendar colour, split into equal segments when several school calendars
* cover the same day (capped at three).
*/
export function schoolHolidayBand(colors: string[]): string {
if (colors.length === 0) return 'transparent'
if (colors.length === 1) return colors[0]
const seg = colors.slice(0, 3)
const stops = seg
.map((c, i) => `${c} ${Math.round((i * 100) / seg.length)}% ${Math.round(((i + 1) * 100) / seg.length)}%`)
.join(', ')
return `linear-gradient(90deg, ${stops})`
}
/**
* Soft background wash for a plain day that falls inside a school break — light
* enough to read as a gentle range highlight without competing with the stronger
* public-holiday / person fills.
*/
export function schoolHolidayWash(color: string): string {
return `color-mix(in srgb, ${color} 15%, transparent)`
}
@@ -1,3 +1,4 @@
import { schoolHolidayBand } from '../../../components/Vacay/holidayVisual'
import { dayVisual, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
interface MVacayMonthProps {
@@ -63,6 +64,15 @@ export default function MVacayMonth({
style={{ background: '#f97316' }}
/>
)}
{visual.school && visual.school.length > 0 && (
<span
aria-hidden
className={`absolute rounded-full ${
mini ? 'inset-x-[2px] bottom-[1.5px] h-[1.5px]' : 'inset-x-[4px] bottom-[3px] h-[2.5px]'
}`}
style={{ background: schoolHolidayBand(visual.school) }}
/>
)}
</button>
)
})}
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ArrowRightLeft, Building2, Calendar, CalendarX, ChevronDown, Globe, Plus, Trash2, Unlink, X } from 'lucide-react'
import { ArrowRightLeft, Building2, Calendar, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -7,7 +7,8 @@ import { useVacayStore } from '../../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import apiClient from '../../../api/client'
import { fetchRegionOptions } from '../../../components/Vacay/holidayRegions'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import type { VacayHolidayCalendar } from '../../../types'
@@ -31,6 +32,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState<Option[]>([])
const [showAddForm, setShowAddForm] = useState(false)
const [showAddSchoolForm, setShowAddSchoolForm] = useState(false)
useEffect(() => {
if (!open) return
@@ -48,6 +50,12 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
if (!plan) return null
// Public and school calendars live in the same holiday_calendars list, split by type
// (a null type predates the school-holiday feature, so it counts as a public holiday).
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
const weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const weekdayChips = [
{ day: 1, label: t('vacay.mon') },
@@ -167,11 +175,12 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
</div>
{plan.holidays_enabled && (
<>
{(plan.holiday_calendars ?? []).map(cal => (
{publicHolidayCalendars.map(cal => (
<CalendarEditor
key={cal.id}
cal={cal}
countries={countries}
calendarType="public_holiday"
onUpdate={data => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
@@ -179,7 +188,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
{showAddForm ? (
<AddCalendarDraft
countries={countries}
onAdd={async data => { await addHolidayCalendar(data); setShowAddForm(false) }}
calendarType="public_holiday"
onAdd={async data => { await addHolidayCalendar({ ...data, type: 'public_holiday' }); setShowAddForm(false) }}
onCancel={() => setShowAddForm(false)}
/>
) : (
@@ -194,6 +204,48 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
)}
</>
)}
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
{/* School holidays + calendar editor */}
<div className="flex items-start gap-[11px] py-3">
<GraduationCap size={17} strokeWidth={2} className="mt-[1px] flex-none text-m-muted" />
<div className="flex-1">
<div className="text-[0.84375rem] font-bold">{t('vacay.schoolHolidays')}</div>
<div className="font-geist text-[0.65625rem] text-m-muted">{t('vacay.schoolHolidaysHint')}</div>
</div>
<MToggle checked={plan.school_holidays_enabled} onChange={() => updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} />
</div>
{plan.school_holidays_enabled && (
<>
{schoolHolidayCalendars.map(cal => (
<CalendarEditor
key={cal.id}
cal={cal}
countries={schoolHolidayCountries}
calendarType="school_holiday"
onUpdate={data => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
))}
{showAddSchoolForm ? (
<AddCalendarDraft
countries={schoolHolidayCountries}
calendarType="school_holiday"
onAdd={async data => { await addHolidayCalendar({ ...data, type: 'school_holiday' }); setShowAddSchoolForm(false) }}
onCancel={() => setShowAddSchoolForm(false)}
/>
) : (
<button
type="button"
onClick={() => setShowAddSchoolForm(true)}
className="mt-2 inline-flex items-center gap-[6px] rounded-full border-[1.5px] border-dashed border-[color:var(--m-rowbr)] px-[13px] py-2 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
<Plus size={12} strokeWidth={2.2} />
{t('vacay.addCalendar')}
</button>
)}
</>
)}
{/* Dissolve Fusion */}
{isFused && (
@@ -282,9 +334,10 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string)
)
}
function CalendarEditor({ cal, countries, onUpdate, onDelete }: {
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: {
cal: VacayHolidayCalendar
countries: Option[]
calendarType?: 'public_holiday' | 'school_holiday'
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
onDelete: () => void
}) {
@@ -297,8 +350,9 @@ function CalendarEditor({ cal, countries, onUpdate, onDelete }: {
useEffect(() => { setLabel(cal.label || '') }, [cal.label])
useEffect(() => {
if (!country) { setRegions([]); return }
fetchRegionOptions(country).then(setRegions)
}, [country])
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(country).then(setRegions)
}, [country, calendarType])
return (
<div className="mt-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
@@ -324,8 +378,9 @@ function CalendarEditor({ cal, countries, onUpdate, onDelete }: {
)
}
function AddCalendarDraft({ countries, onAdd, onCancel }: {
function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, onCancel }: {
countries: Option[]
calendarType?: 'public_holiday' | 'school_holiday'
onAdd: (data: { region: string; color: string; label: string | null }) => void
onCancel: () => void
}) {
@@ -340,8 +395,9 @@ function AddCalendarDraft({ countries, onAdd, onCancel }: {
useEffect(() => {
if (!country) { setRegions([]); return }
fetchRegionOptions(country).then(setRegions)
}, [country])
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(country).then(setRegions)
}, [country, calendarType])
const canAdd = Boolean(country) && (regions.length === 0 || selectedRegion !== '')
@@ -1,3 +1,4 @@
import { schoolHolidayWash } from '../../../components/Vacay/holidayVisual'
import type { HolidaysMap, VacayEntry } from '../../../types'
// Users created before picking a color have color=null; same fallback the
@@ -10,6 +11,9 @@ export interface DayVisual {
boxShadow?: string
// At least one person logged this day as a half day (#552) — the cell shows a ½ badge.
half?: boolean
// Colours of the school-holiday calendars covering this day — drawn as a rounded
// accent band under the number, on top of whatever fill the cell already has.
school?: string[]
}
export interface DayVisualContext {
@@ -84,11 +88,19 @@ export function splitBackground(colors: string[]): string {
* theme-independent surfaces.
*/
function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
const holidayMarkers = ctx.holidays[dateStr]
const holidays = Array.isArray(holidayMarkers) ? holidayMarkers : holidayMarkers ? [holidayMarkers] : []
const publicHoliday = holidays.find(holiday => (holiday.type ?? 'public_holiday') === 'public_holiday')
const schoolColors = holidays.filter(holiday => holiday.type === 'school_holiday').map(holiday => holiday.color)
// The school band sits on top of any fill (person / company / public), so tag it
// onto whatever visual this day resolves to rather than picking a single winner.
const withSchool = (v: DayVisual): DayVisual => (schoolColors.length > 0 ? { ...v, school: schoolColors } : v)
if (dateStr === ctx.todayStr) {
return { background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' }
return withSchool({ background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' })
}
if (ctx.companyHolidaysEnabled && ctx.companyHolidaySet.has(dateStr)) {
return { background: '#F5D9A6', numColor: '#8A5A00' }
return withSchool({ background: '#F5D9A6', numColor: '#8A5A00' })
}
const entries = ctx.entryMap[dateStr]
if (entries && entries.length > 0) {
@@ -98,11 +110,14 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
// true so full days keep their exact { background, numColor } shape.
const visual: DayVisual = { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' }
if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true
return visual
return withSchool(visual)
}
const holiday = ctx.holidays[dateStr]
if (holiday) {
return { background: holiday.color, numColor: holidayInk(holiday.color) }
if (publicHoliday) {
return withSchool({ background: publicHoliday.color, numColor: holidayInk(publicHoliday.color) })
}
// Plain school-break day: soft wash + readable tinted ink, matching the desktop card.
if (schoolColors.length > 0) {
return { background: schoolHolidayWash(schoolColors[0]), numColor: holidayInk(schoolColors[0]), school: schoolColors }
}
if (ctx.weekendDays.includes(dayOfWeek)) {
return { background: 'var(--m-ic)', numColor: 'var(--m-faint)' }
+6 -3
View File
@@ -94,14 +94,17 @@ function VacayPageDesktop(): React.ReactElement {
<VacaySharedCalendars />
{/* Legend */}
{(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends || hasVisibleShared) && (
{(plan?.holidays_enabled || plan?.school_holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends || hasVisibleShared) && (
<div className="vg-card rounded-[22px]" style={{ padding: '14px 18px' }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'var(--vg-ink3)' }}>{t('vacay.legend')}</span>
<div className="mt-3 flex flex-wrap gap-x-3.5 gap-y-2.5">
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday').length === 0 && (
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
)}
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday').map(cal => (
<LegendItem key={cal.id} color={cal.color} label={cal.label || cal.region} />
))}
{plan?.school_holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday').map(cal => (
<LegendItem key={cal.id} color={cal.color} label={cal.label || cal.region} />
))}
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
+80 -17
View File
@@ -6,6 +6,7 @@ import type {
VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar,
VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar,
} from '../types'
import { isSchoolHolidayCountrySupported } from '../vacay/schoolHolidayCountries'
import type {
VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest,
VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest,
@@ -54,6 +55,12 @@ interface VacayHolidayRaw {
counties: string[] | null
}
interface VacaySchoolHolidayRaw {
startDate?: string
endDate?: string
name?: string | { language?: string; text?: string }[]
}
interface VacayApi {
getPlan: () => Promise<VacayPlanResponse>
updatePlan: (data: Partial<VacayPlan>) => Promise<{ plan: VacayPlan }>
@@ -74,8 +81,9 @@ interface VacayApi {
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
getCountries: () => Promise<{ countries: string[] }>
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
getSchoolHolidays: (year: number, country: string, subdivision?: string | null, group?: string | null) => Promise<VacaySchoolHolidayRaw[]>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<{ calendar: VacayHolidayCalendar }>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<{ calendar: VacayHolidayCalendar }>
deleteHolidayCalendar: (id: number) => Promise<unknown>
getShares: () => Promise<{ outgoing: VacayShareOutgoing[]; incoming: VacayShareIncoming[] }>
share: (userId: number) => Promise<unknown>
@@ -105,6 +113,12 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data),
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
getSchoolHolidays: (year, country, subdivision, group) => {
const params = new URLSearchParams()
if (group) params.set('group', group)
const qs = params.toString()
return ax.get(`/addons/vacay/school-holidays/${year}/${country}${subdivision ? `/${subdivision}` : ''}${qs ? `?${qs}` : ''}`).then((r: AxiosResponse) => r.data)
},
addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data),
deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data),
@@ -116,6 +130,42 @@ const api: VacayApi = {
getSharedCalendars: (year) => ax.get(`/addons/vacay/shares/calendars/${year}`).then((r: AxiosResponse) => r.data),
}
function pushHolidayMarker(map: HolidaysMap, date: string, marker: HolidayInfo) {
const existing = map[date]
if (!existing) {
map[date] = [marker]
return
}
map[date] = Array.isArray(existing) ? [...existing, marker] : [existing, marker]
}
function parseCalendarRegion(region: string): { country: string; subdivision: string | null; group: string | null } {
const [base, meta] = region.split('|')
const group = meta?.startsWith('group:') ? meta.slice('group:'.length) : null
return {
country: base.split('-')[0],
subdivision: group ? null : base.includes('-') ? base : null,
group,
}
}
function schoolHolidayName(h: VacaySchoolHolidayRaw): string {
if (typeof h.name === 'string') return h.name
const names = Array.isArray(h.name) ? h.name : []
return names.find(n => n.language?.toUpperCase() === 'DE')?.text || names[0]?.text || 'School holidays'
}
function datesBetween(startStr: string, endStr: string): string[] {
const dates: string[] = []
const start = new Date(startStr + 'T00:00:00')
const end = new Date(endStr + 'T00:00:00')
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return dates
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
return dates
}
interface VacayState {
plan: VacayPlan | null
users: VacayUser[]
@@ -154,8 +204,8 @@ interface VacayState {
loadStats: (year?: number) => Promise<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
loadHolidays: (year?: number) => Promise<void>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<void>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<void>
deleteHolidayCalendar: (id: number) => Promise<void>
loadShares: () => Promise<void>
loadSharedCalendars: (year?: number) => Promise<void>
@@ -331,25 +381,38 @@ export const useVacayStore = create<VacayState>((set, get) => ({
const y = year || get().selectedYear
const plan = get().plan
const calendars = plan?.holiday_calendars ?? []
if (!plan?.holidays_enabled || calendars.length === 0) {
const enabledCalendars = calendars.filter(cal =>
(cal.type === 'school_holiday' && plan?.school_holidays_enabled) ||
((cal.type ?? 'public_holiday') === 'public_holiday' && plan?.holidays_enabled)
)
if (enabledCalendars.length === 0) {
set({ holidays: {} })
return
}
const map: HolidaysMap = {}
for (const cal of calendars) {
const country = cal.region.split('-')[0]
const region = cal.region.includes('-') ? cal.region : null
for (const cal of enabledCalendars) {
const { country, subdivision, group } = parseCalendarRegion(cal.region)
try {
const data = await api.getHolidays(y, country)
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
if (hasRegions && !region) continue
data.forEach((h: VacayHolidayRaw) => {
if (h.global || !h.counties || (region && h.counties.includes(region))) {
if (!map[h.date]) {
map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
if ((cal.type ?? 'public_holiday') === 'school_holiday') {
if (!isSchoolHolidayCountrySupported(country)) continue
const data = await api.getSchoolHolidays(y, country, subdivision, group)
data.forEach((h: VacaySchoolHolidayRaw) => {
if (!h.startDate) return
const name = schoolHolidayName(h)
datesBetween(h.startDate, h.endDate || h.startDate).filter(date => date.startsWith(`${y}-`)).forEach(date => {
pushHolidayMarker(map, date, { name, localName: name, color: cal.color, label: cal.label, type: 'school_holiday' })
})
})
} else {
const data = await api.getHolidays(y, country)
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
if (hasRegions && !subdivision) continue
data.forEach((h: VacayHolidayRaw) => {
if (h.global || !h.counties || (subdivision && h.counties.includes(subdivision))) {
pushHolidayMarker(map, h.date, { name: h.name, localName: h.localName, color: cal.color, label: cal.label, type: 'public_holiday' })
}
}
})
})
}
} catch { /* API error, skip */ }
}
set({ holidays: map })
+4 -1
View File
@@ -266,6 +266,7 @@ export interface WebSocketEvent {
export interface VacayHolidayCalendar {
id: number
plan_id: number
type?: 'public_holiday' | 'school_holiday'
region: string
label: string | null
color: string
@@ -275,6 +276,7 @@ export interface VacayHolidayCalendar {
export interface VacayPlan {
id: number
holidays_enabled: boolean
school_holidays_enabled?: boolean
holidays_region: string | null
holiday_calendars: VacayHolidayCalendar[]
block_weekends: boolean
@@ -326,10 +328,11 @@ export interface HolidayInfo {
localName: string
color: string
label: string | null
type?: 'public_holiday' | 'school_holiday'
}
export interface HolidaysMap {
[date: string]: HolidayInfo
[date: string]: HolidayInfo | HolidayInfo[]
}
// Read-only calendar shares (#444/#667)
@@ -0,0 +1,26 @@
export type SchoolHolidayStrategy = 'country' | 'groups' | 'subdivisions'
// OpenHolidays models school holidays differently per country. Keep this list
// explicit so new countries are only exposed after source coverage was checked.
export const SCHOOL_HOLIDAY_COUNTRY_CONFIG: Record<string, { strategy: SchoolHolidayStrategy; status: 'green' | 'yellow' }> = {
AT: { strategy: 'subdivisions', status: 'green' },
BE: { strategy: 'groups', status: 'yellow' },
CH: { strategy: 'subdivisions', status: 'yellow' },
CZ: { strategy: 'subdivisions', status: 'green' },
DE: { strategy: 'subdivisions', status: 'yellow' },
EE: { strategy: 'country', status: 'green' },
ES: { strategy: 'subdivisions', status: 'green' },
FR: { strategy: 'subdivisions', status: 'green' },
IE: { strategy: 'country', status: 'yellow' },
IT: { strategy: 'subdivisions', status: 'yellow' },
NL: { strategy: 'groups', status: 'yellow' },
PL: { strategy: 'subdivisions', status: 'yellow' },
PT: { strategy: 'subdivisions', status: 'green' },
RS: { strategy: 'country', status: 'yellow' },
SI: { strategy: 'subdivisions', status: 'yellow' },
SK: { strategy: 'subdivisions', status: 'yellow' },
}
export function isSchoolHolidayCountrySupported(country: string): boolean {
return country in SCHOOL_HOLIDAY_COUNTRY_CONFIG
}
@@ -85,6 +85,17 @@ export const vacayHandlers = [
]);
}),
http.get('/api/addons/vacay/school-holidays/regions/:country', () => {
return HttpResponse.json({
groups: [
{ code: 'NL-NO', shortName: 'NO', name: [{ language: 'EN', text: 'Northern Region' }] },
{ code: 'NL-MI', shortName: 'MI', name: [{ language: 'EN', text: 'Central Region' }] },
{ code: 'NL-ZU', shortName: 'ZU', name: [{ language: 'EN', text: 'Southern Region' }] },
],
subdivisions: [],
});
}),
http.put('/api/addons/vacay/color', () => {
return HttpResponse.json({ success: true });
}),
+119 -1
View File
@@ -77,7 +77,8 @@ describe('vacayStore', () => {
expect(Object.keys(state.holidays).length).toBeGreaterThan(0);
expect(state.holidays['2025-12-25']).toBeDefined();
expect(state.holidays['2025-12-25'].name).toBe('Christmas');
const christmas = state.holidays['2025-12-25'];
expect(Array.isArray(christmas) ? christmas[0].name : christmas.name).toBe('Christmas');
});
});
@@ -537,4 +538,121 @@ describe('vacayStore', () => {
expect(state.sharedCalendars[0].hidden).toBe(false);
});
});
describe('FE-STORE-VACAY-027: loadHolidays() with national school holiday calendar', () => {
it('loads school holidays when the calendar has no subdivision', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: {
id: 1,
holidays_enabled: false,
school_holidays_enabled: true,
holidays_region: null,
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'NL', label: 'Niederlande', color: '#22c55e', sort_order: 0, type: 'school_holiday' },
],
block_weekends: false,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
let requestedUrl = '';
server.use(
http.get('/api/addons/vacay/school-holidays/:year/:country', ({ request }) => {
requestedUrl = request.url;
return HttpResponse.json([
{
id: 'nl-summer-2026',
startDate: '2026-07-18',
endDate: '2026-08-30',
name: [{ language: 'EN', text: 'Summer holidays' }],
},
]);
})
);
await useVacayStore.getState().loadHolidays(2026);
const holidays = useVacayStore.getState().holidays;
expect(requestedUrl).toContain('/school-holidays/2026/NL');
expect(holidays['2026-07-18']).toBeDefined();
const summer = holidays['2026-07-18'];
expect(Array.isArray(summer) ? summer[0].type : summer.type).toBe('school_holiday');
});
});
describe('FE-STORE-VACAY-028: loadHolidays() with school holiday group calendar', () => {
it('passes the OpenHolidays group code for regional school holiday zones', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: {
id: 1,
holidays_enabled: false,
school_holidays_enabled: true,
holidays_region: null,
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'NL|group:NL-NO', label: 'Noord', color: '#22c55e', sort_order: 0, type: 'school_holiday' },
],
block_weekends: false,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
let requestedUrl = '';
server.use(
http.get('/api/addons/vacay/school-holidays/:year/:country', ({ request }) => {
requestedUrl = request.url;
return HttpResponse.json([
{
id: 'nl-north-2026',
startDate: '2026-07-04',
endDate: '2026-08-16',
name: [{ language: 'EN', text: 'Summer holidays' }],
},
]);
})
);
await useVacayStore.getState().loadHolidays(2026);
expect(requestedUrl).toContain('/school-holidays/2026/NL');
expect(requestedUrl).toContain('group=NL-NO');
expect(useVacayStore.getState().holidays['2026-07-04']).toBeDefined();
});
});
describe('FE-STORE-VACAY-029: loadHolidays() with unsupported school holiday country', () => {
it('skips school holiday calendars that are not in the approved whitelist', async () => {
useVacayStore.setState({
selectedYear: 2026,
plan: {
id: 1,
holidays_enabled: false,
school_holidays_enabled: true,
holidays_region: null,
holiday_calendars: [
{ id: 1, plan_id: 1, region: 'SE', label: 'Sweden', color: '#22c55e', sort_order: 0, type: 'school_holiday' },
],
block_weekends: false,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
let requested = false;
server.use(
http.get('/api/addons/vacay/school-holidays/:year/:country', () => {
requested = true;
return HttpResponse.json([]);
})
);
await useVacayStore.getState().loadHolidays(2026);
expect(requested).toBe(false);
expect(useVacayStore.getState().holidays).toEqual({});
});
});
});
+15
View File
@@ -3771,6 +3771,21 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// School holidays are a visual Vacay calendar layer. Keep them separate from
// public holidays so applyHolidayCalendars never removes vacation entries for
// school-break dates. Appended LAST: this branch was cut before #1435/#1281
// landed on dev, so an existing DB is already past their slots — only a
// trailing migration re-runs on upgrade and actually adds these columns.
() => {
const planCols = db.prepare("PRAGMA table_info('vacay_plans')").all() as Array<{ name: string }>;
if (!planCols.some(col => col.name === 'school_holidays_enabled')) {
db.exec('ALTER TABLE vacay_plans ADD COLUMN school_holidays_enabled INTEGER DEFAULT 0');
}
const calendarCols = db.prepare("PRAGMA table_info('vacay_holiday_calendars')").all() as Array<{ name: string }>;
if (!calendarCols.some(col => col.name === 'type')) {
db.exec("ALTER TABLE vacay_holiday_calendars ADD COLUMN type TEXT NOT NULL DEFAULT 'public_holiday'");
}
},
];
if (currentVersion < migrations.length) {
+2
View File
@@ -314,6 +314,7 @@ function createTables(db: Database.Database): void {
block_weekends INTEGER DEFAULT 1,
holidays_enabled INTEGER DEFAULT 0,
holidays_region TEXT DEFAULT '',
school_holidays_enabled INTEGER DEFAULT 0,
company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -374,6 +375,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday',
region TEXT NOT NULL,
label TEXT,
color TEXT NOT NULL DEFAULT '#fecaca',
+39 -2
View File
@@ -9,6 +9,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { vacayShareUpdateRequestSchema, type VacayShareUpdateRequest } from '@trek/shared';
@@ -47,14 +48,14 @@ export class VacayController {
@HttpCode(200)
addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number },
@Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
@Headers('x-socket-id') socketId?: string,
) {
if (!body.region) {
throw new HttpException({ error: 'region required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId);
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
return { calendar };
}
@@ -307,4 +308,40 @@ export class VacayController {
}
return result.data;
}
@Get('school-holidays/regions/:country')
async schoolHolidayRegions(@Param('country') country: string) {
const result = await this.vacay.getSchoolHolidayRegions(country, country.toUpperCase() === 'DE' ? 'DE' : 'EN');
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
@Get('school-holidays/:year/:country')
async schoolHolidaysForCountry(
@Param('year') year: string,
@Param('country') country: string,
@Query('group') group?: string,
) {
return this.schoolHolidays(year, country, undefined, group);
}
@Get('school-holidays/:year/:country/:subdivision')
async schoolHolidaysForSubdivision(
@Param('year') year: string,
@Param('country') country: string,
@Param('subdivision') subdivision: string,
@Query('group') group?: string,
) {
return this.schoolHolidays(year, country, subdivision, group);
}
private async schoolHolidays(year: string, country: string, subdivision?: string, group?: string) {
const result = await this.vacay.getSchoolHolidays(year, country, subdivision, country.toUpperCase() === 'DE' ? 'DE' : 'EN', group);
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
}
+10 -2
View File
@@ -26,8 +26,8 @@ export class VacayService {
return svc.updatePlan(planId, body, socketId);
}
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) {
return svc.addHolidayCalendar(planId, region, label, color, sortOrder, socketId);
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type?: 'public_holiday' | 'school_holiday') {
return svc.addHolidayCalendar(planId, region, label, color, sortOrder, socketId, type);
}
updateHolidayCalendar(id: number, planId: number, body: Parameters<typeof svc.updateHolidayCalendar>[2], socketId: string | undefined) {
@@ -106,6 +106,10 @@ export class VacayService {
return svc.getCountries();
}
getSchoolHolidayRegions(country: string, language?: string) {
return svc.getSchoolHolidayRegions(country, language);
}
getHolidays(year: string, country: string) {
return svc.getHolidays(year, country);
}
@@ -133,4 +137,8 @@ export class VacayService {
getSharedCalendars(viewerId: number, year: string) {
return svc.getSharedCalendars(viewerId, year);
}
getSchoolHolidays(year: string, country: string, subdivision?: string | null, language?: string, group?: string | null) {
return svc.getSchoolHolidays(year, country, subdivision, language, group);
}
}
+70 -7
View File
@@ -10,6 +10,7 @@ export interface VacayPlan {
block_weekends: number;
holidays_enabled: number;
holidays_region: string | null;
school_holidays_enabled: number;
company_holidays_enabled: number;
carry_over_enabled: number;
}
@@ -44,9 +45,16 @@ export interface Holiday {
counties?: string[] | null;
}
export interface SchoolHoliday {
startDate?: string;
endDate?: string;
name?: Array<{ language?: string; text?: string }> | string;
}
export interface VacayHolidayCalendar {
id: number;
plan_id: number;
type: 'public_holiday' | 'school_holiday';
region: string;
label: string | null;
color: string;
@@ -196,7 +204,7 @@ export function notifyShareViewers(ownerIds: number[], excludeSid?: string): voi
export async function applyHolidayCalendars(planId: number): Promise<void> {
const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined;
if (!plan?.holidays_enabled) return;
const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
const calendars = db.prepare("SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id").all(planId) as VacayHolidayCalendar[];
if (calendars.length === 0) return;
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
for (const cal of calendars) {
@@ -242,6 +250,7 @@ export interface UpdatePlanBody {
block_weekends?: boolean;
holidays_enabled?: boolean;
holidays_region?: string;
school_holidays_enabled?: boolean;
company_holidays_enabled?: boolean;
carry_over_enabled?: boolean;
weekend_days?: string;
@@ -249,13 +258,14 @@ export interface UpdatePlanBody {
}
export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body;
const { block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body;
const updates: string[] = [];
const params: (string | number)[] = [];
if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); }
if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); }
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
if (school_holidays_enabled !== undefined) { updates.push('school_holidays_enabled = ?'); params.push(school_holidays_enabled ? 1 : 0); }
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
@@ -309,6 +319,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
...updated,
block_weekends: !!updated.block_weekends,
holidays_enabled: !!updated.holidays_enabled,
school_holidays_enabled: !!updated.school_holidays_enabled,
company_holidays_enabled: !!updated.company_holidays_enabled,
carry_over_enabled: !!updated.carry_over_enabled,
holiday_calendars: updatedCalendars,
@@ -320,10 +331,10 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
// Holiday calendars CRUD
// ---------------------------------------------------------------------------
export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) {
export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
const result = db.prepare(
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(planId, region, label || null, color || '#fecaca', sortOrder ?? 0);
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0);
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
notifyPlanUsers(planId, socketId, 'vacay:settings');
return cal;
@@ -332,15 +343,16 @@ export function addHolidayCalendar(planId: number, region: string, label: string
export function updateHolidayCalendar(
calId: number,
planId: number,
body: { region?: string; label?: string | null; color?: string; sort_order?: number },
body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
socketId: string | undefined,
): VacayHolidayCalendar | null {
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId) as VacayHolidayCalendar | undefined;
if (!cal) return null;
const { region, label, color, sort_order } = body;
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (region !== undefined) { updates.push('region = ?'); params.push(region); }
if (type !== undefined) { updates.push('type = ?'); params.push(type); }
if (label !== undefined) { updates.push('label = ?'); params.push(label); }
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
@@ -904,6 +916,7 @@ export function getPlanData(userId: number) {
...plan,
block_weekends: !!plan.block_weekends,
holidays_enabled: !!plan.holidays_enabled,
school_holidays_enabled: !!plan.school_holidays_enabled,
company_holidays_enabled: !!plan.company_holidays_enabled,
carry_over_enabled: !!plan.carry_over_enabled,
holiday_calendars: holidayCalendars,
@@ -947,3 +960,53 @@ export async function getHolidays(year: string, country: string): Promise<{ data
return { error: 'Failed to fetch holidays' };
}
}
export async function getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> {
const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase();
const cacheKey = `school-regions-${country}-${normalizedLanguage}`;
const cached = holidayCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
try {
const [groupsResp, subdivisionsResp] = await Promise.all([
fetch(`https://openholidaysapi.org/Groups?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }),
fetch(`https://openholidaysapi.org/Subdivisions?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }),
]);
if (!groupsResp.ok || !subdivisionsResp.ok) return { error: 'Failed to fetch school holiday regions' };
const data = {
groups: await groupsResp.json(),
subdivisions: await subdivisionsResp.json(),
};
holidayCache.set(cacheKey, { data, time: Date.now() });
return { data };
} catch {
return { error: 'Failed to fetch school holiday regions' };
}
}
export async function getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> {
const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase();
const normalizedSubdivision = subdivision || '';
const normalizedGroup = group || '';
const cacheKey = `school-${year}-${country}-${normalizedSubdivision || 'all'}-${normalizedGroup || 'all'}-${normalizedLanguage}`;
const cached = holidayCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
try {
const params = new URLSearchParams({
countryIsoCode: country,
languageIsoCode: normalizedLanguage,
validFrom: `${year}-01-01`,
validTo: `${year}-12-31`,
});
if (normalizedSubdivision) params.set('subdivisionCode', normalizedSubdivision);
if (normalizedGroup) params.set('groupCode', normalizedGroup);
const resp = await fetch(`https://openholidaysapi.org/SchoolHolidays?${params.toString()}`, {
headers: { accept: 'text/json' },
});
if (!resp.ok) return { error: 'Failed to fetch school holidays' };
const data = await resp.json();
holidayCache.set(cacheKey, { data, time: Date.now() });
return { data };
} catch {
return { error: 'Failed to fetch school holidays' };
}
}
@@ -46,7 +46,7 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } });
expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock');
expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock', undefined);
});
it('404 on update of a missing calendar', () => {
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'السبت',
'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'اختر الدولة',
'vacay.selectRegion': 'اختر المنطقة (اختياري)',
'vacay.addCalendar': 'إضافة تقويم',
'vacay.calendarLabel': 'التسمية',
'vacay.calendarColor': 'اللون',
'vacay.noCalendars': 'لا توجد تقويمات',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'عطل الشركة',
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
@@ -106,5 +107,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'إخفاء من التقويم',
'vacay.sharedEmpty': 'اعرضا تقويميكما لبعضكما البعض دون دمجهما.',
'vacay.sharedLegend': 'مشترك (عرض فقط)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -47,13 +47,14 @@ const vacay: TranslationStrings = {
'vacay.sun': 'Dom',
'vacay.blockWeekendsHint': 'Impedir entradas de férias aos sábados e domingos',
'vacay.publicHolidays': 'Feriados nacionais',
'vacay.publicHolidaysHint': 'Marcar feriados nacionais no calendário',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Selecione o país',
'vacay.selectRegion': 'Selecione a região (opcional)',
'vacay.addCalendar': 'Adicionar calendário',
'vacay.calendarLabel': 'Rótulo (opcional)',
'vacay.calendarColor': 'Cor',
'vacay.noCalendars': 'Nenhum calendário de feriados adicionado ainda',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Feriados da empresa',
'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa',
'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.',
@@ -109,5 +110,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Ocultar do calendário',
'vacay.sharedEmpty': 'Mostrem seus calendários um ao outro sem fundi-los.',
'vacay.sharedLegend': 'Compartilhado (somente visualização)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Ds',
'vacay.sun': 'Dg',
'vacay.publicHolidays': 'Festius',
'vacay.publicHolidaysHint': 'Marca festius al calendari',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Selecciona un país',
'vacay.selectRegion': 'Selecciona una regió (opcional)',
'vacay.companyHolidays': "Festius de l'empresa",
@@ -107,6 +109,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Afegeix un calendari',
'vacay.calendarColor': 'Color del calendari',
'vacay.calendarLabel': 'Etiqueta',
'vacay.noCalendars': 'Sense calendaris',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sun': 'Ne',
'vacay.weekendDays': 'Víkendové dny',
'vacay.publicHolidays': 'Státní svátky',
'vacay.publicHolidaysHint': 'Zobrazit státní svátky v kalendáři',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Vyberte zemi',
'vacay.selectRegion': 'Vyberte region (volitelné)',
'vacay.addCalendar': 'Přidat kalendář',
'vacay.calendarLabel': 'Popisek (volitelné)',
'vacay.calendarColor': 'Barva',
'vacay.noCalendars': 'Zatím nebyly přidány žádné svátkové kalendáře',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Firemní volno',
'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna',
'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Skrýt z kalendáře',
'vacay.sharedEmpty': 'Ukažte si navzájem své kalendáře, aniž byste je propojili.',
'vacay.sharedLegend': 'Sdílené (pouze pro čtení)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+3
View File
@@ -47,12 +47,15 @@ const vacay: TranslationStrings = {
'vacay.sun': 'So',
'vacay.publicHolidays': 'Feiertage',
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
'vacay.schoolHolidays': 'Schulferien',
'vacay.schoolHolidaysHint': 'Schulferien als visuelle Kalender-Ebene markieren',
'vacay.selectCountry': 'Land wählen',
'vacay.selectRegion': 'Region wählen (optional)',
'vacay.addCalendar': 'Kalender hinzufügen',
'vacay.calendarLabel': 'Bezeichnung (optional)',
'vacay.calendarColor': 'Farbe',
'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt',
'vacay.noSchoolCalendars': 'Noch keine Schulferienkalender angelegt',
'vacay.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
+3
View File
@@ -47,12 +47,15 @@ const vacay: TranslationStrings = {
'vacay.sun': 'Sun',
'vacay.publicHolidays': 'Public Holidays',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Select country',
'vacay.selectRegion': 'Select region (optional)',
'vacay.addCalendar': 'Add calendar',
'vacay.calendarLabel': 'Label (optional)',
'vacay.calendarColor': 'Color',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Sáb',
'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festivos',
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Seleccionar país',
'vacay.selectRegion': 'Seleccionar región (opcional)',
'vacay.companyHolidays': 'Festivos de empresa',
@@ -107,6 +109,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Añadir calendario',
'vacay.calendarColor': 'Color del calendario',
'vacay.calendarLabel': 'Etiqueta',
'vacay.noCalendars': 'Sin calendarios',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -47,7 +47,9 @@ const vacay: TranslationStrings = {
'vacay.blockWeekends': 'Bloquer les week-ends',
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
'vacay.publicHolidays': 'Jours fériés',
'vacay.publicHolidaysHint': 'Marquer les jours fériés dans le calendrier',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Sélectionner un pays',
'vacay.selectRegion': 'Sélectionner une région (facultatif)',
'vacay.companyHolidays': "Jours fériés d'entreprise",
@@ -108,6 +110,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Ajouter un calendrier',
'vacay.calendarColor': 'Couleur',
'vacay.calendarLabel': 'Libellé',
'vacay.noCalendars': 'Aucun calendrier',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -47,13 +47,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Σαβ',
'vacay.sun': 'Κυρ',
'vacay.publicHolidays': 'Δημόσιες Αργίες',
'vacay.publicHolidaysHint': 'Σήμανση δημόσιων αργιών στο ημερολόγιο',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Επιλογή χώρας',
'vacay.selectRegion': 'Επιλογή περιοχής (προαιρετικό)',
'vacay.addCalendar': 'Προσθήκη ημερολογίου',
'vacay.calendarLabel': 'Ετικέτα (προαιρετικό)',
'vacay.calendarColor': 'Χρώμα',
'vacay.noCalendars': 'Δεν έχουν προστεθεί ημερολόγια αργιών ακόμη',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Αργίες Εταιρείας',
'vacay.companyHolidaysHint': 'Επιτρέψτε τη σήμανση αργιών για όλη την εταιρεία',
'vacay.companyHolidaysNoDeduct': 'Οι αργίες της εταιρείας δεν προσμετρώνται στις ημέρες διακοπών.',
@@ -109,5 +110,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Απόκρυψη από το ημερολόγιο',
'vacay.sharedEmpty': 'Δείξτε ο ένας στον άλλο τα ημερολόγιά σας χωρίς να τα συγχωνεύσετε.',
'vacay.sharedLegend': 'Κοινόχρηστο (μόνο προβολή)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -38,13 +38,14 @@ const vacay: TranslationStrings = {
'vacay.blockWeekends': 'Hétvégék zárolása',
'vacay.blockWeekendsHint': 'Szabadság-bejegyzések megakadályozása szombaton és vasárnap',
'vacay.publicHolidays': 'Ünnepnapok',
'vacay.publicHolidaysHint': 'Ünnepnapok megjelölése a naptárban',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Ország kiválasztása',
'vacay.selectRegion': 'Régió kiválasztása (opcionális)',
'vacay.addCalendar': 'Naptár hozzáadása',
'vacay.calendarLabel': 'Címke (opcionális)',
'vacay.calendarColor': 'Szín',
'vacay.noCalendars': 'Még nincsenek ünnepnap-naptárak hozzáadva',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.weekendDays': 'Hétvégi napok',
'vacay.mon': 'Hé',
'vacay.tue': 'Ke',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Elrejtés a naptárból',
'vacay.sharedEmpty': 'Mutassátok meg egymásnak a naptáraitokat összevonás nélkül.',
'vacay.sharedLegend': 'Megosztott (csak megtekintés)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Sab',
'vacay.sun': 'Min',
'vacay.publicHolidays': 'Hari Libur Nasional',
'vacay.publicHolidaysHint': 'Tandai hari libur nasional di kalender',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Pilih negara',
'vacay.selectRegion': 'Pilih wilayah (opsional)',
'vacay.addCalendar': 'Tambah kalender',
'vacay.calendarLabel': 'Label (opsional)',
'vacay.calendarColor': 'Warna',
'vacay.noCalendars': 'Belum ada kalender hari libur ditambahkan',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Hari Libur Perusahaan',
'vacay.companyHolidaysHint': 'Izinkan penandaan hari libur seluruh perusahaan',
'vacay.companyHolidaysNoDeduct': 'Hari libur perusahaan tidak dipotong dari jatah cuti.',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Sembunyikan dari kalender',
'vacay.sharedEmpty': 'Saling tunjukkan kalender kalian tanpa menggabungkannya.',
'vacay.sharedLegend': 'Dibagikan (hanya lihat)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -47,13 +47,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Sab',
'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festività pubbliche',
'vacay.publicHolidaysHint': 'Segna le festività pubbliche nel calendario',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Seleziona paese',
'vacay.selectRegion': 'Seleziona regione (opzionale)',
'vacay.addCalendar': 'Aggiungi calendario',
'vacay.calendarLabel': 'Etichetta (opzionale)',
'vacay.calendarColor': 'Colore',
'vacay.noCalendars': 'Ancora nessun calendario delle festività aggiunto',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Ferie aziendali',
'vacay.companyHolidaysHint': 'Consenti di segnare giorni di ferie aziendali',
'vacay.companyHolidaysNoDeduct': 'Le ferie aziendali non vengono conteggiate nei giorni di ferie.',
@@ -109,5 +110,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Nascondi dal calendario',
'vacay.sharedEmpty': 'Mostratevi i calendari a vicenda senza unirli.',
'vacay.sharedLegend': 'Condiviso (sola visualizzazione)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': '土',
'vacay.sun': '日',
'vacay.publicHolidays': '祝日',
'vacay.publicHolidaysHint': 'カレンダーに祝日を表示',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': '国を選択',
'vacay.selectRegion': '地域を選択(任意)',
'vacay.addCalendar': 'カレンダーを追加',
'vacay.calendarLabel': 'ラベル(任意)',
'vacay.calendarColor': '色',
'vacay.noCalendars': '祝日カレンダーはまだありません',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': '会社休日',
'vacay.companyHolidaysHint': '会社全体の休日を設定できます',
'vacay.companyHolidaysNoDeduct': '会社休日は休暇日数に含まれません。',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'カレンダーから非表示',
'vacay.sharedEmpty': '統合せずにお互いのカレンダーを見せ合えます。',
'vacay.sharedLegend': '共有(閲覧のみ)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': '토',
'vacay.sun': '일',
'vacay.publicHolidays': '공휴일',
'vacay.publicHolidaysHint': '캘린더에 공휴일을 표시합니다',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': '국가 선택',
'vacay.selectRegion': '지역 선택 (선택)',
'vacay.addCalendar': '캘린더 추가',
'vacay.calendarLabel': '레이블 (선택)',
'vacay.calendarColor': '색상',
'vacay.noCalendars': '아직 공휴일 캘린더가 없습니다',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': '회사 휴일',
'vacay.companyHolidaysHint': '회사 전체 휴일 표시를 허용합니다',
'vacay.companyHolidaysNoDeduct': '회사 휴일은 휴가 일수에서 차감되지 않습니다.',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': '캘린더에서 숨기기',
'vacay.sharedEmpty': '캘린더를 합치지 않고 서로에게 보여주세요.',
'vacay.sharedLegend': '공유됨 (보기 전용)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Za',
'vacay.sun': 'Zo',
'vacay.publicHolidays': 'Feestdagen',
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Selecteer land',
'vacay.selectRegion': 'Selecteer regio (optioneel)',
'vacay.companyHolidays': 'Bedrijfsvakanties',
@@ -106,6 +108,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Kalender toevoegen',
'vacay.calendarColor': 'Kleur',
'vacay.calendarLabel': 'Label',
'vacay.noCalendars': 'Geen kalenders',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Sob',
'vacay.sun': 'Nd',
'vacay.publicHolidays': 'Święta państwowe',
'vacay.publicHolidaysHint': 'Oznacz święta państwowe w kalendarzu',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Wybierz kraj',
'vacay.selectRegion': 'Wybierz region (opcjonalnie)',
'vacay.addCalendar': 'Dodaj kalendarz',
'vacay.calendarLabel': 'Etykieta (opcjonalnie)',
'vacay.calendarColor': 'Kolor',
'vacay.noCalendars': 'Nie dodano jeszcze kalendarzy świąt',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Urlopy firmowe',
'vacay.companyHolidaysHint': 'Pozwala oznaczać dni wolne od pracy w kalendarzu',
'vacay.companyHolidaysNoDeduct': 'Urlopy firmowe nie są odejmowane od puli dni urlopowych.',
@@ -108,5 +109,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Ukryj w kalendarzu',
'vacay.sharedEmpty': 'Pokażcie sobie nawzajem swoje kalendarze bez ich łączenia.',
'vacay.sharedLegend': 'Udostępnione (tylko podgląd)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Сб',
'vacay.sun': 'Вс',
'vacay.publicHolidays': 'Государственные праздники',
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Выберите страну',
'vacay.selectRegion': 'Выберите регион (необязательно)',
'vacay.companyHolidays': 'Корпоративные выходные',
@@ -106,6 +108,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Добавить календарь',
'vacay.calendarColor': 'Цвет',
'vacay.calendarLabel': 'Название',
'vacay.noCalendars': 'Нет календарей',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Lör',
'vacay.sun': 'Sön',
'vacay.publicHolidays': 'Allmänna helgdagar',
'vacay.publicHolidaysHint': 'Markera allmäna helgdagar i kalendern',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Välj land',
'vacay.selectRegion': 'Välj region (valfritt)',
'vacay.addCalendar': 'Lägg till kalender',
'vacay.calendarLabel': 'Etikett (valfritt)',
'vacay.calendarColor': 'Färg',
'vacay.noCalendars': 'Inga helgdagskalendrar har ännu tillagts',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Företagshelger',
'vacay.companyHolidaysHint': 'Tillåt markering av helgdagar som gäller för hela företaget',
'vacay.companyHolidaysNoDeduct': 'Företagets helgdagar räknas inte in i semesterdagarna.',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Dölj från kalendern',
'vacay.sharedEmpty': 'Visa varandra era kalendrar utan att slå samman dem.',
'vacay.sharedLegend': 'Delad (endast visning)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Doygunluk',
'vacay.sun': 'Güneş',
'vacay.publicHolidays': 'Resmi Tatiller',
'vacay.publicHolidaysHint': 'Resmi tatil günlerini takvimde işaretleyin',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Ülke seçin',
'vacay.selectRegion': 'Bölge seçin (isteğe bağlı)',
'vacay.addCalendar': 'Takvim ekle',
'vacay.calendarLabel': 'Etiket (isteğe bağlı)',
'vacay.calendarColor': 'Renk',
'vacay.noCalendars': 'Henüz tatil takvimi eklenmedi',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Şirket Tatilleri',
'vacay.companyHolidaysHint': 'Şirket çapında tatil günlerinin işaretlenmesine izin ver',
'vacay.companyHolidaysNoDeduct': 'Şirket tatilleri tatil günlerinden sayılmaz.',
@@ -107,5 +108,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Takvimden gizle',
'vacay.sharedEmpty': 'Takvimlerinizi birleştirmeden birbirinize gösterin.',
'vacay.sharedLegend': 'Paylaşılan (yalnızca görüntüleme)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Сб',
'vacay.sun': 'Нд',
'vacay.publicHolidays': 'Державні свята',
'vacay.publicHolidaysHint': 'Позначати державні свята в календарі',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Виберіть країну',
'vacay.selectRegion': 'Виберіть регіон (необов’язково)',
'vacay.companyHolidays': 'Корпоративні вихідні',
@@ -106,6 +108,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': 'Добавить календарь',
'vacay.calendarColor': 'Колір',
'vacay.calendarLabel': 'Назва',
'vacay.noCalendars': 'Немає календарів',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,13 +46,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'Thứ bảy',
'vacay.sun': 'Chủ nhật',
'vacay.publicHolidays': 'Ngày lễ',
'vacay.publicHolidaysHint': 'Đánh dấu các ngày lễ trong lịch',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'Chọn quốc gia',
'vacay.selectRegion': 'Chọn khu vực (tùy chọn)',
'vacay.addCalendar': 'Thêm lịch',
'vacay.calendarLabel': 'Nhãn (tùy chọn)',
'vacay.calendarColor': 'Màu sắc',
'vacay.noCalendars': 'Chưa có lịch nghỉ lễ nào được thêm vào',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'Ngày lễ của công ty',
'vacay.companyHolidaysHint': 'Cho phép đánh dấu ngày nghỉ lễ toàn công ty',
'vacay.companyHolidaysNoDeduct': 'Ngày nghỉ của công ty không được tính vào ngày nghỉ.',
@@ -108,5 +109,7 @@ const vacay: TranslationStrings = {
'vacay.hideFromCalendar': 'Ẩn khỏi lịch',
'vacay.sharedEmpty': 'Cho nhau xem lịch của mình mà không cần hợp nhất chúng.',
'vacay.sharedLegend': 'Được chia sẻ (chỉ xem)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': '週六',
'vacay.sun': '週日',
'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日曆中標記公共假日',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': '選擇國家',
'vacay.selectRegion': '選擇地區(可選)',
'vacay.companyHolidays': '公司假日',
@@ -105,6 +107,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': '新增日曆',
'vacay.calendarColor': '顏色',
'vacay.calendarLabel': '標籤',
'vacay.noCalendars': '無日曆',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+5 -2
View File
@@ -46,7 +46,9 @@ const vacay: TranslationStrings = {
'vacay.sat': '周六',
'vacay.sun': '周日',
'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日历中标记公共假日',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': '选择国家',
'vacay.selectRegion': '选择地区(可选)',
'vacay.companyHolidays': '公司假日',
@@ -105,6 +107,7 @@ const vacay: TranslationStrings = {
'vacay.addCalendar': '添加日历',
'vacay.calendarColor': '颜色',
'vacay.calendarLabel': '标签',
'vacay.noCalendars': '无日历',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
};
export default vacay;
+1
View File
@@ -18,6 +18,7 @@ const open = z.record(z.string(), z.unknown());
export const vacayAddHolidayCalendarRequestSchema = z.object({
region: z.string().min(1),
type: z.enum(['public_holiday', 'school_holiday']).optional(),
label: z.string().nullable().optional(),
color: z.string().optional(),
sort_order: z.number().optional(),