Task #28: Let users pick a 12-hour (AM/PM) clock instead of 24-hour

Add a per-user 12/24-hour clock preference that complements the existing
clock-style preference and is honored by every clock variant on the home
screen.

Schema & API
- Added `clockHour12 boolean` (nullable) column to `users` table
  (lib/db/src/schema/users.ts) and synced via direct `ALTER TABLE`
  because `drizzle-kit push` was blocked by an unrelated interactive
  rename prompt for the existing `app_opens` table.
- Extended OpenAPI `AuthUser` and `UserProfile` with `clockHour12`,
  added `UpdateClockHour12Body` schema, and a new
  `PATCH /auth/me/clock-hour12` endpoint. Regenerated zod + react-query
  clients via `pnpm --filter @workspace/api-spec run codegen`.
- Implemented the new route handler in
  `artifacts/api-server/src/routes/auth.ts`; `buildAuthUser` now
  surfaces `clockHour12`.

Frontend
- `lib/i18n-format.ts` no longer hard-codes `hour12: false`; callers
  may pass `hour12` in options. Default remains 24-hour to keep all
  other timestamps unchanged (chat etc. left as-is — see follow-up).
- `components/clock.tsx` exports `resolveClockHour12` and threads a new
  `hour12` prop through `Clock` and `AnalogClockWidget`. All five
  variants (full/digital/digital-no-seconds/analog/minimal) plus the
  large analog widget now honor the choice.
- `components/clock-style-picker.tsx` gained a 12-hour / 24-hour
  segmented toggle that calls the new endpoint with optimistic cache
  updates. The variant previews also reflect the active hour format.
- `pages/home.tsx` passes `user.clockHour12` to the header clock,
  widget, and picker.
- Added `home.clockStyle.hourFormat.{label,h12,h24}` strings in EN and
  AR. Arabic uses Latin digits and "ص/م" via Intl's localized
  dayPeriod.

Verification
- `pnpm -w run typecheck` passes.
- E2E test: logged in, switched to 12-hour, verified AM/PM in header
  and previews, reloaded to confirm persistence, switched back to
  24-hour, reloaded again — all green.

Replit-Task-Id: 03ea8cbe-ace7-4d36-afc5-49ebc9706c67
This commit is contained in:
riyadhafraa
2026-04-21 07:16:50 +00:00
parent 8d90771409
commit bfdfffd8b5
13 changed files with 318 additions and 8 deletions
+24
View File
@@ -16,6 +16,7 @@ import {
LoginBody,
UpdateLanguageBody,
UpdateClockStyleBody,
UpdateClockHour12Body,
ForgotPasswordBody,
ResetPasswordBody,
VerifyResetTokenBody,
@@ -38,6 +39,7 @@ function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) {
displayNameEn: user.displayNameEn,
preferredLanguage: user.preferredLanguage,
clockStyle: user.clockStyle,
clockHour12: user.clockHour12,
avatarUrl: user.avatarUrl,
isActive: user.isActive,
roles,
@@ -326,6 +328,28 @@ router.patch("/auth/me/language", requireAuth, async (req, res): Promise<void> =
res.json(buildAuthUser(user, roles));
});
router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise<void> => {
const parsed = UpdateClockHour12Body.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [user] = await db
.update(usersTable)
.set({ clockHour12: parsed.data.clockHour12 ?? null })
.where(eq(usersTable.id, req.session.userId!))
.returning();
if (!user) {
res.status(401).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles));
});
router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise<void> => {
const parsed = UpdateClockStyleBody.safeParse(req.body);
if (!parsed.success) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateClockStyle,
useUpdateClockHour12,
getGetMeQueryKey,
type AuthUser,
type ClockStyle as ClockStyleType,
@@ -17,15 +18,17 @@ import {
Clock,
CLOCK_STYLES,
resolveClockStyle,
resolveClockHour12,
useNow,
useHomeClockVisibility,
} from "@/components/clock";
type Props = {
currentStyle: ClockStyleType | null | undefined;
currentHour12: boolean | null | undefined;
};
export function ClockStylePicker({ currentStyle }: Props) {
export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
const { t, i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const queryClient = useQueryClient();
@@ -33,7 +36,9 @@ export function ClockStylePicker({ currentStyle }: Props) {
const [open, setOpen] = useState(false);
const updateClockStyle = useUpdateClockStyle();
const updateClockHour12 = useUpdateClockHour12();
const active = resolveClockStyle(currentStyle);
const activeHour12 = resolveClockHour12(currentHour12);
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
const choose = (style: ClockStyleType) => {
@@ -62,6 +67,32 @@ export function ClockStylePicker({ currentStyle }: Props) {
setOpen(false);
};
const chooseHour12 = (next: boolean) => {
if (next === activeHour12) return;
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
if (previous) {
queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), {
...previous,
clockHour12: next,
});
}
updateClockHour12.mutate(
{ data: { clockHour12: next } },
{
onSuccess: (data) => {
queryClient.setQueryData(getGetMeQueryKey(), data);
},
onError: () => {
if (previous) {
queryClient.setQueryData(getGetMeQueryKey(), previous);
} else {
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
}
},
},
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -105,6 +136,36 @@ export function ClockStylePicker({ currentStyle }: Props) {
</span>
</button>
<div className="h-px bg-foreground/10 mx-1 mb-1" />
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.hourFormat.label")}
</div>
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
aria-label={t("home.clockStyle.hourFormat.label")}
>
{([false, true] as const).map((value) => {
const isActive = value === activeHour12;
const labelKey = value ? "h12" : "h24";
return (
<button
key={labelKey}
type="button"
onClick={() => chooseHour12(value)}
disabled={updateClockHour12.isPending}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`home.clockStyle.hourFormat.${labelKey}`)}
</button>
);
})}
</div>
<div className="h-px bg-foreground/10 mx-1 mb-1" />
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.label")}
</div>
@@ -123,7 +184,13 @@ export function ClockStylePicker({ currentStyle }: Props) {
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
<Clock style={style} time={now} lang={lang} size="sm" />
<Clock
style={style}
time={now}
lang={lang}
hour12={activeHour12}
size="sm"
/>
</div>
<div className="text-xs text-foreground font-medium truncate">
{t(`home.clockStyle.options.${style}`)}
+19 -2
View File
@@ -36,9 +36,16 @@ type ClockProps = {
style: ClockStyle | null | undefined;
time: Date;
lang: string;
hour12?: boolean | null;
size?: "sm" | "md";
};
export const DEFAULT_CLOCK_HOUR12 = false;
export function resolveClockHour12(value: boolean | null | undefined): boolean {
return value ?? DEFAULT_CLOCK_HOUR12;
}
function AnalogClock({ time, size = "md" }: { time: Date; size?: "sm" | "md" }) {
const hours = time.getHours() % 12;
const minutes = time.getMinutes();
@@ -128,12 +135,15 @@ function AnalogClock({ time, size = "md" }: { time: Date; size?: "sm" | "md" })
export function AnalogClockWidget({
time,
lang,
hour12,
className,
}: {
time: Date;
lang: string;
hour12?: boolean | null;
className?: string;
}) {
const use12 = resolveClockHour12(hour12);
const hours = time.getHours() % 12;
const minutes = time.getMinutes();
const seconds = time.getSeconds();
@@ -310,6 +320,7 @@ export function AnalogClockWidget({
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: use12,
})}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
@@ -365,16 +376,22 @@ export function useHomeClockVisibility(): [boolean, (next: boolean) => void] {
return [visible, setVisible];
}
export function Clock({ style, time, lang, size = "md" }: ClockProps) {
export function Clock({ style, time, lang, hour12, size = "md" }: ClockProps) {
const resolved = resolveClockStyle(style);
const use12 = resolveClockHour12(hour12);
const dayName = formatWeekday(time, lang);
const hijriDate = formatHijri(time, lang);
const gregorianDate = formatDate(time, lang);
const timeNoSec = formatTime(time, lang, { hour: "2-digit", minute: "2-digit" });
const timeNoSec = formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
hour12: use12,
});
const timeWithSec = formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: use12,
});
const timeFontClass =
+2 -1
View File
@@ -18,9 +18,10 @@ export function formatTime(
options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" },
): string {
const d = date instanceof Date ? date : new Date(date);
const hour12 = options.hour12 ?? false;
return new Intl.DateTimeFormat(withLatn(baseLocale(lang)), {
...options,
hour12: false,
hour12,
numberingSystem: "latn",
}).format(d);
}
+5
View File
@@ -81,6 +81,11 @@
"clockStyle": {
"label": "نمط الساعة",
"showWidget": "إظهار ساعة الحائط في الشاشة الرئيسية",
"hourFormat": {
"label": "صيغة الساعة",
"h12": "12 ساعة (ص/م)",
"h24": "24 ساعة"
},
"options": {
"full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)",
"digital": "رقمي مع الثواني",
+5
View File
@@ -81,6 +81,11 @@
"clockStyle": {
"label": "Clock style",
"showWidget": "Show wall clock on home screen",
"hourFormat": {
"label": "Hour format",
"h12": "12-hour (AM/PM)",
"h24": "24-hour"
},
"options": {
"full": "Full (time, weekday, Hijri & Gregorian)",
"digital": "Digital with seconds",
+15 -3
View File
@@ -269,12 +269,20 @@ export default function HomePage() {
{/* Clock + dates */}
<div className="px-2 shrink-0">
<Clock style={user?.clockStyle ?? null} time={time} lang={lang} />
<Clock
style={user?.clockStyle ?? null}
hour12={user?.clockHour12 ?? null}
time={time}
lang={lang}
/>
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink basis-0 flex-1 justify-end">
<ClockStylePicker currentStyle={user?.clockStyle ?? null} />
<ClockStylePicker
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<button
onClick={toggleLanguage}
aria-label="Toggle language"
@@ -316,7 +324,11 @@ export default function HomePage() {
</div>
{homeClockVisible && (
<div className="hidden md:block shrink-0">
<AnalogClockWidget time={time} lang={lang} />
<AnalogClockWidget
time={time}
lang={lang}
hour12={user?.clockHour12 ?? null}
/>
</div>
)}
</div>