'Assign to me by default' toggle in Account.tsx

This commit is contained in:
2026-01-29 22:43:02 +00:00
parent 130f564c33
commit e339274069
10 changed files with 1418 additions and 5 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "preferences" json DEFAULT '{"assignByDefault":false}'::json NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,13 @@
"when": 1769643481882, "when": 1769643481882,
"tag": "0028_quick_supernaut", "tag": "0028_quick_supernaut",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1769726204311,
"tag": "0029_fantastic_venom",
"breakpoints": true
} }
] ]
} }

View File

@@ -39,6 +39,7 @@ export async function updateById(
avatarURL?: string | null; avatarURL?: string | null;
iconPreference?: IconStyle; iconPreference?: IconStyle;
plan?: string; plan?: string;
preferences?: Record<string, boolean>;
}, },
): Promise<UserRecord | undefined> { ): Promise<UserRecord | undefined> {
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();

View File

@@ -14,5 +14,6 @@ export default async function me(req: AuthedRequest) {
user: safeUser as Omit<UserRecord, "passwordHash">, user: safeUser as Omit<UserRecord, "passwordHash">,
csrfToken: req.csrfToken, csrfToken: req.csrfToken,
emailVerified: user.emailVerified, emailVerified: user.emailVerified,
preferences: user.preferences,
}); });
} }

View File

@@ -8,16 +8,16 @@ export default async function update(req: AuthedRequest) {
const parsed = await parseJsonBody(req, UserUpdateRequestSchema); const parsed = await parseJsonBody(req, UserUpdateRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
const { name, password, avatarURL, iconPreference } = parsed.data; const { name, password, avatarURL, iconPreference, preferences } = parsed.data;
const user = await getUserById(req.userId); const user = await getUserById(req.userId);
if (!user) { if (!user) {
return errorResponse("user not found", "USER_NOT_FOUND", 404); return errorResponse("user not found", "USER_NOT_FOUND", 404);
} }
if (!name && !password && avatarURL === undefined && !iconPreference) { if (!name && !password && avatarURL === undefined && !iconPreference && preferences === undefined) {
return errorResponse( return errorResponse(
"at least one of name, password, avatarURL, or iconPreference must be provided", "at least one of name, password, avatarURL, iconPreference, or preferences must be provided",
"NO_UPDATES", "NO_UPDATES",
400, 400,
); );
@@ -42,7 +42,13 @@ export default async function update(req: AuthedRequest) {
} }
const { updateById } = await import("../../db/queries/users"); const { updateById } = await import("../../db/queries/users");
const updatedUser = await updateById(user.id, { name, passwordHash, avatarURL, iconPreference }); const updatedUser = await updateById(user.id, {
name,
passwordHash,
avatarURL,
iconPreference,
preferences,
});
if (!updatedUser) { if (!updatedUser) {
return errorResponse("failed to update user", "UPDATE_FAILED", 500); return errorResponse("failed to update user", "UPDATE_FAILED", 500);

View File

@@ -11,6 +11,7 @@ import { Field } from "@/components/ui/field";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { UploadAvatar } from "@/components/upload-avatar"; import { UploadAvatar } from "@/components/upload-avatar";
import { useUpdateUser } from "@/lib/query/hooks"; import { useUpdateUser } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
@@ -29,6 +30,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel"); const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
const [preferences, setPreferences] = useState<Record<string, boolean>>({});
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
@@ -40,6 +42,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
setAvatarUrl(currentUser.avatarURL || null); setAvatarUrl(currentUser.avatarURL || null);
const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE; const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
setIconPreference(effectiveIconStyle); setIconPreference(effectiveIconStyle);
setPreferences(currentUser.preferences ?? {});
setPassword(""); setPassword("");
setError(""); setError("");
@@ -60,6 +63,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
password: password.trim() || undefined, password: password.trim() || undefined,
avatarURL, avatarURL,
iconPreference, iconPreference,
preferences,
}); });
setError(""); setError("");
setUser(data); setUser(data);
@@ -129,6 +133,18 @@ function Account({ trigger }: { trigger?: ReactNode }) {
/> />
<Label className="text-lg -mt-2">Preferences</Label> <Label className="text-lg -mt-2">Preferences</Label>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Switch
checked={Boolean(preferences.assignByDefault)}
onCheckedChange={(checked) => {
setPreferences((prev) => ({ ...prev, assignByDefault: checked }));
}}
/>
<span className="text-sm">Assign to me by default</span>
</div>
</div>
<div className="flex gap-8 justify w-full"> <div className="flex gap-8 justify w-full">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Label className="text-sm">Light/Dark Mode</Label> <Label className="text-sm">Light/Dark Mode</Label>

View File

@@ -66,6 +66,14 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]); const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
const [status, setStatus] = useState<string>(defaultStatus); const [status, setStatus] = useState<string>(defaultStatus);
const [type, setType] = useState<string>(defaultType); const [type, setType] = useState<string>(defaultType);
// set default assignee based on user preference when dialog opens
useEffect(() => {
if (open && user.preferences?.assignByDefault) {
setAssigneeIds([`${user.id}`]);
}
}, [open, user]);
useEffect(() => { useEffect(() => {
if (!status && defaultStatus) setStatus(defaultStatus); if (!status && defaultStatus) setStatus(defaultStatus);
if (!type && defaultType) setType(defaultType); if (!type && defaultType) setType(defaultType);

View File

@@ -412,6 +412,7 @@ export const UserUpdateRequestSchema = z.object({
.optional(), .optional(),
avatarURL: z.string().url().nullable().optional(), avatarURL: z.string().url().nullable().optional(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]).optional(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]).optional(),
preferences: z.record(z.boolean()).optional(),
}); });
export type UserUpdateRequest = z.infer<typeof UserUpdateRequestSchema>; export type UserUpdateRequest = z.infer<typeof UserUpdateRequestSchema>;
@@ -431,6 +432,7 @@ export const UserResponseSchema = z.object({
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]), iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
plan: z.string().nullable().optional(), plan: z.string().nullable().optional(),
preferences: z.record(z.boolean()).optional(),
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(),
}); });

View File

@@ -50,6 +50,10 @@ export const DEFAULT_FEATURES: Record<string, boolean> = {
sprints: true, sprints: true,
}; };
export const DEFAULT_USER_PREFERENCES: Record<string, boolean> = {
assignByDefault: false,
};
export const iconStyles = ["pixel", "lucide", "phosphor"] as const; export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
export type IconStyle = (typeof iconStyles)[number]; export type IconStyle = (typeof iconStyles)[number];
@@ -64,6 +68,10 @@ export const User = pgTable("User", {
plan: varchar({ length: 32 }).notNull().default("free"), plan: varchar({ length: 32 }).notNull().default("free"),
emailVerified: boolean().notNull().default(false), emailVerified: boolean().notNull().default(false),
emailVerifiedAt: timestamp({ withTimezone: false }), emailVerifiedAt: timestamp({ withTimezone: false }),
preferences: json("preferences")
.$type<Record<string, boolean>>()
.notNull()
.default(DEFAULT_USER_PREFERENCES),
createdAt: timestamp({ withTimezone: false }).defaultNow(), createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(),
}); });
@@ -227,7 +235,9 @@ export const SessionInsertSchema = createInsertSchema(Session);
export const TimedSessionSelectSchema = createSelectSchema(TimedSession); export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
export const TimedSessionInsertSchema = createInsertSchema(TimedSession); export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
export type UserRecord = z.infer<typeof UserSelectSchema>; export type UserRecord = z.infer<typeof UserSelectSchema> & {
preferences: Record<string, boolean>;
};
export type UserInsert = z.infer<typeof UserInsertSchema>; export type UserInsert = z.infer<typeof UserInsertSchema>;
export type OrganisationRecord = z.infer<typeof OrganisationSelectSchema> & { export type OrganisationRecord = z.infer<typeof OrganisationSelectSchema> & {