From c303e1cf2b5e2399895523349f39cc8d6cb9f54c Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 7 Sep 2026 09:09:26 +0200 Subject: [PATCH] refactor: Datums- und Zeitraster-Helfer nach src/lib/weekly/date-utils.ts Zweiter Schnitt an WeeklyView.tsx (D1). 13 reine Funktionen ohne Komponenten-State: getStartOfWeek, getDayName, getTimeSlots, formatHour, getWeekNumber, formatCustomHeader und weitere. 11.830 -> 11.655 Zeilen. Mit npm run build verifiziert (Exit 0). Nebenbefund: getWeekNumber existiert im Projekt dreimal -- hier, in WeekPrintModal.tsx und in api/user/export-week-view/route.ts. Das Ersetzen der Duplikate steht in TODO.md; vorher gehoeren die drei Implementierungen auf Gleichheit geprueft. v1.117.6 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++ TODO.md | 9 +- package-lock.json | 4 +- package.json | 2 +- src/components/WeeklyView.tsx | 182 +----------------------------- src/lib/weekly/date-utils.ts | 201 ++++++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 182 deletions(-) create mode 100644 src/lib/weekly/date-utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bdc979..7ad80eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project are documented in this file. +## [1.117.6] - 2026-09-07 + +### Changed +- Datums-, Wochen- und Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` verschoben + (13 reine Funktionen ohne Komponenten-State: `getStartOfWeek`, `getDayName`, + `getTimeSlots`, `formatHour`, `getWeekNumber`, `formatCustomHeader` und weitere). + `WeeklyView.tsx`: 11.830 → 11.655 Zeilen. Mit `npm run build` verifiziert (Exit 0). + +### Notes +- `getWeekNumber` existiert im Projekt **dreimal** — hier, in `WeekPrintModal.tsx` und in + `app/api/user/export-week-view/route.ts`. Die Duplikate zu ersetzen steht in TODO.md; + vorher müssen die drei Implementierungen auf Gleichheit geprüft werden. + ## [1.117.5] - 2026-09-07 ### Changed diff --git a/TODO.md b/TODO.md index be57c10..4576b04 100644 --- a/TODO.md +++ b/TODO.md @@ -128,7 +128,7 @@ Betroffen sind genau zwei Dateien: | Datei | Zeilen | |---|---:| -| `src/components/WeeklyView.tsx` | 11.830 *(war 11.906)* | +| `src/components/WeeklyView.tsx` | 11.655 *(war 11.906)* | | `src/components/SettingsSidebar.tsx` | 5.445 | **Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe @@ -137,7 +137,12 @@ Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt. #### WeeklyView.tsx → Zielstruktur - [x] Typen nach `src/lib/weekly/types.ts` (7 Typen, 97 Zeilen) — Build verifiziert - [ ] `src/components/weekly/` anlegen -- [ ] Reine Helfer nach `src/lib/weekly/` (Datum, Slots, Formatierung) +- [x] Datums-/Zeitraster-Helfer nach `src/lib/weekly/date-utils.ts` (13 Funktionen, 202 Zeilen) +- [ ] Cookie-/Geräteeinstellungen nach `src/lib/weekly/device-settings.ts` +- [ ] Formatierungs-Helfer (`stripHtml`, `invertColor`, `isAllDayEvent`) auslagern +- [ ] `getWeekNumber` existiert **dreimal** im Projekt (WeeklyView, `WeekPrintModal.tsx`, + `api/user/export-week-view/route.ts`) — Duplikate gegen `date-utils.ts` ersetzen, + vorher die drei Implementierungen auf Gleichheit prüfen - [ ] Drag & Drop in einen Hook `useTaskDragDrop.ts` - [ ] Someday-Listen in `SomedayLists.tsx` - [ ] All-Day-Leiste in `AllDayBar.tsx` diff --git a/package-lock.json b/package-lock.json index fd02fd8..862a718 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.117.5", + "version": "1.117.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.117.5", + "version": "1.117.6", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index 0a3ab17..b06985e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.117.5", + "version": "1.117.6", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 0074a8b..a82850b 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -213,195 +213,23 @@ const useGoogleFonts = (fonts: string[]) => { }, [fonts]); }; -// Date utilities -function getStartOfWeek(date: Date, startDay: number = 0): Date { - const d = new Date(date); - const day = d.getDay(); - const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct. - // Wait, let's re-verify: - // Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday). - // Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct. - // Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday). - // Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct. - // What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct. +import { + getStartOfWeek, formatDateHeader, getDayName, isSameDay, formatDateToISO, + parseCalendarDate, formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, + getCWReferenceDate, formatCustomHeader, getSelectedDay, +} from "@/lib/weekly/date-utils"; - // Better logic: - // const day = d.getDay(); - // const diff = (day < startDay ? 7 : 0) + day - startDay; - // d.setDate(d.getDate() - diff); - // - // Let's stick to a robust one: - const currentDay = d.getDay(); - const distance = (currentDay - startDay + 7) % 7; - d.setDate(d.getDate() - distance); - return d; -} -function formatDateHeader(date: Date, locale: string = "en-US"): string { - return date.toLocaleDateString(locale, { day: "numeric", month: "short" }); // e.g. 12. Feb. -} -function getDayName(date: Date, locale: string = "en-US", format?: string, customNames?: string, weekStartDay: number = 0, dayCase: string = "capitalize"): string { - let name: string; - if (format === "custom" && customNames) { - // Split by comma or semicolon to allow spaces in names - const names = customNames.split(/[,;]+/).map(s => s.trim()).filter(Boolean); - if (names.length === 7) { - // Adjust index based on weekStartDay (0=Sun, 1=Mon) - const index = (date.getDay() - weekStartDay + 7) % 7; - name = names[index]; - } else { - name = date.toLocaleDateString(locale, { weekday: "long" }); - } - } else { - const weekdayOption = format === "narrow" ? "narrow" : (format === "short" ? "short" : "long"); - try { - name = date.toLocaleDateString(locale, { weekday: weekdayOption }); - } catch (e) { - name = date.toLocaleDateString("en-US", { weekday: weekdayOption }); - } - } - if (dayCase === "uppercase") return name.toUpperCase(); - if (dayCase === "normal") return name.toLowerCase(); - // capitalize: first letter uppercase, rest lowercase - return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); -} -function isSameDay(d1: Date, d2: Date): boolean { - return d1.toDateString() === d2.toDateString(); -} -function formatDateToISO(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} -/** - * Parses a date string from a calendar event. - * If strictly a date (YYYY-MM-DD), it's parsed as local mid-night. - * If an ISO string with time, it's parsed regularly. - */ -function parseCalendarDate(dateStr: string): Date { - if (!dateStr) return new Date(); - // If it's date-only (YYYY-MM-DD), parse as local midnight - if (!dateStr.includes("T")) { - const parts = dateStr.split("-").map(Number); - if (parts.length === 3) { - return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0); - } - } - // If it's an ISO string but we want local midnight (e.g. from cache or older backend) - // we still parse it. The fix in the backend should reduce this. - return new Date(dateStr); -} -function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string { - if (timeFormat === "12h") { - const h = hour % 12 || 12; - const ampm = hour >= 12 ? "PM" : "AM"; - const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; - return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`; - } - // 24h format - if (format === "short" && minutes === 0) { - return `${hour}`; - } - return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; -} -function getTimeSlots( - cellDuration: CellDuration, - startHour: number, - endHour: number, -): string[] { - const slots: string[] = []; - const startMins = startHour * 60; - const endMins = endHour * 60; - for (let mins = startMins; mins < endMins; mins += cellDuration) { - const h = Math.floor(mins / 60); - const m = mins % 60; - slots.push(`${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`); - } - return slots; -} -function getHourFromSlot(slot: string): number { - return parseInt(slot.split(":")[0], 10); -} -function getWeekNumber(date: Date): number { - // ISO 8601 week number: weeks start on Monday - const d = new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), - ); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); -} -// Get the Monday of the ISO week that the first visible day belongs to. -// This ensures CW changes when the first visible day crosses into a new ISO week. -function getCWReferenceDate(days: Date[]): Date { - if (days.length === 0) return new Date(); - const first = days[0]; - const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat - // Calculate distance back to Monday (ISO week start) - // Sunday (0) → go back 6 days to previous Monday - // Monday (1) → 0, Tuesday (2) → 1, etc. - const distToMonday = dow === 0 ? 6 : dow - 1; - return new Date(first.getTime() - distToMonday * 86400000); -} -// Format a custom header string using tokens -function formatCustomHeader(format: string, days: Date[], language: string, t: any, refDateOverride?: Date): string { - if (!format) return ""; - - // Choose the reference date: if today is within the visible days, use today. - // Otherwise, use the standard CW reference date (start of week). - const today = new Date(); - const isTodayInWeek = days.some(d => - d.getDate() === today.getDate() && - d.getMonth() === today.getMonth() && - d.getFullYear() === today.getFullYear() - ); - const refDate = refDateOverride ?? (isTodayInWeek ? today : getCWReferenceDate(days)); - - // Define token mappings - const tokens: Record = { - "YYYY": refDate.getFullYear().toString(), - "WW": getWeekNumber(refDate).toString().padStart(2, '0'), - "MMMM": refDate.toLocaleDateString(language, { month: 'long' }), - "MMM": refDate.toLocaleDateString(language, { month: 'short' }), - "MM": (refDate.getMonth() + 1).toString().padStart(2, '0'), - "M": (refDate.getMonth() + 1).toString(), - "DDDD": refDate.toLocaleDateString(language, { weekday: 'long' }), - "DDD": refDate.toLocaleDateString(language, { weekday: 'short' }), - "DD": refDate.getDate().toString().padStart(2, '0'), - "D": refDate.getDate().toString(), - "[TODAY]": today.toLocaleDateString(language, { day: '2-digit', month: '2-digit', year: 'numeric' }) - }; - - // Single-pass replacement using regex to avoid nested replacements (e.g. M in MMMM) - // Standalone 'W' removed to allow literal 'W' (like in 'KW') - const regex = /\[TODAY\]|YYYY|WW|MMMM|MMM|MM|M|DDDD|DDD|DD|D/g; - - return format.replace(regex, (match) => tokens[match] || match); -} - -// Get the "selected" day for current_day header. -// Priority: explicit selection → today if visible → first visible day -function getSelectedDay(days: Date[], explicitSelection?: Date | null): Date { - if (explicitSelection) return explicitSelection; - const today = new Date(); - return days.some(d => - d.getDate() === today.getDate() && - d.getMonth() === today.getMonth() && - d.getFullYear() === today.getFullYear() - ) ? today : days[0]; -} // Check if an event is an all-day event // Defined outside component to avoid stale closure issues in useCallbacks diff --git a/src/lib/weekly/date-utils.ts b/src/lib/weekly/date-utils.ts new file mode 100644 index 0000000..f817538 --- /dev/null +++ b/src/lib/weekly/date-utils.ts @@ -0,0 +1,201 @@ +import type { CellDuration } from "@/lib/weekly/types"; + +/** + * Datums-, Wochen- und Zeitraster-Helfer der Wochenansicht. + * + * Reine Funktionen ohne Komponenten-State — lagen bis v1.117.6 in WeeklyView.tsx. + * + * Hinweis: getWeekNumber existiert im Projekt noch zweimal separat, in + * WeekPrintModal.tsx und in app/api/user/export-week-view/route.ts. Diese Duplikate + * gegen diese Fassung zu ersetzen ist ein eigener Schritt (siehe TODO.md D1). + */ + +// Date utilities +export function getStartOfWeek(date: Date, startDay: number = 0): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct. + // Wait, let's re-verify: + // Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday). + // Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct. + // Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday). + // Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct. + // What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct. + + // Better logic: + // const day = d.getDay(); + // const diff = (day < startDay ? 7 : 0) + day - startDay; + // d.setDate(d.getDate() - diff); + // + // Let's stick to a robust one: + const currentDay = d.getDay(); + const distance = (currentDay - startDay + 7) % 7; + d.setDate(d.getDate() - distance); + return d; +} + +export function formatDateHeader(date: Date, locale: string = "en-US"): string { + return date.toLocaleDateString(locale, { day: "numeric", month: "short" }); // e.g. 12. Feb. +} + +export function getDayName(date: Date, locale: string = "en-US", format?: string, customNames?: string, weekStartDay: number = 0, dayCase: string = "capitalize"): string { + let name: string; + if (format === "custom" && customNames) { + // Split by comma or semicolon to allow spaces in names + const names = customNames.split(/[,;]+/).map(s => s.trim()).filter(Boolean); + if (names.length === 7) { + // Adjust index based on weekStartDay (0=Sun, 1=Mon) + const index = (date.getDay() - weekStartDay + 7) % 7; + name = names[index]; + } else { + name = date.toLocaleDateString(locale, { weekday: "long" }); + } + } else { + const weekdayOption = format === "narrow" ? "narrow" : (format === "short" ? "short" : "long"); + try { + name = date.toLocaleDateString(locale, { weekday: weekdayOption }); + } catch (e) { + name = date.toLocaleDateString("en-US", { weekday: weekdayOption }); + } + } + if (dayCase === "uppercase") return name.toUpperCase(); + if (dayCase === "normal") return name.toLowerCase(); + // capitalize: first letter uppercase, rest lowercase + return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); +} + +export function isSameDay(d1: Date, d2: Date): boolean { + return d1.toDateString() === d2.toDateString(); +} + +export function formatDateToISO(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Parses a date string from a calendar event. + * If strictly a date (YYYY-MM-DD), it's parsed as local mid-night. + * If an ISO string with time, it's parsed regularly. + */ +export function parseCalendarDate(dateStr: string): Date { + if (!dateStr) return new Date(); + // If it's date-only (YYYY-MM-DD), parse as local midnight + if (!dateStr.includes("T")) { + const parts = dateStr.split("-").map(Number); + if (parts.length === 3) { + return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0); + } + } + // If it's an ISO string but we want local midnight (e.g. from cache or older backend) + // we still parse it. The fix in the backend should reduce this. + return new Date(dateStr); +} + +export function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string { + if (timeFormat === "12h") { + const h = hour % 12 || 12; + const ampm = hour >= 12 ? "PM" : "AM"; + const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; + return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`; + } + // 24h format + if (format === "short" && minutes === 0) { + return `${hour}`; + } + return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +} + +export function getTimeSlots( + cellDuration: CellDuration, + startHour: number, + endHour: number, +): string[] { + const slots: string[] = []; + const startMins = startHour * 60; + const endMins = endHour * 60; + for (let mins = startMins; mins < endMins; mins += cellDuration) { + const h = Math.floor(mins / 60); + const m = mins % 60; + slots.push(`${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`); + } + return slots; +} + +export function getHourFromSlot(slot: string): number { + return parseInt(slot.split(":")[0], 10); +} + +export function getWeekNumber(date: Date): number { + // ISO 8601 week number: weeks start on Monday + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +// Get the Monday of the ISO week that the first visible day belongs to. +// This ensures CW changes when the first visible day crosses into a new ISO week. +export function getCWReferenceDate(days: Date[]): Date { + if (days.length === 0) return new Date(); + const first = days[0]; + const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat + // Calculate distance back to Monday (ISO week start) + // Sunday (0) → go back 6 days to previous Monday + // Monday (1) → 0, Tuesday (2) → 1, etc. + const distToMonday = dow === 0 ? 6 : dow - 1; + return new Date(first.getTime() - distToMonday * 86400000); +} + +// Format a custom header string using tokens +export function formatCustomHeader(format: string, days: Date[], language: string, t: any, refDateOverride?: Date): string { + if (!format) return ""; + + // Choose the reference date: if today is within the visible days, use today. + // Otherwise, use the standard CW reference date (start of week). + const today = new Date(); + const isTodayInWeek = days.some(d => + d.getDate() === today.getDate() && + d.getMonth() === today.getMonth() && + d.getFullYear() === today.getFullYear() + ); + const refDate = refDateOverride ?? (isTodayInWeek ? today : getCWReferenceDate(days)); + + // Define token mappings + const tokens: Record = { + "YYYY": refDate.getFullYear().toString(), + "WW": getWeekNumber(refDate).toString().padStart(2, '0'), + "MMMM": refDate.toLocaleDateString(language, { month: 'long' }), + "MMM": refDate.toLocaleDateString(language, { month: 'short' }), + "MM": (refDate.getMonth() + 1).toString().padStart(2, '0'), + "M": (refDate.getMonth() + 1).toString(), + "DDDD": refDate.toLocaleDateString(language, { weekday: 'long' }), + "DDD": refDate.toLocaleDateString(language, { weekday: 'short' }), + "DD": refDate.getDate().toString().padStart(2, '0'), + "D": refDate.getDate().toString(), + "[TODAY]": today.toLocaleDateString(language, { day: '2-digit', month: '2-digit', year: 'numeric' }) + }; + + // Single-pass replacement using regex to avoid nested replacements (e.g. M in MMMM) + // Standalone 'W' removed to allow literal 'W' (like in 'KW') + const regex = /\[TODAY\]|YYYY|WW|MMMM|MMM|MM|M|DDDD|DDD|DD|D/g; + + return format.replace(regex, (match) => tokens[match] || match); +} + +// Get the "selected" day for current_day header. +// Priority: explicit selection → today if visible → first visible day +export function getSelectedDay(days: Date[], explicitSelection?: Date | null): Date { + if (explicitSelection) return explicitSelection; + const today = new Date(); + return days.some(d => + d.getDate() === today.getDate() && + d.getMonth() === today.getMonth() && + d.getFullYear() === today.getFullYear() + ) ? today : days[0]; +}