Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/calculation/isoWeek.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
2 changes: 2 additions & 0 deletions src/locales/de/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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(),
Expand Down
77 changes: 77 additions & 0 deletions src/locales/de/parsers/DEISOCalendarWeekParser.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
71 changes: 71 additions & 0 deletions test/de/de_iso_calendar_week.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});