diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 312a184e..712013cf 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -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 = res.json(buildAuthUser(user, roles)); }); +router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise => { + 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 => { const parsed = UpdateClockStyleBody.safeParse(req.body); if (!parsed.success) { diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 6e7ad374..8c2ac7c4 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/components/clock-style-picker.tsx b/artifacts/teaboy-os/src/components/clock-style-picker.tsx index de86502c..c16514db 100644 --- a/artifacts/teaboy-os/src/components/clock-style-picker.tsx +++ b/artifacts/teaboy-os/src/components/clock-style-picker.tsx @@ -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(getGetMeQueryKey()); + if (previous) { + queryClient.setQueryData(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 ( @@ -105,6 +136,36 @@ export function ClockStylePicker({ currentStyle }: Props) {
+
+ {t("home.clockStyle.hourFormat.label")} +
+
+ {([false, true] as const).map((value) => { + const isActive = value === activeHour12; + const labelKey = value ? "h12" : "h24"; + return ( + + ); + })} +
+
{t("home.clockStyle.label")}
@@ -123,7 +184,13 @@ export function ClockStylePicker({ currentStyle }: Props) { >
- +
{t(`home.clockStyle.options.${style}`)} diff --git a/artifacts/teaboy-os/src/components/clock.tsx b/artifacts/teaboy-os/src/components/clock.tsx index bfe1d200..7050850e 100644 --- a/artifacts/teaboy-os/src/components/clock.tsx +++ b/artifacts/teaboy-os/src/components/clock.tsx @@ -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, })}
@@ -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 = diff --git a/artifacts/teaboy-os/src/lib/i18n-format.ts b/artifacts/teaboy-os/src/lib/i18n-format.ts index 30e6c1fc..0ce5d4df 100644 --- a/artifacts/teaboy-os/src/lib/i18n-format.ts +++ b/artifacts/teaboy-os/src/lib/i18n-format.ts @@ -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); } diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 0fd74ef7..b87d7432 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -81,6 +81,11 @@ "clockStyle": { "label": "نمط الساعة", "showWidget": "إظهار ساعة الحائط في الشاشة الرئيسية", + "hourFormat": { + "label": "صيغة الساعة", + "h12": "12 ساعة (ص/م)", + "h24": "24 ساعة" + }, "options": { "full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)", "digital": "رقمي مع الثواني", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 55a488cc..215b3f65 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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", diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index c46411c5..ac9d7809 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -269,12 +269,20 @@ export default function HomePage() { {/* Clock + dates */}
- +
{/* Controls */}
- +
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 219d986d..77e09f84 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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[]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 2aaa43e0..94757c32 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getUpdateClockHour12Url(), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateClockHour12Body), + }); +}; + +export const getUpdateClockHour12MutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return updateClockHour12(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateClockHour12MutationResult = NonNullable< + Awaited> +>; +export type UpdateClockHour12MutationBody = BodyType; +export type UpdateClockHour12MutationError = ErrorType; + +/** + * @summary Update preferred 12/24-hour clock format + */ +export const useUpdateClockHour12 = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getUpdateClockHour12MutationOptions(options)); +}; + /** * @summary List all active apps */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 5b2e57eb..25696612 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 01838637..bb6c7da9 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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()), diff --git a/lib/db/src/schema/users.ts b/lib/db/src/schema/users.ts index 583ae433..5b9c0aaf 100644 --- a/lib/db/src/schema/users.ts +++ b/lib/db/src/schema/users.ts @@ -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(),