diff --git a/package.json b/package.json index dcfce44..b4b1b45 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "axios": "^1.6.0", "date-fns": "^4.1.0", + "dompurify": "^3.2.1", "exceljs": "^4.4.0", "i18next": "^23.8.2", "i18next-browser-languagedetector": "^8.0.0", diff --git a/src/apis/openholidays-api.ts b/src/apis/openholidays-api.ts new file mode 100644 index 0000000..d81dce1 --- /dev/null +++ b/src/apis/openholidays-api.ts @@ -0,0 +1,86 @@ +import axios from "axios"; +import { format } from "date-fns"; + +const client = axios.create({ + baseURL: 'https://openholidaysapi.org/', + headers: { + "Content-type": "application/json", + }, +}); + +export const loadSubdivisions = async (languageCode?: OHApiLanguageCode): Promise => { + const params = getBaseParams(languageCode); + return (await client.get('/subdivisions', { params: params })).data; +} + +export const loadPublicHolidays = async (validFromDate: Date, validToDate: Date, languageCode?: OHApiLanguageCode): Promise => { + const params = getBaseParams(languageCode); + params.append('validFrom', format(validFromDate, 'yyyy-MM-dd')); + params.append('validTo', format(validToDate, 'yyyy-MM-dd')); + + return (await client.get('/publicholidays', { params: params })).data; +} + +export const loadSchoolHolidays = async (validFromDate: Date, validToDate: Date, languageCode?: OHApiLanguageCode): Promise => { + const params = getBaseParams(languageCode); + params.append('validFrom', format(validFromDate, 'yyyy-MM-dd')); + params.append('validTo', format(validToDate, 'yyyy-MM-dd')); + + return (await client.get('/schoolholidays', { params: params })).data; +} + +const getBaseParams = (languageCode?: OHApiLanguageCode): URLSearchParams => { + const params = new URLSearchParams(); + params.append('countryIsoCode', 'CH'); + + if (languageCode) { + params.append('languageIsoCode', languageCode); + } + + return params; +} + +export const parseLanguageOrDefault = (lang: string): OHApiLanguageCode => { + const openApiLanguageCode = lang.toUpperCase(); + return isValidLanguageCode(openApiLanguageCode) + ? openApiLanguageCode as OHApiLanguageCode + : 'EN'; +} + +const isValidLanguageCode = (code: string): boolean => code === 'DE' || code === 'FR' || code === 'IT' || code === 'EN'; + +export type OHApiLanguageCode = 'DE' | 'FR' | 'IT' | 'EN'; + +export interface OHApiSubdivision { + name: OHApiLanguageText[]; + shortName: string; + category: OHApiLanguageText[]; + code: string; + isoCode: string; + children: string[]; + officialLanguages: string[]; + comment: string | null; +} + +export interface OHApiHoliday { + id: string; + name: OHApiLanguageText[]; + type: string; + startDate: string; + endDate: string; + nationwide: boolean; + regionalScope: string; + temporalScope: string; + subdivisions?: OHApiSubdivisionInfo[]; + comment?: OHApiLanguageText[]; +} + +export interface OHApiLanguageText { + language: OHApiLanguageCode; + text: string; +} + +export interface OHApiSubdivisionInfo { + code: string; + shortName: string; +} diff --git a/src/i18n/de.json b/src/i18n/de.json index 442e818..f75436a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -17,6 +17,7 @@ "calendarPage": { "title": "Kalender", "startDate": "Erster Lagertag", + "viewHolidays": "Ferientage suchen", "responsible": "Verantwortlich", "puffer": "Puffer (Tage)", "pufferDescription": "Reduziert das Datum der Termine um die angegebene Anzahl an Tagen.", @@ -57,6 +58,19 @@ "link": "Zum Kapitel" } }, + "holidaysModal": { + "title": "Ferien und Feiertage", + "description": "Wähle einen Kanton aus und definiere das Jahr für welches du die Ferien und Feiertage sehen möchtest. Wenn du auf ein Datum klickst wird dieses als der erste Lagertag ausgewählt.", + "canton": "Kanton", + "selectCanton": "Kanton auswählen...", + "year": "Jahr", + "from": "Von", + "to": "Bis", + "type": "Art", + "noResults": "Keine Informationen zu Ferien und Feiertage gefunden.", + "close": "Schliessen", + "dataHint": "Die Informationen werden durch das OpenHolidays API bereitgestellt." + }, "searchPage": { "title": "Suche", "searchPlaceholder": "Suchbegriff eingeben...", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index fbb85d2..d81d8bd 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -17,6 +17,7 @@ "calendarPage": { "title": "Calendrier", "startDate": "Premier jour du camp", + "viewHolidays": "Chercher des jours de vacances", "responsible": "Responsable", "puffer": "Marge (jours)", "pufferDescription": "Réduit la date des rendez-vous du nombre de jours indiqué.", @@ -57,6 +58,19 @@ "link": "Au chapitre" } }, + "holidaysModal": { + "title": "Ferien und Feiertage", + "description": "Choisis un canton et définis l'année pour laquelle tu souhaites voir les vacances et les jours fériés. Si tu cliques sur une date, celle-ci sera sélectionnée comme premier jour de camp.", + "canton": "Canton", + "selectCanton": "Sélectionner un canton...", + "year": "Année", + "from": "Du", + "to": "À", + "type": "Type", + "noResults": "Aucune information trouvée sur les vacances et les jours fériés", + "close": "Fermer", + "dataHint": "Les informations sont fournies par API OpenHolidays." + }, "searchPage": { "title": "Recherche", "searchPlaceholder": "Saisir un terme de recherche...", diff --git a/src/i18n/it.json b/src/i18n/it.json index c456078..b0335e9 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -17,6 +17,7 @@ "calendarPage": { "title": "Calendario", "startDate": "Primo giorno del campo", + "viewHolidays": "Ricerca giorni di vacanza", "responsible": "Responsabile", "puffer": "Tampone (giorni)", "pufferDescription": "Riduce la data degli appuntamenti del numero di giorni specificato.", @@ -57,6 +58,19 @@ "link": "Al capitolo" } }, + "holidaysModal": { + "title": "Ferien und Feiertage", + "description": "Selezionare un cantone e definire l'anno per il quale si desidera visualizzare le vacanze e i giorni festivi. Se si fa clic su una data, questa verrà selezionata come primo giorno del campo.", + "canton": "Canton", + "selectCanton": "Selezionare il cantone...", + "year": "Anno", + "from": "Da", + "to": "A", + "type": "Tipo", + "noResults": "Non sono state trovate informazioni sulle vacanze e sui giorni festivi.", + "close": "Chiudere", + "dataHint": "Le informazioni sono fornite da API di OpenHolidays." + }, "searchPage": { "title": "Cerca", "searchPlaceholder": "Inserisci il termine di ricerca...", diff --git a/src/pages/calendar/components/CalendarForm.tsx b/src/pages/calendar/components/CalendarForm.tsx index 0d7af3b..d955c50 100644 --- a/src/pages/calendar/components/CalendarForm.tsx +++ b/src/pages/calendar/components/CalendarForm.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, useCallback, useEffect, useState } from 'react' + import React, { ChangeEvent, useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next'; import CalendarTable from './CalendarTable'; import { CalendarTask } from './Task'; @@ -12,6 +12,9 @@ import { Tooltip } from 'react-tooltip' import { sessionCache } from "../../../shared/session-cache"; import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ModalContext } from "../../../components/modal/ModalContext"; +import HolidaySelectModal, { HolidayModalResultData } from "./HolidaySelectModal"; + const dateFormat = 'yyyy-MM-dd' const initialStartDate = format(Date.now(), dateFormat) @@ -26,6 +29,7 @@ const calendarDesignationCacheKey = 'calendar-designation' function CalendarForm() { const { t } = useTranslation() + const { openModal } = useContext(ModalContext); const defaultCalendarDesignation = t('calendarPage.defaultDesignation'); @@ -87,6 +91,15 @@ function CalendarForm() { sessionCache.set(calendarDesignationCacheKey, newPrefix); } + const openHolidaysModal = async () => { + const result = await openModal(HolidaySelectModal, {}, { isWide: true }); + if (result.isCancelled || !result.data?.selectedDate) { + return + } + + updateStartDate(result.data.selectedDate) + }; + useEffect(() => { const parsedStartDate = parse(startDate, dateFormat, Date.now()) const isValidDate = isValid(parsedStartDate) @@ -173,6 +186,9 @@ function CalendarForm() { + + {t('calendarPage.viewHolidays')} +
diff --git a/src/pages/calendar/components/HolidaySelectModal.tsx b/src/pages/calendar/components/HolidaySelectModal.tsx new file mode 100644 index 0000000..d72d7ae --- /dev/null +++ b/src/pages/calendar/components/HolidaySelectModal.tsx @@ -0,0 +1,195 @@ +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useTranslation } from "react-i18next"; +import { Canton, Holiday, swissHolidaysProvider } from "./swiss-holidays-provider"; +import Loading from "../../../components/loading/Loading"; +import { useModal } from "../../../components/modal/useModal"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { Tooltip } from "react-tooltip"; +import { format } from "date-fns"; +import { sessionCache } from "../../../shared/session-cache"; +import './holiday-select-modal.less' +import DOMPurify from 'dompurify'; + +export interface HolidayModalResultData { + selectedDate: string; +} + +const selectedCantonCodeCacheKey = 'selected-canton' +const selectedYearCacheKey = 'selected-year' + +const getSuggestedYear = (): number => { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); // Months are 0-based... + + // Assume that in and after October, the next year is more relevant to the user + return currentMonth < 9 + ? currentYear + : currentYear + 1; +} + +function HolidaySelectModal() { + + const suggestedYear = getSuggestedYear(); + const maxYear = suggestedYear + 10; + const minYear = 2020; // OpenHolidays API provides Swiss data from 2020 onwards + + const { t } = useTranslation() + const { close, cancel } = useModal() + + const [isLoaded, setIsLoaded] = useState(false) + const [isUpdatingHolidays, setIsUpdatingHolidays] = useState(false) + const [cantons, setCantons] = useState([]) + const [holidays, setHolidays] = useState([]) + const [selectedCanton, setSelectedCanton] = useState(null) + const [selectedYear, setSelectedYear] = useState(suggestedYear) + + useEffect(() => { + const load = async () => { + const cantons = await swissHolidaysProvider.loadCantons() + setCantons(cantons) + + const cachedCantonCode = sessionCache.get(selectedCantonCodeCacheKey) + if (cachedCantonCode) { + const cachedCanton = cantons.find(canton => canton.code === cachedCantonCode) + if (cachedCanton) { + setSelectedCanton(cachedCanton) + } + } + + const cachedYear = sessionCache.get(selectedYearCacheKey) + if (cachedYear) { + setSelectedYear(cachedYear) + } + + setIsLoaded(true) + } + load().catch(console.error) + }, []) + + useEffect(() => { + if (!selectedCanton + || selectedYear < minYear + || selectedYear > maxYear) { + return + } + + sessionCache.set(selectedCantonCodeCacheKey, selectedCanton?.code); + sessionCache.set(selectedYearCacheKey, selectedYear); + + const load = async () => { + setIsUpdatingHolidays(true) + + const holidays = await swissHolidaysProvider.loadHolidays(selectedYear, selectedCanton) + setHolidays(holidays) + + setIsUpdatingHolidays(false) + } + load().catch(console.error) + }, [selectedYear, selectedCanton]); + + const onCantonChanged = (e: ChangeEvent) => { + const newResponsible = e.currentTarget.value + const selectedCanton = cantons.find(canton => canton.code === newResponsible) || null + setSelectedCanton(selectedCanton) + } + + const onYearChanged = (e: ChangeEvent) => { + const newYear = parseInt(e.currentTarget.value) || suggestedYear; + setSelectedYear(newYear) + } + + const selectDate = (selectedDate: string) => { + close({ selectedDate }) + } + + if (!isLoaded) { + return + } + + return <> +
+

{t('holidaysModal.title')}

+
+
+
{t('holidaysModal.description')}
+ +
+
+ + +
+
+ + +
+
+ + {selectedCanton && !isUpdatingHolidays && ( + + + + + + + + + + {holidays.map(holiday => ( + + + + + + ))} + {holidays.length === 0 && ( + + + + )} + +
{t('holidaysModal.from')}{t('holidaysModal.to')}{t('holidaysModal.type')}
+ selectDate(holiday.startDate)}> + {format(holiday.startDate, 'EEEEEE, dd.MM.yyyy')} + + + selectDate(holiday.endDate)}> + {format(holiday.endDate, 'EEEEEE, dd.MM.yyyy')} + + + {holiday.name} + {holiday.comment.length > 0 && + <> + + + {holiday.comment} + + + } +
{t('holidaysModal.noResults')}
+ )} + + {isUpdatingHolidays && } +
+
+ + +
+
+ ; +} + +export default HolidaySelectModal \ No newline at end of file diff --git a/src/pages/calendar/components/holiday-select-modal.less b/src/pages/calendar/components/holiday-select-modal.less new file mode 100644 index 0000000..d62c342 --- /dev/null +++ b/src/pages/calendar/components/holiday-select-modal.less @@ -0,0 +1,32 @@ +.holiday-selection { + display: grid; + gap: 1.5em; + + .filter { + display: grid; + gap: 1em; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + + table.holidays { + thead tr th.date { + width: 120px; + } + + tbody tr td.type { + font-weight: 600; + display: flex; + gap: 0.3em; + align-items: center; + } + } +} + +.md-footer { + justify-content: space-between; + + .data-hint { + font-style: italic; + font-size: 13px; + } +} \ No newline at end of file diff --git a/src/pages/calendar/components/swiss-holidays-provider.ts b/src/pages/calendar/components/swiss-holidays-provider.ts new file mode 100644 index 0000000..ad2dd15 --- /dev/null +++ b/src/pages/calendar/components/swiss-holidays-provider.ts @@ -0,0 +1,108 @@ +import { loadPublicHolidays, loadSchoolHolidays, loadSubdivisions, OHApiHoliday, OHApiLanguageCode, parseLanguageOrDefault } from "../../../apis/openholidays-api"; +import i18n from "../../../i18n"; + +class SwissHolidaysProvider { + + private cantonCache: Canton[] = []; + private publicHolidaysCache: Map = new Map() + private schoolHolidaysCache: Map = new Map() + + private relevantPublicHolidayNames: string[] = ['Ascension Day', 'Easter Monday', 'Pentecost Monday']; + private apiLanguage: OHApiLanguageCode + + constructor(lang: string) { + this.apiLanguage = parseLanguageOrDefault(lang); + } + + public async loadCantons(): Promise { + if (this.cantonCache.length > 0) { + return this.cantonCache; + } + + const subdivisions = await loadSubdivisions() + + const cantons = subdivisions.map(subdivision => { + return { + code: subdivision.code, + shortName: subdivision.shortName, + name: subdivision.name.find(name => name.language === this.apiLanguage)?.text + } as Canton + }) + + this.cantonCache = cantons; + return cantons; + } + + public async loadHolidays(year: number, canton?: Canton | null) { + const from = new Date(year, 0, 1) + const to = new Date(year, 11, 31) + + let publicHolidays = await this.loadPublicHolidaysCached(year, from, to); + let schoolHolidays = await this.loadSchoolHolidaysCached(year, from, to); + + if (canton) { + publicHolidays = publicHolidays.filter(h => h.nationwide || h.subdivisions?.some(s => s.code === canton.code)); + schoolHolidays = schoolHolidays.filter(h => h.subdivisions?.some(s => s.code === canton.code)); + } + + return publicHolidays + .concat(schoolHolidays) + .sort((a, b) => a.startDate.localeCompare(b.startDate)) + .map(h => { + const comments = h.comment ?? []; + const commentsInSelectedLanguage = comments.filter(c => c.language == this.apiLanguage); + // If there are no comments in the selected language, use all available comments as they might still + // contain useful information and can be translated by the user + const relevantComments = commentsInSelectedLanguage.length > 0 + ? commentsInSelectedLanguage + : comments; + + return { + id: h.id, + name: h.name.find(name => name.language === this.apiLanguage)?.text ?? h.name[0].text ?? 'unknown', + startDate: h.startDate, + endDate: h.endDate, + comment: relevantComments.map(c => c.text).join(', ') + } as Holiday; + }); + } + + private async loadPublicHolidaysCached(year: number, from: Date, to: Date) { + if (this.publicHolidaysCache.has(year)) { + return this.publicHolidaysCache.get(year)!; + } + + const relevantPublicHolidays = (await loadPublicHolidays(from, to)) + .filter(h => this.relevantPublicHolidayNames.includes(h.name.find(name => name.language === 'EN')?.text ?? '')); + this.publicHolidaysCache.set(year, relevantPublicHolidays); + + return relevantPublicHolidays; + } + + private async loadSchoolHolidaysCached(year: number, from: Date, to: Date) { + if (this.schoolHolidaysCache.has(year)) { + return this.schoolHolidaysCache.get(year)!; + } + + const schoolHolidays = await loadSchoolHolidays(from, to) + this.schoolHolidaysCache.set(year, schoolHolidays); + + return schoolHolidays; + } +} + +export const swissHolidaysProvider = new SwissHolidaysProvider(i18n.language); + +export interface Canton { + code: string; + shortName: string; + name: string; +} + +export interface Holiday { + id: string; + name: string; + startDate: string; + endDate: string; + comment: string; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9656595..e067303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -576,6 +576,11 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -901,6 +906,13 @@ devlop@^1.0.0, devlop@^1.1.0: dependencies: dequal "^2.0.0" +dompurify@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.1.tgz#d480972aeb1a59eb8ac47cba95558fbd72a0127b" + integrity sha512-NBHEsc0/kzRYQd+AY6HR6B/IgsqzBABrqJbpCDQII/OK6h7B7LXzweZTDsqSW2LkTRpoxf18YUP+YjGySk6B3w== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + duplexer2@~0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"