import type { RouterOutputs } from "@api/trpc/routers/_app"; import { tz } from "@date-fns/tz"; import { UTCDate, utc } from "@date-fns/utc"; import { addDays, addMinutes, addSeconds, differenceInSeconds, eachDayOfInterval, format, isValid, parse, parseISO, setHours, setMinutes, } from "date-fns"; import { parseDateAsUTC } from "./date"; export const NEW_EVENT_ID = "new-event"; // API Response type from the router type ApiTrackerRecord = RouterOutputs["trackerEntries"]["byDate"]["data"][number]; // Internal tracker record type with consistent Date handling export interface TrackerRecord { id: string; date: string | null; description: string | null; duration: number | null; start: Date; stop: Date; user: { id: string; fullName: string | null; avatarUrl: string | null; } | null; trackerProject: { id: string; name: string; currency: string | null; rate: number | null; customer: { id: string; name: string; } | null; } | null; } /** * Creates a safe Date using UTCDate for better UTC handling */ export const createSafeDate = ( dateInput: string | Date | null | undefined, fallback?: Date, ): Date => { if (!dateInput) return fallback || new UTCDate(); if (typeof dateInput === "string") { // Try parseISO first (handles ISO 8601 formats) const date = parseISO(dateInput); if (isValid(date)) { return date; } // Try UTCDate constructor as final fallback try { const utcDate = utc(dateInput); if (isValid(utcDate)) { return new Date(utcDate.getTime()); } } catch (error) { console.warn("Date parsing failed:", error); } return fallback || new UTCDate(); } return isValid(dateInput) ? dateInput : fallback || new UTCDate(); }; /** * Format time from date with optional timezone support */ export const formatTimeFromDate = ( date: Date | string | null, timezone?: string, ): string => { const safeDate = createSafeDate(date); if (timezone && timezone !== "UTC") { try { const createTZDate = tz(timezone); const tzDate = createTZDate(safeDate); return format(tzDate, "HH:mm"); } catch (error) { console.warn("Timezone formatting failed:", error); } } return format(safeDate, "HH:mm"); }; /** * Parse time with midnight crossing support using timezone-aware parsing */ export const parseTimeWithMidnightCrossing = ( startTime: string, stopTime: string, baseDate: Date, timezone?: string, ): { start: Date; stop: Date; duration: number } => { if (timezone && timezone !== "UTC") { try { const createTZDate = tz(timezone); // Create timezone-aware base date const tzBaseDate = createTZDate(baseDate); // Parse times in the timezone context const startDate = parse(startTime, "HH:mm", tzBaseDate); let stopDate = parse(stopTime, "HH:mm", tzBaseDate); // If stop time is before start time, assume it's on the next day if (stopDate < startDate) { stopDate = addDays(stopDate, 1); } const duration = differenceInSeconds(stopDate, startDate); return { start: new Date(startDate.getTime()), stop: new Date(stopDate.getTime()), duration, }; } catch (error) { console.warn("Timezone time parsing failed:", error); } } // Fallback to UTC parsing const startDate = parse(startTime, "HH:mm", baseDate); let stopDate = parse(stopTime, "HH:mm", baseDate); // If stop time is before start time, assume it's on the next day if (stopDate < startDate) { stopDate = addDays(stopDate, 1); } const duration = differenceInSeconds(stopDate, startDate); return { start: startDate, stop: stopDate, duration }; }; /** * Get slot from date with timezone support (already updated) */ export const getSlotFromDate = ( date: Date | string | null, timezone?: string, ): number => { const safeDate = createSafeDate(date); if (timezone && timezone !== "UTC") { try { // Use tz() function to create timezone-aware date const createTZDate = tz(timezone); const tzDate = createTZDate(safeDate); return tzDate.getHours() * 4 + Math.floor(tzDate.getMinutes() / 15); } catch (error) { console.warn("TZDate slot calculation failed:", error); // Fallback to browser timezone } } // Fallback to browser timezone (for backward compatibility) return safeDate.getHours() * 4 + Math.floor(safeDate.getMinutes() / 15); }; /** * Calculate duration between dates with timezone support */ export const calculateDuration = ( start: Date | string | null, stop: Date | string | null, ): number => { const startDate = createSafeDate(start); const stopDate = createSafeDate(stop); // If stop is before start, assume stop is on the next day if (stopDate < startDate) { const nextDayStop = addDays(stopDate, 1); return differenceInSeconds(nextDayStop, startDate); } return differenceInSeconds(stopDate, startDate); }; /** * Format hour with timezone support */ export const formatHour = ( hour: number, timeFormat?: number | null, _timezone?: string, ) => { // Create a simple date with the hour - no timezone conversion needed for labels const date = new Date(2024, 0, 1, hour, 0, 0, 0); // Use arbitrary date, just set the hour return format(date, timeFormat === 12 ? "hh:mm a" : "HH:mm"); }; /** * Create new event with timezone-aware time creation */ export const createNewEvent = ( slot: number, selectedProjectId: string | null, selectedDate?: string | null, timezone?: string, ): TrackerRecord => { // Parse as UTC calendar date to avoid timezone shift const baseDate = selectedDate ? parseDateAsUTC(selectedDate) : new UTCDate(); // Use the original date string directly if available const dateStr = selectedDate || format(baseDate, "yyyy-MM-dd"); if (timezone && timezone !== "UTC") { try { const createTZDate = tz(timezone); const tzBaseDate = createTZDate(baseDate); const startDate = setMinutes( setHours(tzBaseDate, Math.floor(slot / 4)), (slot % 4) * 15, ); const endDate = addMinutes(startDate, 15); // When selectedDate is null, compute date from tzBaseDate (user's local date) // to avoid UTC date mismatch (e.g., 11 PM local vs 4 AM UTC next day) const tzDateStr = selectedDate || format(tzBaseDate, "yyyy-MM-dd"); return { id: NEW_EVENT_ID, date: tzDateStr, description: null, duration: 15 * 60, // 15 minutes in seconds start: new Date(startDate.getTime()), stop: new Date(endDate.getTime()), user: null, trackerProject: selectedProjectId ? { id: selectedProjectId, name: "", currency: null, rate: null, customer: null, } : null, }; } catch (error) { console.warn("Timezone event creation failed:", error); } } // Fallback to UTC creation const startDate = setMinutes( setHours(baseDate, Math.floor(slot / 4)), (slot % 4) * 15, ); const endDate = addMinutes(startDate, 15); return { id: NEW_EVENT_ID, date: dateStr, description: null, duration: 15 * 60, // 15 minutes in seconds start: startDate, stop: endDate, user: null, trackerProject: selectedProjectId ? { id: selectedProjectId, name: "", currency: null, rate: null, customer: null, } : null, }; }; // Tracker record transformation export const transformApiRecord = ( apiRecord: ApiTrackerRecord, selectedDate: string | null, ): TrackerRecord => { const start = apiRecord.start ? parseISO(apiRecord.start) : parseISO(`${apiRecord.date || selectedDate}T09:00:00`); const stop = apiRecord.stop ? parseISO(apiRecord.stop) : addSeconds(start, apiRecord.duration || 0); return { id: apiRecord.id, date: apiRecord.date, description: apiRecord.description, duration: apiRecord.duration, start: isValid(start) ? start : new Date(), stop: isValid(stop) ? stop : addMinutes(isValid(start) ? start : new Date(), 15), user: apiRecord.user, trackerProject: apiRecord.trackerProject ? { id: apiRecord.trackerProject.id, name: apiRecord.trackerProject.name || "", currency: apiRecord.trackerProject.currency, rate: apiRecord.trackerProject.rate, customer: apiRecord.trackerProject.customer, } : null, }; }; export const updateEventTime = ( event: TrackerRecord, start: Date, stop: Date, ): TrackerRecord => { return { ...event, start: isValid(start) ? start : event.start, stop: isValid(stop) ? stop : event.stop, duration: calculateDuration(start, stop), }; }; // Date range utilities export function sortDates(dates: string[]) { return dates.sort((a, b) => parseISO(a).getTime() - parseISO(b).getTime()); } export function getTrackerDates( range: string[] | null, selectedDate: string | null, ): Date[] { if (range) { // Parse as UTC calendar dates to avoid timezone shift return sortDates(range).map((dateString) => parseDateAsUTC(dateString)); } if (selectedDate) { // Parse as UTC calendar date to avoid timezone shift return [parseDateAsUTC(selectedDate)]; } return [new Date()]; } export const getDates = ( selectedDate: string | null, sortedRange: string[] | null, ): string[] => { if (selectedDate) return [selectedDate]; if (sortedRange && sortedRange.length === 2) { const [start, end] = sortedRange; if (start && end) { return eachDayOfInterval({ start: parseISO(start), end: parseISO(end), }).map((date) => format(date, "yyyy-MM-dd")); } } return []; }; // Validation utilities export const isValidTimeSlot = (slot: number): boolean => { return slot >= 0 && slot < 96; // 24 hours * 4 slots per hour }; export const isValidDateString = (dateStr: string): boolean => { return isValid(parseISO(dateStr)); }; // Form data conversion utilities export const convertToFormData = (record: TrackerRecord) => { return { id: record.id === NEW_EVENT_ID ? undefined : record.id, start: formatTimeFromDate(record.start), stop: formatTimeFromDate(record.stop), projectId: record.trackerProject?.id || "", description: record.description || "", duration: calculateDuration(record.start, record.stop), }; }; export const convertFromFormData = ( formData: { id?: string; start: string; stop: string; projectId: string; assignedId?: string; description?: string; duration: number; }, baseDate: Date, dates: string[], timezone?: string, // Add timezone parameter ): { id?: string; start: string; stop: string; dates: string[]; assignedId: string | null; projectId: string; description: string | null; duration: number; } => { const { start: startDate, stop: stopDate, duration, } = parseTimeWithMidnightCrossing( formData.start, formData.stop, baseDate, timezone, ); return { id: formData.id === NEW_EVENT_ID ? undefined : formData.id, start: startDate.toISOString(), stop: stopDate.toISOString(), dates, assignedId: formData.assignedId || null, projectId: formData.projectId, description: formData.description || null, duration: duration, }; };