Commit c7c389f4 authored by ThinhNC's avatar ThinhNC

fix(datetime): use Vietnam timezone for dates and display

parent 5c0c24fc
# Project Memory (Frontend)
## Date/time architecture (2026-08-16)
- `Asia/Ho_Chi_Minh` is the single business/display timezone.
- Transaction, Budget, and Saving Goal calendar dates are sent unchanged as `YYYY-MM-DD`, without parsing through the device timezone.
- Datetime-local inputs such as transfers and contributions represent Vietnamese wall time and are converted to offset-aware UTC ISO strings before sending.
- Timestamp display uses `Intl` with `Asia/Ho_Chi_Minh`; report requests never send the device offset.
File này lưu trữ các quyết định thiết kế dài hạn và trạng thái hiện tại của dự án để đảm bảo tính nhất quán qua các phiên làm việc của Agent.
## Quyết định đang có hiệu lực
......
......@@ -10,6 +10,7 @@ function Clock() {
const updateClock = () => {
const now = new Date();
const formattedTime = now.toLocaleString(intlLocale, {
timeZone: "Asia/Ho_Chi_Minh",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
......
......@@ -94,7 +94,10 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [intlLocale]);
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(intlLocale, options).format(new Date(value)), [intlLocale]);
new Intl.DateTimeFormat(intlLocale, {
timeZone: "Asia/Ho_Chi_Minh",
...options,
}).format(new Date(value)), [intlLocale]);
const contextValue = useMemo<I18nContextValue>(() => ({
locale,
......
export const BUSINESS_TIME_ZONE = 'Asia/Ho_Chi_Minh' as const;
function zonedParts(instant: Date) {
return Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: BUSINESS_TIME_ZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(instant).map((part) => [part.type, part.value]),
);
}
export function todayInBusinessTime(): string {
const parts = zonedParts(new Date());
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function instantToBusinessDate(value: string | Date): string {
const parts = zonedParts(value instanceof Date ? value : new Date(value));
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function instantToBusinessDateTimeInput(value?: string | Date): string {
const parts = zonedParts(value ? (value instanceof Date ? value : new Date(value)) : new Date());
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
}
export function businessWallTimeToIso(value: string): string {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/.exec(value);
if (!match) throw new Error('Datetime must use YYYY-MM-DDTHH:mm format');
const [year, month, day] = match[1].split('-').map(Number);
const [hour, minute] = match[2].split(':').map(Number);
const second = Number(match[3] ?? 0);
const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, second);
let candidate = new Date(wallClockUtc);
for (let attempt = 0; attempt < 2; attempt += 1) {
const parts = zonedParts(candidate);
const representedAsUtc = Date.UTC(
Number(parts.year), Number(parts.month) - 1, Number(parts.day),
Number(parts.hour), Number(parts.minute), Number(parts.second),
);
candidate = new Date(candidate.getTime() + wallClockUtc - representedAsUtc);
}
return candidate.toISOString();
}
export function businessDateStartIso(date: string): string {
return businessWallTimeToIso(`${date}T00:00`);
}
export function addCalendarDays(date: string, amount: number): string {
const [year, month, day] = date.split('-').map(Number);
const result = new Date(Date.UTC(year, month - 1, day + amount));
return result.toISOString().slice(0, 10);
}
export function formatBusinessDate(date: string, locale: string, options?: Intl.DateTimeFormatOptions) {
const [year, month, day] = date.slice(0, 10).split('-').map(Number);
return new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
.format(new Date(Date.UTC(year, month - 1, day)));
}
......@@ -41,13 +41,11 @@ export function parseMoneyInput(value: string, locale: string): string {
}
export function toLocalDate(value?: string): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
}
export function toLocalDateTime(value?: string): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
return instantToBusinessDateTimeInput(value);
}
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
......@@ -12,6 +12,7 @@ import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -56,14 +57,12 @@ function parseAmountInput(value: string, locale: string): string {
}
function localDate(value?: string): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
}
function toIsoDate(value: string): string {
return new Date(`${value}T00:00:00`).toISOString();
return value;
}
function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
......@@ -94,7 +93,7 @@ const createSchema = (t: TranslationFunction) => z.object({
if (values.period === "CUSTOM" && !values.endDate) {
context.addIssue({ code: "custom", path: ["endDate"], message: t("validation.budgetEndDateRequired") });
}
if (values.endDate && values.startDate && values.endDate <= values.startDate) {
if (values.endDate && values.startDate && values.endDate < values.startDate) {
context.addIssue({ code: "custom", path: ["endDate"], message: t("validation.budgetEndDateAfterStart") });
}
});
......
......@@ -23,6 +23,7 @@ import { CategoryTreeNode } from "@/types/category";
import { BudgetCard } from "./components/BudgetCard";
import { BudgetFormModal } from "./components/BudgetFormModal";
import { BudgetSkeleton } from "./components/BudgetSkeleton";
import { todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 6;
type TypeFilter = "ALL" | BudgetType;
......@@ -30,14 +31,13 @@ type PeriodFilter = "ALL" | BudgetPeriod;
type TimeFilter = "ALL" | "CURRENT" | "DATE";
function localToday(): string {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
return todayInBusinessTime();
}
function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undefined {
if (timeFilter === "ALL") return undefined;
if (timeFilter === "CURRENT") return new Date().toISOString();
return date ? new Date(`${date}T12:00:00`).toISOString() : undefined;
if (timeFilter === "CURRENT") return todayInBusinessTime();
return date || undefined;
}
function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
......
......@@ -20,23 +20,18 @@ import { OverviewMetrics } from "./components/OverviewMetrics";
import { ReportFilters } from "./components/ReportFilters";
import { ReportEmptyState, ReportErrorState, ReportSectionSkeleton } from "./components/ReportState";
import { SavingGoalsAnalytics } from "./components/SavingGoalsAnalytics";
function localDateValue(date: Date): string {
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
}
import { addCalendarDays, businessDateStartIso, todayInBusinessTime } from "@/lib/business-time";
function initialCustomDates(): { from: string; to: string } {
const now = new Date();
const today = todayInBusinessTime();
return {
from: localDateValue(new Date(now.getFullYear(), now.getMonth(), 1)),
to: localDateValue(now),
from: `${today.slice(0, 7)}-01`,
to: today,
};
}
function toInclusiveBoundary(value: string, endBoundary: boolean): string {
const date = new Date(`${value}T00:00:00`);
if (endBoundary) date.setDate(date.getDate() + 1);
return date.toISOString();
return businessDateStartIso(endBoundary ? addCalendarDays(value, 1) : value);
}
const ReportsPage: React.FC = () => {
......@@ -66,7 +61,6 @@ const ReportsPage: React.FC = () => {
dateFrom: toInclusiveBoundary(dateFrom, false),
dateTo: toInclusiveBoundary(dateTo, true),
} : {}),
timezoneOffsetMinutes: -new Date().getTimezoneOffset(),
...(walletId ? { walletId } : {}),
...(!walletId && currency ? { currency } : {}),
granularity: "AUTO",
......
......@@ -8,13 +8,14 @@ import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input";
import { SavingContribution, SavingContributionInput } from "@/types/saving-goal";
import { businessWallTimeToIso } from "@/lib/business-time";
const createSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.contributionAmountRequired"))
.regex(positiveAmountPattern, t("validation.contributionAmountInvalid"))
.refine((value) => Number(value) > 0, t("validation.contributionAmountInvalid")),
contributedAt: z.string().min(1, t("validation.contributionDateRequired"))
.refine((value) => new Date(value).getTime() <= Date.now(), t("validation.contributionDateFuture")),
.refine((value) => new Date(businessWallTimeToIso(value)).getTime() <= Date.now(), t("validation.contributionDateFuture")),
note: z.string().max(500, t("validation.contributionNoteMax")),
});
......@@ -77,7 +78,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => onSubmit({
amount: values.amount,
contributedAt: new Date(values.contributedAt).toISOString(),
contributedAt: businessWallTimeToIso(values.contributedAt),
note: values.note.trim() || null,
}))}
noValidate
......
......@@ -9,14 +9,13 @@ import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input";
import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal";
import { addCalendarDays, todayInBusinessTime } from "@/lib/business-time";
const GOAL_COLORS = ["#16A34A", "#0D9488", "#3B82F6", "#6366F1", "#A855F7", "#EC4899", "#EF4444", "#F97316", "#EAB308", "#64748B"];
const GOAL_ICONS = ["laptop", "plane", "home", "car", "graduation-cap", "gift"];
function tomorrow(): string {
const date = new Date();
date.setDate(date.getDate() + 1);
return toLocalDate(date.toISOString());
return addCalendarDays(todayInBusinessTime(), 1);
}
const createSchema = (t: TranslationFunction, currentDate?: string) => z.object({
......@@ -26,7 +25,7 @@ const createSchema = (t: TranslationFunction, currentDate?: string) => z.object(
.refine((value) => Number(value) > 0, t("validation.goalAmountInvalid")),
currency: z.string().length(3, t("validation.currencyLength")).regex(/^[A-Za-z]{3}$/, t("validation.currencyLetters")),
targetDate: z.string().min(1, t("validation.goalDateRequired"))
.refine((value) => value === currentDate || new Date(`${value}T23:59:59`).getTime() > Date.now(), t("validation.goalDateFuture")),
.refine((value) => value === currentDate || value >= todayInBusinessTime(), t("validation.goalDateFuture")),
description: z.string().max(500, t("validation.descriptionMax")),
icon: z.string().min(1).max(100),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, t("validation.colorInvalid")),
......@@ -89,7 +88,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
name: values.name.trim(),
targetAmount: values.targetAmount,
currency: values.currency.toUpperCase(),
targetDate: new Date(`${values.targetDate}T23:59:59`).toISOString(),
targetDate: values.targetDate,
description: values.description.trim() || null,
icon: values.icon,
color: values.color,
......
......@@ -20,17 +20,18 @@ import {
import { SavingGoalCard } from "./components/SavingGoalCard";
import { SavingGoalFormModal } from "./components/SavingGoalFormModal";
import { SavingGoalSkeleton } from "./components/SavingGoalSkeleton";
import { addCalendarDays, todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 6;
type StatusFilter = "ALL" | SavingGoalStatus;
type DueFilter = "ALL" | "OVERDUE" | "NEXT_30_DAYS" | "CUSTOM";
function endOfLocalDay(value: string): string | undefined {
return value ? new Date(`${value}T23:59:59.999`).toISOString() : undefined;
return value || undefined;
}
function startOfLocalDay(value: string): string | undefined {
return value ? new Date(`${value}T00:00:00`).toISOString() : undefined;
return value || undefined;
}
const SavingGoalsPage: React.FC = () => {
......@@ -50,11 +51,10 @@ const SavingGoalsPage: React.FC = () => {
const [sortBy, order] = sort.split(":") as [SavingGoalSortField, SavingGoalSortOrder];
const dueRange = useMemo(() => {
if (dueFilter === "OVERDUE") return { dueTo: new Date().toISOString() };
if (dueFilter === "OVERDUE") return { dueTo: addCalendarDays(todayInBusinessTime(), -1) };
if (dueFilter === "NEXT_30_DAYS") {
const end = new Date();
end.setDate(end.getDate() + 30);
return { dueFrom: new Date().toISOString(), dueTo: end.toISOString() };
const today = todayInBusinessTime();
return { dueFrom: today, dueTo: addCalendarDays(today, 30) };
}
if (dueFilter === "CUSTOM") return { dueFrom: startOfLocalDay(dueFrom), dueTo: endOfLocalDay(dueTo) };
return {};
......
......@@ -126,8 +126,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
timeStyle: "short",
}).format(new Date(transaction.date));
timeZone: "UTC",
}).format(new Date(`${transaction.date}T00:00:00Z`));
} catch {
return transaction.date;
}
......
......@@ -12,6 +12,7 @@ import { useCategoryTree } from "@/hooks/use-categories";
import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -103,11 +104,9 @@ function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[]
}
const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0];
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
if (!dateInput) return todayInBusinessTime();
if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput;
return instantToBusinessDate(dateInput);
};
export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
......@@ -318,16 +317,12 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const submitForm = (values: TransactionFormValues) => {
if (fileError) return;
// Construct local Date at noon to avoid timezone shift on backend parser
const localDate = new Date(values.date);
localDate.setHours(12, 0, 0, 0);
const payload = {
amount: values.amount,
type: values.type,
walletId: values.walletId,
categoryId: values.categoryId,
date: localDate.toISOString(),
date: values.date,
description: values.description?.trim() || null,
location: values.location?.trim() || null,
};
......
......@@ -32,15 +32,14 @@ import { TransactionFormModal } from "./components/TransactionFormModal";
import { TransactionDetailModal } from "./components/TransactionDetailModal";
import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { transactionService } from "@/services/transaction.service";
import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 10;
const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0];
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
if (!dateInput) return todayInBusinessTime();
if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput;
return instantToBusinessDate(dateInput);
};
function getNumberSeparators(locale: string): { group: string; decimal: string } {
......@@ -152,14 +151,10 @@ const TransactionsPage: React.FC = () => {
if (type) q.type = type;
if (dateFrom) {
const from = new Date(dateFrom);
from.setHours(0, 0, 0, 0);
q.dateFrom = from.toISOString();
q.dateFrom = dateFrom;
}
if (dateTo) {
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
q.dateTo = to.toISOString();
q.dateTo = dateTo;
}
if (minAmount) q.minAmount = minAmount;
......@@ -208,8 +203,8 @@ const TransactionsPage: React.FC = () => {
return Object.keys(groupedTransactions).sort((a, b) => {
// Sort dates according to overall query sort order
return order === "desc"
? new Date(b).getTime() - new Date(a).getTime()
: new Date(a).getTime() - new Date(b).getTime();
? b.localeCompare(a)
: a.localeCompare(b);
});
}, [groupedTransactions, order]);
......@@ -242,10 +237,8 @@ const TransactionsPage: React.FC = () => {
// Generate localized group header
const getGroupHeaderLabel = (dateStr: string) => {
const todayStr = getLocalDateString(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = getLocalDateString(yesterday);
const todayStr = todayInBusinessTime();
const yesterdayStr = addCalendarDays(todayStr, -1);
if (dateStr === todayStr) {
return t("transaction.today");
......@@ -257,7 +250,7 @@ const TransactionsPage: React.FC = () => {
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
}).format(new Date(dateStr));
}).format(new Date(`${dateStr}T00:00:00Z`));
} catch {
return dateStr;
}
......
......@@ -12,6 +12,7 @@ import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -78,10 +79,7 @@ function hasSufficientBalance(amount: string, balance: string): boolean {
}
function toLocalDateTimeInput(value?: string): string {
const date = value ? new Date(value) : new Date();
const validDate = Number.isNaN(date.getTime()) ? new Date() : date;
const localDate = new Date(validDate.getTime() - validDate.getTimezoneOffset() * 60_000);
return localDate.toISOString().slice(0, 16);
return instantToBusinessDateTimeInput(value);
}
function createTransferSchema(t: TranslationFunction, wallets: Wallet[]) {
......@@ -220,7 +218,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
destinationWalletId: values.destinationWalletId,
amount: values.amount,
note: values.note?.trim() || null,
transferredAt: new Date(values.transferredAt).toISOString(),
transferredAt: businessWallTimeToIso(values.transferredAt),
});
};
......
......@@ -11,6 +11,7 @@ import { useWalletSearch } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format";
import { addCalendarDays, businessDateStartIso } from "@/lib/business-time";
import {
CreateTransferInput,
Transfer,
......@@ -28,13 +29,13 @@ import { TransferSkeleton } from "./components/TransferSkeleton";
const PAGE_SIZE = 10;
function startOfLocalDay(value: string): string {
const date = new Date(`${value}T00:00:00`);
return date.toISOString();
return businessDateStartIso(value);
}
function endOfLocalDay(value: string): string {
const date = new Date(`${value}T23:59:59.999`);
return date.toISOString();
return new Date(
new Date(businessDateStartIso(addCalendarDays(value, 1))).getTime() - 1,
).toISOString();
}
const TransfersPage: React.FC = () => {
......
......@@ -10,7 +10,6 @@ export interface ReportQuery {
period: ReportPeriodPreset;
dateFrom?: string;
dateTo?: string;
timezoneOffsetMinutes: number;
walletId?: string;
currency?: string;
granularity: ReportGranularity;
......@@ -20,7 +19,7 @@ export interface ReportPeriod {
preset: ReportPeriodPreset;
from: string;
to: string;
timezoneOffsetMinutes: number;
timeZone: "Asia/Ho_Chi_Minh";
generatedAt: string;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment