Root cause: postgres.js v3 parses `timestamp without timezone` columns as
`new Date("YYYY-MM-DD HH:mm:ss")` (space format, no Z). V8 treats this as
*local time*, so on a non-UTC server (Dokploy host = Europe/Helsinki UTC+3)
the parsed Date object is 3 hours off, making logged times show as server
time instead of the user's IST.
Fixes:
- db/index.ts: add custom `timestamp` type parser that forces UTC by
converting space-format to ISO with 'Z' before calling new Date().
Also set `connection: { TimeZone: "UTC" }` so PostgreSQL sessions always
store/return timestamps in UTC regardless of server OS timezone.
- CalendarView.tsx: use `dateIST()` for day grouping (fixes midnight-boundary
bug where a 12:30 AM IST entry appeared on the previous UTC day) and
`fmtTime()` for time display (replaces toLocaleTimeString without timezone).
- MedicineTab.tsx: replace toLocaleString() with fmtDate/fmtTime (IST-aware).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
7 KiB
TypeScript
187 lines
7 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import type { Log, LogType } from "@/types";
|
||
import { dateIST, fmtTime } from "@/lib/date-ist";
|
||
|
||
const TYPE_COLORS: Record<LogType, string> = {
|
||
feed: "bg-rose-400",
|
||
sleep: "bg-blue-400",
|
||
diaper: "bg-amber-400",
|
||
};
|
||
|
||
function getIcon(type: LogType) {
|
||
if (type === "feed") return "🍼";
|
||
if (type === "sleep") return "😴";
|
||
if (type === "diaper") return "🚼";
|
||
return "📝";
|
||
}
|
||
|
||
interface Props {
|
||
logs: Log[];
|
||
filter: LogType | "all";
|
||
}
|
||
|
||
export function CalendarView({ logs, filter }: Props) {
|
||
const now = new Date();
|
||
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
|
||
const [selectedDay, setSelectedDay] = useState<string | null>(now.toISOString().slice(0, 10));
|
||
|
||
const year = calMonth.getFullYear();
|
||
const month = calMonth.getMonth();
|
||
const today = now.toISOString().slice(0, 10);
|
||
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
|
||
|
||
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||
|
||
const logsByDate = useMemo(() => {
|
||
const map: Record<string, Log[]> = {};
|
||
filteredLogs.forEach(log => {
|
||
const key = dateIST(log.loggedAt); // IST date, not UTC
|
||
if (!map[key]) map[key] = [];
|
||
map[key].push(log);
|
||
});
|
||
return map;
|
||
}, [filteredLogs]);
|
||
|
||
const firstDow = new Date(year, month, 1).getDay();
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||
const cells: (number | null)[] = [
|
||
...Array(firstDow).fill(null),
|
||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||
];
|
||
|
||
const dayKey = (d: number) =>
|
||
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||
|
||
const selectedLogs = useMemo(
|
||
() =>
|
||
selectedDay
|
||
? (logsByDate[selectedDay] || [])
|
||
.slice()
|
||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||
: [],
|
||
[selectedDay, logsByDate],
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||
{/* Month nav */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<button
|
||
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
|
||
>
|
||
‹
|
||
</button>
|
||
<span className="font-semibold text-gray-800 dark:text-white">
|
||
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||
</span>
|
||
<button
|
||
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
|
||
disabled={isCurrentMonth}
|
||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
|
||
{/* DOW headers */}
|
||
<div className="grid grid-cols-7 mb-1">
|
||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
|
||
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day cells */}
|
||
<div className="grid grid-cols-7 gap-y-1">
|
||
{cells.map((day, i) => {
|
||
if (!day) return <div key={i} />;
|
||
const key = dayKey(day);
|
||
const dayLogs = logsByDate[key] || [];
|
||
const isToday = key === today;
|
||
const isSel = key === selectedDay;
|
||
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
|
||
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => setSelectedDay(isSel ? null : key)}
|
||
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
|
||
isSel ? "bg-rose-400" :
|
||
isToday ? "bg-rose-50 dark:bg-rose-900/30" :
|
||
"hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
}`}
|
||
>
|
||
<span className={`text-sm leading-none ${
|
||
isSel ? "text-white font-semibold" :
|
||
isToday ? "text-rose-500 font-bold" :
|
||
"text-gray-700 dark:text-gray-300"
|
||
}`}>
|
||
{day}
|
||
</span>
|
||
<div className="flex gap-0.5 mt-1 h-2 items-center">
|
||
{types.slice(0, 3).map(t => (
|
||
<span key={t} className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`} />
|
||
))}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
|
||
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
|
||
<span className="capitalize">{t}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selected day detail */}
|
||
{selectedDay && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="font-semibold text-gray-800 dark:text-white">
|
||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
|
||
weekday: "long", month: "short", day: "numeric",
|
||
})}
|
||
</h3>
|
||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
|
||
</span>
|
||
</div>
|
||
|
||
{selectedLogs.length === 0 ? (
|
||
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{selectedLogs.map(log => (
|
||
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
|
||
<span className="text-xl">{getIcon(log.type)}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||
{[
|
||
log.subType?.replace(/_/g, " "),
|
||
log.amount ? `${log.amount}ml` : null,
|
||
log.notes,
|
||
].filter(Boolean).join(" · ")}
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-gray-400 flex-shrink-0">
|
||
{fmtTime(log.loggedAt)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|