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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user