Merge branch 'master' into development

This commit is contained in:
2026-01-30 00:41:42 +00:00
17 changed files with 1573 additions and 225 deletions

View File

@@ -1,6 +1,6 @@
<img src="packages/frontend/public/favicon.svg" width="128" /> <img src="packages/frontend/public/favicon.svg" width="128" />
# Sprint # [Sprint](https://sprintpm.org)
Super simple project management tool for developers. Super simple project management tool for developers.

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

@@ -95,17 +95,24 @@ const issueComments = [
"needs product input before proceeding", "needs product input before proceeding",
]; ];
const passwordHash = await hashPassword("a"); const SEED_PASSWORD = process.env.SEED_PASSWORD;
if (!SEED_PASSWORD) {
console.error("SEED_PASSWORD is not set");
process.exit(1);
}
const passwordHash = await hashPassword(SEED_PASSWORD);
const users = [ const users = [
{ name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null }, { name: "demo user 1", username: "demo1", email: "demo1@example.com", passwordHash, avatarURL: null },
{ name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null }, { name: "demo user 2", username: "demo2", email: "demo2@example.com", passwordHash, avatarURL: null },
// anything past here is just to have more users to assign issues to // anything past here is just to have more users to assign issues to
{ name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null }, { name: "demo user 3", username: "demo3", email: "demo3@example.com", passwordHash, avatarURL: null },
{ name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null }, { name: "demo user 4", username: "demo4", email: "demo4@example.com", passwordHash, avatarURL: null },
{ name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null }, { name: "demo user 5", username: "demo5", email: "demo5@example.com", passwordHash, avatarURL: null },
{ name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null }, { name: "demo user 6", username: "demo6", email: "demo6@example.com", passwordHash, avatarURL: null },
{ name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null }, { name: "demo user 7", username: "demo7", email: "demo7@example.com", passwordHash, avatarURL: null },
{ name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null }, { name: "demo user 8", username: "demo8", email: "demo8@example.com", passwordHash, avatarURL: null },
]; ];
async function seed() { async function seed() {
@@ -312,9 +319,9 @@ async function seed() {
console.log(`created ${commentValues.length} issue comments`); console.log(`created ${commentValues.length} issue comments`);
console.log("database seeding complete"); console.log("database seeding complete");
console.log("\ndemo accounts (password: a):"); console.log("\ndemo accounts:");
console.log(" - u1"); console.log(" - demo1");
console.log(" - u2"); console.log(" - demo2");
} catch (error) { } catch (error) {
console.error("failed to seed database:", error); console.error("failed to seed database:", error);
process.exit(1); process.exit(1);

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

@@ -2,33 +2,16 @@
import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Avatar from "@/components/avatar";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { UploadAvatar } from "@/components/upload-avatar"; import { UploadAvatar } from "@/components/upload-avatar";
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils"; import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
const DEMO_USERS = [ export default function LogInForm() {
{ name: "User 1", username: "u1", password: "a" },
{ name: "User 2", username: "u2", password: "a" },
];
export default function LogInForm({
showWarning,
setShowWarning,
}: {
showWarning: boolean;
setShowWarning: (value: boolean) => void;
}) {
const { setUser, setEmailVerified } = useSession(); const { setUser, setEmailVerified } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -141,70 +124,6 @@ export default function LogInForm({
}; };
return ( return (
<>
{/* under construction warning */}
{showWarning && (
<div className="relative flex flex-col items-center gap-2 max-w-lg">
<IconButton
size="md"
className="absolute top-2 right-2"
onClick={() => {
localStorage.setItem("hide-under-construction", "true");
setShowWarning(false);
}}
>
<Icon icon="x" />
</IconButton>
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" />
<div className="text-center text-sm text-muted-foreground font-500">
<p>
This application is currently under construction. Your data is very likely to be lost at some
point.
</p>
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2">
It is not recommended for production use.
</p>
<p className="mt-2">But you're more than welcome to have a look around!</p>
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}>
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
Login Details
</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
<div className="grid grid-cols-2 gap-4">
{DEMO_USERS.map((user) => (
<button
type="button"
key={user.username}
className="space-y-2 border border-background hover:border-border hover:bg-border/10 cursor-pointer p-2 text-left"
onClick={() => {
setMode("login");
setUsername(user.username);
setPassword(user.password);
setLoginDetailsOpen(false);
resetForm();
}}
>
<div className="flex items-center gap-2">
<Avatar name={user.name} username={user.username} />
<span className="font-semibold">{user.name}</span>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
<span className="font-medium text-foreground">Username:</span> {user.username}
</p>
<p>
<span className="font-medium text-foreground">Password:</span> {user.password}
</p>
</div>
</button>
))}
</div>
</DialogContent>
</Dialog>
</div>
</div>
)}
<div> <div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div <div
@@ -324,6 +243,5 @@ export default function LogInForm({
)} )}
</div> </div>
</div> </div>
</>
); );
} }

View File

@@ -17,9 +17,6 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { user, isLoading, emailVerified } = useSession(); const { user, isLoading, emailVerified } = useSession();
const [hasRedirected, setHasRedirected] = useState(false); const [hasRedirected, setHasRedirected] = useState(false);
const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true";
});
useEffect(() => { useEffect(() => {
if (open && !isLoading && user && emailVerified && !hasRedirected) { if (open && !isLoading && user && emailVerified && !hasRedirected) {
@@ -46,9 +43,9 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8", showWarning && "w-md pt-4")}> <DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8")}>
<DialogTitle className="sr-only">Log In or Register</DialogTitle> <DialogTitle className="sr-only">Log In or Register</DialogTitle>
<LogInForm showWarning={showWarning} setShowWarning={setShowWarning} /> <LogInForm />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -62,17 +62,30 @@ export default function OrgIcon({
"flex items-center justify-center rounded-sm overflow-hidden", "flex items-center justify-center rounded-sm overflow-hidden",
"text-white font-medium select-none", "text-white font-medium select-none",
!iconURL && backgroundClass, !iconURL && backgroundClass,
`w-${size || 6}`,
`h-${size || 6}`,
className, className,
)} )}
style={{ width: `calc(var(--spacing) * ${size || 6})`, height: `calc(var(--spacing) * ${size || 6})` }}
> >
{iconURL ? ( {iconURL ? (
<img src={iconURL} alt={name} className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} /> <img
src={iconURL}
alt={name}
className={`rounded-md object-cover`}
style={{
width: `calc(var(--spacing) * ${size || 6})`,
height: `calc(var(--spacing) * ${size || 6})`,
}}
/>
) : ( ) : (
<div className={cn("flex items-center justify-center", `w-${size || 6}`, `h-${size || 6}`)}> <span
<span className={cn("", textClass)}>{getInitials(name)}</span> className={cn("flex items-center justify-center", textClass)}
</div> style={{
width: `calc(var(--spacing) * ${size || 6})`,
height: `calc(var(--spacing) * ${size || 6})`,
}}
>
{getInitials(name)}
</span>
)} )}
</div> </div>
); );

View File

@@ -58,12 +58,12 @@ export default function Landing() {
> >
Pricing Pricing
</a> */} </a> */}
<a {/* <a
href="#faq" href="#faq"
className="hidden md:block text-sm font-500 hover:text-personality transition-colors" className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
> >
FAQ FAQ
</a> </a> */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
{!isLoading && user ? ( {!isLoading && user ? (

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> & {