diff --git a/apps/client/src/widgets/launch_bar/Calendar.tsx b/apps/client/src/widgets/launch_bar/Calendar.tsx index f081eee8c..8af6628da 100644 --- a/apps/client/src/widgets/launch_bar/Calendar.tsx +++ b/apps/client/src/widgets/launch_bar/Calendar.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import server from "../../services/server"; import { TargetedMouseEvent, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { Dayjs } from "@triliumnext/commons"; +import { Dayjs, getWeekInfo, WeekSettings } from "@triliumnext/commons"; import { t } from "../../services/i18n"; interface DateNotesForMonth { @@ -22,6 +22,7 @@ const DAYS_OF_WEEK = [ interface DateRangeInfo { weekNumbers: number[]; + weekYears: number[]; dates: Dayjs[]; } @@ -36,19 +37,27 @@ export interface CalendarArgs { export default function Calendar(args: CalendarArgs) { const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek"); + const [ firstWeekOfYear ] = useTriliumOptionInt("firstWeekOfYear"); + const [ minDaysInFirstWeek ] = useTriliumOptionInt("minDaysInFirstWeek"); const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek); + const weekSettings = { + firstDayOfWeek: firstDayOfWeekISO, + firstWeekOfYear: firstWeekOfYear ?? 0, + minDaysInFirstWeek: minDaysInFirstWeek ?? 4 + }; + const date = args.date; const firstDay = date.startOf('month'); const firstDayISO = firstDay.isoWeekday(); - const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO); + const monthInfo = getMonthInformation(date, firstDayISO, weekSettings); return ( <>
- {firstDayISO !== firstDayOfWeekISO && } - + {firstDayISO !== firstDayOfWeekISO && } +
@@ -67,7 +76,7 @@ function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number } ) } -function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) { +function PreviousMonthDays({ date, info: { dates, weekNumbers, weekYears }, weekSettings, ...args }: { date: Dayjs, info: DateRangeInfo, weekSettings: WeekSettings } & CalendarArgs) { const prevMonth = date.subtract(1, 'month').format('YYYY-MM'); const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState(); @@ -77,27 +86,28 @@ function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { da return ( <> - + {dates.map(date => )} ) } -function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) { +function CurrentMonthDays({ date, weekSettings, ...args }: { date: Dayjs, weekSettings: WeekSettings } & CalendarArgs) { let dateCursor = date; const currentMonth = date.month(); const items: VNode[] = []; const curMonthString = date.format('YYYY-MM'); const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState(); + const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = weekSettings; useEffect(() => { server.get(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth); }, [ date ]); while (dateCursor.month() === currentMonth) { - const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO); - if (dateCursor.isoWeekday() === firstDayOfWeekISO) { - items.push() + const { weekYear, weekNumber } = getWeekInfo(dateCursor, weekSettings); + if (dateCursor.isoWeekday() === firstDayOfWeek) { + items.push() } items.push() @@ -141,14 +151,8 @@ function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDat ); } -function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick) { - const localDate = date.local(); - - // Handle case where week is in between years. - let year = localDate.year(); - if (localDate.month() === 11 && weekNumber === 1) year++; - - const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`; +function CalendarWeek({ date, weekNumber, weekYear, weekNotes, onWeekClicked }: { weekNumber: number, weekYear: number, weekNotes: string[] } & Pick) { + const weekString = `${weekYear}-W${String(weekNumber).padStart(2, '0')}`; if (onWeekClicked) { return ( @@ -169,33 +173,33 @@ function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumb >{weekNumber}); } -export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) { +export function getMonthInformation(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings) { return { - prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO), - nextMonth: getNextMonthDays(date, firstDayOfWeekISO) + prevMonth: getPrevMonthDays(date, firstDayISO, weekSettings), + nextMonth: getNextMonthDays(date, weekSettings.firstDayOfWeek) } } -function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo { +function getPrevMonthDays(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings): DateRangeInfo { const prevMonthLastDay = date.subtract(1, 'month').endOf('month'); - const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7; + const daysToAdd = (firstDayISO - weekSettings.firstDayOfWeek + 7) % 7; const dates: Dayjs[] = []; const firstDay = date.startOf('month'); - const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO); + const { weekYear, weekNumber } = getWeekInfo(firstDay, weekSettings); // Get dates from previous month for (let i = daysToAdd - 1; i >= 0; i--) { dates.push(prevMonthLastDay.subtract(i, 'day')); } - return { weekNumbers: [ weekNumber ], dates }; + return { weekNumbers: [ weekNumber ], weekYears: [ weekYear ], dates }; } -function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo { +function getNextMonthDays(date: Dayjs, firstDayOfWeek: number): DateRangeInfo { const lastDayOfMonth = date.endOf('month'); const lastDayISO = lastDayOfMonth.isoWeekday(); - const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1; + const lastDayOfUserWeek = ((firstDayOfWeek + 6 - 1) % 7) + 1; const nextMonthFirstDay = date.add(1, 'month').startOf('month'); const dates: Dayjs[] = []; @@ -206,16 +210,5 @@ function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo dates.push(nextMonthFirstDay.add(i, 'day')); } } - return { weekNumbers: [], dates }; -} - -export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number { - const weekStart = getWeekStartDate(date, firstDayOfWeekISO); - return weekStart.isoWeek(); -} - -function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs { - const currentISO = date.isoWeekday(); - const diff = (currentISO - firstDayOfWeekISO + 7) % 7; - return date.clone().subtract(diff, "day").startOf("day"); + return { weekNumbers: [], weekYears: [], dates }; } diff --git a/apps/server/src/services/date_notes.ts b/apps/server/src/services/date_notes.ts index 304c3a5c1..0aa15aa33 100644 --- a/apps/server/src/services/date_notes.ts +++ b/apps/server/src/services/date_notes.ts @@ -2,7 +2,7 @@ import type BNote from "../becca/entities/bnote.js"; import attributeService from "./attributes.js"; import cloningService from "./cloning.js"; -import { dayjs, Dayjs } from "@triliumnext/commons"; +import { dayjs, Dayjs, getFirstDayOfWeek1, getWeekInfo, WeekSettings } from "@triliumnext/commons"; import hoistedNoteService from "./hoisted_note.js"; import noteService from "./notes.js"; import optionService from "./options.js"; @@ -63,7 +63,8 @@ function getJournalNoteTitle( rootNote: BNote, timeUnit: TimeUnit, dateObj: Dayjs, - number: number + number: number, + weekYear?: number // Optional: the week year for cross-year weeks ) { const patterns = { year: rootNote.getOwnedLabelValue("yearPattern") || "{year}", @@ -79,9 +80,14 @@ function getJournalNoteTitle( const numberStr = number.toString(); const ordinalStr = ordinal(dateObj); + // For week notes, use the weekYear if provided (handles cross-year weeks) + const yearForDisplay = (timeUnit === "week" && weekYear !== undefined) + ? weekYear.toString() + : dateObj.format("YYYY"); + const allReplacements: Record = { // Common date formats - "{year}": dateObj.format("YYYY"), + "{year}": yearForDisplay, // Month related "{isoMonth}": dateObj.format("YYYY-MM"), @@ -286,6 +292,14 @@ function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote { return monthNote as unknown as BNote; } +function getWeekSettings(): WeekSettings { + return { + firstDayOfWeek: parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10), + firstWeekOfYear: parseInt(optionService.getOptionOrNull("firstWeekOfYear") ?? "0", 10), + minDaysInFirstWeek: parseInt(optionService.getOptionOrNull("minDaysInFirstWeek") ?? "4", 10) + }; +} + function getWeekStartDate(date: Dayjs): Dayjs { const firstDayISO = parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10); const day = date.isoWeekday(); @@ -294,9 +308,8 @@ function getWeekStartDate(date: Dayjs): Dayjs { } function getWeekNumberStr(date: Dayjs): string { - const isoYear = date.isoWeekYear(); - const isoWeekNum = date.isoWeek(); - return `${isoYear}-W${isoWeekNum.toString().padStart(2, "0")}`; + const { weekYear, weekNumber } = getWeekInfo(date, getWeekSettings()); + return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`; } function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) { @@ -329,17 +342,19 @@ function getWeekNote(weekStr: string, _rootNote: BNote | null = null): BNote | n const [ yearStr, weekNumStr ] = weekStr.trim().split("-W"); const weekNumber = parseInt(weekNumStr); + const weekYear = parseInt(yearStr); - const firstDayOfYear = dayjs().year(parseInt(yearStr)).month(0).date(1); - const weekStartDate = firstDayOfYear.add(weekNumber - 1, "week"); - const startDate = getWeekStartDate(weekStartDate); - const endDate = dayjs(startDate).add(6, "day"); + // Calculate week start date based on user's first week of year settings. + // This correctly handles cross-year weeks based on user preferences. + const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, getWeekSettings()); + const startDate = firstDayOfWeek1.add(weekNumber - 1, "week"); + const endDate = startDate.add(6, "day"); const startMonth = startDate.month(); const endMonth = endDate.month(); const monthNote = getMonthNote(startDate.format("YYYY-MM-DD"), rootNote); - const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber); + const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber, weekYear); sql.transactional(() => { weekNote = createNote(monthNote, noteTitle); diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 1ae730a56..fbcd6da8d 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -13,3 +13,4 @@ export * from "./lib/attribute_names.js"; export * from "./lib/utils.js"; export * from "./lib/dayjs.js"; export * from "./lib/notes.js"; +export * from "./lib/week_utils.js"; diff --git a/packages/commons/src/lib/week_utils.spec.ts b/packages/commons/src/lib/week_utils.spec.ts new file mode 100644 index 000000000..4c13f9e10 --- /dev/null +++ b/packages/commons/src/lib/week_utils.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { dayjs } from "./dayjs.js"; +import { getWeekInfo, getFirstDayOfWeek1, getWeekString, WeekSettings, DEFAULT_WEEK_SETTINGS } from "./week_utils.js"; + +describe("week_utils", () => { + describe("getWeekInfo", () => { + describe("with firstWeekOfYear=0 (first week contains first day of year)", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 0, + minDaysInFirstWeek: 4 + }; + + it("2025-12-29 should be 2026-W01 (cross-year week)", () => { + // 2026-01-01 is Thursday, so the week containing it starts on 2025-12-29 (Monday) + // This week should be 2026-W01 because it contains 2026-01-01 + const result = getWeekInfo(dayjs("2025-12-29"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(1); + }); + + it("2026-01-01 should be 2026-W01", () => { + const result = getWeekInfo(dayjs("2026-01-01"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(1); + }); + + it("2025-12-28 should be 2025-W52", () => { + // 2025-12-28 is Sunday, which is the last day of the week starting 2025-12-22 + const result = getWeekInfo(dayjs("2025-12-28"), settings); + expect(result.weekYear).toBe(2025); + expect(result.weekNumber).toBe(52); + }); + + it("2026-01-05 should be 2026-W02", () => { + // 2026-01-05 is Monday, start of second week + const result = getWeekInfo(dayjs("2026-01-05"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(2); + }); + }); + + describe("with firstWeekOfYear=1 (ISO standard, first week contains first Thursday)", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 1, + minDaysInFirstWeek: 4 + }; + + it("2023-01-01 should be 2022-W52 (Jan 1 is Sunday)", () => { + // 2023-01-01 is Sunday, so the week starts on 2022-12-26 + // Since this week doesn't contain Jan 4, it's 2022-W52 + const result = getWeekInfo(dayjs("2023-01-01"), settings); + expect(result.weekYear).toBe(2022); + expect(result.weekNumber).toBe(52); + }); + + it("2023-01-02 should be 2023-W01 (first Monday)", () => { + const result = getWeekInfo(dayjs("2023-01-02"), settings); + expect(result.weekYear).toBe(2023); + expect(result.weekNumber).toBe(1); + }); + }); + + describe("with firstWeekOfYear=2 (minimum days in first week)", () => { + // 2026-01-01 is Thursday + // The week containing Jan 1 starts on 2025-12-29 (Monday) + // This week has 4 days in 2026 (Thu, Fri, Sat, Sun = Jan 1-4) + + describe("with minDaysInFirstWeek=1", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 2, + minDaysInFirstWeek: 1 + }; + + it("2025-12-29 should be 2026-W01 (4 days >= 1 minimum)", () => { + // Week has 4 days in 2026, which is >= 1 + const result = getWeekInfo(dayjs("2025-12-29"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(1); + }); + + it("2026-01-01 should be 2026-W01", () => { + const result = getWeekInfo(dayjs("2026-01-01"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(1); + }); + }); + + describe("with minDaysInFirstWeek=7", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 2, + minDaysInFirstWeek: 7 + }; + + it("2025-12-29 should be 2025-W52 (4 days < 7 minimum, so this is last week of 2025)", () => { + // Week has only 4 days in 2026, which is < 7 + // So this week belongs to 2025 + const result = getWeekInfo(dayjs("2025-12-29"), settings); + expect(result.weekYear).toBe(2025); + expect(result.weekNumber).toBe(52); + }); + + it("2026-01-01 should be 2025-W52 (still last week of 2025)", () => { + const result = getWeekInfo(dayjs("2026-01-01"), settings); + expect(result.weekYear).toBe(2025); + expect(result.weekNumber).toBe(52); + }); + + it("2026-01-05 should be 2026-W01 (first full week of 2026)", () => { + // 2026-01-05 is Monday, start of the first full week + const result = getWeekInfo(dayjs("2026-01-05"), settings); + expect(result.weekYear).toBe(2026); + expect(result.weekNumber).toBe(1); + }); + }); + }); + }); + + describe("getFirstDayOfWeek1", () => { + it("with firstWeekOfYear=0, returns the first day of the week containing Jan 1", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 0, + minDaysInFirstWeek: 4 + }; + // 2026-01-01 is Thursday, so week starts on 2025-12-29 + const result = getFirstDayOfWeek1(2026, settings); + expect(result.format("YYYY-MM-DD")).toBe("2025-12-29"); + }); + + it("with firstWeekOfYear=1, returns the first day of the week containing Jan 4", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 1, + minDaysInFirstWeek: 4 + }; + // 2023-01-04 is Wednesday, so week starts on 2023-01-02 + const result = getFirstDayOfWeek1(2023, settings); + expect(result.format("YYYY-MM-DD")).toBe("2023-01-02"); + }); + }); + + describe("getWeekString", () => { + it("generates correct week string for cross-year week", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 0, + minDaysInFirstWeek: 4 + }; + expect(getWeekString(dayjs("2025-12-29"), settings)).toBe("2026-W01"); + }); + + it("generates correct week string with padded week number", () => { + const settings: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 0, + minDaysInFirstWeek: 4 + }; + expect(getWeekString(dayjs("2026-01-05"), settings)).toBe("2026-W02"); + }); + }); +}); diff --git a/packages/commons/src/lib/week_utils.ts b/packages/commons/src/lib/week_utils.ts new file mode 100644 index 000000000..db7cf4134 --- /dev/null +++ b/packages/commons/src/lib/week_utils.ts @@ -0,0 +1,143 @@ +import { dayjs, Dayjs } from "./dayjs.js"; + +/** + * Week settings for calculating week numbers. + */ +export interface WeekSettings { + /** First day of the week (1=Monday to 7=Sunday) */ + firstDayOfWeek: number; + /** + * How to determine the first week of the year: + * - 0: First week contains first day of the year + * - 1: First week contains first Thursday (ISO 8601 standard) + * - 2: First week has minimum days + */ + firstWeekOfYear: number; + /** Minimum days in first week (used when firstWeekOfYear=2) */ + minDaysInFirstWeek: number; +} + +/** + * Default week settings (first week contains first day of year, week starts on Monday). + */ +export const DEFAULT_WEEK_SETTINGS: WeekSettings = { + firstDayOfWeek: 1, + firstWeekOfYear: 0, + minDaysInFirstWeek: 4 +}; + +/** + * Gets the first day of week 1 for a given year, based on user settings. + * + * @param year The year to calculate for + * @param settings Week calculation settings + * @returns The first day of week 1 + */ +export function getFirstDayOfWeek1(year: number, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs { + const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = settings; + + const jan1 = dayjs(`${year}-01-01`); + const jan1Weekday = jan1.isoWeekday(); // 1=Monday, 7=Sunday + + // Calculate the first day of the week containing Jan 1 + const daysToSubtract = (jan1Weekday - firstDayOfWeek + 7) % 7; + const weekContainingJan1Start = jan1.subtract(daysToSubtract, "day"); + + if (firstWeekOfYear === 0) { + // First week contains first day of the year + return weekContainingJan1Start; + } else if (firstWeekOfYear === 1) { + // First week contains first Thursday (ISO 8601 standard) + const jan4 = dayjs(`${year}-01-04`); + const jan4Weekday = jan4.isoWeekday(); + const daysToSubtractFromJan4 = (jan4Weekday - firstDayOfWeek + 7) % 7; + return jan4.subtract(daysToSubtractFromJan4, "day"); + } else { + // First week has minimum days + const daysInFirstWeek = 7 - daysToSubtract; + if (daysInFirstWeek >= minDaysInFirstWeek) { + return weekContainingJan1Start; + } else { + return weekContainingJan1Start.add(1, "week"); + } + } +} + +/** + * Gets the week year and week number for a given date based on user settings. + * + * @param date The date to calculate week info for + * @param settings Week calculation settings + * @returns Object with weekYear and weekNumber + */ +export function getWeekInfo(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): { weekYear: number; weekNumber: number } { + const { firstDayOfWeek } = settings; + + // Get the start of the week containing this date + const dateWeekday = date.isoWeekday(); + const daysToSubtract = (dateWeekday - firstDayOfWeek + 7) % 7; + const weekStart = date.subtract(daysToSubtract, "day"); + + // Try current year first + let year = date.year(); + let firstDayOfWeek1 = getFirstDayOfWeek1(year, settings); + + // If the week start is before week 1 of current year, it belongs to previous year + if (weekStart.isBefore(firstDayOfWeek1)) { + year--; + firstDayOfWeek1 = getFirstDayOfWeek1(year, settings); + } else { + // Check if this might belong to next year's week 1 + const nextYearFirstDayOfWeek1 = getFirstDayOfWeek1(year + 1, settings); + if (!weekStart.isBefore(nextYearFirstDayOfWeek1)) { + year++; + firstDayOfWeek1 = nextYearFirstDayOfWeek1; + } + } + + // Calculate week number + const weekNumber = weekStart.diff(firstDayOfWeek1, "week") + 1; + + return { weekYear: year, weekNumber }; +} + +/** + * Generates a week string in the format "YYYY-Www" (e.g., "2026-W01"). + * + * @param date The date to generate the week string for + * @param settings Week calculation settings + * @returns Week string in format "YYYY-Www" + */ +export function getWeekString(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): string { + const { weekYear, weekNumber } = getWeekInfo(date, settings); + return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`; +} + +/** + * Gets the start date of the week containing the given date. + * + * @param date The date to find the week start for + * @param firstDayOfWeek First day of the week (1=Monday to 7=Sunday) + * @returns The start of the week + */ +export function getWeekStartDate(date: Dayjs, firstDayOfWeek: number = 1): Dayjs { + const dateWeekday = date.isoWeekday(); + const diff = (dateWeekday - firstDayOfWeek + 7) % 7; + return date.clone().subtract(diff, "day").startOf("day"); +} + +/** + * Parses a week string and returns the start date of that week. + * + * @param weekStr Week string in format "YYYY-Www" (e.g., "2026-W01") + * @param settings Week calculation settings + * @returns The start date of the week + */ +export function parseWeekString(weekStr: string, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs { + const [yearStr, weekNumStr] = weekStr.trim().split("-W"); + const weekNumber = parseInt(weekNumStr); + const weekYear = parseInt(yearStr); + + const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, settings); + return firstDayOfWeek1.add(weekNumber - 1, "week"); +}