diff --git a/package.json b/package.json index 35dd488..c387c68 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cheerio": "^1.0.0-rc.10", "clsx": "^1.1.1", "eslint-config-next": "^15.1.6", + "ics": "^3.8.1", "jotai": "^2.9.2", "next": "15.3.7", "next-themes": "^0.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d9f903..23ce439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: eslint-config-next: specifier: ^15.1.6 version: 15.1.6(eslint@9.24.0)(typescript@4.9.5) + ics: + specifier: ^3.8.1 + version: 3.8.1 jotai: specifier: ^2.9.2 version: 2.11.0(@types/react@19.0.8)(react@19.0.0) @@ -1504,6 +1507,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ics@3.8.1: + resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1887,6 +1893,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1978,6 +1987,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + runes2@1.1.4: + resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2173,6 +2185,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2180,6 +2195,9 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + ts-api-utils@2.0.0: resolution: {integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==} engines: {node: '>=18.12'} @@ -2199,6 +2217,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2273,6 +2295,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + snapshots: '@babel/runtime@7.26.0': @@ -3712,6 +3737,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ics@3.8.1: + dependencies: + nanoid: 3.3.8 + runes2: 1.1.4 + yup: 1.7.1 + ignore@5.3.2: {} immutable@5.0.3: {} @@ -4109,6 +4140,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-expr@2.0.6: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -4213,6 +4246,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + runes2@1.1.4: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -4478,12 +4513,16 @@ snapshots: throttle-debounce@3.0.1: {} + tiny-case@1.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toggle-selection@1.0.6: {} + toposort@2.0.2: {} + ts-api-utils@2.0.0(typescript@4.9.5): dependencies: typescript: 4.9.5 @@ -4503,6 +4542,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.3 @@ -4608,3 +4649,10 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 diff --git a/src/app/api/holidays/route.ts b/src/app/api/holidays/route.ts new file mode 100644 index 0000000..4fdc12c --- /dev/null +++ b/src/app/api/holidays/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getHolidays, type Holiday } from "@/lib/scrapeHolidays"; +import { generateICS } from "@/lib/generateICS"; + +// Cache configuration constants +const SECONDS_IN_DAY = 86400; +const SECONDS_IN_MONTH = 30 * SECONDS_IN_DAY; // 2,592,000 seconds +const SECONDS_IN_WEEK = 7 * SECONDS_IN_DAY; // 604,800 seconds + +// Cache headers configuration +const CACHE_MAX_AGE = SECONDS_IN_DAY; // 1 day for browsers +const CACHE_S_MAXAGE = SECONDS_IN_MONTH; // 1 month for edge cache +const CACHE_STALE_WHILE_REVALIDATE = SECONDS_IN_WEEK; // 1 week stale-while-revalidate + +// Month constants (1-indexed) +const MONTHS = { + JANUARY: 1, + FEBRUARY: 2, + MARCH: 3, + APRIL: 4, + MAY: 5, + JUNE: 6, + JULY: 7, + AUGUST: 8, + SEPTEMBER: 9, + OCTOBER: 10, + NOVEMBER: 11, + DECEMBER: 12, +} as const; + +// We fetch next year's holidays when we're 1 month away from the new year +const MONTH_TO_START_FETCHING_NEXT_YEAR = MONTHS.NOVEMBER; + +/** + * Gets the current date information + */ +const getCurrentDateInfo = () => { + const now = new Date(); + return { + year: now.getFullYear(), + month: now.getMonth() + 1, // Convert from 0-indexed to 1-indexed + }; +}; + +/** + * Determines which years to fetch based on current date + * If we're in November or December, also fetch next year's holidays + * This ensures we have next year's data ready before the year transition + */ +const getYearsToFetch = (): number[] => { + const { year, month } = getCurrentDateInfo(); + const years: number[] = [year]; + + // If we're 1 month or less away from the new year, also fetch next year + if (month >= MONTH_TO_START_FETCHING_NEXT_YEAR) { + years.push(year + 1); + } + + return years; +}; + +/** + * Fetches holidays for a single year + * Throws error if fetch fails (to preserve error context) + */ +const fetchHolidaysForYear = async (year: number): Promise => { + return await getHolidays(year); +}; + +/** + * Fetches holidays for multiple years, ensuring current year is always included + */ +const fetchHolidaysForYears = async (years: number[]): Promise => { + const currentYear = getCurrentDateInfo().year; + const allHolidays: Holiday[] = []; + + for (const year of years) { + try { + const holidays = await fetchHolidaysForYear(year); + allHolidays.push(...holidays); + } catch (error) { + // If current year fails to fetch, re-throw the error + if (year === currentYear) { + throw error; + } + // For next year, log but continue (it might not be available yet) + console.error(`Failed to fetch holidays for year ${year} (non-critical):`, error); + } + } + + return allHolidays; +}; + +/** + * Sorts holidays by date in ascending order + */ +const sortHolidaysByDate = (holidays: Holiday[]): Holiday[] => { + return [...holidays].sort((a, b) => a.date.localeCompare(b.date)); +}; + +/** + * Builds cache control header value + */ +const buildCacheControlHeader = (): string => { + return `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAXAGE}, stale-while-revalidate=${CACHE_STALE_WHILE_REVALIDATE}`; +}; + +/** + * Builds response headers for ICS file + */ +const buildICSHeaders = (year: number) => { + return { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `attachment; filename="polish-holidays-${year}.ics"`, + "Cache-Control": buildCacheControlHeader(), + }; +}; + +/** + * Creates a successful ICS response + */ +const createICSSuccessResponse = (holidays: Holiday[], year: number): NextResponse => { + const icsContent = generateICS(holidays); + return new NextResponse(icsContent, { + status: 200, + headers: buildICSHeaders(year), + }); +}; + +/** + * Creates an error response + */ +const createErrorResponse = (message: string, status: number = 500): NextResponse => { + return NextResponse.json({ message }, { status }); +}; + +export async function GET(request: NextRequest) { + try { + const years = getYearsToFetch(); + const holidays = await fetchHolidaysForYears(years); + const sortedHolidays = sortHolidaysByDate(holidays); + const currentYear = getCurrentDateInfo().year; + + return createICSSuccessResponse(sortedHolidays, currentYear); + } catch (error) { + console.error("Error generating holidays ICS:", error); + return createErrorResponse("Failed to generate holidays calendar", 500); + } +} diff --git a/src/lib/generateICS.ts b/src/lib/generateICS.ts new file mode 100644 index 0000000..a71f560 --- /dev/null +++ b/src/lib/generateICS.ts @@ -0,0 +1,58 @@ +import { createEvents, type EventAttributes } from "ics"; +import type { Holiday } from "./scrapeHolidays"; + +/** + * Parses a date string (YYYY-MM-DD) into [year, month, day] array + * Month is 1-indexed (1-12) as required by the ics library + */ +const parseDateToArray = (dateStr: string): [number, number, number] => { + const [year, month, day] = dateStr.split("-").map(Number); + return [year, month, day]; +}; + +/** + * Calculates the next day for all-day events + * The ics library requires end date to be the day after for all-day events + */ +const getNextDay = (dateArray: [number, number, number]): [number, number, number] => { + const [year, month, day] = dateArray; + const date = new Date(year, month - 1, day); // month is 0-indexed in Date + date.setDate(date.getDate() + 1); + return [date.getFullYear(), date.getMonth() + 1, date.getDate()]; +}; + +/** + * Transforms a holiday into an ICS event attribute + */ +const holidayToEvent = (holiday: Holiday): EventAttributes => { + const start = parseDateToArray(holiday.date); + const end = getNextDay(start); + + return { + title: holiday.name, + start, + end, + startInputType: "local", + endInputType: "local", + productId: "dudek.sh//Polish Holidays//EN", + }; +}; + +/** + * Generates ICS calendar content from holidays using the ics library + */ +export const generateICS = (holidays: Holiday[]): string => { + const events = holidays.map(holidayToEvent); + + const { error, value } = createEvents(events); + + if (error) { + throw new Error(`Failed to generate ICS: ${error.message}`); + } + + if (!value) { + throw new Error("ICS generation returned no value"); + } + + return value; +}; diff --git a/src/lib/scrapeHolidays.ts b/src/lib/scrapeHolidays.ts new file mode 100644 index 0000000..8c9d521 --- /dev/null +++ b/src/lib/scrapeHolidays.ts @@ -0,0 +1,102 @@ +import * as cheerio from "cheerio"; + +export type Holiday = { + date: string; // YYYY-MM-DD format + name: string; +}; + +const fetchHolidaysPage = async (year: number): Promise => { + const url = `https://www.kalendarzswiat.pl/swieta/wolne_od_pracy/${year}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; HolidayScraper/1.0)", + }, + // Next.js fetch should handle SSL properly, but adding cache for reliability + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch holidays for year ${year}: ${response.status} ${response.statusText}`, + ); + } + + return response.text(); + } catch (error) { + // Re-throw with more context + if (error instanceof Error) { + // Check if it's an SSL certificate error + if ( + error.message.includes("certificate") || + error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") || + (error as any).code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" + ) { + throw new Error( + `SSL certificate verification failed for year ${year}. This is likely a development environment issue. ` + + `The code should work in production. Error: ${error.message}`, + { cause: error }, + ); + } + throw new Error(`Network error fetching holidays for year ${year}: ${error.message}`, { + cause: error, + }); + } + throw error; + } +}; + +const parseHolidays = (html: string, year: number): Holiday[] => { + const $ = cheerio.load(html); + const holidays: Holiday[] = []; + + // Find the holidays table inside the .cbox container + // This is more specific than just "table" and avoids the summary table + $(".cbox table.tab_easy tr").each((_, element) => { + const $row = $(element); + + // Look for the data-date attribute in the first column + const dateAttr = $row.find("td:first-child a[data-date]").attr("data-date"); + + if (!dateAttr) { + return; // Skip rows without date + } + + // Get the holiday name from the second column + const $nameCell = $row.find("td:nth-child(2)"); + let name = $nameCell.find("a").first().text().trim(); + + // If no link, get text directly + if (!name) { + name = $nameCell.text().trim(); + } + + if (!name) { + return; // Skip rows without name + } + + // Parse the date attribute (format: "YYYY-M-D" where M and D are not zero-padded) + // Examples: "2026-1-1", "2026-5-24", "2026-11-1", "2026-12-25" + // Convert to YYYY-MM-DD format with zero-padding + const dateParts = dateAttr.split("-"); + if (dateParts.length === 3) { + const yearPart = dateParts[0]; + const monthPart = dateParts[1].padStart(2, "0"); + const dayPart = dateParts[2].padStart(2, "0"); + const formattedDate = `${yearPart}-${monthPart}-${dayPart}`; + + holidays.push({ + date: formattedDate, + name: name, + }); + } + }); + + return holidays; +}; + +export const getHolidays = async (year: number): Promise => { + const html = await fetchHolidaysPage(year); + return parseHolidays(html, year); +};