Add per-user clock style preference for the home status bar.

Original task #20: Let each user pick their own home-screen clock
style (analog/digital/minimal/etc.), persisted on the user record
and restored on login / other devices. Default = "full".

Changes:
- DB: added `clock_style varchar(30)` (nullable) to `users`
  (lib/db/src/schema/users.ts) and applied via direct ALTER TABLE
  (drizzle-kit push had unrelated interactive prompts about
  app_opens / user_sessions which were not safe to answer).
- OpenAPI: added `ClockStyle` enum (full/digital/digital-no-seconds
  /analog/minimal), `UpdateClockStyleBody`, exposed `clockStyle`
  on AuthUser and UserProfile, and added PATCH /auth/me/clock-style.
  Regenerated typed client and zod schemas.
- API: `buildAuthUser` and `buildUserProfile` include `clockStyle`;
  new authenticated endpoint updates the current user's style and
  returns the refreshed AuthUser.
- Frontend: new `Clock` component (artifacts/teaboy-os/src/
  components/clock.tsx) with five variants sharing a single `useNow`
  tick hook, plus an SVG analog clock; honors existing
  Latin-digit/locale formatting helpers. New `ClockStylePicker`
  (popover) shown in the status bar with a live preview for each
  option and optimistic update through the AuthUser query cache.
- home.tsx replaces the hard-coded clock block with `<Clock>` driven
  by `user.clockStyle`; trigger button placed next to the language
  toggle.
- i18n: added `home.clockStyle.label` and per-style option labels
  to en.json and ar.json.

Verified via e2e: register → default "full" rendered → switch to
analog → reload → analog persists → switch to minimal → time-only
renders. RTL layout + Latin digits both correct after language
toggle.

Replit-Task-Id: 475a7439-b357-400d-805f-5f2fda20ed24
This commit is contained in:
riyadhafraa
2026-04-20 16:40:49 +00:00
parent 4a481261a2
commit 284cb751ed
14 changed files with 591 additions and 23 deletions
@@ -65,6 +65,24 @@ export interface UpdateLanguageBody {
language: string;
}
/**
* Per-user home-screen clock style preference
*/
export type ClockStyle = (typeof ClockStyle)[keyof typeof ClockStyle];
export const ClockStyle = {
full: "full",
digital: "digital",
"digital-no-seconds": "digital-no-seconds",
analog: "analog",
minimal: "minimal",
} as const;
export interface UpdateClockStyleBody {
/** Clock style; pass null to clear and use the default */
clockStyle: ClockStyle | null;
}
export interface AuthUser {
id: number;
username: string;
@@ -74,6 +92,7 @@ export interface AuthUser {
/** @nullable */
displayNameEn?: string | null;
preferredLanguage: string;
clockStyle?: ClockStyle | null;
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
@@ -90,6 +109,7 @@ export interface UserProfile {
/** @nullable */
displayNameEn?: string | null;
preferredLanguage: string;
clockStyle?: ClockStyle | null;
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
+87
View File
@@ -45,6 +45,7 @@ import type {
SuccessResponse,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateClockStyleBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateServiceBody,
@@ -882,6 +883,92 @@ export const useUpdateLanguage = <
return useMutation(getUpdateLanguageMutationOptions(options));
};
/**
* @summary Update preferred home-screen clock style
*/
export const getUpdateClockStyleUrl = () => {
return `/api/auth/me/clock-style`;
};
export const updateClockStyle = async (
updateClockStyleBody: UpdateClockStyleBody,
options?: RequestInit,
): Promise<AuthUser> => {
return customFetch<AuthUser>(getUpdateClockStyleUrl(), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateClockStyleBody),
});
};
export const getUpdateClockStyleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateClockStyle>>,
TError,
{ data: BodyType<UpdateClockStyleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateClockStyle>>,
TError,
{ data: BodyType<UpdateClockStyleBody> },
TContext
> => {
const mutationKey = ["updateClockStyle"];
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 updateClockStyle>>,
{ data: BodyType<UpdateClockStyleBody> }
> = (props) => {
const { data } = props ?? {};
return updateClockStyle(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateClockStyleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateClockStyle>>
>;
export type UpdateClockStyleMutationBody = BodyType<UpdateClockStyleBody>;
export type UpdateClockStyleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Update preferred home-screen clock style
*/
export const useUpdateClockStyle = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateClockStyle>>,
TError,
{ data: BodyType<UpdateClockStyleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateClockStyle>>,
TError,
{ data: BodyType<UpdateClockStyleBody> },
TContext
> => {
return useMutation(getUpdateClockStyleMutationOptions(options));
};
/**
* @summary List all active apps
*/
+49
View File
@@ -245,6 +245,31 @@ paths:
schema:
$ref: "#/components/schemas/AuthUser"
/auth/me/clock-style:
patch:
operationId: updateClockStyle
tags: [auth]
summary: Update preferred home-screen clock style
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateClockStyleBody"
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:
@@ -970,6 +995,22 @@ components:
required:
- language
ClockStyle:
type: string
enum: [full, digital, digital-no-seconds, analog, minimal]
description: Per-user home-screen clock style preference
UpdateClockStyleBody:
type: object
properties:
clockStyle:
oneOf:
- $ref: "#/components/schemas/ClockStyle"
- type: "null"
description: Clock style; pass null to clear and use the default
required:
- clockStyle
AuthUser:
type: object
properties:
@@ -985,6 +1026,10 @@ components:
type: ["string", "null"]
preferredLanguage:
type: string
clockStyle:
oneOf:
- $ref: "#/components/schemas/ClockStyle"
- type: "null"
avatarUrl:
type: ["string", "null"]
isActive:
@@ -1020,6 +1065,10 @@ components:
type: ["string", "null"]
preferredLanguage:
type: string
clockStyle:
oneOf:
- $ref: "#/components/schemas/ClockStyle"
- type: "null"
avatarUrl:
type: ["string", "null"]
isActive:
+83
View File
@@ -43,6 +43,14 @@ export const LoginResponse = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -114,6 +122,14 @@ export const GetMeResponse = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -134,6 +150,49 @@ export const UpdateLanguageResponse = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
createdAt: zod.coerce.date(),
});
/**
* @summary Update preferred home-screen clock style
*/
export const UpdateClockStyleBody = zod.object({
clockStyle: zod
.union([
zod
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
.describe("Per-user home-screen clock style preference"),
zod.null(),
])
.describe("Clock style; pass null to clear and use the default"),
});
export const UpdateClockStyleResponse = 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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -674,6 +733,14 @@ export const ListUsersResponseItem = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -711,6 +778,14 @@ export const GetUserResponse = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
@@ -738,6 +813,14 @@ export const UpdateUserResponse = zod.object({
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(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
+1
View File
@@ -10,6 +10,7 @@ export const usersTable = pgTable("users", {
displayNameAr: varchar("display_name_ar", { length: 200 }),
displayNameEn: varchar("display_name_en", { length: 200 }),
preferredLanguage: varchar("preferred_language", { length: 10 }).notNull().default("ar"),
clockStyle: varchar("clock_style", { length: 30 }),
avatarUrl: text("avatar_url"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),