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:
parent
774e8f29d4
commit
309fd5aa29
7 changed files with 118 additions and 31 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]}`}>
|
||||
|
|
|
|||
|
|
@ -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
86
src/lib/date-ist.ts
Normal 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" });
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue