From bc224a22916d422a003f7927a7a3b6255835fac2 Mon Sep 17 00:00:00 2001
From: keyhan
Date: Sat, 20 Jun 2026 00:15:43 +0330
Subject: [PATCH] fix(dashboard): Persian Jalali formatting for resource credit
dates
Render the prepaid resource credit "remaining" and "expires" text in
Persian with Jalali calendar when locale is fa: localized digits and
day/hour/minute units for time left, and weekday + Jalali date + time
for the expiry. Parts are assembled explicitly so order is stable
regardless of the runtime's ICU pattern data.
Co-Authored-By: Claude Opus 4.8
---
frontend/src/app/[lang]/dashboard/page.tsx | 9 ++--
frontend/src/lib/format-utils.ts | 62 ++++++++++++++++++++--
2 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/frontend/src/app/[lang]/dashboard/page.tsx b/frontend/src/app/[lang]/dashboard/page.tsx
index df93d67..2f368cb 100644
--- a/frontend/src/app/[lang]/dashboard/page.tsx
+++ b/frontend/src/app/[lang]/dashboard/page.tsx
@@ -3,14 +3,14 @@
import { useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { Link } from '@/i18n/Link';
-import { useT } from '@/i18n/I18nProvider';
+import { useT, useLocale } from '@/i18n/I18nProvider';
import type { Dictionary } from '@/i18n/dictionaries/fa';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Application } from '@/types';
import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react';
import type { ResourceCredit } from '@/types';
-import { formatExpiresAtLocal } from '@/lib/format-utils';
+import { formatExpiresAtLocal, formatRemainingForLocale } from '@/lib/format-utils';
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
import { filterApplications, filterManagedServices } from '@/lib/product-type';
@@ -65,6 +65,7 @@ function serviceSubtitle(app: Application): string {
export default function DashboardPage() {
const t = useT();
+ const locale = useLocale();
const h = t.dashboard.home;
const user = useAuthStore((s) => s.user);
@@ -116,10 +117,10 @@ export default function DashboardPage() {
- {h.remaining.replace('{label}', credit.remainingLabel)}
+ {h.remaining.replace('{label}', formatRemainingForLocale(credit.expiresAt, locale))}
- {h.expires.replace('{date}', formatExpiresAtLocal(credit.expiresAt))}
+ {h.expires.replace('{date}', formatExpiresAtLocal(credit.expiresAt, locale))}
- {h.cpu}: {credit.cpuLimit}
diff --git a/frontend/src/lib/format-utils.ts b/frontend/src/lib/format-utils.ts
index a9b4f6f..7541f99 100644
--- a/frontend/src/lib/format-utils.ts
+++ b/frontend/src/lib/format-utils.ts
@@ -31,11 +31,67 @@ export function formatRemainingDurationMs(remainingMs: number): string {
return 'less than 1m';
}
-/** Expiry timestamp in the user's locale and timezone. */
-export function formatExpiresAtLocal(expiresAt: string | Date): string {
+/** Persian (با ارقام فارسی) human-readable time left, e.g. «۲۹ روز و ۱۹ ساعت و ۵۱ دقیقه». */
+function formatRemainingDurationFa(remainingMs: number): string {
+ const ms = Math.max(0, remainingMs);
+ const days = Math.floor(ms / 86400000);
+ const hours = Math.floor((ms % 86400000) / 3600000);
+ const minutes = Math.floor((ms % 3600000) / 60000);
+ const fa = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false });
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${fa(days)} روز`);
+ if (hours > 0) parts.push(`${fa(hours)} ساعت`);
+ if (minutes > 0) parts.push(`${fa(minutes)} دقیقه`);
+ if (parts.length === 0) return 'کمتر از ۱ دقیقه';
+ return parts.join(' و ');
+}
+
+/** Locale-aware time left from an expiry timestamp. */
+export function formatRemainingForLocale(
+ expiresAt: string | Date,
+ locale: string,
+): string {
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
if (Number.isNaN(date.getTime())) return '—';
- return date.toLocaleString(undefined, {
+ const remainingMs = date.getTime() - Date.now();
+ return locale === 'fa'
+ ? formatRemainingDurationFa(remainingMs)
+ : formatRemainingDurationMs(remainingMs);
+}
+
+/**
+ * Expiry timestamp in the user's locale and timezone. For Persian it renders the
+ * Jalali date with weekday and time, e.g. «یکشنبه، ۲۹ تیر ۱۴۰۵، ساعت ۱۹:۵۸».
+ */
+export function formatExpiresAtLocal(
+ expiresAt: string | Date,
+ locale?: string,
+): string {
+ const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
+ if (Number.isNaN(date.getTime())) return '—';
+ if (locale === 'fa') {
+ // Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
+ // of the runtime's ICU pattern data.
+ const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).formatToParts(date);
+ const get = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((p) => p.type === type)?.value ?? '';
+ const weekday = get('weekday');
+ const dayNum = get('day');
+ const month = get('month');
+ const year = get('year');
+ const time = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(date);
+ return `${weekday}، ${dayNum} ${month} ${year}، ساعت ${time}`;
+ }
+ return date.toLocaleString(locale || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});