mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)' }
|
||||
|
||||
@@ -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')} />}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}),
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user