Merge pull request #2 from hex248/development

Made better use of --personality colour
This commit is contained in:
Oliver Bryan
2026-01-27 12:45:34 +00:00
committed by GitHub
18 changed files with 198 additions and 52 deletions

View File

@@ -1,5 +1,16 @@
import "dotenv/config";
import { Issue, Organisation, OrganisationMember, Project, User } from "@sprint/shared";
import {
DEFAULT_ISSUE_TYPES,
DEFAULT_STATUS_COLOURS,
Issue,
IssueAssignee,
IssueComment,
Organisation,
OrganisationMember,
Project,
Sprint,
User,
} from "@sprint/shared";
import bcrypt from "bcrypt";
import { drizzle } from "drizzle-orm/node-postgres";
@@ -66,6 +77,24 @@ const issues = [
{ title: "Add batch processing", description: "Need to process large datasets efficiently." },
];
const issueStatuses = Object.keys(DEFAULT_STATUS_COLOURS);
const issueTypes = Object.keys(DEFAULT_ISSUE_TYPES);
const issueComments = [
"started looking into this, will share updates soon",
"i can reproduce this on staging",
"adding details in the description",
"should be a small fix, pairing with u2",
"blocked on api response shape",
"added logs, issue still happening",
"fix is ready for review",
"can we confirm expected behavior?",
"this seems related to recent deploy",
"i will take this one",
"qa verified on latest build",
"needs product input before proceeding",
];
const passwordHash = await hashPassword("a");
const users = [
{ name: "user 1", username: "u1", passwordHash, avatarURL: null },
@@ -146,14 +175,48 @@ async function seed() {
console.log(`created ${projects.length} projects`);
// create 3-6 issues per project
console.log("creating sprints...");
const sprintValues = [];
const now = new Date();
for (const project of projects) {
sprintValues.push(
{
projectId: project.id,
name: "Sprint 1",
color: "#3b82f6",
startDate: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000),
endDate: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000),
},
{
projectId: project.id,
name: "Sprint 2",
color: "#22c55e",
startDate: new Date(now.getTime()),
endDate: new Date(now.getTime() + 13 * 24 * 60 * 60 * 1000),
},
);
}
const createdSprints = await db.insert(Sprint).values(sprintValues).returning();
const sprintsByProject = new Map<number, (typeof createdSprints)[number][]>();
for (const sprint of createdSprints) {
const list = sprintsByProject.get(sprint.projectId) ?? [];
list.push(sprint);
sprintsByProject.set(sprint.projectId, list);
}
console.log(`created ${createdSprints.length} sprints`);
// create 6-12 issues per project
console.log("creating issues...");
const allUsers = [u1, u2];
const issueValues = [];
let issueIndex = 0;
for (const project of projects) {
const numIssues = Math.floor(Math.random() * 4) + 3; // 3-6 issues
const numIssues = Math.floor(Math.random() * 7) + 6; // 6-12 issues
for (let i = 1; i <= numIssues; i++) {
const creator = allUsers[Math.floor(Math.random() * allUsers.length)];
if (!creator) {
@@ -164,20 +227,89 @@ async function seed() {
throw new Error("failed to select issue");
}
issueIndex++;
const status = issueStatuses[Math.floor(Math.random() * issueStatuses.length)];
const type = issueTypes[Math.floor(Math.random() * issueTypes.length)];
if (!status || !type) {
throw new Error("failed to select issue status or type");
}
const projectSprints = sprintsByProject.get(project.id);
if (!projectSprints || projectSprints.length === 0) {
throw new Error("failed to select project sprint");
}
const sprint = projectSprints[Math.floor(Math.random() * projectSprints.length)];
if (!sprint) {
throw new Error("failed to select sprint");
}
issueValues.push({
projectId: project.id,
number: i,
title: issue.title,
description: issue.description,
status,
type,
creatorId: creator.id,
sprintId: sprint.id,
});
}
}
await db.insert(Issue).values(issueValues);
const createdIssues = await db.insert(Issue).values(issueValues).returning();
console.log(`created ${issueValues.length} issues`);
console.log(`created ${createdIssues.length} issues`);
console.log("creating issue assignees...");
const assigneeValues = [];
for (const issue of createdIssues) {
const assigneeCount = Math.floor(Math.random() * 3);
const picked = new Set<number>();
for (let i = 0; i < assigneeCount; i++) {
const assignee = usersDB[Math.floor(Math.random() * usersDB.length)];
if (!assignee) {
throw new Error("failed to select issue assignee");
}
if (picked.has(assignee.id)) {
continue;
}
picked.add(assignee.id);
assigneeValues.push({
issueId: issue.id,
userId: assignee.id,
});
}
}
if (assigneeValues.length > 0) {
await db.insert(IssueAssignee).values(assigneeValues);
}
console.log(`created ${assigneeValues.length} issue assignees`);
console.log("creating issue comments...");
const commentValues = [];
for (const issue of createdIssues) {
const commentCount = Math.floor(Math.random() * 3);
for (let i = 0; i < commentCount; i++) {
const commenter = usersDB[Math.floor(Math.random() * usersDB.length)];
const comment = issueComments[Math.floor(Math.random() * issueComments.length)];
if (!commenter || !comment) {
throw new Error("failed to select issue comment data");
}
commentValues.push({
issueId: issue.id,
userId: commenter.id,
body: comment,
});
}
}
if (commentValues.length > 0) {
await db.insert(IssueComment).values(commentValues);
}
console.log(`created ${commentValues.length} issue comments`);
console.log("database seeding complete");
console.log("\ndemo accounts (password: a):");

View File

@@ -40,7 +40,7 @@ const main = async () => {
"/user/by-username": withGlobal(withAuth(routes.userByUsername)),
"/user/update": withGlobal(withAuth(withCSRF(routes.userUpdate))),
"/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))),
"/user/upload-avatar": withGlobal(routes.userUploadAvatar),
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
"/issue/by-id": withGlobal(withAuth(routes.issueById)),

View File

@@ -66,10 +66,10 @@
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive: oklch(61.275% 0.20731 24.986);
--border: oklch(73.802% 0.00008 271.152);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: var(--personality);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -105,7 +105,7 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(100% 0.00011 271.152 / 0.22);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--ring: var(--personality);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -132,6 +132,10 @@
background-color: var(--personality);
color: var(--background);
}
a:focus-visible {
outline: 1px solid var(--personality);
outline-offset: 2px;
}
}
* {

View File

@@ -49,6 +49,7 @@ export default function Avatar({
size,
textClass = "text-xs",
strong = false,
skipOrgCheck = false,
className,
}: {
avatarURL?: string | null;
@@ -57,6 +58,7 @@ export default function Avatar({
size?: number;
textClass?: string;
strong?: boolean;
skipOrgCheck?: boolean;
className?: string;
}) {
// if the username matches the authed user, use their avatarURL and name (avoid stale data)
@@ -69,13 +71,15 @@ export default function Avatar({
? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)]
: "bg-muted";
const showAvatar = skipOrgCheck || selectedOrganisation?.Organisation.features.userAvatars;
return (
<div
className={cn(
"flex items-center justify-center rounded-full",
"text-white font-medium select-none",
name && "border",
(!avatarURL || !selectedOrganisation?.Organisation.features.userAvatars) && backgroundClass,
(!avatarURL || !showAvatar) && backgroundClass,
"transition-colors",
`w-${size || 6}`,
@@ -83,7 +87,7 @@ export default function Avatar({
className,
)}
>
{selectedOrganisation?.Organisation.features.userAvatars && avatarURL ? (
{showAvatar && avatarURL ? (
<img
src={avatarURL}
alt="Avatar"

View File

@@ -118,7 +118,6 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
</div>
{isAuthor ? (
<IconButton
variant="ghost"
onClick={() => handleDelete(comment)}
disabled={deletingId === comment.Comment.id}
title="Delete comment"

View File

@@ -454,7 +454,7 @@ export function IssueDetails({
/>
</div>
</div>
{organisation?.Organisation.features.description &&
{organisation?.Organisation.features.issueDescriptions &&
(description || isEditingDescription ? (
<Textarea
ref={descriptionRef}

View File

@@ -147,32 +147,33 @@ export function IssuesTable({
e.preventDefault();
};
const showId = columns.id == null || columns.id === true;
const showTitle = columns.title == null || columns.title === true;
const showDescription = columns.description == null || columns.description === true;
const showAssignee = columns.assignee == null || columns.assignee === true;
return (
<Table className={cn("table-fixed", className)}>
<TableHeader>
<TableRow hoverEffect={false} className="bg-secondary">
{(columns.id == null || columns.id === true) && (
{showId && (
<TableHead className="text-right w-10 border-r text-xs font-medium text-muted-foreground">
ID
</TableHead>
)}
{(columns.title == null || columns.title === true) && (
<TableHead className="text-xs font-medium text-muted-foreground">Title</TableHead>
)}
{(columns.description == null || columns.description === true) && (
{showTitle && <TableHead className="text-xs font-medium text-muted-foreground">Title</TableHead>}
{showDescription && (
<TableHead className="text-xs font-medium text-muted-foreground">Description</TableHead>
)}
{/* below is kept blank to fill the space, used as the "Assignee" column */}
{(columns.assignee == null || columns.assignee === true) && (
<TableHead className="w-[1%]"></TableHead>
)}
{showAssignee && <TableHead className="w-[1%]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{issues.map((issueData) => (
<TableRow
key={issueData.Issue.id}
className="cursor-pointer max-w-full"
className={cn("cursor-pointer max-w-full")}
onClick={() => {
if (issueData.Issue.id === selectedIssueId) {
selectIssue(null);
@@ -181,8 +182,13 @@ export function IssuesTable({
selectIssue(issueData);
}}
>
{(columns.id == null || columns.id === true) && (
<TableCell className="font-medium border-r text-right p-0">
{showId && (
<TableCell
className={cn(
"font-medium border-r text-right p-0",
issueData.Issue.id === selectedIssueId && "shadow-[inset_2px_0_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
@@ -192,8 +198,15 @@ export function IssuesTable({
</a>
</TableCell>
)}
{(columns.title == null || columns.title === true) && (
<TableCell className="min-w-0 p-0">
{showTitle && (
<TableCell
className={cn(
"min-w-0 p-0",
!showId &&
issueData.Issue.id === selectedIssueId &&
"shadow-[inset_2px_0_0_0_var(--personality)]",
)}
>
<a
href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick}
@@ -215,7 +228,7 @@ export function IssuesTable({
</a>
</TableCell>
)}
{(columns.description == null || columns.description === true) && (
{showDescription && (
<TableCell className="overflow-hidden p-0">
<a
href={getIssueUrl(issueData.Issue.number)}
@@ -226,7 +239,7 @@ export function IssuesTable({
</a>
</TableCell>
)}
{(columns.assignee == null || columns.assignee === true) && (
{showAssignee && (
<TableCell className="h-[32px] p-0">
<a
href={getIssueUrl(issueData.Issue.number)}

View File

@@ -222,6 +222,7 @@ export default function LogInForm() {
username={username || undefined}
avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl}
skipOrgCheck
className="mb-2"
/>
{avatarURL && (

View File

@@ -724,7 +724,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
});
}}
>
<Icon icon="trash" className="size-4" />
<Icon icon="trash" className="size-4" color="white" />
Delete
</Button>
)}
@@ -872,7 +872,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
});
}}
>
<Icon icon="trash" className="size-4" />
<Icon icon="trash" className="size-4" color="white" />
Delete
</Button>
)}
@@ -951,7 +951,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
}}
className="hover:bg-destructive/10"
>
<Icon icon="trash" className="size-4" />
<Icon icon="trash" className="size-4" color="white" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -75,11 +75,9 @@ export function TimerControls({
<div className={cn("ml-auto flex items-center", isCompact ? "gap-1" : "gap-2")}>
<IconButton
size={"sm"}
variant="dummy"
aria-label={running ? "Pause timer" : "Resume timer"}
disabled={disabled}
onClick={handleToggle}
className={"hover:opacity-70"}
>
{running ? (
<Icon icon="pause" size={isCompact ? 14 : 16} />
@@ -87,14 +85,7 @@ export function TimerControls({
<Icon icon="play" size={isCompact ? 14 : 16} />
)}
</IconButton>
<IconButton
size={"sm"}
variant="destructive"
aria-label="End timer"
disabled={disabled || !hasTimer}
onClick={handleEnd}
className={"hover:opacity-70"}
>
<IconButton size={"sm"} aria-label="End timer" disabled={disabled || !hasTimer} onClick={handleEnd}>
<Icon icon="stop" size={isCompact ? 14 : 16} color={"var(--destructive)"} />
</IconButton>
</div>

View File

@@ -11,7 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white hover:bg-destructive/80 active:bg-destructive/70 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 dark:hover:bg-destructive/70",
outline: "bg-transparent border dark:hover:bg-muted/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",

View File

@@ -59,14 +59,14 @@ function DialogContent({
<DialogPrimitive.Close
data-slot="dialog-close"
className={cn(
"cursor-pointer ring-offset-background focus:ring-ring",
"cursor-pointer",
"data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
"absolute opacity-70",
closePos === "top-left" && "top-4 left-4",
closePos === "top-right" && "top-4 right-4",
closePos === "bottom-left" && "bottom-4 left-4",
closePos === "bottom-right" && "bottom-4 right-4",
"hover:opacity-100 focus:ring-2 focus:ring-offset-2 ",
"hover:opacity-100",
"ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
)}

View File

@@ -7,8 +7,8 @@ const iconButtonVariants = cva(
{
variants: {
variant: {
default: "hover:text-foreground/70",
destructive: "text-destructive hover:text-destructive/70",
default: "hover:text-foreground/70 hover:opacity-70",
destructive: "text-destructive hover:opacity-70",
yellow: "text-yellow-500 hover:text-yellow-500/70",
green: "text-green-500 hover:text-green-500/70",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",

View File

@@ -26,7 +26,7 @@ function Input({
className={cn(
"border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center border bg-transparent",
"transition-[color,box-shadow]",
"has-[:focus-visible]:border-ring",
"has-[:focus-visible]:border-[var(--personality)] ",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"aria-invalid:border-destructive",
className,

View File

@@ -15,7 +15,7 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer data-[state=checked]:bg-personality data-[state=unchecked]:bg-input",
"focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80",
"group/switch inline-flex shrink-0 items-center rounded-full border border-transparent",
"outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
@@ -27,7 +27,7 @@ function Switch({
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
"bg-background dark:data-[state=unchecked]:bg-personality dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>

View File

@@ -13,6 +13,7 @@ export function UploadAvatar({
username,
avatarURL,
onAvatarUploaded,
skipOrgCheck = false,
className,
}: {
name?: string;
@@ -20,6 +21,7 @@ export function UploadAvatar({
avatarURL?: string | null;
onAvatarUploaded: (avatarURL: string) => void;
label?: string;
skipOrgCheck?: boolean;
className?: string;
}) {
const [uploading, setUploading] = useState(false);
@@ -71,6 +73,7 @@ export function UploadAvatar({
size={24}
textClass={"text-4xl"}
strong
skipOrgCheck={skipOrgCheck}
/>
{!uploading && showEdit && (

View File

@@ -330,13 +330,13 @@ export default function Timeline() {
>
<div
className={cn(
"absolute inset-y-0 w-px bg-primary",
"absolute inset-y-0 w-px bg-[var(--personality)]",
showTodayLabel && "mt-1",
)}
/>
{showTodayLabel && (
<div className="absolute -top-1.5">
<span className="bg-primary px-1 py-0.5 text-[10px] font-semibold text-primary-foreground whitespace-nowrap">
<span className="bg-[var(--personality)] px-1 py-0.5 text-[10px] font-semibold text-[var(--background)] whitespace-nowrap">
TODAY
</span>
</div>

View File

@@ -1,7 +1,6 @@
# HIGH PRIORITY
- BUGS:
- issue descriptions not showing
- FEATURES:
- pricing page
- see jira and other competitors