I'm building an attendance tracking application using Next.js, Prisma, and PostgreSQL. I'm experiencing inconsistent timezone handling between my local development environment and my production environment (deployed on Vercel).
Problem Description:
In development, when I save attendance data (e.g., at 3:30 AM IST on February 7th), it's correctly stored in the database in UTC (e.g., 10:00 PM on February 6th). When I retrieve the data for "today" (February 7th), the API correctly converts the UTC timestamp back to IST, and the attendance record is displayed.
However, in production (on Vercel), the API route does not convert the UTC timestamp to the local timezone (Asia/Kolkata) when retrieving data. It returns the raw UTC timestamp. This causes attendance records saved early in the morning (IST) to appear on the previous day's records in the UI.
Expected Behavior:
The API should consistently convert database timestamps (stored in UTC) to the user's timezone (Asia/Kolkata) before sending the data to the client, both in development and production.
"use client";
import { useState, useEffect } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { format } from 'date-fns-tz';
interface Worker {
id: string;
name: string;
type: string;
photoUrl: string | null;
hourlyRate: number;
present: boolean;
hoursWorked: number;
isModified?: boolean;
}
export default function AttendancePage({ params }: { params: { id: string } }) {
const [workers, setWorkers] = useState<Worker[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchWorkers = async () => {
try {
const response = await fetch(`/api/attendance?projectId=${params.id}`);
if (!response.ok) throw new Error("Failed to fetch workers");
const data = await response.json();
setWorkers(data);
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
fetchWorkers();
}, [params.id]);
const updateWorkerAttendance = (workerId: string, present: boolean) => {
setWorkers(
workers.map((worker) =>
worker.id === workerId
? { ...worker, present, isModified: true }
: worker
)
);
};
const updateWorkerHours = (workerId: string, hours: number) => {
setWorkers(
workers.map((worker) =>
worker.id === workerId
? { ...worker, hoursWorked: hours, isModified: true }
: worker
)
);
};
const handleSave = async () => {
// ... (rest of the handleSave function is unchanged)
const modifiedAttendance = workers.filter((worker) => worker.isModified);
if (modifiedAttendance.length === 0) {
toast.info("No changes to save");
return;
}
setSaving(true);
try {
const response = await fetch("/api/attendance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: params.id,
attendance: modifiedAttendance.map(
({ id, present, hoursWorked }) => ({
id,
present,
hoursWorked,
})
),
}),
});
if (!response.ok) throw new Error("Failed to save attendance");
// Clear modification flags after successful save
setWorkers(workers.map((worker) => ({ ...worker, isModified: false })));
toast.success("Attendance saved successfully");
} catch (error) {
console.error("Error:", error);
toast.error("Failed to save attendance");
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-6">Loading...</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Daily Attendance</h1>
<div className="flex items-center gap-4">
<div className="text-muted-foreground">
{format(new Date(), "EEEE, MMMM d, yyyy", { timeZone: 'Asia/Kolkata' })}
</div>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Attendance"}
</Button>
</div>
</div>
{/* Table and other UI elements (unchanged) */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Worker Type</TableHead>
<TableHead>Present</TableHead>
<TableHead>Working Hours</TableHead>
<TableHead>Daily Income</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workers.map((worker) => (
<TableRow key={worker.id}>
<TableCell className="flex items-center gap-2">
<Avatar>
<AvatarImage src={worker.photoUrl || ""} />
<AvatarFallback>
{worker.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{worker.name}
</TableCell>
<TableCell>
<Badge variant="secondary">{worker.type}</Badge>
</TableCell>
<TableCell>
<Checkbox
checked={worker.present}
onCheckedChange={(checked) =>
updateWorkerAttendance(worker.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<Input
type="number"
className="w-[100px]"
placeholder="0"
value={worker.hoursWorked || ""}
onChange={(e) =>
updateWorkerHours(
worker.id,
parseFloat(e.target.value) || 0
)
}
disabled={!worker.present}
/>
</TableCell>
<TableCell>
₹{(worker.hourlyRate * (worker.hoursWorked || 0)).toFixed(2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
And here's the API route (/api/attendance.ts):
// /api/attendance.ts
import { prisma } from "@/lib/prisma";
import { NextRequest } from "next/server";
import { zonedTimeToUtc, utcToZonedTime, format } from 'date-fns-tz';
const TIME_ZONE = 'Asia/Kolkata'; // Define the timezone consistently
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const projectId = searchParams.get("projectId");
if (!projectId) {
return Response.json(
{ error: "Project ID is required" },
{ status: 400 }
);
}
const workers = await prisma.workerAssignment.findMany({
where: {
projectId: projectId,
endDate: null, // Only get currently assigned workers
},
include: {
worker: true,
},
});
// --- Get start of TODAY in Asia/Kolkata ---
const today = new Date();
const startOfToday = utcToZonedTime(today, TIME_ZONE); // Convert *now* to Asia/Kolkata
startOfToday.setHours(0, 0, 0, 0); // Set to midnight
const startOfTodayUTC = zonedTimeToUtc(startOfToday, TIME_ZONE); // Convert back to UTC for querying
const tomorrow = new Date(startOfTodayUTC);
tomorrow.setDate(tomorrow.getDate() + 1);
const attendance = await prisma.attendance.findMany({
where: {
projectId: projectId,
date: {
gte: startOfTodayUTC,
lt: tomorrow,
},
},
});
const formattedWorkers = workers.map((assignment) => {
const todayAttendance = attendance.find(
(a) => a.workerId === assignment.worker.id
);
// --- Convert database UTC timestamps to Asia/Kolkata ---
let present = false;
let hoursWorked = 0;
if (todayAttendance) {
const zonedDate = utcToZonedTime(todayAttendance.date, TIME_ZONE);
present = todayAttendance.present;
hoursWorked = todayAttendance.hoursWorked
}
return {
id: assignment.worker.id,
name: assignment.worker.name,
type: assignment.worker.type,
photoUrl: assignment.worker.photoUrl,
hourlyRate: assignment.worker.hourlyRate,
present: present,
hoursWorked: hoursWorked,
};
});
return Response.json(formattedWorkers);
} catch (error) {
console.error("Error fetching workers:", error);
return Response.json({ error: "Failed to fetch workers" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { projectId, attendance } = body;
if (!projectId || !attendance) {
return Response.json(
{ error: "Project ID and attendance data are required" },
{ status: 400 }
);
}
// --- Get start of TODAY in Asia/Kolkata ---
const today = new Date();
const startOfToday = utcToZonedTime(today, TIME_ZONE);
startOfToday.setHours(0, 0, 0, 0);
const startOfTodayUTC = zonedTimeToUtc(startOfToday, TIME_ZONE);
const promises = attendance.map(async (record: any) => {
return prisma.attendance.upsert({
where: {
workerId_projectId_date: {
workerId: record.id,
projectId: projectId,
date: startOfTodayUTC, // Use the consistent date object
},
},
create: {
workerId: record.id,
projectId: projectId,
date: startOfTodayUTC,
present: record.present,
hoursWorked: record.hoursWorked,
},
update: {
present: record.present,
hoursWorked: record.hoursWorked,
},
});
});
await Promise.all(promises);
return Response.json({ success: true });
} catch (error) {
console.error("Error saving attendance:", error);
return Response.json(
{ error: "Failed to save attendance" },
{ status: 500 }
);
}
}
Database Schema (Prisma):
model Attendance {
id String @id @default(cuid())
worker Worker @relation(fields: [workerId], references: [id])
workerId String
project Project @relation(fields: [projectId], references: [id])
projectId String
date DateTime @default(now())
present Boolean @default(false)
hoursWorked Float @default(0)
overtime Float @default(0)
photoUrl String?
confidence Float?
createdAt DateTime @default(now())
@@unique([workerId, projectId, date])
@@index([projectId])
}
Why is the timezone conversion working correctly in my local development environment but not in production on Vercel? How can I ensure consistent timezone handling in my API route's response, regardless of the environment? Is there something I'm missing in my date-fns-tz implementation, or is there another factor at play?
I've tried using the date-fns-tz library to explicitly handle timezone conversions in the API route's GET method, as shown in the code above. I'm converting the database's UTC timestamps to Asia/Kolkata using utcToZonedTime before sending the data to the client.