fixing the time between UTC and timezone from New York

This commit is contained in:
2026-09-18 22:15:46 -04:00
parent 58d3499070
commit 4f72574411
4 changed files with 649 additions and 234 deletions
+76 -8
View File
@@ -14,6 +14,65 @@ type HomePageProps = {
onNavigate: (path: string) => void; 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) { export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -60,14 +119,23 @@ export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
}; };
}, [backendPreference]); }, [backendPreference]);
const workingToday = new Set( const today = new Date();
schedules const schedulesByUser = schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
.filter( const userKey = getScheduleUserKey(schedule);
(schedule): schedule is Schedule & { user_id: number } => if (!userKey) {
typeof schedule.user_id === "number" && Boolean(schedule.enter_time && !schedule.out_time), return accumulator;
) }
.map((schedule) => schedule.user_id), accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule];
).size; 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 ( return (
<> <>
+188 -50
View File
@@ -1,3 +1,9 @@
.schedulePage {
display: grid;
gap: 18px;
min-height: 0;
}
.scheduleToolbar { .scheduleToolbar {
display: grid; display: grid;
gap: 14px; 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-rows: auto auto minmax(0, 1fr);
min-height: 0;
border: 1px solid #e5eaf0;
}
.calendarHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid #e5eaf0;
background: #ffffff;
}
.monthTitle h2 {
margin: 0;
font-size: 1.6rem;
letter-spacing: -0.03em;
color: #111827;
}
.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;
}
}
}
.todayButton {
width: auto !important;
padding: 0 14px;
font-size: 0.82rem;
background: #ffffff !important;
box-shadow: inset 0 0 0 1px #d9e1ec;
}
.calendarWeekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
background: #f8fafc;
border-bottom: 1px solid #e5eaf0;
}
.weekdayLabel {
padding: 10px 12px;
text-align: center;
color: #6b7280;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.calendarScroll {
overflow: auto;
min-height: 0;
max-height: min(68vh, 70vh);
}
.calendarGrid {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(130px, 1fr)); grid-template-columns: repeat(7, minmax(130px, 1fr));
gap: 12px; grid-auto-rows: minmax(122px, auto);
gap: 1px;
background: #e5eaf0;
min-width: 980px;
} }
.dayCard { .calendarDay {
min-height: 210px;
padding: 16px;
border-radius: 20px;
background: #ffffff; background: #ffffff;
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07); padding: 8px 8px 10px;
display: flex;
h3 { flex-direction: column;
margin: 0 0 14px; gap: 8px;
font-size: 1rem; min-height: 122px;
span {
display: block;
margin-top: 4px;
color: #687487;
font-size: 0.8rem;
font-weight: 500;
}
}
} }
.shiftList { .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; display: grid;
gap: 10px; gap: 6px;
align-content: start;
} }
.shift { .event {
border-radius: 14px; --company-color: #2563eb;
padding: 10px; display: grid;
background: #f3f6fb; 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;
}
strong, .eventTitle {
span { font-size: 0.78rem;
display: block; font-weight: 700;
} color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
span { .eventMeta {
color: #687487; display: inline-flex;
font-size: 0.86rem; align-items: center;
margin-top: 4px; 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 { .error {
@@ -121,26 +265,20 @@
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.scheduleToolbar { .calendarHeader {
gap: 12px; flex-direction: column;
align-items: stretch;
} }
.weekNavigator { .headerActions {
gap: 10px; justify-content: space-between;
h2 {
min-width: 0;
font-size: 1rem;
} }
button { .monthTitle h2 {
width: 38px; font-size: 1.3rem;
height: 38px;
flex: 0 0 auto;
}
} }
.calendar { .calendarScroll {
overflow-x: auto; max-height: 62vh;
} }
} }
+196 -99
View File
@@ -4,51 +4,112 @@ import { getWeekSchedule } from "@api/scheduleApi";
import { PageHeader } from "@components/PageHeader"; import { PageHeader } from "@components/PageHeader";
import type { BackendPreference } from "@/types/backend"; import type { BackendPreference } from "@/types/backend";
import type { Schedule } from "@/types/schedule"; import type { Schedule } from "@/types/schedule";
import type { DayOfWeek } from "@/types/users";
import { useCompaniesStore } from "../store/companiesStore";
import styles from "./SchedulePage.module.scss"; import styles from "./SchedulePage.module.scss";
import { useCompaniesStore } from "../store/companiesStore";
type SchedulePageProps = { type SchedulePageProps = {
backendPreference: BackendPreference; backendPreference: BackendPreference;
}; };
const weekDays: DayOfWeek[] = [ const businessTimeZone = "America/New_York";
"sunday",
"monday", const weekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
"tuesday",
"wednesday", const companyColorPalette = [
"thursday", "#2563eb",
"friday", "#dc2626",
"saturday", "#16a34a",
"#9333ea",
"#ea580c",
"#0891b2",
"#be123c",
"#4f46e5",
"#65a30d",
"#c026d3",
"#0e7490",
"#9d174d",
]; ];
function formatDay(day: DayOfWeek): string { function getCompanyColor(company: string | undefined): string {
return day.charAt(0).toUpperCase() + day.slice(1); 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 { function normalizeBackendTimestamp(value: string): string {
const weekStart = new Date(date); const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value);
weekStart.setHours(0, 0, 0, 0); const baseValue = hasTimezone ? value : `${value}Z`;
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); return baseValue.replace(/(\.\d{3})\d+/, "$1");
return weekStart;
} }
function addDays(date: Date, days: number): Date { function parseBackendDateAsUTC(value: string): Date {
const nextDate = new Date(date); return new Date(normalizeBackendTimestamp(value));
nextDate.setDate(nextDate.getDate() + days);
return nextDate;
} }
function formatDate(value: Date): string { function getBusinessDateKey(date: Date): string {
return value.toLocaleDateString([], { return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone });
month: "short",
day: "numeric",
year: "numeric",
});
} }
function formatShortDate(value: Date): string { function getBusinessWeekdayIndex(date: Date): number {
return value.toLocaleDateString([], { month: "short", day: "numeric" }); const weekday = new Intl.DateTimeFormat("en-US", {
timeZone: businessTimeZone,
weekday: "short",
}).format(date);
const mapping: Record<string, number> = {
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 { function formatTime(value: string | null): string {
@@ -56,7 +117,11 @@ function formatTime(value: string | null): string {
return "Not registered"; 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) { export function SchedulePage({ backendPreference }: SchedulePageProps) {
@@ -64,24 +129,31 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedCompany, setSelectedCompany] = useState(""); 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 companyNames = useCompaniesStore((state) => state.companyNames);
const companiesError = useCompaniesStore((state) => state.error); const companiesError = useCompaniesStore((state) => state.error);
const loadCompanies = useCompaniesStore((state) => state.loadCompanies); const loadCompanies = useCompaniesStore((state) => state.loadCompanies);
const selectedWeekEnd = useMemo(() => addDays(selectedWeekStart, 6), [selectedWeekStart]); const todayKey = useMemo(() => getBusinessDateKey(new Date()), []);
const selectedWeekEndExclusive = useMemo( const calendarStartKey = useMemo(
() => addDays(selectedWeekStart, 7), () => getCalendarGridStartKey(selectedMonthStartKey),
[selectedWeekStart], [selectedMonthStartKey],
);
const selectedWeekDays = useMemo(
() => weekDays.map((day, index) => ({ day, date: addDays(selectedWeekStart, index) })),
[selectedWeekStart],
); );
const schedulesByDay = useMemo(() => { const calendarDays = useMemo(() => {
return schedules.reduce<Record<DayOfWeek, Schedule[]>>( return Array.from({ length: 42 }, (_, index) => {
(accumulator, schedule) => { 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]);
const schedulesByDateKey = useMemo(() => {
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
if (selectedCompany && schedule.company !== selectedCompany) { if (selectedCompany && schedule.company !== selectedCompany) {
return accumulator; return accumulator;
} }
@@ -90,38 +162,38 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
return accumulator; return accumulator;
} }
const enterDate = new Date(schedule.enter_time); const enterDate = parseBackendDateAsUTC(schedule.enter_time);
if ( if (Number.isNaN(enterDate.getTime())) {
Number.isNaN(enterDate.getTime()) ||
enterDate < selectedWeekStart ||
enterDate >= selectedWeekEndExclusive
) {
return accumulator; return accumulator;
} }
const day = weekDays[enterDate.getDay()]; const businessDateKey = getBusinessDateKey(enterDate);
accumulator[day] = [...accumulator[day], schedule]; accumulator[businessDateKey] = [...(accumulator[businessDateKey] ?? []), schedule];
return accumulator; return accumulator;
}, }, {});
{ }, [schedules, selectedCompany]);
sunday: [],
monday: [],
tuesday: [],
wednesday: [],
thursday: [],
friday: [],
saturday: [],
},
);
}, [schedules, selectedCompany, selectedWeekEndExclusive, selectedWeekStart]);
function showPreviousWeek() { function showPreviousMonth() {
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, -7)); 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() { function showNextMonth() {
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, 7)); 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(() => { useEffect(() => {
@@ -166,25 +238,31 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
}, [companyNames, selectedCompany]); }, [companyNames, selectedCompany]);
return ( return (
<> <div className={styles.schedulePage}>
<PageHeader <PageHeader
title="Schedule" title="Schedule"
description="Weekly scheduler calendar starts on Sunday and ends on Saturday so you can see who is working each day." description="Monthly calendar shows who is working each day. Week starts on Sunday."
/> />
{error ? <p className={styles.error}>{error}</p> : null} {error ? <p className={styles.error}>{error}</p> : null}
{companiesError ? <p className={styles.error}>{companiesError}</p> : null} {companiesError ? <p className={styles.error}>{companiesError}</p> : null}
{isLoading ? <p className={styles.muted}>Loading schedule...</p> : null} {isLoading ? <p className={styles.muted}>Loading schedule...</p> : null}
<div className={styles.scheduleToolbar}> <div className={styles.calendarShell} aria-label="Monthly schedule calendar">
<div className={styles.weekNavigator} aria-label="Select schedule week"> <div className={styles.calendarHeader}>
<button type="button" onClick={showPreviousWeek} aria-label="Previous week"> <div className={styles.monthTitle}>
<h2>{formatMonthLabel(selectedMonthStartKey)}</h2>
</div>
<div className={styles.headerActions}>
<div className={styles.navGroup}>
<button type="button" onClick={showPreviousMonth} aria-label="Previous month">
<FaChevronLeft aria-hidden="true" /> <FaChevronLeft aria-hidden="true" />
</button> </button>
<h2> <button type="button" className={styles.todayButton} onClick={showToday}>
Week from {formatDate(selectedWeekStart)} to {formatDate(selectedWeekEnd)} Today
</h2> </button>
<button type="button" onClick={showNextWeek} aria-label="Next week"> <button type="button" onClick={showNextMonth} aria-label="Next month">
<FaChevronRight aria-hidden="true" /> <FaChevronRight aria-hidden="true" />
</button> </button>
</div>
<label className={styles.companyFilter}> <label className={styles.companyFilter}>
<select <select
value={selectedCompany} value={selectedCompany}
@@ -200,35 +278,54 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
</label> </label>
</div> </div>
</div> </div>
<section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday"> <div className={styles.calendarWeekdays} aria-hidden="true">
{selectedWeekDays.map(({ day, date }) => ( {weekdayLabels.map((label) => (
<article className={styles.dayCard} key={day}> <div key={label} className={styles.weekdayLabel}>
<h3> {label}
{formatDay(day)} </div>
<span>{formatShortDate(date)}</span> ))}
</h3> </div>
<div className={styles.shiftList}> <div className={styles.calendarScroll}>
{schedulesByDay[day].length > 0 ? ( <div className={styles.calendarGrid}>
schedulesByDay[day].map((schedule) => ( {calendarDays.map(({ dateKey, isCurrentMonth, isToday, dayNumber }) => {
<div className={styles.shift} key={schedule.id}> const daySchedules = schedulesByDateKey[dateKey] ?? [];
<strong> return (
{schedule.user <div
? `${schedule.user.name} ${schedule.user.last_name}` key={dateKey}
: "Unknown user"} className={`${styles.calendarDay} ${isCurrentMonth ? "" : styles.outsideMonth} ${isToday ? styles.todayCell : ""}`}
</strong> >
<span>{schedule.company ?? "No company"}</span> <div className={styles.dayHeader}>
<span> <span className={`${styles.dayNumber} ${isToday ? styles.todayBadge : ""}`}>
{formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)} {dayNumber}
</span>
</div>
<div className={styles.eventList}>
{daySchedules.length > 0 ? (
daySchedules.map((schedule) => (
<div
className={styles.event}
key={schedule.id}
style={{ ["--company-color" as string]: getCompanyColor(schedule.company) }}
title={`${schedule.user ? `${schedule.user.name} ${schedule.user.last_name}` : "Unknown user"} ${formatTime(schedule.enter_time)} - ${formatTime(schedule.out_time)}`}
>
<span className={styles.eventTitle}>
{schedule.user ? `${schedule.user.name} ${schedule.user.last_name}` : "Unknown user"}
</span>
<span className={styles.eventMeta}>
<span className={styles.companyDot} /> {schedule.company ?? "No company"} · {formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)}
</span> </span>
</div> </div>
)) ))
) : ( ) : (
<p className={styles.muted}>No shifts scheduled</p> <span className={styles.emptyDay} />
)} )}
</div> </div>
</article> </div>
))} );
</section> })}
</> </div>
</div>
</div>
</div>
); );
} }
+155 -43
View File
@@ -20,56 +20,96 @@ type UserStatus = "off-duty" | "working" | "worked";
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"]; const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
const statusOptions: UserStatus[] = ["off-duty", "working", "worked"]; const statusOptions: UserStatus[] = ["off-duty", "working", "worked"];
const usersPerPage = 10; const usersPerPage = 10;
const businessTimeZone = "America/New_York";
function isSameLocalDate(firstDate: Date, secondDate: Date): boolean { function normalizeBackendTimestamp(value: string): string {
return ( const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value);
firstDate.getFullYear() === secondDate.getFullYear() && const baseValue = hasTimezone ? value : `${value}Z`;
firstDate.getMonth() === secondDate.getMonth() && return baseValue.replace(/(\.\d{3})\d+/, "$1");
firstDate.getDate() === secondDate.getDate()
);
} }
function getWeekStart(date: Date): Date { function parseBackendDateAsUTC(value: string): Date {
const weekStart = new Date(date); return new Date(normalizeBackendTimestamp(value));
weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
return weekStart;
} }
function addDays(date: Date, days: number): Date { function getBusinessDateKey(date: Date): string {
const nextDate = new Date(date); return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone });
nextDate.setDate(nextDate.getDate() + days);
return nextDate;
} }
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<string, number> = {
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) { if (!schedule.enter_time) {
return false; return false;
} }
const enterDate = new Date(schedule.enter_time); const enterDate = parseBackendDateAsUTC(schedule.enter_time);
return ( if (Number.isNaN(enterDate.getTime())) {
!Number.isNaN(enterDate.getTime()) && enterDate >= weekStart && enterDate < weekEndExclusive 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[] { function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] {
const todayKey = getBusinessDateKey(today);
return schedules.filter((schedule) => { return schedules.filter((schedule) => {
if (!schedule.enter_time) { if (!schedule.enter_time) {
return false; 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 { function getLatestSchedule(schedules: Schedule[]): Schedule | undefined {
return [...schedules].sort((firstSchedule, secondSchedule) => { return [...schedules].sort((firstSchedule, secondSchedule) => {
const firstTime = firstSchedule.enter_time ? new Date(firstSchedule.enter_time).getTime() : 0; const firstTime = firstSchedule.enter_time
const secondTime = secondSchedule.enter_time ? new Date(secondSchedule.enter_time).getTime() : 0; ? parseBackendDateAsUTC(firstSchedule.enter_time).getTime()
: 0;
const secondTime = secondSchedule.enter_time
? parseBackendDateAsUTC(secondSchedule.enter_time).getTime()
: 0;
return firstTime - secondTime; return firstTime - secondTime;
}).at(-1); }).at(-1);
@@ -107,7 +147,9 @@ function formatDateTime(value: string | null): string {
return "--"; return "--";
} }
return new Date(value).toLocaleString(); return parseBackendDateAsUTC(value).toLocaleString("en-US", {
timeZone: businessTimeZone,
});
} }
function getNameScheduleKey(name: string, lastName: string): string { function getNameScheduleKey(name: string, lastName: string): string {
@@ -134,6 +176,29 @@ function getSchedulesForUser(user: Employee, schedulesByUser: Record<string, Sch
return getEmployeeScheduleKeys(user).flatMap((key) => schedulesByUser[key] ?? []); return getEmployeeScheduleKeys(user).flatMap((key) => 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) { export function UsersPage({ backendPreference }: UsersPageProps) {
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -157,8 +222,6 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null; const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
const today = new Date(); const today = new Date();
const currentWeekStart = getWeekStart(today);
const currentWeekEndExclusive = addDays(currentWeekStart, 7);
const schedulesByUser = useMemo(() => { const schedulesByUser = useMemo(() => {
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => { return schedules.reduce<Record<string, Schedule[]>>((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 { function getDayOfWeekFromDateTime(value: string): import("@/types/users").DayOfWeek {
const days: import("@/types/users").DayOfWeek[] = [ return getBusinessWeekdayFromDate(new Date(value));
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
return days[new Date(value).getDay()];
} }
function getShiftFromDateTime(value: string): WorkTime { function getShiftFromDateTime(value: string): WorkTime {
const hour = new Date(value).getHours(); const hour = getBusinessHourFromDate(new Date(value));
if (hour < 12) { if (hour < 12) {
return "morning"; return "morning";
@@ -321,13 +394,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
setSavingActionUserId(checkingInUser.id); setSavingActionUserId(checkingInUser.id);
try { try {
await clockIn(backendPreference, checkingInUser.id, { const createdSchedule = await clockIn(backendPreference, checkingInUser.id, {
day_of_week: getDayOfWeekFromDateTime(checkInDateTime), day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
enter_time: new Date(checkInDateTime).toISOString(), enter_time: new Date(checkInDateTime).toISOString(),
shift: getShiftFromDateTime(checkInDateTime), shift: getShiftFromDateTime(checkInDateTime),
}); });
const normalizedSchedule = normalizeScheduleForUser(createdSchedule, checkingInUser);
setSchedules((previousSchedules) => mergeSchedule(previousSchedules, normalizedSchedule));
setCheckingInUser(null); setCheckingInUser(null);
await loadUsersPage(); await loadUsersPage();
setSchedules((previousSchedules) =>
previousSchedules.some((schedule) => schedule.id === normalizedSchedule.id)
? previousSchedules
: [...previousSchedules, normalizedSchedule],
);
} catch (saveError) { } catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to check in user"); setError(saveError instanceof Error ? saveError.message : "Unable to check in user");
} finally { } finally {
@@ -340,11 +420,43 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
setSavingActionUserId(userId); setSavingActionUserId(userId);
try { try {
await clockOut(backendPreference, userId, { const outTime = new Date().toISOString();
const updatedSchedule = await clockOut(backendPreference, userId, {
day_of_week: activeSchedule.day_of_week, day_of_week: activeSchedule.day_of_week,
out_time: new Date().toISOString(), out_time: outTime,
}); });
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(); 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) { } catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to check out user"); setError(saveError instanceof Error ? saveError.message : "Unable to check out user");
} finally { } finally {
@@ -433,8 +545,8 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
const company = companies.find((item) => item.id === user.company_id); const company = companies.find((item) => item.id === user.company_id);
const daysWorked = new Set( const daysWorked = new Set(
userSchedules userSchedules
.filter((schedule) => isScheduleInWeek(schedule, currentWeekStart, currentWeekEndExclusive)) .filter((schedule) => isScheduleInWeek(schedule, today))
.map((schedule) => new Date(schedule.enter_time as string).getDay()), .map((schedule) => getBusinessDateKey(parseBackendDateAsUTC(schedule.enter_time as string))),
).size; ).size;
return ( return (