tia/src/components/CalendarView.tsx
Mannu cfb0f4b2eb Fix timestamp timezone — logs now always show in IST regardless of server TZ
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>
2026-05-28 23:13:57 +05:30

187 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}