fix(timezone): all date/time display now uses IST (Asia/Kolkata)

New src/lib/date-ist.ts utility:
- hourIST()   — current hour in IST (replaces new Date().getHours() on server)
- isTodayIST() / dateIST() — IST-aware "today" comparisons
- fmtTime()   — time display with timeZone: "Asia/Kolkata"
- fmtDate()   — date display with timeZone: "Asia/Kolkata"
- dayLabel()  — "Today" / "Yesterday" / "Mon, 26 May" in IST

Applied across: home (greeting, today-summary, log times),
activity (day grouping, bar chart, log times), ai, growth,
milestones, settings — eliminating Finland-timezone artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 10:05:15 +05:30
parent 774e8f29d4
commit 309fd5aa29
7 changed files with 118 additions and 31 deletions

View file

@ -8,6 +8,7 @@ import { api } from "@/lib/api";
import { CalendarView } from "@/components/CalendarView";
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
import type { Log, LogType } from "@/types";
import { dateIST, todayIST, fmtTime, fmtDate, dayLabel } from "@/lib/date-ist";
type ViewMode = "timeline" | "calendar";
@ -23,13 +24,9 @@ function getIcon(type: LogType) {
return "📝";
}
// dateStr is now an IST YYYY-MM-DD string (from dateIST())
function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr);
const today = new Date().toDateString();
const yesterday = new Date(Date.now() - 86_400_000).toDateString();
if (d.toDateString() === today) return "Today";
if (d.toDateString() === yesterday) return "Yesterday";
return d.toLocaleDateString("en-IN", { weekday: "short", month: "short", day: "numeric" });
return dayLabel(dateStr);
}
export default function ActivityPage() {
@ -100,8 +97,8 @@ export default function ActivityPage() {
};
// Today's stats (computed from loaded logs — no extra fetch)
const todayStr = new Date().toDateString();
const todayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
const todayStr = todayIST();
const todayLogs = logs.filter(l => dateIST(l.loggedAt) === todayStr);
const todayCounts = {
feed: todayLogs.filter(l => l.type === "feed").length,
sleep: todayLogs.filter(l => l.type === "sleep").length,
@ -111,21 +108,21 @@ export default function ActivityPage() {
// Last 4 calendar days (computed from loaded logs — no extra fetch)
const last4Days = Array.from({ length: 4 }, (_, i) => {
const d = new Date(Date.now() - i * 86_400_000);
const dateStr = d.toDateString();
const dayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === dateStr);
const dateStr = dateIST(d);
const dLogs = logs.filter(l => dateIST(l.loggedAt) === dateStr);
return {
date: dateStr,
label: i === 0 ? "Today" : i === 1 ? "Yest." : d.toLocaleDateString("en-IN", { weekday: "short" }),
feed: dayLogs.filter(l => l.type === "feed").length,
sleep: dayLogs.filter(l => l.type === "sleep").length,
diaper: dayLogs.filter(l => l.type === "diaper").length,
label: i === 0 ? "Today" : i === 1 ? "Yest." : fmtDate(d, { weekday: "short" }),
feed: dLogs.filter(l => l.type === "feed").length,
sleep: dLogs.filter(l => l.type === "sleep").length,
diaper: dLogs.filter(l => l.type === "diaper").length,
};
});
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
const date = new Date(log.loggedAt).toDateString();
const date = dateIST(log.loggedAt);
const existing = acc.find(d => d.date === date);
if (existing) existing.logs.push(log);
else acc.push({ date, logs: [log] });
@ -359,7 +356,7 @@ export default function ActivityPage() {
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{fmtTime(log.loggedAt)}
</span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none"></span>
</div>
@ -413,7 +410,7 @@ export default function ActivityPage() {
{[
selectedLog.subType?.replace(/_/g, " "),
selectedLog.amount ? `${selectedLog.amount}ml` : null,
new Date(selectedLog.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
fmtTime(selectedLog.loggedAt),
].filter(Boolean).join(" · ")}
</div>
</div>
@ -473,7 +470,7 @@ export default function ActivityPage() {
const { date: sheetDateStr, type: sheetType } = daySheetDate;
const sheetLogs = logs
.filter(l =>
new Date(l.loggedAt).toDateString() === sheetDateStr &&
dateIST(l.loggedAt) === sheetDateStr &&
l.type === sheetType
)
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
@ -538,7 +535,7 @@ export default function ActivityPage() {
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{fmtTime(log.loggedAt)}
</span>
<span className="text-gray-300 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg"></span>
</div>

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useFamily } from "@/app/FamilyProvider";
import { Button, Input, ConfirmDialog } from "@/components/ui";
import type { AIChat, ChatSession } from "@/types";
import { fmtDate } from "@/lib/date-ist";
export default function AIChatPage() {
const { childId } = useFamily();
@ -186,7 +187,7 @@ export default function AIChatPage() {
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{new Date(session.updatedAt).toLocaleDateString()}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{fmtDate(session.updatedAt)}</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}

View file

@ -6,6 +6,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist";
import type { GrowthRecord, Goal } from "@/types";
import {
Chart as ChartJS,
@ -190,7 +191,7 @@ export default function GrowthPage() {
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = growthData.map(r => [
new Date(r.measured_at).toLocaleDateString(),
fmtDate(r.measured_at),
r.weight_kg || "",
r.height_cm || "",
r.head_circumference_cm || "",
@ -423,7 +424,7 @@ export default function GrowthPage() {
<div>
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
<div className="text-sm text-gray-500">
{new Date(latest.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
{fmtDate(latest.measured_at)} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
</div>
</div>
{velocity && (
@ -637,7 +638,7 @@ export default function GrowthPage() {
<div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center">
<div>
<div className="text-sm text-gray-500">
{new Date(record.measured_at).toLocaleDateString()} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
{fmtDate(record.measured_at)} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
</div>
<div className="flex gap-2 mt-1">
{record.weight_kg && (

View file

@ -8,6 +8,7 @@ import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
import { LogModal, type LogType } from "@/components/LogModal";
import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
import type { Log, AIChat, ChatSession } from "@/types";
async function getSessions(cid: string): Promise<ChatSession[]> {
@ -32,15 +33,14 @@ async function createSession(cid: string): Promise<ChatSession | null> {
function getGreeting() {
const hour = new Date().getHours();
const hour = hourIST();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
function TodaySummary({ logs }: { logs: Log[] }) {
const todayStr = new Date().toDateString();
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
const today = logs.filter(l => isTodayIST(l.loggedAt));
const counts = {
feed: today.filter(l => l.type === "feed").length,
diaper: today.filter(l => l.type === "diaper").length,
@ -400,7 +400,7 @@ export default function HomePage() {
<TodaySummary logs={recentLogs} />
{stage && (() => {
const h = new Date().getHours();
const h = hourIST();
type Suggestion = { label: string; type: "feed" | "sleep" | "diaper" };
const matrix: Record<BabyStage, Suggestion> =
h >= 5 && h < 9 ? { newborn: { label: "Feed", type: "feed" }, infant: { label: "Feed", type: "feed" }, sitter: { label: "Feed", type: "feed" }, crawler: { label: "Feed", type: "feed" }, toddler: { label: "Feed", type: "feed" }, walker: { label: "Feed", type: "feed" } } :
@ -465,7 +465,7 @@ export default function HomePage() {
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{fmtTime(log.loggedAt)}</div></div>
</div>
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
</div>

View file

@ -4,6 +4,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { useStageCheck } from "@/hooks/useStageCheck";
import { MILESTONES, type MilestoneDef } from "@/lib/milestones";
import { Button, Input } from "@/components/ui";
import { fmtDate } from "@/lib/date-ist";
type Category = "all" | "social" | "motor" | "language" | "cognitive";
type Filter = "all" | "achieved" | "upcoming";
@ -194,7 +195,7 @@ export default function MilestonesPage() {
<p className="text-xs text-gray-400 mt-1">{m.ageRangeLabel}</p>
{m.achieved && m.achievedAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
{new Date(m.achievedAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}
{fmtDate(m.achievedAt, { day: "numeric", month: "short" })}
</p>
)}
<span className={`inline-block text-xs px-1.5 py-0.5 rounded-full mt-2 ${CATEGORY_COLORS[m.category]}`}>

View file

@ -7,6 +7,7 @@ import { useTheme } from "@/app/ThemeProvider";
import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, Select, Badge } from "@/components/ui";
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
import { fmtDate } from "@/lib/date-ist";
interface Member {
id: string;
@ -81,7 +82,7 @@ export default function SettingsPage() {
if (records.length === 0) { alert("No growth records to export."); return; }
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
new Date(r.measured_at).toLocaleDateString(),
fmtDate(r.measured_at),
r.weight_kg ?? "",
r.height_cm ?? "",
r.head_circumference_cm ?? "",
@ -315,7 +316,7 @@ export default function SettingsPage() {
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
<div>
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
<div className="text-xs text-gray-400">Pending · expires {new Date(invite.expiresAt).toLocaleDateString()}</div>
<div className="text-xs text-gray-400">Pending · expires {fmtDate(invite.expiresAt)}</div>
</div>
<button
onClick={() => deleteInvite(invite.id)}

86
src/lib/date-ist.ts Normal file
View file

@ -0,0 +1,86 @@
/**
* date-ist.ts IST-aware date/time helpers.
*
* All functions explicitly pass timeZone: "Asia/Kolkata" so they produce
* consistent IST output regardless of where they run Next.js SSR on a
* European server OR the user's browser.
*
* Never use new Date().toDateString(), .getHours(), or .toLocaleString()
* without a timeZone option those use the host's local timezone.
*/
const TZ = "Asia/Kolkata";
const LOCALE = "en-IN";
/** ISO date string (YYYY-MM-DD) for today in IST — use for comparisons. */
export function todayIST(): string {
// "sv-SE" locale gives YYYY-MM-DD — a clean, comparable format.
return new Date().toLocaleDateString("sv-SE", { timeZone: TZ });
}
/** ISO date string (YYYY-MM-DD) for any timestamp, evaluated in IST. */
export function dateIST(isoOrDate: string | Date): string {
return new Date(isoOrDate).toLocaleDateString("sv-SE", { timeZone: TZ });
}
/** True if the given timestamp falls on today in IST. */
export function isTodayIST(isoOrDate: string | Date): boolean {
return dateIST(isoOrDate) === todayIST();
}
/** ISO date string N calendar days before today in IST. */
function nDaysAgoIST(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toLocaleDateString("sv-SE", { timeZone: TZ });
}
/** Current hour 0-23 in IST. Replaces new Date().getHours() on the server. */
export function hourIST(): number {
// Intl gives a locale-formatted hour string; parseInt handles "24" edge case.
const str = new Intl.DateTimeFormat(LOCALE, {
timeZone: TZ,
hour: "numeric",
hour12: false,
}).format(new Date());
const h = parseInt(str, 10);
return h === 24 ? 0 : h;
}
/** Format a timestamp as "hh:mm AM/PM" in IST. */
export function fmtTime(isoOrDate: string | Date): string {
return new Date(isoOrDate).toLocaleTimeString(LOCALE, {
timeZone: TZ,
hour: "2-digit",
minute: "2-digit",
});
}
/** Format a timestamp as a date string in IST with given Intl options. */
export function fmtDate(
isoOrDate: string | Date,
opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }
): string {
return new Date(isoOrDate).toLocaleDateString(LOCALE, { timeZone: TZ, ...opts });
}
/**
* "Today" | "Yesterday" | "Mon, 26 May" for activity group headers.
* Replaces the toDateString()-based pattern throughout the app.
*/
export function dayLabel(isoOrDate: string | Date): string {
const d = dateIST(isoOrDate);
if (d === todayIST()) return "Today";
if (d === nDaysAgoIST(1)) return "Yesterday";
return fmtDate(isoOrDate, { weekday: "short", month: "short", day: "numeric" });
}
/**
* Like dayLabel but short ("Today" | "Yest." | "Mon").
* Used in the activity page bar chart labels.
*/
export function shortDayLabel(isoOrDate: string | Date, index: number): string {
if (index === 0) return "Today";
if (index === 1) return "Yest.";
return fmtDate(isoOrDate, { weekday: "short" });
}