From 4f7257441140b6a9f2ca240e89eba0f225703095 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 18 Sep 2026 22:15:46 -0400 Subject: [PATCH] fixing the time between UTC and timezone from New York --- src/pages/HomePage.tsx | 84 ++++++- src/pages/SchedulePage.module.scss | 238 +++++++++++++++---- src/pages/SchedulePage.tsx | 361 ++++++++++++++++++----------- src/pages/UsersPage.tsx | 200 ++++++++++++---- 4 files changed, 649 insertions(+), 234 deletions(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index dee8242..669fa5a 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -14,6 +14,65 @@ type HomePageProps = { onNavigate: (path: string) => void; }; +const businessTimeZone = "America/New_York"; + +function normalizeBackendTimestamp(value: string): string { + const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value); + const baseValue = hasTimezone ? value : `${value}Z`; + return baseValue.replace(/(\.\d{3})\d+/, "$1"); +} + +function parseBackendDateAsUTC(value: string): Date { + return new Date(normalizeBackendTimestamp(value)); +} + +function getBusinessDateKey(date: Date): string { + return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone }); +} + +function getNameScheduleKey(name: string, lastName: string): string { + return `name:${name.trim().toLowerCase()}:${lastName.trim().toLowerCase()}`; +} + +function getScheduleUserKey(schedule: Schedule): string | null { + if (typeof schedule.user_id === "number") { + return `id:${schedule.user_id}`; + } + + if (schedule.user) { + return getNameScheduleKey(schedule.user.name, schedule.user.last_name); + } + + return null; +} + +function getEmployeeScheduleKeys(user: Employee): string[] { + return [`id:${user.id}`, getNameScheduleKey(user.name, user.last_name)]; +} + +function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] { + const todayKey = getBusinessDateKey(today); + return schedules.filter((schedule) => { + if (!schedule.enter_time) { + return false; + } + const enterDate = parseBackendDateAsUTC(schedule.enter_time); + return !Number.isNaN(enterDate.getTime()) && getBusinessDateKey(enterDate) === todayKey; + }); +} + +function getLatestSchedule(schedules: Schedule[]): Schedule | undefined { + return [...schedules].sort((firstSchedule, secondSchedule) => { + const firstTime = firstSchedule.enter_time + ? parseBackendDateAsUTC(firstSchedule.enter_time).getTime() + : 0; + const secondTime = secondSchedule.enter_time + ? parseBackendDateAsUTC(secondSchedule.enter_time).getTime() + : 0; + return firstTime - secondTime; + }).at(-1); +} + export function HomePage({ backendPreference, onNavigate }: HomePageProps) { const [companies, setCompanies] = useState([]); const [schedules, setSchedules] = useState([]); @@ -60,14 +119,23 @@ export function HomePage({ backendPreference, onNavigate }: HomePageProps) { }; }, [backendPreference]); - const workingToday = new Set( - schedules - .filter( - (schedule): schedule is Schedule & { user_id: number } => - typeof schedule.user_id === "number" && Boolean(schedule.enter_time && !schedule.out_time), - ) - .map((schedule) => schedule.user_id), - ).size; + const today = new Date(); + const schedulesByUser = schedules.reduce>((accumulator, schedule) => { + const userKey = getScheduleUserKey(schedule); + if (!userKey) { + return accumulator; + } + accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule]; + return accumulator; + }, {}); + + const workingToday = users.filter((user) => { + const keys = getEmployeeScheduleKeys(user); + const userSchedules = keys.flatMap((key) => schedulesByUser[key] ?? []); + const todaySchedules = getTodaySchedules(userSchedules, today); + const latestSchedule = getLatestSchedule(todaySchedules); + return Boolean(latestSchedule?.enter_time && !latestSchedule.out_time); + }).length; return ( <> diff --git a/src/pages/SchedulePage.module.scss b/src/pages/SchedulePage.module.scss index 1cc34bc..9fd3af3 100644 --- a/src/pages/SchedulePage.module.scss +++ b/src/pages/SchedulePage.module.scss @@ -1,3 +1,9 @@ +.schedulePage { + display: grid; + gap: 18px; + min-height: 0; +} + .scheduleToolbar { display: grid; gap: 14px; @@ -61,53 +67,191 @@ } } -.calendar { +.calendarShell { + overflow: hidden; + background: #ffffff; + border-radius: 24px; + box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); display: grid; - grid-template-columns: repeat(7, minmax(130px, 1fr)); - gap: 12px; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; + border: 1px solid #e5eaf0; } -.dayCard { - min-height: 210px; - padding: 16px; - border-radius: 20px; +.calendarHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #e5eaf0; background: #ffffff; - box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07); +} - h3 { - margin: 0 0 14px; - font-size: 1rem; +.monthTitle h2 { + margin: 0; + font-size: 1.6rem; + letter-spacing: -0.03em; + color: #111827; +} - span { - display: block; - margin-top: 4px; - color: #687487; - font-size: 0.8rem; - font-weight: 500; +.headerActions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.navGroup { + display: inline-flex; + align-items: center; + gap: 8px; + + button { + display: inline-grid; + place-items: center; + width: 36px; + height: 32px; + border: 0; + border-radius: 999px; + color: #1f2937; + background: #f3f6fb; + cursor: pointer; + font-weight: 700; + + &:hover { + background: #e8eefb; } } } -.shiftList { - display: grid; - gap: 10px; +.todayButton { + width: auto !important; + padding: 0 14px; + font-size: 0.82rem; + background: #ffffff !important; + box-shadow: inset 0 0 0 1px #d9e1ec; } -.shift { - border-radius: 14px; - padding: 10px; - background: #f3f6fb; +.calendarWeekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + background: #f8fafc; + border-bottom: 1px solid #e5eaf0; +} - strong, - span { - display: block; - } +.weekdayLabel { + padding: 10px 12px; + text-align: center; + color: #6b7280; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} - span { - color: #687487; - font-size: 0.86rem; - margin-top: 4px; - } +.calendarScroll { + overflow: auto; + min-height: 0; + max-height: min(68vh, 70vh); +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, minmax(130px, 1fr)); + grid-auto-rows: minmax(122px, auto); + gap: 1px; + background: #e5eaf0; + min-width: 980px; +} + +.calendarDay { + background: #ffffff; + padding: 8px 8px 10px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 122px; +} + +.outsideMonth { + background: #fcfdff; + color: #9aa6b8; +} + +.todayCell { + background: #f8fafc; +} + +.dayHeader { + display: flex; + justify-content: flex-end; +} + +.dayNumber { + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + color: #1f2937; +} + +.todayBadge { + background: #ef4444; + color: #ffffff; +} + +.eventList { + display: grid; + gap: 6px; + align-content: start; +} + +.event { + --company-color: #2563eb; + display: grid; + gap: 2px; + padding: 6px 8px; + border-radius: 10px; + background: color-mix(in srgb, var(--company-color) 12%, #ffffff); + border-left: 3px solid var(--company-color); + overflow: hidden; +} + +.eventTitle { + font-size: 0.78rem; + font-weight: 700; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.eventMeta { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: #475569; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.companyDot { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 999px; + background: var(--company-color); +} + +.emptyDay { + min-height: 1px; } .error { @@ -121,26 +265,20 @@ } @media (max-width: 980px) { - .scheduleToolbar { - gap: 12px; + .calendarHeader { + flex-direction: column; + align-items: stretch; } - .weekNavigator { - gap: 10px; - - h2 { - min-width: 0; - font-size: 1rem; - } - - button { - width: 38px; - height: 38px; - flex: 0 0 auto; - } + .headerActions { + justify-content: space-between; } - .calendar { - overflow-x: auto; + .monthTitle h2 { + font-size: 1.3rem; + } + + .calendarScroll { + max-height: 62vh; } } diff --git a/src/pages/SchedulePage.tsx b/src/pages/SchedulePage.tsx index f091983..0a0bf6a 100644 --- a/src/pages/SchedulePage.tsx +++ b/src/pages/SchedulePage.tsx @@ -4,51 +4,112 @@ import { getWeekSchedule } from "@api/scheduleApi"; import { PageHeader } from "@components/PageHeader"; import type { BackendPreference } from "@/types/backend"; import type { Schedule } from "@/types/schedule"; -import type { DayOfWeek } from "@/types/users"; -import { useCompaniesStore } from "../store/companiesStore"; import styles from "./SchedulePage.module.scss"; +import { useCompaniesStore } from "../store/companiesStore"; type SchedulePageProps = { backendPreference: BackendPreference; }; -const weekDays: DayOfWeek[] = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", +const businessTimeZone = "America/New_York"; + +const weekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const companyColorPalette = [ + "#2563eb", + "#dc2626", + "#16a34a", + "#9333ea", + "#ea580c", + "#0891b2", + "#be123c", + "#4f46e5", + "#65a30d", + "#c026d3", + "#0e7490", + "#9d174d", ]; -function formatDay(day: DayOfWeek): string { - return day.charAt(0).toUpperCase() + day.slice(1); +function getCompanyColor(company: string | undefined): string { + if (!company) { + return "#64748b"; + } + + const hash = [...company].reduce((total, char) => total + char.charCodeAt(0), 0); + return companyColorPalette[hash % companyColorPalette.length]; } -function getWeekStart(date: Date): Date { - const weekStart = new Date(date); - weekStart.setHours(0, 0, 0, 0); - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - return weekStart; +function normalizeBackendTimestamp(value: string): string { + const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value); + const baseValue = hasTimezone ? value : `${value}Z`; + return baseValue.replace(/(\.\d{3})\d+/, "$1"); } -function addDays(date: Date, days: number): Date { - const nextDate = new Date(date); - nextDate.setDate(nextDate.getDate() + days); - return nextDate; +function parseBackendDateAsUTC(value: string): Date { + return new Date(normalizeBackendTimestamp(value)); } -function formatDate(value: Date): string { - return value.toLocaleDateString([], { - month: "short", - day: "numeric", - year: "numeric", - }); +function getBusinessDateKey(date: Date): string { + return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone }); } -function formatShortDate(value: Date): string { - return value.toLocaleDateString([], { month: "short", day: "numeric" }); +function getBusinessWeekdayIndex(date: Date): number { + const weekday = new Intl.DateTimeFormat("en-US", { + timeZone: businessTimeZone, + weekday: "short", + }).format(date); + const mapping: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + return mapping[weekday] ?? 0; +} + +function addDaysToDateKey(dateKey: string, days: number): string { + const [year, month, day] = dateKey.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +function getBusinessMonthStartKey(date: Date): string { + const businessKey = getBusinessDateKey(date); + return `${businessKey.slice(0, 7)}-01`; +} + +function getBusinessWeekdayIndexForKey(dateKey: string): number { + const [year, month, day] = dateKey.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); + return getBusinessWeekdayIndex(date); +} + +function getCalendarGridStartKey(monthStartKey: string): string { + const weekday = getBusinessWeekdayIndexForKey(monthStartKey); + return addDaysToDateKey(monthStartKey, -weekday); +} + +function formatMonthLabel(monthStartKey: string): string { + const [year, month] = monthStartKey.split("-").map(Number); + const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + return `${monthNames[month - 1]} ${year}`; } function formatTime(value: string | null): string { @@ -56,7 +117,11 @@ function formatTime(value: string | null): string { return "Not registered"; } - return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return parseBackendDateAsUTC(value).toLocaleTimeString("en-US", { + timeZone: businessTimeZone, + hour: "2-digit", + minute: "2-digit", + }); } export function SchedulePage({ backendPreference }: SchedulePageProps) { @@ -64,64 +129,71 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) { const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(true); const [selectedCompany, setSelectedCompany] = useState(""); - const [selectedWeekStart, setSelectedWeekStart] = useState(() => getWeekStart(new Date())); + const [selectedMonthStartKey, setSelectedMonthStartKey] = useState(() => + getBusinessMonthStartKey(new Date()), + ); const companyNames = useCompaniesStore((state) => state.companyNames); const companiesError = useCompaniesStore((state) => state.error); const loadCompanies = useCompaniesStore((state) => state.loadCompanies); - const selectedWeekEnd = useMemo(() => addDays(selectedWeekStart, 6), [selectedWeekStart]); - const selectedWeekEndExclusive = useMemo( - () => addDays(selectedWeekStart, 7), - [selectedWeekStart], - ); - const selectedWeekDays = useMemo( - () => weekDays.map((day, index) => ({ day, date: addDays(selectedWeekStart, index) })), - [selectedWeekStart], + const todayKey = useMemo(() => getBusinessDateKey(new Date()), []); + const calendarStartKey = useMemo( + () => getCalendarGridStartKey(selectedMonthStartKey), + [selectedMonthStartKey], ); - const schedulesByDay = useMemo(() => { - return schedules.reduce>( - (accumulator, schedule) => { - if (selectedCompany && schedule.company !== selectedCompany) { - return accumulator; - } + const calendarDays = useMemo(() => { + return Array.from({ length: 42 }, (_, index) => { + const dateKey = addDaysToDateKey(calendarStartKey, index); + const isCurrentMonth = dateKey.slice(0, 7) === selectedMonthStartKey.slice(0, 7); + const isToday = dateKey === todayKey; + const dayNumber = Number(dateKey.slice(8, 10)); + return { dateKey, isCurrentMonth, isToday, dayNumber }; + }); + }, [calendarStartKey, selectedMonthStartKey, todayKey]); - if (!schedule.enter_time) { - return accumulator; - } - - const enterDate = new Date(schedule.enter_time); - - if ( - Number.isNaN(enterDate.getTime()) || - enterDate < selectedWeekStart || - enterDate >= selectedWeekEndExclusive - ) { - return accumulator; - } - - const day = weekDays[enterDate.getDay()]; - accumulator[day] = [...accumulator[day], schedule]; + const schedulesByDateKey = useMemo(() => { + return schedules.reduce>((accumulator, schedule) => { + if (selectedCompany && schedule.company !== selectedCompany) { return accumulator; - }, - { - sunday: [], - monday: [], - tuesday: [], - wednesday: [], - thursday: [], - friday: [], - saturday: [], - }, - ); - }, [schedules, selectedCompany, selectedWeekEndExclusive, selectedWeekStart]); + } - function showPreviousWeek() { - setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, -7)); + if (!schedule.enter_time) { + return accumulator; + } + + const enterDate = parseBackendDateAsUTC(schedule.enter_time); + + if (Number.isNaN(enterDate.getTime())) { + return accumulator; + } + + const businessDateKey = getBusinessDateKey(enterDate); + accumulator[businessDateKey] = [...(accumulator[businessDateKey] ?? []), schedule]; + return accumulator; + }, {}); + }, [schedules, selectedCompany]); + + function showPreviousMonth() { + setSelectedMonthStartKey((currentKey) => { + const [y, m] = currentKey.split("-").map(Number); + const d = new Date(Date.UTC(y, m - 1, 1)); + d.setUTCMonth(d.getUTCMonth() - 1); + return d.toISOString().slice(0, 10); + }); } - function showNextWeek() { - setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, 7)); + function showNextMonth() { + setSelectedMonthStartKey((currentKey) => { + const [y, m] = currentKey.split("-").map(Number); + const d = new Date(Date.UTC(y, m - 1, 1)); + d.setUTCMonth(d.getUTCMonth() + 1); + return d.toISOString().slice(0, 10); + }); + } + + function showToday() { + setSelectedMonthStartKey(getBusinessMonthStartKey(new Date())); } useEffect(() => { @@ -166,69 +238,94 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) { }, [companyNames, selectedCompany]); return ( - <> +
{error ?

{error}

: null} {companiesError ?

{companiesError}

: null} {isLoading ?

Loading schedule...

: null} -
-
- -

- Week from {formatDate(selectedWeekStart)} to {formatDate(selectedWeekEnd)} -

- - +
+
+
+

{formatMonthLabel(selectedMonthStartKey)}

+
+
+
+ + + +
+ +
-
-
- {selectedWeekDays.map(({ day, date }) => ( -
-

- {formatDay(day)} - {formatShortDate(date)} -

-
- {schedulesByDay[day].length > 0 ? ( - schedulesByDay[day].map((schedule) => ( -
- - {schedule.user - ? `${schedule.user.name} ${schedule.user.last_name}` - : "Unknown user"} - - {schedule.company ?? "No company"} - - {formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)} + +
+
+ {calendarDays.map(({ dateKey, isCurrentMonth, isToday, dayNumber }) => { + const daySchedules = schedulesByDateKey[dateKey] ?? []; + return ( +
+
+ + {dayNumber}
- )) - ) : ( -

No shifts scheduled

- )} -
-
- ))} -
- +
+ {daySchedules.length > 0 ? ( + daySchedules.map((schedule) => ( +
+ + {schedule.user ? `${schedule.user.name} ${schedule.user.last_name}` : "Unknown user"} + + + {schedule.company ?? "No company"} ยท {formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)} + +
+ )) + ) : ( + + )} +
+
+ ); + })} +
+
+ + ); } diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 4db652d..1ef0a6e 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -20,56 +20,96 @@ type UserStatus = "off-duty" | "working" | "worked"; const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"]; const statusOptions: UserStatus[] = ["off-duty", "working", "worked"]; const usersPerPage = 10; +const businessTimeZone = "America/New_York"; -function isSameLocalDate(firstDate: Date, secondDate: Date): boolean { - return ( - firstDate.getFullYear() === secondDate.getFullYear() && - firstDate.getMonth() === secondDate.getMonth() && - firstDate.getDate() === secondDate.getDate() - ); +function normalizeBackendTimestamp(value: string): string { + const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value); + const baseValue = hasTimezone ? value : `${value}Z`; + return baseValue.replace(/(\.\d{3})\d+/, "$1"); } -function getWeekStart(date: Date): Date { - const weekStart = new Date(date); - weekStart.setHours(0, 0, 0, 0); - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - return weekStart; +function parseBackendDateAsUTC(value: string): Date { + return new Date(normalizeBackendTimestamp(value)); } -function addDays(date: Date, days: number): Date { - const nextDate = new Date(date); - nextDate.setDate(nextDate.getDate() + days); - return nextDate; +function getBusinessDateKey(date: Date): string { + return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone }); } -function isScheduleInWeek(schedule: Schedule, weekStart: Date, weekEndExclusive: Date): boolean { +function getBusinessWeekdayIndex(date: Date): number { + const weekday = new Intl.DateTimeFormat("en-US", { + timeZone: businessTimeZone, + weekday: "short", + }).format(date); + const mapping: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + return mapping[weekday] ?? 0; +} + +function addDaysToDateKey(dateKey: string, days: number): string { + const [year, month, day] = dateKey.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +function getBusinessWeekStartKey(date: Date): string { + const todayKey = getBusinessDateKey(date); + const weekdayIndex = getBusinessWeekdayIndex(date); + return addDaysToDateKey(todayKey, -weekdayIndex); +} + +function getBusinessWeekEndExclusiveKey(date: Date): string { + return addDaysToDateKey(getBusinessWeekStartKey(date), 7); +} + +function isScheduleInWeek(schedule: Schedule, today: Date): boolean { if (!schedule.enter_time) { return false; } - const enterDate = new Date(schedule.enter_time); + const enterDate = parseBackendDateAsUTC(schedule.enter_time); - return ( - !Number.isNaN(enterDate.getTime()) && enterDate >= weekStart && enterDate < weekEndExclusive - ); + if (Number.isNaN(enterDate.getTime())) { + return false; + } + + const businessDateKey = getBusinessDateKey(enterDate); + const weekStartKey = getBusinessWeekStartKey(today); + const weekEndKey = getBusinessWeekEndExclusiveKey(today); + + return businessDateKey >= weekStartKey && businessDateKey < weekEndKey; } function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] { + const todayKey = getBusinessDateKey(today); + return schedules.filter((schedule) => { if (!schedule.enter_time) { return false; } - const enterDate = new Date(schedule.enter_time); + const enterDate = parseBackendDateAsUTC(schedule.enter_time); - return !Number.isNaN(enterDate.getTime()) && isSameLocalDate(enterDate, today); + return !Number.isNaN(enterDate.getTime()) && getBusinessDateKey(enterDate) === todayKey; }); } function getLatestSchedule(schedules: Schedule[]): Schedule | undefined { return [...schedules].sort((firstSchedule, secondSchedule) => { - const firstTime = firstSchedule.enter_time ? new Date(firstSchedule.enter_time).getTime() : 0; - const secondTime = secondSchedule.enter_time ? new Date(secondSchedule.enter_time).getTime() : 0; + const firstTime = firstSchedule.enter_time + ? parseBackendDateAsUTC(firstSchedule.enter_time).getTime() + : 0; + const secondTime = secondSchedule.enter_time + ? parseBackendDateAsUTC(secondSchedule.enter_time).getTime() + : 0; return firstTime - secondTime; }).at(-1); @@ -107,7 +147,9 @@ function formatDateTime(value: string | null): string { return "--"; } - return new Date(value).toLocaleString(); + return parseBackendDateAsUTC(value).toLocaleString("en-US", { + timeZone: businessTimeZone, + }); } function getNameScheduleKey(name: string, lastName: string): string { @@ -134,6 +176,29 @@ function getSchedulesForUser(user: Employee, schedulesByUser: Record schedulesByUser[key] ?? []); } +function normalizeScheduleForUser(schedule: Schedule, user: Employee): Schedule { + return { + ...schedule, + user_id: schedule.user_id ?? user.id, + user: schedule.user ?? { + name: user.name, + last_name: user.last_name, + preference: user.preference, + }, + out_time: schedule.out_time ?? null, + }; +} + +function mergeSchedule(schedules: Schedule[], schedule: Schedule): Schedule[] { + const existingIndex = schedules.findIndex((item) => item.id === schedule.id); + + if (existingIndex === -1) { + return [...schedules, schedule]; + } + + return schedules.map((item, index) => (index === existingIndex ? schedule : item)); +} + export function UsersPage({ backendPreference }: UsersPageProps) { const [companies, setCompanies] = useState([]); const [schedules, setSchedules] = useState([]); @@ -157,8 +222,6 @@ export function UsersPage({ backendPreference }: UsersPageProps) { const selectedUser = users.find((user) => user.id === selectedUserId) ?? null; const today = new Date(); - const currentWeekStart = getWeekStart(today); - const currentWeekEndExclusive = addDays(currentWeekStart, 7); const schedulesByUser = useMemo(() => { return schedules.reduce>((accumulator, schedule) => { @@ -274,21 +337,31 @@ export function UsersPage({ backendPreference }: UsersPageProps) { } } + function getBusinessWeekdayFromDate(date: Date): import("@/types/users").DayOfWeek { + return new Intl.DateTimeFormat("en-US", { + timeZone: businessTimeZone, + weekday: "long", + }) + .format(date) + .toLowerCase() as import("@/types/users").DayOfWeek; + } + + function getBusinessHourFromDate(date: Date): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: businessTimeZone, + hour: "numeric", + hour12: false, + }).formatToParts(date); + const hourPart = parts.find((part) => part.type === "hour"); + return hourPart ? Number(hourPart.value) % 24 : 0; + } + function getDayOfWeekFromDateTime(value: string): import("@/types/users").DayOfWeek { - const days: import("@/types/users").DayOfWeek[] = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - ]; - return days[new Date(value).getDay()]; + return getBusinessWeekdayFromDate(new Date(value)); } function getShiftFromDateTime(value: string): WorkTime { - const hour = new Date(value).getHours(); + const hour = getBusinessHourFromDate(new Date(value)); if (hour < 12) { return "morning"; @@ -321,13 +394,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) { setSavingActionUserId(checkingInUser.id); try { - await clockIn(backendPreference, checkingInUser.id, { + const createdSchedule = await clockIn(backendPreference, checkingInUser.id, { day_of_week: getDayOfWeekFromDateTime(checkInDateTime), enter_time: new Date(checkInDateTime).toISOString(), shift: getShiftFromDateTime(checkInDateTime), }); + const normalizedSchedule = normalizeScheduleForUser(createdSchedule, checkingInUser); + setSchedules((previousSchedules) => mergeSchedule(previousSchedules, normalizedSchedule)); setCheckingInUser(null); await loadUsersPage(); + setSchedules((previousSchedules) => + previousSchedules.some((schedule) => schedule.id === normalizedSchedule.id) + ? previousSchedules + : [...previousSchedules, normalizedSchedule], + ); } catch (saveError) { setError(saveError instanceof Error ? saveError.message : "Unable to check in user"); } finally { @@ -340,11 +420,43 @@ export function UsersPage({ backendPreference }: UsersPageProps) { setSavingActionUserId(userId); try { - await clockOut(backendPreference, userId, { + const outTime = new Date().toISOString(); + const updatedSchedule = await clockOut(backendPreference, userId, { day_of_week: activeSchedule.day_of_week, - out_time: new Date().toISOString(), + out_time: outTime, }); - await loadUsersPage(); + const user = users.find((item) => item.id === userId); + + if (user) { + const normalizedSchedule = normalizeScheduleForUser( + { + ...updatedSchedule, + out_time: updatedSchedule.out_time ?? outTime, + }, + user, + ); + setSchedules((previousSchedules) => mergeSchedule(previousSchedules, normalizedSchedule)); + await loadUsersPage(); + setSchedules((previousSchedules) => { + const existingSchedule = previousSchedules.find( + (schedule) => schedule.id === normalizedSchedule.id, + ); + + if (!existingSchedule) { + return [...previousSchedules, normalizedSchedule]; + } + + if (!existingSchedule.out_time && normalizedSchedule.out_time) { + return previousSchedules.map((schedule) => + schedule.id === normalizedSchedule.id ? normalizedSchedule : schedule, + ); + } + + return previousSchedules; + }); + } else { + await loadUsersPage(); + } } catch (saveError) { setError(saveError instanceof Error ? saveError.message : "Unable to check out user"); } finally { @@ -433,8 +545,8 @@ export function UsersPage({ backendPreference }: UsersPageProps) { const company = companies.find((item) => item.id === user.company_id); const daysWorked = new Set( userSchedules - .filter((schedule) => isScheduleInWeek(schedule, currentWeekStart, currentWeekEndExclusive)) - .map((schedule) => new Date(schedule.enter_time as string).getDay()), + .filter((schedule) => isScheduleInWeek(schedule, today)) + .map((schedule) => getBusinessDateKey(parseBackendDateAsUTC(schedule.enter_time as string))), ).size; return (