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
@@ -83,6 +83,14 @@ export interface UpdateClockStyleBody {
clockStyle: ClockStyle | null;
}
export interface UpdateClockHour12Body {
/**
* Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour)
* @nullable
*/
clockHour12: boolean | null;
}
export interface AuthUser {
id: number;
username: string;
@@ -94,6 +102,8 @@ export interface AuthUser {
preferredLanguage: string;
clockStyle?: ClockStyle | null;
/** @nullable */
clockHour12?: boolean | null;
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
roles: string[];
@@ -111,6 +121,8 @@ export interface UserProfile {
preferredLanguage: string;
clockStyle?: ClockStyle | null;
/** @nullable */
clockHour12?: boolean | null;
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
roles: string[];
+87
View File
@@ -45,6 +45,7 @@ import type {
SuccessResponse,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateClockHour12Body,
UpdateClockStyleBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
@@ -969,6 +970,92 @@ export const useUpdateClockStyle = <
return useMutation(getUpdateClockStyleMutationOptions(options));
};
/**
* @summary Update preferred 12/24-hour clock format
*/
export const getUpdateClockHour12Url = () => {
return `/api/auth/me/clock-hour12`;
};
export const updateClockHour12 = async (
updateClockHour12Body: UpdateClockHour12Body,
options?: RequestInit,
): Promise<AuthUser> => {
return customFetch<AuthUser>(getUpdateClockHour12Url(), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateClockHour12Body),
});
};
export const getUpdateClockHour12MutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateClockHour12>>,
TError,
{ data: BodyType<UpdateClockHour12Body> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateClockHour12>>,
TError,
{ data: BodyType<UpdateClockHour12Body> },
TContext
> => {
const mutationKey = ["updateClockHour12"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateClockHour12>>,
{ data: BodyType<UpdateClockHour12Body> }
> = (props) => {
const { data } = props ?? {};
return updateClockHour12(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateClockHour12MutationResult = NonNullable<
Awaited<ReturnType<typeof updateClockHour12>>
>;
export type UpdateClockHour12MutationBody = BodyType<UpdateClockHour12Body>;
export type UpdateClockHour12MutationError = ErrorType<ErrorResponse>;
/**
* @summary Update preferred 12/24-hour clock format
*/
export const useUpdateClockHour12 = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateClockHour12>>,
TError,
{ data: BodyType<UpdateClockHour12Body> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateClockHour12>>,
TError,
{ data: BodyType<UpdateClockHour12Body> },
TContext
> => {
return useMutation(getUpdateClockHour12MutationOptions(options));
};
/**
* @summary List all active apps
*/
+38
View File
@@ -270,6 +270,31 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/auth/me/clock-hour12:
patch:
operationId: updateClockHour12
tags: [auth]
summary: Update preferred 12/24-hour clock format
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateClockHour12Body"
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/AuthUser"
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Apps
/apps:
get:
@@ -1025,6 +1050,15 @@ components:
required:
- clockStyle
UpdateClockHour12Body:
type: object
properties:
clockHour12:
type: ["boolean", "null"]
description: Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour)
required:
- clockHour12
AuthUser:
type: object
properties:
@@ -1044,6 +1078,8 @@ components:
oneOf:
- $ref: "#/components/schemas/ClockStyle"
- type: "null"
clockHour12:
type: ["boolean", "null"]
avatarUrl:
type: ["string", "null"]
isActive:
@@ -1083,6 +1119,8 @@ components:
oneOf:
- $ref: "#/components/schemas/ClockStyle"
- type: "null"
clockHour12:
type: ["boolean", "null"]
avatarUrl:
type: ["string", "null"]
isActive:
+41
View File
@@ -51,6 +51,7 @@ export const LoginResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -130,6 +131,7 @@ export const GetMeResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -158,6 +160,7 @@ export const UpdateLanguageResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -193,6 +196,41 @@ export const UpdateClockStyleResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
createdAt: zod.coerce.date(),
});
/**
* @summary Update preferred 12/24-hour clock format
*/
export const UpdateClockHour12Body = zod.object({
clockHour12: zod
.boolean()
.nullable()
.describe(
"Whether to use 12-hour (AM\/PM) clock format; null clears and uses the default (24-hour)",
),
});
export const UpdateClockHour12Response = zod.object({
id: zod.number(),
username: zod.string(),
email: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
preferredLanguage: zod.string(),
clockStyle: zod
.union([
zod
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
.describe("Per-user home-screen clock style preference"),
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -741,6 +779,7 @@ export const ListUsersResponseItem = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -786,6 +825,7 @@ export const GetUserResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -821,6 +861,7 @@ export const UpdateUserResponse = zod.object({
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
+1
View File
@@ -11,6 +11,7 @@ export const usersTable = pgTable("users", {
displayNameEn: varchar("display_name_en", { length: 200 }),
preferredLanguage: varchar("preferred_language", { length: 10 }).notNull().default("ar"),
clockStyle: varchar("clock_style", { length: 30 }),
clockHour12: boolean("clock_hour12"),
avatarUrl: text("avatar_url"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),