Commit a693b3ae authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/vietnam-timezone-frontend' into 'develop'

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

See merge request !16
parents a3d26fa2 c7c389f4
# Project Memory (Frontend) # 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. 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 ## Quyết định đang có hiệu lực
......
...@@ -10,6 +10,7 @@ function Clock() { ...@@ -10,6 +10,7 @@ function Clock() {
const updateClock = () => { const updateClock = () => {
const now = new Date(); const now = new Date();
const formattedTime = now.toLocaleString(intlLocale, { const formattedTime = now.toLocaleString(intlLocale, {
timeZone: "Asia/Ho_Chi_Minh",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
......
...@@ -94,7 +94,10 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children ...@@ -94,7 +94,10 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [intlLocale]); }, [intlLocale]);
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) => 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>(() => ({ const contextValue = useMemo<I18nContextValue>(() => ({
locale, 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 { ...@@ -41,13 +41,11 @@ export function parseMoneyInput(value: string, locale: string): string {
} }
export function toLocalDate(value?: string): string { export function toLocalDate(value?: string): string {
const date = value ? new Date(value) : new Date(); if (!value) return todayInBusinessTime();
if (Number.isNaN(date.getTime())) return ""; return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
} }
export function toLocalDateTime(value?: string): string { export function toLocalDateTime(value?: string): string {
const date = value ? new Date(value) : new Date(); return instantToBusinessDateTimeInput(value);
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
} }
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
...@@ -12,6 +12,7 @@ import { TranslationFunction, useI18n } from "@/i18n"; ...@@ -12,6 +12,7 @@ import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget"; import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category"; import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -56,14 +57,12 @@ function parseAmountInput(value: string, locale: string): string { ...@@ -56,14 +57,12 @@ function parseAmountInput(value: string, locale: string): string {
} }
function localDate(value?: string): string { function localDate(value?: string): string {
const date = value ? new Date(value) : new Date(); if (!value) return todayInBusinessTime();
if (Number.isNaN(date.getTime())) return ""; return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
} }
function toIsoDate(value: string): string { 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 }> { function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
...@@ -94,7 +93,7 @@ const createSchema = (t: TranslationFunction) => z.object({ ...@@ -94,7 +93,7 @@ const createSchema = (t: TranslationFunction) => z.object({
if (values.period === "CUSTOM" && !values.endDate) { if (values.period === "CUSTOM" && !values.endDate) {
context.addIssue({ code: "custom", path: ["endDate"], message: t("validation.budgetEndDateRequired") }); 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") }); context.addIssue({ code: "custom", path: ["endDate"], message: t("validation.budgetEndDateAfterStart") });
} }
}); });
......
...@@ -23,6 +23,7 @@ import { CategoryTreeNode } from "@/types/category"; ...@@ -23,6 +23,7 @@ import { CategoryTreeNode } from "@/types/category";
import { BudgetCard } from "./components/BudgetCard"; import { BudgetCard } from "./components/BudgetCard";
import { BudgetFormModal } from "./components/BudgetFormModal"; import { BudgetFormModal } from "./components/BudgetFormModal";
import { BudgetSkeleton } from "./components/BudgetSkeleton"; import { BudgetSkeleton } from "./components/BudgetSkeleton";
import { todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
type TypeFilter = "ALL" | BudgetType; type TypeFilter = "ALL" | BudgetType;
...@@ -30,14 +31,13 @@ type PeriodFilter = "ALL" | BudgetPeriod; ...@@ -30,14 +31,13 @@ type PeriodFilter = "ALL" | BudgetPeriod;
type TimeFilter = "ALL" | "CURRENT" | "DATE"; type TimeFilter = "ALL" | "CURRENT" | "DATE";
function localToday(): string { function localToday(): string {
const now = new Date(); return todayInBusinessTime();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
} }
function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undefined { function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undefined {
if (timeFilter === "ALL") return undefined; if (timeFilter === "ALL") return undefined;
if (timeFilter === "CURRENT") return new Date().toISOString(); if (timeFilter === "CURRENT") return todayInBusinessTime();
return date ? new Date(`${date}T12:00:00`).toISOString() : undefined; return date || undefined;
} }
function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> { function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
......
...@@ -20,23 +20,18 @@ import { OverviewMetrics } from "./components/OverviewMetrics"; ...@@ -20,23 +20,18 @@ import { OverviewMetrics } from "./components/OverviewMetrics";
import { ReportFilters } from "./components/ReportFilters"; import { ReportFilters } from "./components/ReportFilters";
import { ReportEmptyState, ReportErrorState, ReportSectionSkeleton } from "./components/ReportState"; import { ReportEmptyState, ReportErrorState, ReportSectionSkeleton } from "./components/ReportState";
import { SavingGoalsAnalytics } from "./components/SavingGoalsAnalytics"; import { SavingGoalsAnalytics } from "./components/SavingGoalsAnalytics";
import { addCalendarDays, businessDateStartIso, todayInBusinessTime } from "@/lib/business-time";
function localDateValue(date: Date): string {
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
}
function initialCustomDates(): { from: string; to: string } { function initialCustomDates(): { from: string; to: string } {
const now = new Date(); const today = todayInBusinessTime();
return { return {
from: localDateValue(new Date(now.getFullYear(), now.getMonth(), 1)), from: `${today.slice(0, 7)}-01`,
to: localDateValue(now), to: today,
}; };
} }
function toInclusiveBoundary(value: string, endBoundary: boolean): string { function toInclusiveBoundary(value: string, endBoundary: boolean): string {
const date = new Date(`${value}T00:00:00`); return businessDateStartIso(endBoundary ? addCalendarDays(value, 1) : value);
if (endBoundary) date.setDate(date.getDate() + 1);
return date.toISOString();
} }
const ReportsPage: React.FC = () => { const ReportsPage: React.FC = () => {
...@@ -66,7 +61,6 @@ const ReportsPage: React.FC = () => { ...@@ -66,7 +61,6 @@ const ReportsPage: React.FC = () => {
dateFrom: toInclusiveBoundary(dateFrom, false), dateFrom: toInclusiveBoundary(dateFrom, false),
dateTo: toInclusiveBoundary(dateTo, true), dateTo: toInclusiveBoundary(dateTo, true),
} : {}), } : {}),
timezoneOffsetMinutes: -new Date().getTimezoneOffset(),
...(walletId ? { walletId } : {}), ...(walletId ? { walletId } : {}),
...(!walletId && currency ? { currency } : {}), ...(!walletId && currency ? { currency } : {}),
granularity: "AUTO", granularity: "AUTO",
......
...@@ -8,13 +8,14 @@ import { Modal } from "@/components/ui/Modal"; ...@@ -8,13 +8,14 @@ import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input"; import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input";
import { SavingContribution, SavingContributionInput } from "@/types/saving-goal"; import { SavingContribution, SavingContributionInput } from "@/types/saving-goal";
import { businessWallTimeToIso } from "@/lib/business-time";
const createSchema = (t: TranslationFunction) => z.object({ const createSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.contributionAmountRequired")) amount: z.string().trim().min(1, t("validation.contributionAmountRequired"))
.regex(positiveAmountPattern, t("validation.contributionAmountInvalid")) .regex(positiveAmountPattern, t("validation.contributionAmountInvalid"))
.refine((value) => Number(value) > 0, t("validation.contributionAmountInvalid")), .refine((value) => Number(value) > 0, t("validation.contributionAmountInvalid")),
contributedAt: z.string().min(1, t("validation.contributionDateRequired")) 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")), note: z.string().max(500, t("validation.contributionNoteMax")),
}); });
...@@ -77,7 +78,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -77,7 +78,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => onSubmit({ onSubmit={handleSubmit((values) => onSubmit({
amount: values.amount, amount: values.amount,
contributedAt: new Date(values.contributedAt).toISOString(), contributedAt: businessWallTimeToIso(values.contributedAt),
note: values.note.trim() || null, note: values.note.trim() || null,
}))} }))}
noValidate noValidate
......
...@@ -9,14 +9,13 @@ import { Modal } from "@/components/ui/Modal"; ...@@ -9,14 +9,13 @@ import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input"; import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input";
import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal"; 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_COLORS = ["#16A34A", "#0D9488", "#3B82F6", "#6366F1", "#A855F7", "#EC4899", "#EF4444", "#F97316", "#EAB308", "#64748B"];
const GOAL_ICONS = ["laptop", "plane", "home", "car", "graduation-cap", "gift"]; const GOAL_ICONS = ["laptop", "plane", "home", "car", "graduation-cap", "gift"];
function tomorrow(): string { function tomorrow(): string {
const date = new Date(); return addCalendarDays(todayInBusinessTime(), 1);
date.setDate(date.getDate() + 1);
return toLocalDate(date.toISOString());
} }
const createSchema = (t: TranslationFunction, currentDate?: string) => z.object({ const createSchema = (t: TranslationFunction, currentDate?: string) => z.object({
...@@ -26,7 +25,7 @@ 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")), .refine((value) => Number(value) > 0, t("validation.goalAmountInvalid")),
currency: z.string().length(3, t("validation.currencyLength")).regex(/^[A-Za-z]{3}$/, t("validation.currencyLetters")), currency: z.string().length(3, t("validation.currencyLength")).regex(/^[A-Za-z]{3}$/, t("validation.currencyLetters")),
targetDate: z.string().min(1, t("validation.goalDateRequired")) 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")), description: z.string().max(500, t("validation.descriptionMax")),
icon: z.string().min(1).max(100), icon: z.string().min(1).max(100),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, t("validation.colorInvalid")), 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> = ({ ...@@ -89,7 +88,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
name: values.name.trim(), name: values.name.trim(),
targetAmount: values.targetAmount, targetAmount: values.targetAmount,
currency: values.currency.toUpperCase(), currency: values.currency.toUpperCase(),
targetDate: new Date(`${values.targetDate}T23:59:59`).toISOString(), targetDate: values.targetDate,
description: values.description.trim() || null, description: values.description.trim() || null,
icon: values.icon, icon: values.icon,
color: values.color, color: values.color,
......
...@@ -20,17 +20,18 @@ import { ...@@ -20,17 +20,18 @@ import {
import { SavingGoalCard } from "./components/SavingGoalCard"; import { SavingGoalCard } from "./components/SavingGoalCard";
import { SavingGoalFormModal } from "./components/SavingGoalFormModal"; import { SavingGoalFormModal } from "./components/SavingGoalFormModal";
import { SavingGoalSkeleton } from "./components/SavingGoalSkeleton"; import { SavingGoalSkeleton } from "./components/SavingGoalSkeleton";
import { addCalendarDays, todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
type StatusFilter = "ALL" | SavingGoalStatus; type StatusFilter = "ALL" | SavingGoalStatus;
type DueFilter = "ALL" | "OVERDUE" | "NEXT_30_DAYS" | "CUSTOM"; type DueFilter = "ALL" | "OVERDUE" | "NEXT_30_DAYS" | "CUSTOM";
function endOfLocalDay(value: string): string | undefined { 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 { function startOfLocalDay(value: string): string | undefined {
return value ? new Date(`${value}T00:00:00`).toISOString() : undefined; return value || undefined;
} }
const SavingGoalsPage: React.FC = () => { const SavingGoalsPage: React.FC = () => {
...@@ -50,11 +51,10 @@ const SavingGoalsPage: React.FC = () => { ...@@ -50,11 +51,10 @@ const SavingGoalsPage: React.FC = () => {
const [sortBy, order] = sort.split(":") as [SavingGoalSortField, SavingGoalSortOrder]; const [sortBy, order] = sort.split(":") as [SavingGoalSortField, SavingGoalSortOrder];
const dueRange = useMemo(() => { const dueRange = useMemo(() => {
if (dueFilter === "OVERDUE") return { dueTo: new Date().toISOString() }; if (dueFilter === "OVERDUE") return { dueTo: addCalendarDays(todayInBusinessTime(), -1) };
if (dueFilter === "NEXT_30_DAYS") { if (dueFilter === "NEXT_30_DAYS") {
const end = new Date(); const today = todayInBusinessTime();
end.setDate(end.getDate() + 30); return { dueFrom: today, dueTo: addCalendarDays(today, 30) };
return { dueFrom: new Date().toISOString(), dueTo: end.toISOString() };
} }
if (dueFilter === "CUSTOM") return { dueFrom: startOfLocalDay(dueFrom), dueTo: endOfLocalDay(dueTo) }; if (dueFilter === "CUSTOM") return { dueFrom: startOfLocalDay(dueFrom), dueTo: endOfLocalDay(dueTo) };
return {}; return {};
......
...@@ -126,8 +126,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -126,8 +126,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
try { try {
return new Intl.DateTimeFormat(intlLocale, { return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full", dateStyle: "full",
timeStyle: "short", timeZone: "UTC",
}).format(new Date(transaction.date)); }).format(new Date(`${transaction.date}T00:00:00Z`));
} catch { } catch {
return transaction.date; return transaction.date;
} }
......
...@@ -12,6 +12,7 @@ import { useCategoryTree } from "@/hooks/use-categories"; ...@@ -12,6 +12,7 @@ import { useCategoryTree } from "@/hooks/use-categories";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category"; import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction"; 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})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -103,11 +104,9 @@ function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] ...@@ -103,11 +104,9 @@ function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[]
} }
const getLocalDateString = (dateInput?: string | Date) => { const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date(); if (!dateInput) return todayInBusinessTime();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0]; if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput;
const offset = date.getTimezoneOffset(); return instantToBusinessDate(dateInput);
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
}; };
export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
...@@ -318,16 +317,12 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -318,16 +317,12 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const submitForm = (values: TransactionFormValues) => { const submitForm = (values: TransactionFormValues) => {
if (fileError) return; 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 = { const payload = {
amount: values.amount, amount: values.amount,
type: values.type, type: values.type,
walletId: values.walletId, walletId: values.walletId,
categoryId: values.categoryId, categoryId: values.categoryId,
date: localDate.toISOString(), date: values.date,
description: values.description?.trim() || null, description: values.description?.trim() || null,
location: values.location?.trim() || null, location: values.location?.trim() || null,
}; };
......
...@@ -32,15 +32,14 @@ import { TransactionFormModal } from "./components/TransactionFormModal"; ...@@ -32,15 +32,14 @@ import { TransactionFormModal } from "./components/TransactionFormModal";
import { TransactionDetailModal } from "./components/TransactionDetailModal"; import { TransactionDetailModal } from "./components/TransactionDetailModal";
import { TransactionSkeleton } from "./components/TransactionSkeleton"; import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const getLocalDateString = (dateInput?: string | Date) => { const getLocalDateString = (dateInput?: string | Date) => {
const date = dateInput ? new Date(dateInput) : new Date(); if (!dateInput) return todayInBusinessTime();
if (isNaN(date.getTime())) return new Date().toISOString().split("T")[0]; if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput;
const offset = date.getTimezoneOffset(); return instantToBusinessDate(dateInput);
const localDate = new Date(date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split("T")[0];
}; };
function getNumberSeparators(locale: string): { group: string; decimal: string } { function getNumberSeparators(locale: string): { group: string; decimal: string } {
...@@ -152,14 +151,10 @@ const TransactionsPage: React.FC = () => { ...@@ -152,14 +151,10 @@ const TransactionsPage: React.FC = () => {
if (type) q.type = type; if (type) q.type = type;
if (dateFrom) { if (dateFrom) {
const from = new Date(dateFrom); q.dateFrom = dateFrom;
from.setHours(0, 0, 0, 0);
q.dateFrom = from.toISOString();
} }
if (dateTo) { if (dateTo) {
const to = new Date(dateTo); q.dateTo = dateTo;
to.setHours(23, 59, 59, 999);
q.dateTo = to.toISOString();
} }
if (minAmount) q.minAmount = minAmount; if (minAmount) q.minAmount = minAmount;
...@@ -208,8 +203,8 @@ const TransactionsPage: React.FC = () => { ...@@ -208,8 +203,8 @@ const TransactionsPage: React.FC = () => {
return Object.keys(groupedTransactions).sort((a, b) => { return Object.keys(groupedTransactions).sort((a, b) => {
// Sort dates according to overall query sort order // Sort dates according to overall query sort order
return order === "desc" return order === "desc"
? new Date(b).getTime() - new Date(a).getTime() ? b.localeCompare(a)
: new Date(a).getTime() - new Date(b).getTime(); : a.localeCompare(b);
}); });
}, [groupedTransactions, order]); }, [groupedTransactions, order]);
...@@ -242,10 +237,8 @@ const TransactionsPage: React.FC = () => { ...@@ -242,10 +237,8 @@ const TransactionsPage: React.FC = () => {
// Generate localized group header // Generate localized group header
const getGroupHeaderLabel = (dateStr: string) => { const getGroupHeaderLabel = (dateStr: string) => {
const todayStr = getLocalDateString(new Date()); const todayStr = todayInBusinessTime();
const yesterday = new Date(); const yesterdayStr = addCalendarDays(todayStr, -1);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = getLocalDateString(yesterday);
if (dateStr === todayStr) { if (dateStr === todayStr) {
return t("transaction.today"); return t("transaction.today");
...@@ -257,7 +250,7 @@ const TransactionsPage: React.FC = () => { ...@@ -257,7 +250,7 @@ const TransactionsPage: React.FC = () => {
try { try {
return new Intl.DateTimeFormat(intlLocale, { return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full", dateStyle: "full",
}).format(new Date(dateStr)); }).format(new Date(`${dateStr}T00:00:00Z`));
} catch { } catch {
return dateStr; return dateStr;
} }
......
...@@ -12,6 +12,7 @@ import { getErrorMessage } from "@/lib/error-message"; ...@@ -12,6 +12,7 @@ import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer"; import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet"; import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -78,10 +79,7 @@ function hasSufficientBalance(amount: string, balance: string): boolean { ...@@ -78,10 +79,7 @@ function hasSufficientBalance(amount: string, balance: string): boolean {
} }
function toLocalDateTimeInput(value?: string): string { function toLocalDateTimeInput(value?: string): string {
const date = value ? new Date(value) : new Date(); return instantToBusinessDateTimeInput(value);
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);
} }
function createTransferSchema(t: TranslationFunction, wallets: Wallet[]) { function createTransferSchema(t: TranslationFunction, wallets: Wallet[]) {
...@@ -220,7 +218,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -220,7 +218,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
destinationWalletId: values.destinationWalletId, destinationWalletId: values.destinationWalletId,
amount: values.amount, amount: values.amount,
note: values.note?.trim() || null, 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"; ...@@ -11,6 +11,7 @@ import { useWalletSearch } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
import { addCalendarDays, businessDateStartIso } from "@/lib/business-time";
import { import {
CreateTransferInput, CreateTransferInput,
Transfer, Transfer,
...@@ -28,13 +29,13 @@ import { TransferSkeleton } from "./components/TransferSkeleton"; ...@@ -28,13 +29,13 @@ import { TransferSkeleton } from "./components/TransferSkeleton";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
function startOfLocalDay(value: string): string { function startOfLocalDay(value: string): string {
const date = new Date(`${value}T00:00:00`); return businessDateStartIso(value);
return date.toISOString();
} }
function endOfLocalDay(value: string): string { function endOfLocalDay(value: string): string {
const date = new Date(`${value}T23:59:59.999`); return new Date(
return date.toISOString(); new Date(businessDateStartIso(addCalendarDays(value, 1))).getTime() - 1,
).toISOString();
} }
const TransfersPage: React.FC = () => { const TransfersPage: React.FC = () => {
......
...@@ -10,7 +10,6 @@ export interface ReportQuery { ...@@ -10,7 +10,6 @@ export interface ReportQuery {
period: ReportPeriodPreset; period: ReportPeriodPreset;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
timezoneOffsetMinutes: number;
walletId?: string; walletId?: string;
currency?: string; currency?: string;
granularity: ReportGranularity; granularity: ReportGranularity;
...@@ -20,7 +19,7 @@ export interface ReportPeriod { ...@@ -20,7 +19,7 @@ export interface ReportPeriod {
preset: ReportPeriodPreset; preset: ReportPeriodPreset;
from: string; from: string;
to: string; to: string;
timezoneOffsetMinutes: number; timeZone: "Asia/Ho_Chi_Minh";
generatedAt: string; 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