Task #19: Force Latin digits + redesign home screen

- New helper artifacts/teaboy-os/src/lib/i18n-format.ts wraps Intl with
  numberingSystem: "latn" so numerals always render 0-9 even in Arabic,
  while month/weekday names stay localized. Exports formatTime,
  formatDate, formatHijri, formatWeekday, formatDateTime, formatNumber
  and a greetingKey() helper for time-of-day greetings.

- Replaced every toLocaleTimeString / toLocaleDateString /
  toLocaleString / direct Intl.DateTimeFormat call in home, admin,
  chat and notifications with the new helpers. No remaining
  Arabic-Indic digits anywhere in the UI (verified e2e).

- Redesigned the home screen:
    * Tighter status bar with three balanced sections: identity (avatar
      + name + admin tag), centered clock + Hijri/Gregorian dates,
      grouped controls (language, bell, logout).
    * Personal greeting line ("Good morning / صباح الخير …") driven by
      local hour and the user's display name.
    * Admin-only 4-card stat row (Apps / Services / Messages / Alerts)
      with soft glass + small colored icon tiles.
    * Polished apps grid with section count badge and a friendly empty
      state. Drag-and-drop reorder behavior preserved.
    * Type-safe Lucide icon resolver (no unsafe casts).

- Added i18n keys home.greeting.{morning,afternoon,evening},
  home.noApps, home.noAppsHint, home.stats.* in ar.json and en.json.

- Cleaned up obsolete follow-up plan file now that this work shipped.

Verified via TypeScript + e2e Playwright run in both Arabic (RTL) and
English (LTR): login → home redesign → notifications → chat, all
numerals Latin in both languages.
This commit is contained in:
Riyadh
2026-04-20 15:05:11 +00:00
parent 98a75f7d55
commit 17d2387918
8 changed files with 311 additions and 89 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+101
View File
@@ -0,0 +1,101 @@
/**
* Locale-aware date/number formatters that ALWAYS render Latin (0-9) digits,
* even when the active language is Arabic. Month and weekday names still
* localize to Arabic as expected.
*/
function withLatn(locale: string): string {
return locale.includes("-u-") ? `${locale}-nu-latn` : `${locale}-u-nu-latn`;
}
function baseLocale(lang: string): string {
return lang === "ar" ? "ar" : "en-US";
}
export function formatTime(
date: Date | string | number,
lang: string,
options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" },
): string {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
...options,
hour12: false,
numberingSystem: "latn",
}).format(d);
}
export function formatDate(
date: Date | string | number,
lang: string,
options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
},
): string {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
...options,
numberingSystem: "latn",
}).format(d);
}
export function formatHijri(
date: Date | string | number,
lang: string,
options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
},
): string {
const d = date instanceof Date ? date : new Date(date);
const loc = lang === "ar" ? "ar-u-ca-islamic-umalqura-nu-latn" : "en-u-ca-islamic-umalqura-nu-latn";
return new Intl.DateTimeFormat(loc, {
...options,
numberingSystem: "latn",
}).format(d);
}
export function formatWeekday(
date: Date | string | number,
lang: string,
weekday: "long" | "short" | "narrow" = "long",
): string {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
weekday,
numberingSystem: "latn",
}).format(d);
}
export function formatDateTime(
date: Date | string | number,
lang: string,
): string {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
dateStyle: "medium",
timeStyle: "short",
numberingSystem: "latn",
hour12: false,
}).format(d);
}
export function formatNumber(value: number, lang: string): string {
return new Intl.NumberFormat(withLatn(baseLocale(lang)), {
numberingSystem: "latn",
}).format(value);
}
/**
* Localized time-of-day greeting key. Uses the local hour to pick one of
* "morning", "afternoon", or "evening". Caller provides the i18n strings.
*/
export function greetingKey(date: Date = new Date()): "morning" | "afternoon" | "evening" {
const h = date.getHours();
if (h < 12) return "morning";
if (h < 18) return "afternoon";
return "evening";
}
+14 -1
View File
@@ -35,7 +35,20 @@
"rights": "جميع الحقوق محفوظة."
},
"home": {
"myApps": "تطبيقاتي"
"myApps": "تطبيقاتي",
"noApps": "لا توجد تطبيقات بعد",
"noAppsHint": "ستظهر هنا التطبيقات التي تملك صلاحية الوصول إليها.",
"greeting": {
"morning": "صباح الخير، {{name}}",
"afternoon": "مساء الخير، {{name}}",
"evening": "مساء الخير، {{name}}"
},
"stats": {
"apps": "التطبيقات",
"services": "الخدمات",
"messages": "الرسائل",
"alerts": "التنبيهات"
}
},
"services": {
"title": "الخدمات",
+14 -1
View File
@@ -35,7 +35,20 @@
"rights": "All rights reserved."
},
"home": {
"myApps": "My Apps"
"myApps": "My Apps",
"noApps": "No apps yet",
"noAppsHint": "Apps you have access to will appear here.",
"greeting": {
"morning": "Good morning, {{name}}",
"afternoon": "Good afternoon, {{name}}",
"evening": "Good evening, {{name}}"
},
"stats": {
"apps": "Apps",
"services": "Services",
"messages": "Messages",
"alerts": "Alerts"
}
},
"services": {
"title": "Services",
+3 -9
View File
@@ -36,6 +36,7 @@ import { Switch } from "@/components/ui/switch";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
type AdminSection = "dashboard" | "apps" | "services" | "users" | "settings";
@@ -778,15 +779,10 @@ function DashboardSection({
? [...apps].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0]
: undefined;
const dateLocale = lang === "ar" ? "ar" : "en";
const formatDate = (iso?: string) => {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "short",
day: "numeric",
});
return formatDateUtil(iso, lang, { year: "numeric", month: "short", day: "numeric" });
} catch {
return "";
}
@@ -814,9 +810,7 @@ function DashboardSection({
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
const dayLabel = (iso: string) => {
try {
return new Date(iso + "T00:00:00Z").toLocaleDateString(dateLocale, {
weekday: "short",
});
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
} catch {
return "";
}
+2 -4
View File
@@ -16,6 +16,7 @@ import { io } from "socket.io-client";
import { ArrowRight, ArrowLeft, MessageCircle, Plus, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatTime } from "@/lib/i18n-format";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -265,10 +266,7 @@ export default function ChatPage() {
{msg.content}
</div>
<div className="text-xs text-muted-foreground mt-1 px-1">
{new Date(msg.createdAt).toLocaleTimeString(lang === "ar" ? "ar-SA" : "en-US", {
hour: "2-digit",
minute: "2-digit",
})}
{formatTime(msg.createdAt, lang)}
</div>
</div>
</div>
+175 -73
View File
@@ -16,8 +16,18 @@ import {
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { Bell, LogOut, Globe } from "lucide-react";
import {
Bell,
LogOut,
Globe,
Grid2X2,
Coffee,
MessageSquare,
BellRing,
Sparkles,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import i18n from "@/i18n";
import {
DndContext,
@@ -35,6 +45,28 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
formatTime,
formatDate,
formatHijri,
formatWeekday,
formatNumber,
greetingKey,
} from "@/lib/i18n-format";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
return x in LucideIcons;
}
function resolveIcon(name: string): LucideIcon {
if (isIconName(name)) {
const Candidate = LucideIcons[name] as unknown;
if (typeof Candidate === "function" || typeof Candidate === "object") {
return Candidate as LucideIcon;
}
}
return LucideIcons.Grid2X2;
}
function gradientForColor(color: string): string {
const c = (color ?? "").toLowerCase();
@@ -51,18 +83,17 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon
const lang = i18nInstance.language;
const name = lang === "ar" ? app.nameAr : app.nameEn;
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
?? LucideIcons.Grid2X2;
const Icon = resolveIcon(app.iconName);
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 group"
className="flex flex-col items-center gap-2 group select-none"
>
<div className={`icon-tile w-16 h-16 ${gradientForColor(app.color)} group-hover:scale-105`}>
<Icon size={28} color="#FFFFFF" />
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
<Icon size={30} color="#FFFFFF" />
</div>
<span className="text-xs text-foreground/80 font-medium text-center leading-tight max-w-16 truncate">
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
{name}
</span>
</button>
@@ -85,6 +116,27 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
);
}
type StatCardProps = {
label: string;
value: string;
Icon: typeof Grid2X2;
iconClass: string;
};
function StatCard({ label, value, Icon, iconClass }: StatCardProps) {
return (
<div className="glass-panel rounded-2xl p-3 flex items-center gap-3">
<div className={`shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${iconClass}`}>
<Icon size={18} />
</div>
<div className="min-w-0">
<div className="text-xl font-bold text-foreground leading-none tabular-nums">{value}</div>
<div className="text-[11px] text-muted-foreground mt-1 truncate">{label}</div>
</div>
</div>
);
}
export default function HomePage() {
const { t, i18n: i18nInstance } = useTranslation();
const [, setLocation] = useLocation();
@@ -152,25 +204,21 @@ export default function HomePage() {
return () => clearInterval(timer);
}, []);
const dateLocale = lang === "ar" ? "ar-SA" : "en-US";
const dayName = new Intl.DateTimeFormat(dateLocale, { weekday: "long" }).format(time);
const hijriDate = new Intl.DateTimeFormat(
lang === "ar" ? "ar-SA-u-ca-islamic-umalqura" : "en-US-u-ca-islamic-umalqura",
{ day: "numeric", month: "long", year: "numeric" },
).format(time);
const gregorianDate = new Intl.DateTimeFormat(
lang === "ar" ? "ar-SA-u-ca-gregory" : "en-US",
{ day: "numeric", month: "long", year: "numeric" },
).format(time);
const clock = formatTime(time, lang);
const dayName = formatWeekday(time, lang);
const hijriDate = formatHijri(time, lang);
const gregorianDate = formatDate(time, lang);
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0;
const displayName = lang === "ar"
? (user?.displayNameAr ?? user?.username)
: (user?.displayNameEn ?? user?.username);
? (user?.displayNameAr ?? user?.username ?? "")
: (user?.displayNameEn ?? user?.username ?? "");
const isAdmin = user?.roles?.includes("admin") ?? false;
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
const handleLogout = () => {
logout.mutate(undefined, {
onSuccess: () => {
@@ -191,52 +239,63 @@ export default function HomePage() {
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? [];
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
return (
<div className="min-h-screen os-bg flex flex-col overflow-hidden relative">
{/* Status Bar */}
<div className="topbar-glass px-4 py-3 flex items-center justify-between gap-2 z-10 sticky top-0">
<div className="flex items-center gap-3 min-w-0 shrink">
<span className="text-foreground font-medium text-sm truncate max-w-24">{displayName}</span>
<div className="topbar-glass px-4 sm:px-6 py-3 flex items-center justify-between gap-3 z-10 sticky top-0">
{/* Identity */}
<div className="flex items-center gap-2.5 min-w-0 shrink basis-0 flex-1">
<div className="w-9 h-9 rounded-full bg-primary/15 text-primary flex items-center justify-center font-bold text-sm shrink-0">
{initials}
</div>
<div className="min-w-0 hidden sm:block">
<div className="text-foreground font-semibold text-sm truncate leading-tight">{displayName}</div>
{isAdmin && (
<div className="text-[10px] text-muted-foreground leading-tight">Admin</div>
)}
</div>
</div>
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2">
<div className="font-mono text-sm font-bold">
{time.toLocaleTimeString(dateLocale, {
hour: "2-digit",
minute: "2-digit",
})}
{/* Clock + dates */}
<div className="flex flex-col items-center text-foreground leading-tight min-w-0 px-2 shrink-0">
<div className="font-mono text-base sm:text-lg font-bold tabular-nums tracking-tight">
{clock}
</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-full truncate">
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5 max-w-[60vw] truncate">
<span className="truncate">{dayName}</span>
<span aria-hidden="true">·</span>
<span className="truncate">{hijriDate}</span>
<span className="truncate tabular-nums">{hijriDate}</span>
</div>
<div className="text-[10px] text-muted-foreground max-w-full truncate">
<div className="text-[10px] text-muted-foreground/80 max-w-[60vw] truncate tabular-nums">
{gregorianDate}
</div>
</div>
<div className="flex items-center gap-2">
{/* Controls */}
<div className="flex items-center gap-1 shrink basis-0 flex-1 justify-end">
<button
onClick={toggleLanguage}
className="text-muted-foreground hover:text-foreground transition-colors text-xs font-medium px-2 py-1 rounded-lg hover:bg-slate-100"
aria-label="Toggle language"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
>
{t("common.language")}
<Globe size={18} />
</button>
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-xs rounded-full w-4 h-4 flex items-center justify-center font-bold">
<span className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<button
onClick={handleLogout}
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut size={18} />
</button>
@@ -244,59 +303,97 @@ export default function HomePage() {
</div>
{/* Main Content */}
<div className="flex-1 p-6 pb-28 overflow-y-auto">
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
{/* Greeting */}
<div className="mb-6 max-w-3xl">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
{greeting}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
</p>
</div>
{/* Stats Row — admin only */}
{isAdmin && stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<div className="glass-panel rounded-2xl p-3 text-center">
<div className="text-2xl font-bold text-primary">{stats.totalApps}</div>
<div className="text-xs text-muted-foreground mt-1">{t("home.myApps")}</div>
</div>
<div className="glass-panel rounded-2xl p-3 text-center">
<div className="text-2xl font-bold text-secondary">{stats.totalServices}</div>
<div className="text-xs text-muted-foreground mt-1">{t("nav.services")}</div>
</div>
<div className="glass-panel rounded-2xl p-3 text-center">
<div className="text-2xl font-bold text-accent">{stats.unreadMessages}</div>
<div className="text-xs text-muted-foreground mt-1">{t("nav.chat")}</div>
</div>
<div className="glass-panel rounded-2xl p-3 text-center">
<div className="text-2xl font-bold text-destructive">{stats.unreadNotifications}</div>
<div className="text-xs text-muted-foreground mt-1">{t("nav.notifications")}</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8 max-w-4xl">
<StatCard
label={t("home.stats.apps")}
value={formatNumber(stats.totalApps, lang)}
Icon={Grid2X2}
iconClass="bg-primary/15 text-primary"
/>
<StatCard
label={t("home.stats.services")}
value={formatNumber(stats.totalServices, lang)}
Icon={Coffee}
iconClass="bg-amber-100 text-amber-600"
/>
<StatCard
label={t("home.stats.messages")}
value={formatNumber(stats.unreadMessages, lang)}
Icon={MessageSquare}
iconClass="bg-emerald-100 text-emerald-600"
/>
<StatCard
label={t("home.stats.alerts")}
value={formatNumber(stats.unreadNotifications, lang)}
Icon={BellRing}
iconClass="bg-rose-100 text-rose-600"
/>
</div>
)}
{/* Apps Grid */}
<div className="mb-4">
<h3 className="text-sm font-medium text-muted-foreground mb-4">{t("home.myApps")}</h3>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
{orderedApps?.map((app) => (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.myApps")}
</h3>
{orderedApps && orderedApps.length > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatNumber(orderedApps.length, lang)}
</span>
)}
</div>
{orderedApps && orderedApps.length === 0 ? (
<div className="glass-panel rounded-2xl p-8 text-center max-w-md mx-auto">
<div className="mx-auto w-12 h-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center mb-3">
<Sparkles size={22} />
</div>
</SortableContext>
</DndContext>
<div className="font-semibold text-foreground">{t("home.noApps")}</div>
<div className="text-sm text-muted-foreground mt-1">{t("home.noAppsHint")}</div>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-x-3 gap-y-5">
{orderedApps?.map((app) => (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* Bottom Dock */}
<div className="fixed bottom-0 left-0 right-0 p-4 z-10">
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto">
<div className="fixed bottom-0 left-0 right-0 p-4 z-10 pointer-events-none">
<div className="dock-glass rounded-3xl px-6 py-3 flex items-center justify-around max-w-sm mx-auto pointer-events-auto">
{dockApps.slice(0, 4).map((app) => {
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number; color?: string }>>)[app.iconName]
?? LucideIcons.Grid2X2;
const Icon = resolveIcon(app.iconName);
const showBadge = app.slug === "notifications" && unreadCount > 0;
return (
<button
key={app.id}
onClick={() => setLocation(app.route)}
className="flex flex-col items-center gap-1 group"
className="relative flex flex-col items-center gap-1 group"
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform"
@@ -304,6 +401,11 @@ export default function HomePage() {
>
<Icon size={22} color={app.color} />
</div>
{showBadge && (
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold tabular-nums">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
);
})}
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ArrowRight, ArrowLeft, Bell, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatDateTime } from "@/lib/i18n-format";
export default function NotificationsPage() {
const { t, i18n } = useTranslation();
@@ -113,7 +114,7 @@ export default function NotificationsPage() {
)}
</div>
<div className="text-xs text-muted-foreground mt-2">
{new Date(n.createdAt).toLocaleString(lang === "ar" ? "ar-SA" : "en-US")}
{formatDateTime(n.createdAt, lang)}
</div>
</div>
);