diff --git a/src/calculation/isoWeek.ts b/src/calculation/isoWeek.ts new file mode 100644 index 00000000..57b4b578 --- /dev/null +++ b/src/calculation/isoWeek.ts @@ -0,0 +1,57 @@ +/** + * ISO-8601 calendar week helpers (week 1 contains 4 January; weeks Mon–Sun). + */ + +export type Ymd = { year: number; month: number; day: number }; + +function ymdFromUtcDate(d: Date): Ymd { + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + }; +} + +function addDaysYmd(ymd: Ymd, days: number): Ymd { + const d = new Date(Date.UTC(ymd.year, ymd.month - 1, ymd.day, 12)); + d.setUTCDate(d.getUTCDate() + days); + return ymdFromUtcDate(d); +} + +/** + * ISO week-year and week number for a local date (Y-M-D via UTC noon). + */ +export function getISOWeekParts(date: Date): { year: number; week: number } { + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const day = date.getDate(); + const utc = Date.UTC(y, m - 1, day, 12); + const dow = new Date(utc).getUTCDay() || 7; // Mon=1 … Sun=7 + const thursdayUtc = utc + (4 - dow) * 86400000; + const isoYear = new Date(thursdayUtc).getUTCFullYear(); + const jan4Utc = Date.UTC(isoYear, 0, 4, 12); + const jan4Dow = new Date(jan4Utc).getUTCDay() || 7; + const mondayW1Utc = jan4Utc - (jan4Dow - 1) * 86400000; + const mondayThisUtc = utc - (dow - 1) * 86400000; + const week = Math.floor((mondayThisUtc - mondayW1Utc) / (7 * 86400000)) + 1; + return { year: isoYear, week }; +} + +/** + * Monday–Sunday Y-M-D for ISO week `week` of ISO week-year `year`. + * Returns null if week 53 does not exist in that year. + */ +export function isoWeekMondaySunday(year: number, week: number): { monday: Ymd; sunday: Ymd } | null { + if (!Number.isFinite(year) || year < 1970 || year > 2100) return null; + if (!Number.isFinite(week) || week < 1 || week > 53) return null; + + const jan4: Ymd = { year, month: 1, day: 4 }; + const jan4Wd = new Date(Date.UTC(year, 0, 4, 12)).getUTCDay(); // 0=Sun + const mondayOffset = jan4Wd === 0 ? -6 : 1 - jan4Wd; + const mondayW1 = addDaysYmd(jan4, mondayOffset); + const monday = addDaysYmd(mondayW1, (week - 1) * 7); + const thursday = addDaysYmd(monday, 3); + if (thursday.year !== year) return null; + const sunday = addDaysYmd(monday, 6); + return { monday, sunday }; +} diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts index 26fbd387..9d8cb4c5 100644 --- a/src/locales/de/index.ts +++ b/src/locales/de/index.ts @@ -14,6 +14,7 @@ import DECasualTimeParser from "./parsers/DECasualTimeParser"; import DEMonthNameLittleEndianParser from "./parsers/DEMonthNameLittleEndianParser"; import DETimeUnitRelativeFormatParser from "./parsers/DETimeUnitRelativeFormatParser"; import DETimeUnitWithinFormatParser from "./parsers/DETimeUnitWithinFormatParser"; +import DEISOCalendarWeekParser from "./parsers/DEISOCalendarWeekParser"; export { Chrono, Parser, Refiner, ParsingResult, ParsingComponents, ReferenceWithTimezone }; export { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday }; @@ -44,6 +45,7 @@ export function createConfiguration(strictMode = true, littleEndian = true): Con parsers: [ new ISOFormatParser(), new SlashDateFormatParser(littleEndian), + new DEISOCalendarWeekParser(), new DETimeExpressionParser(), new DESpecificTimeExpressionParser(), new DEMonthNameLittleEndianParser(), diff --git a/src/locales/de/parsers/DEISOCalendarWeekParser.ts b/src/locales/de/parsers/DEISOCalendarWeekParser.ts new file mode 100644 index 00000000..1cad621c --- /dev/null +++ b/src/locales/de/parsers/DEISOCalendarWeekParser.ts @@ -0,0 +1,77 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingResult } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { getISOWeekParts, isoWeekMondaySunday } from "../../../calculation/isoWeek"; + +/** + * German ISO calendar weeks: "KW 40", "KW40/2026", "Kalenderwoche 12", "Woche 12/2026". + * Bare week numbers without year use the reference date's ISO week-year + * (does not roll elapsed weeks into the next year). + * + * Result is a date range Monday 00:00–Sunday (end day implied). + */ +const PATTERN = new RegExp( + "(?:" + + "(?:kw|kalenderwoche)\\s*([0-9]{1,2})(?:\\s*[/.]\\s*|\\s+)?([0-9]{4})?" + + "|" + + "woche\\s+([0-9]{1,2})(?:\\s*[/.]\\s*|\\s+)([0-9]{4})" + + "|" + + // Bare "Woche N" only with explicit year elsewhere avoided — digit-only week needs KW/Kalenderwoche + // or "Woche N/YYYY" (above). Optional: "Woche N" when followed by EOL/punct and not "diese Woche". + "woche\\s+([0-9]{1,2})(?=\\W|$)" + + ")" + + "(?=\\W|$)", + "i" +); + +export default class DEISOCalendarWeekParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult | null { + let week: number; + let year: number | null = null; + + if (match[1]) { + week = parseInt(match[1], 10); + year = match[2] ? parseInt(match[2], 10) : null; + } else if (match[3]) { + week = parseInt(match[3], 10); + year = parseInt(match[4], 10); + } else if (match[5]) { + week = parseInt(match[5], 10); + year = null; + } else { + return null; + } + + if (week < 1 || week > 53) return null; + + const ref = context.reference.getDateWithAdjustedTimezone(); + if (year == null) { + year = getISOWeekParts(ref).year; + } + + const window = isoWeekMondaySunday(year, week); + if (!window) return null; + + const result = context.createParsingResult(match.index, match[0]); + result.start.assign("year", window.monday.year); + result.start.assign("month", window.monday.month); + result.start.assign("day", window.monday.day); + result.start.imply("hour", 0); + result.start.imply("minute", 0); + result.start.imply("second", 0); + + result.end = result.start.clone(); + result.end.assign("year", window.sunday.year); + result.end.assign("month", window.sunday.month); + result.end.assign("day", window.sunday.day); + result.end.imply("hour", 23); + result.end.imply("minute", 59); + result.end.imply("second", 59); + + return result; + } +} diff --git a/test/de/de_iso_calendar_week.test.ts b/test/de/de_iso_calendar_week.test.ts new file mode 100644 index 00000000..26bc990f --- /dev/null +++ b/test/de/de_iso_calendar_week.test.ts @@ -0,0 +1,71 @@ +import * as chrono from "../../src"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; +import { getISOWeekParts, isoWeekMondaySunday } from "../../src/calculation/isoWeek"; + +test("Test - ISO week helpers", () => { + expect(isoWeekMondaySunday(2026, 42)).toEqual({ + monday: { year: 2026, month: 10, day: 12 }, + sunday: { year: 2026, month: 10, day: 18 }, + }); + expect(isoWeekMondaySunday(2026, 40)).toEqual({ + monday: { year: 2026, month: 9, day: 28 }, + sunday: { year: 2026, month: 10, day: 4 }, + }); + expect(isoWeekMondaySunday(2026, 1)).toEqual({ + monday: { year: 2025, month: 12, day: 29 }, + sunday: { year: 2026, month: 1, day: 4 }, + }); + expect(isoWeekMondaySunday(2025, 53)).toBeNull(); + + const parts = getISOWeekParts(new Date(2026, 9 - 1, 21, 12)); + expect(parts).toEqual({ year: 2026, week: 39 }); +}); + +test("Test - KW / Kalenderwoche with explicit year", () => { + const ref = new Date(2026, 9 - 1, 21, 12); + + testSingleCase(chrono.de, "KW 42/2026", ref, (result) => { + expect(result.text).toBe("KW 42/2026"); + expect(result.start.get("year")).toBe(2026); + expect(result.start.get("month")).toBe(10); + expect(result.start.get("day")).toBe(12); + expect(result.end.get("year")).toBe(2026); + expect(result.end.get("month")).toBe(10); + expect(result.end.get("day")).toBe(18); + }); + + testSingleCase(chrono.de, "Kalenderwoche 40", ref, (result) => { + expect(result.text).toBe("Kalenderwoche 40"); + expect(result.start.get("day")).toBe(28); + expect(result.start.get("month")).toBe(9); + expect(result.end.get("day")).toBe(4); + expect(result.end.get("month")).toBe(10); + }); + + testSingleCase(chrono.de, "Woche 42/2026", ref, (result) => { + expect(result.text).toBe("Woche 42/2026"); + expect(result.start.get("day")).toBe(12); + expect(result.start.get("month")).toBe(10); + }); +}); + +test("Test - bare KW uses reference ISO week-year", () => { + // Mid-Oct 2026 = ISO week 42; bare KW 40 stays in 2026 (no roll to next year) + const midOct = new Date(2026, 10 - 1, 12, 12); + testSingleCase(chrono.de, "KW 40", midOct, (result) => { + expect(result.text).toBe("KW 40"); + expect(result.start.get("year")).toBe(2026); + expect(result.start.get("month")).toBe(9); + expect(result.start.get("day")).toBe(28); + expect(result.end.get("day")).toBe(4); + expect(result.end.get("month")).toBe(10); + }); + + testSingleCase(chrono.de, "Arbeitspakete in KW 40 angelegt", midOct, (result) => { + expect(result.text).toBe("KW 40"); + }); +}); + +test("Test - diese Woche is not an ISO calendar week", () => { + testUnexpectedResult(chrono.de, "Termine diese Woche", new Date(2026, 9 - 1, 21)); +});