From c1b7c92e65ddbcc6fb8a932217bfa0ac682dd255 Mon Sep 17 00:00:00 2001
From: Philipp Nowinski <mail@pno.dev>
Date: Mon, 1 Jun 2026 11:59:40 +0200
Subject: [PATCH 1/4] feat: ability to re-order projects in sidebar

---
 src/components/AppProviders.tsx         |   2 -
 src/components/app-sidebar.tsx          |  42 ++--
 src/components/nav-main.tsx             |   2 -
 src/components/nav-organization.tsx     |   2 -
 src/components/nav-projects.tsx         | 277 ++++++++++++++++++++++++
 src/components/nav-user.tsx             |   2 -
 src/components/ui/avatar.tsx            |   2 -
 src/components/ui/collapsible.tsx       |   2 -
 src/components/ui/date-picker.tsx       |   2 -
 src/components/ui/sheet.tsx             |   2 -
 src/components/ui/sidebar.tsx           |   2 -
 src/components/ui/tooltip.tsx           |   2 -
 src/components/ui/truncated-tooltip.tsx |   2 -
 src/db/mutations/user-preferences.ts    | 180 ++++++++++++++-
 src/db/queries/bundles.ts               |  13 +-
 src/db/queries/projects.ts              |  20 +-
 src/db/schema.ts                        |  10 +-
 src/lib/project-order.ts                |  39 ++++
 src/lib/user-preferences.server.ts      |  21 ++
 19 files changed, 568 insertions(+), 56 deletions(-)
 create mode 100644 src/components/nav-projects.tsx
 create mode 100644 src/lib/project-order.ts
 create mode 100644 src/lib/user-preferences.server.ts

diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx
index e0f4314..a434d8d 100644
--- a/src/components/AppProviders.tsx
+++ b/src/components/AppProviders.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import type { ReactNode } from "react";
 import { TooltipProvider } from "~/components/ui/tooltip";
 
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index ad46472..1431018 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -6,8 +6,6 @@ import {
   ClockFading,
   ClockPlus,
   Flag,
-  Folder,
-  FolderPlus,
   GitBranch,
   LayoutDashboard,
   LucideIcon,
@@ -16,6 +14,7 @@ import {
 } from "lucide-react";
 
 import { NavMain } from "~/components/nav-main";
+import { NavProjects } from "~/components/nav-projects";
 import { NavOrganization } from "~/components/nav-organization";
 import { NavSettings } from "~/components/nav-settings";
 import {
@@ -74,22 +73,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
   const projectsQuery = useProjectsQuery();
   const sprintsQuery = useSprintsQuery();
 
-  const navMain = React.useMemo(() => {
-    const projectItems: NavItem[] = [
-      { title: "All", url: "/tasks", icon: Folder },
-      ...projectsQuery.data.map((project) => ({
-        title: project.name,
-        url: `/projects/${project.id}/tasks`,
-        icon: project.logo || Folder,
-      })),
+  const dashboardNav = React.useMemo(
+    () => [
       {
-        title: "Add Project",
-        url: FORM_SHEET_CREATE_LINKS.project.to,
-        search: FORM_SHEET_CREATE_LINKS.project.search,
-        icon: FolderPlus,
+        title: "Dashboard",
+        url: "/dashboard",
+        icon: LayoutDashboard,
       },
-    ];
+    ],
+    []
+  );
 
+  const sprintsNav = React.useMemo(() => {
     const sprintItems: NavItem[] = [
       ...sprintsQuery.data.map((sprint) => ({
         title: sprint.name,
@@ -106,17 +101,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
     ];
 
     return [
-      {
-        title: "Dashboard",
-        url: "/dashboard",
-        icon: LayoutDashboard,
-      },
-      {
-        title: "Projects",
-        url: "#",
-        icon: Folder,
-        items: projectItems,
-      },
       {
         title: "Sprints",
         url: "#",
@@ -124,7 +108,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
         items: sprintItems,
       },
     ];
-  }, [projectsQuery.data, sprintsQuery.data]);
+  }, [sprintsQuery.data]);
 
   return (
     <Sidebar {...props}>
@@ -145,7 +129,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
         </SidebarMenu>
       </SidebarHeader>
       <SidebarContent>
-        <NavMain items={navMain} />
+        <NavMain items={dashboardNav} />
+        <NavProjects projects={projectsQuery.data} />
+        <NavMain items={sprintsNav} />
         <NavSettings settings={settingsNav} />
         {/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
       </SidebarContent>
diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx
index 51e1110..ca8dfec 100644
--- a/src/components/nav-main.tsx
+++ b/src/components/nav-main.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import { Link, useRouterState } from "@tanstack/react-router";
 import { ChevronRight, type LucideIcon } from "lucide-react";
 
diff --git a/src/components/nav-organization.tsx b/src/components/nav-organization.tsx
index 796b503..9c90540 100644
--- a/src/components/nav-organization.tsx
+++ b/src/components/nav-organization.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import { ClientOnly, Link, useNavigate } from "@tanstack/react-router";
 import { Building2, ChevronsUpDown, Plus, Settings } from "lucide-react";
 import { authClient } from "~/lib/auth-client";
diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx
new file mode 100644
index 0000000..9984b0d
--- /dev/null
+++ b/src/components/nav-projects.tsx
@@ -0,0 +1,277 @@
+import { ClientOnly, Link, getRouteApi, useRouterState } from "@tanstack/react-router";
+import {
+  DndContext,
+  DragEndEvent,
+  KeyboardSensor,
+  PointerSensor,
+  closestCenter,
+  useSensor,
+  useSensors,
+} from "@dnd-kit/core";
+import {
+  SortableContext,
+  arrayMove,
+  sortableKeyboardCoordinates,
+  useSortable,
+  verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { ChevronRight, Folder, FolderPlus, GripVertical } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import {
+  SidebarGroup,
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+  SidebarMenuSub,
+  SidebarMenuSubButton,
+  SidebarMenuSubItem,
+} from "~/components/ui/sidebar";
+import { useReorderProjectsMutation } from "~/db/mutations/user-preferences";
+import type { Project } from "~/db/schema";
+import { authClient } from "~/lib/auth-client";
+import {
+  FORM_SHEET_CREATE_LINKS,
+  isFormSheetNavItemActive,
+} from "~/lib/form-sheet-search";
+import { cn, getOwningIdentity } from "~/lib/utils";
+
+const signedInRoute = getRouteApi("/_signed-in");
+
+function isNavPathActive(pathname: string, url: string) {
+  if (url === "#") return false;
+  return pathname === url || pathname.startsWith(`${url}/`);
+}
+
+function SortableProjectItem({
+  project,
+  pathname,
+  routeSearch,
+}: {
+  project: Project;
+  pathname: string;
+  routeSearch: ReturnType<typeof signedInRoute.useSearch>;
+}) {
+  const [isClient, setIsClient] = useState(false);
+  const url = `/projects/${project.id}/tasks`;
+  const active = isFormSheetNavItemActive(pathname, url, undefined, routeSearch);
+  const logoUrl = project.logo ?? undefined;
+
+  useEffect(() => {
+    setIsClient(true);
+  }, []);
+
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id: project.id });
+
+  const style = isClient
+    ? {
+        transform: CSS.Transform.toString(transform),
+        transition,
+      }
+    : undefined;
+
+  return (
+    <SidebarMenuSubItem
+      ref={isClient ? setNodeRef : undefined}
+      style={style}
+      className={cn(isClient && isDragging && "z-50 opacity-50")}
+    >
+      <div className="flex min-w-0 items-center">
+        <button
+          type="button"
+          className={cn(
+            "text-sidebar-foreground/45 ml-6 shrink-0 touch-none p-1",
+            isClient && "cursor-grab active:cursor-grabbing"
+          )}
+          aria-label={`Reorder ${project.name}`}
+          {...(isClient ? { ...attributes, ...listeners } : {})}
+        >
+          <GripVertical className="size-4" />
+        </button>
+        <SidebarMenuSubButton asChild isActive={active} className="min-w-0 flex-1 pl-2">
+          <Link to={url} className="flex min-w-0 items-center gap-2">
+            {logoUrl ? (
+              <img
+                src={logoUrl}
+                className="size-4 shrink-0 rounded-sm"
+                alt=""
+              />
+            ) : (
+              <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
+            )}
+            <span className="flex-1 truncate">{project.name}</span>
+          </Link>
+        </SidebarMenuSubButton>
+      </div>
+    </SidebarMenuSubItem>
+  );
+}
+
+function NavProjectsList({
+  projects,
+  ownerId,
+}: {
+  projects: Project[];
+  ownerId: string;
+}) {
+  const { location } = useRouterState();
+  const pathname = location.pathname;
+  const routeSearch = signedInRoute.useSearch();
+  const reorderProjects = useReorderProjectsMutation();
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 8 },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    })
+  );
+
+  const handleDragEnd = useCallback(
+    (event: DragEndEvent) => {
+      const { active, over } = event;
+      if (!over || active.id === over.id) {
+        return;
+      }
+
+      const oldIndex = projects.findIndex((project) => project.id === active.id);
+      const newIndex = projects.findIndex((project) => project.id === over.id);
+      if (oldIndex === -1 || newIndex === -1) {
+        return;
+      }
+
+      const reordered = arrayMove(projects, oldIndex, newIndex);
+      void reorderProjects({
+        ownerId,
+        projectIds: reordered.map((project) => project.id),
+      });
+    },
+    [ownerId, projects, reorderProjects]
+  );
+
+  return (
+    <ClientOnly
+      fallback={
+        <>
+          {projects.map((project) => (
+            <SortableProjectItem
+              key={project.id}
+              project={project}
+              pathname={pathname}
+              routeSearch={routeSearch}
+            />
+          ))}
+        </>
+      }
+    >
+      <DndContext
+        sensors={sensors}
+        collisionDetection={closestCenter}
+        onDragEnd={handleDragEnd}
+      >
+        <SortableContext
+          items={projects.map((project) => project.id)}
+          strategy={verticalListSortingStrategy}
+        >
+          {projects.map((project) => (
+            <SortableProjectItem
+              key={project.id}
+              project={project}
+              pathname={pathname}
+              routeSearch={routeSearch}
+            />
+          ))}
+        </SortableContext>
+      </DndContext>
+    </ClientOnly>
+  );
+}
+
+export function NavProjects({ projects }: { projects: Project[] }) {
+  const { location } = useRouterState();
+  const pathname = location.pathname;
+  const routeSearch = signedInRoute.useSearch();
+  const { data: session } = authClient.useSession();
+  const ownerId = session ? getOwningIdentity(session) : "";
+
+  const allActive = isNavPathActive(pathname, "/tasks");
+  const addActive = isFormSheetNavItemActive(
+    pathname,
+    FORM_SHEET_CREATE_LINKS.project.to,
+    FORM_SHEET_CREATE_LINKS.project.search,
+    routeSearch
+  );
+
+  return (
+    <SidebarGroup className="py-1">
+      <SidebarMenu>
+        <Collapsible defaultOpen className="group/collapsible">
+          <SidebarMenuItem>
+            <CollapsibleTrigger asChild>
+              <SidebarMenuButton className="pr-3 font-medium [&>svg:last-child]:transition-transform [&>svg:last-child]:duration-200 group-data-[state=open]/collapsible:[&>svg:last-child]:rotate-90">
+                <Folder className="text-sidebar-foreground/50" />
+                <span className="flex-1">Projects</span>
+                <ChevronRight className="text-sidebar-foreground/45 size-4 shrink-0" />
+              </SidebarMenuButton>
+            </CollapsibleTrigger>
+            <CollapsibleContent>
+              <SidebarMenuSub>
+                <SidebarMenuSubItem>
+                  <SidebarMenuSubButton asChild isActive={allActive}>
+                    <Link to="/tasks">
+                      <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
+                      <span className="flex-1">All</span>
+                    </Link>
+                  </SidebarMenuSubButton>
+                </SidebarMenuSubItem>
+
+                {ownerId ? (
+                  <NavProjectsList projects={projects} ownerId={ownerId} />
+                ) : (
+                  projects.map((project) => (
+                    <SortableProjectItem
+                      key={project.id}
+                      project={project}
+                      pathname={pathname}
+                      routeSearch={routeSearch}
+                    />
+                  ))
+                )}
+
+                <SidebarMenuSubItem>
+                  <SidebarMenuSubButton
+                    asChild
+                    isActive={addActive}
+                    className="font-normal text-muted-foreground hover:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:text-sidebar-primary"
+                  >
+                    <Link
+                      to={FORM_SHEET_CREATE_LINKS.project.to}
+                      search={FORM_SHEET_CREATE_LINKS.project.search}
+                    >
+                      <FolderPlus className="text-sidebar-foreground/45 size-4 shrink-0" />
+                      <span className="flex-1">Add Project</span>
+                    </Link>
+                  </SidebarMenuSubButton>
+                </SidebarMenuSubItem>
+              </SidebarMenuSub>
+            </CollapsibleContent>
+          </SidebarMenuItem>
+        </Collapsible>
+      </SidebarMenu>
+    </SidebarGroup>
+  );
+}
diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx
index 5a12d26..256365d 100644
--- a/src/components/nav-user.tsx
+++ b/src/components/nav-user.tsx
@@ -1,5 +1,3 @@
-"use client"
-
 import {
   BadgeCheck,
   Bell,
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
index 3e73926..1f34a79 100644
--- a/src/components/ui/avatar.tsx
+++ b/src/components/ui/avatar.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import * as React from "react";
 import * as AvatarPrimitive from "@radix-ui/react-avatar";
 
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
index 3c11926..fd09d21 100644
--- a/src/components/ui/collapsible.tsx
+++ b/src/components/ui/collapsible.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
 
 import { cn } from "~/lib/utils";
diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx
index a419290..b659e46 100644
--- a/src/components/ui/date-picker.tsx
+++ b/src/components/ui/date-picker.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import * as React from "react";
 import { CalendarIcon } from "lucide-react";
 
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index 22321b2..c0f69e1 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -1,5 +1,3 @@
-"use client"
-
 import * as React from "react"
 import * as SheetPrimitive from "@radix-ui/react-dialog"
 import { XIcon } from "lucide-react"
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index b2c9da5..b58c28b 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import * as React from "react";
 import { Slot } from "@radix-ui/react-slot";
 import { cva, VariantProps } from "class-variance-authority";
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 6f8bca8..8a547c8 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import * as React from "react"
 import * as TooltipPrimitive from "@radix-ui/react-tooltip"
 
diff --git a/src/components/ui/truncated-tooltip.tsx b/src/components/ui/truncated-tooltip.tsx
index 224e2b4..5a99b69 100644
--- a/src/components/ui/truncated-tooltip.tsx
+++ b/src/components/ui/truncated-tooltip.tsx
@@ -1,5 +1,3 @@
-"use client";
-
 import { cloneElement, type ReactElement } from "react";
 import {
   Tooltip,
diff --git a/src/db/mutations/user-preferences.ts b/src/db/mutations/user-preferences.ts
index 18f577f..ad77818 100644
--- a/src/db/mutations/user-preferences.ts
+++ b/src/db/mutations/user-preferences.ts
@@ -1,18 +1,25 @@
 import { useQueryClient } from "@tanstack/react-query";
 import { createServerFn, useServerFn } from "@tanstack/react-start";
-import { eq, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
 import { useCallback } from "react";
 import { db } from "~/db";
 import {
+  projects,
+  reorderProjectsValidator,
   userPreferences,
   userPreferencesSchema,
   updateUserPreferencesValidator,
+  type Project,
   type UserPreferences,
 } from "~/db/schema";
 import {
   userPreferencesQueryOptions,
 } from "~/db/queries/user-preferences";
+import { projectsQueryOptions } from "~/db/queries/projects";
+import { sortProjectsByUserOrder } from "~/lib/project-order";
+import { loadUserPreferences } from "~/lib/user-preferences.server";
 import { requireSessionFromRequest } from "~/lib/session";
+import { getOwningIdentity } from "~/lib/utils";
 
 const updateUserPreferences = createServerFn({ method: "POST" })
   .inputValidator(updateUserPreferencesValidator)
@@ -44,6 +51,177 @@ const updateUserPreferences = createServerFn({ method: "POST" })
     return userPreferencesSchema.parse(row?.preferences ?? data);
   });
 
+function normalizeProjectOrder(
+  projectIds: string[],
+  ownerProjects: Project[]
+): string[] {
+  const ownerIds = new Set(ownerProjects.map((project) => project.id));
+  const seen = new Set<string>();
+  const ordered: string[] = [];
+
+  for (const id of projectIds) {
+    if (ownerIds.has(id) && !seen.has(id)) {
+      ordered.push(id);
+      seen.add(id);
+    }
+  }
+
+  const byCreatedAt = [...ownerProjects].sort(
+    (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
+  );
+
+  for (const project of byCreatedAt) {
+    if (!seen.has(project.id)) {
+      ordered.push(project.id);
+    }
+  }
+
+  return ordered;
+}
+
+const reorderProjects = createServerFn({ method: "POST" })
+  .inputValidator(reorderProjectsValidator)
+  .handler(async ({ data }): Promise<UserPreferences> => {
+    const session = await requireSessionFromRequest();
+    const userId = session.user.id;
+    const activeOwner = getOwningIdentity(session);
+
+    if (data.ownerId !== activeOwner) {
+      throw new Error("Cannot reorder projects for a different owner context");
+    }
+
+    const ownerProjects = await db.query.projects.findMany({
+      where: (model, { eq }) => eq(model.owner, data.ownerId),
+    });
+
+    const normalizedOrder = normalizeProjectOrder(data.projectIds, ownerProjects);
+
+    if (normalizedOrder.length !== ownerProjects.length) {
+      throw new Error("Project order must include all accessible projects");
+    }
+
+    if (normalizedOrder.length > 0) {
+      const validRows = await db
+        .select({ id: projects.id })
+        .from(projects)
+        .where(
+          and(
+            eq(projects.owner, data.ownerId),
+            inArray(projects.id, normalizedOrder)
+          )
+        );
+
+      if (validRows.length !== normalizedOrder.length) {
+        throw new Error("Invalid project ids in reorder request");
+      }
+    }
+
+    const current = await loadUserPreferences(userId);
+    const merged = userPreferencesSchema.parse({
+      ...current,
+      projectOrderByOwner: {
+        ...current.projectOrderByOwner,
+        [data.ownerId]: normalizedOrder,
+      },
+    });
+
+    const now = new Date();
+
+    await db
+      .insert(userPreferences)
+      .values({
+        userId,
+        preferences: merged,
+        updatedAt: now,
+      })
+      .onConflictDoUpdate({
+        target: userPreferences.userId,
+        set: {
+          preferences: merged,
+          updatedAt: now,
+        },
+      });
+
+    return merged;
+  });
+
+export function useReorderProjectsMutation() {
+  const queryClient = useQueryClient();
+  const _reorder = useServerFn(reorderProjects);
+
+  return useCallback(
+    async (input: { ownerId: string; projectIds: string[] }) => {
+      const previousPreferences = queryClient.getQueryData<UserPreferences>(
+        userPreferencesQueryOptions().queryKey
+      );
+      const previousProjects = queryClient.getQueryData<Project[]>(
+        projectsQueryOptions().queryKey
+      );
+
+      const optimisticPreferences = userPreferencesSchema.parse({
+        ...(previousPreferences ?? {}),
+        projectOrderByOwner: {
+          ...(previousPreferences?.projectOrderByOwner ?? {}),
+          [input.ownerId]: input.projectIds,
+        },
+      });
+
+      queryClient.setQueryData(
+        userPreferencesQueryOptions().queryKey,
+        optimisticPreferences
+      );
+
+      if (previousProjects) {
+        queryClient.setQueryData(
+          projectsQueryOptions().queryKey,
+          sortProjectsByUserOrder(previousProjects, input.projectIds)
+        );
+      }
+
+      try {
+        const result = await _reorder({ data: input });
+        queryClient.setQueryData(
+          userPreferencesQueryOptions().queryKey,
+          result
+        );
+        if (previousProjects) {
+          queryClient.setQueryData(
+            projectsQueryOptions().queryKey,
+            sortProjectsByUserOrder(
+              previousProjects,
+              result.projectOrderByOwner[input.ownerId]
+            )
+          );
+        }
+        return result;
+      } catch (error) {
+        if (previousPreferences !== undefined) {
+          queryClient.setQueryData(
+            userPreferencesQueryOptions().queryKey,
+            previousPreferences
+          );
+        } else {
+          queryClient.invalidateQueries({
+            queryKey: userPreferencesQueryOptions().queryKey,
+          });
+        }
+        if (previousProjects !== undefined) {
+          queryClient.setQueryData(
+            projectsQueryOptions().queryKey,
+            previousProjects
+          );
+        } else {
+          queryClient.invalidateQueries({
+            queryKey: projectsQueryOptions().queryKey,
+          });
+        }
+        throw error;
+      }
+    },
+    [queryClient, _reorder]
+  );
+}
+
 export function useUpdateUserPreferencesMutation() {
   const queryClient = useQueryClient();
   const _update = useServerFn(updateUserPreferences);
diff --git a/src/db/queries/bundles.ts b/src/db/queries/bundles.ts
index 0f91ffe..27e1266 100644
--- a/src/db/queries/bundles.ts
+++ b/src/db/queries/bundles.ts
@@ -5,6 +5,11 @@ import type { CommentWithAuthor } from "~/db/queries/comments.server";
 import type { AppUser } from "~/db/queries/users";
 import type { Label, Project, Sprint, Status, TaskWithRelations } from "~/db/schema";
 import { requireSessionFromRequest } from "~/lib/session";
+import {
+  getSavedProjectOrder,
+  sortProjectsByUserOrder,
+} from "~/lib/project-order";
+import { loadUserPreferences } from "~/lib/user-preferences.server";
 import { getOwningIdentity } from "~/lib/utils";
 
 export type ProjectBoardBundle = {
@@ -209,7 +214,7 @@ export const fetchSidebarBundle = createServerFn({ method: "GET" }).handler(
     const session = await requireSessionFromRequest();
     const owner = getOwningIdentity(session);
 
-    const [projects, sprints] = await Promise.all([
+    const [rawProjects, sprints, preferences] = await Promise.all([
       db.query.projects.findMany({
         where: (model, { eq }) => eq(model.owner, owner),
       }),
@@ -217,8 +222,14 @@ export const fetchSidebarBundle = createServerFn({ method: "GET" }).handler(
         where: (model, { eq }) => eq(model.owner, owner),
         orderBy: (fields, { asc: ascFn }) => [ascFn(fields.createdAt)],
       }),
+      loadUserPreferences(session.user.id),
     ]);
 
+    const projects = sortProjectsByUserOrder(
+      rawProjects,
+      getSavedProjectOrder(preferences.projectOrderByOwner, owner)
+    );
+
     return { projects, sprints };
   }
 );
diff --git a/src/db/queries/projects.ts b/src/db/queries/projects.ts
index f9d6303..8dd41c2 100644
--- a/src/db/queries/projects.ts
+++ b/src/db/queries/projects.ts
@@ -2,15 +2,29 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
 import { createServerFn } from "@tanstack/react-start";
 import { db } from "~/db";
 import { requireSessionFromRequest } from "~/lib/session";
+import {
+  getSavedProjectOrder,
+  sortProjectsByUserOrder,
+} from "~/lib/project-order";
+import { loadUserPreferences } from "~/lib/user-preferences.server";
 import { getOwningIdentity } from "~/lib/utils";
 import { useEventSource } from "~/hooks/use-event-source";
 
 const fetchProjects = createServerFn({ method: "GET" }).handler(async () => {
   console.info("Fetching projects...");
   const session = await requireSessionFromRequest();
-  return await db.query.projects.findMany({
-    where: (model, { eq }) => eq(model.owner, getOwningIdentity(session)),
-  });
+  const owner = getOwningIdentity(session);
+  const [projects, preferences] = await Promise.all([
+    db.query.projects.findMany({
+      where: (model, { eq }) => eq(model.owner, owner),
+    }),
+    loadUserPreferences(session.user.id),
+  ]);
+
+  return sortProjectsByUserOrder(
+    projects,
+    getSavedProjectOrder(preferences.projectOrderByOwner, owner)
+  );
 });
 
 export const projectsQueryOptions = () =>
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 5094c3c..d078bcf 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -281,9 +281,17 @@ export type TaskViewMode = (typeof TASK_VIEW_MODES)[number];
 
 export const userPreferencesSchema = z.object({
   taskViewMode: z.enum(TASK_VIEW_MODES).default("board"),
+  projectOrderByOwner: z
+    .record(z.string(), z.array(z.string().uuid()))
+    .default({}),
 });
 export type UserPreferences = z.infer<typeof userPreferencesSchema>;
 
+export const reorderProjectsValidator = z.object({
+  ownerId: z.string().min(1),
+  projectIds: z.array(z.string().uuid()),
+});
+
 export const updateUserPreferencesValidator = userPreferencesSchema
   .partial()
   .refine((data) => Object.keys(data).length > 0, {
@@ -295,7 +303,7 @@ export const userPreferences = createTable("user_preferences", {
   preferences: jsonb("preferences")
     .$type<UserPreferences>()
     .notNull()
-    .default({ taskViewMode: "board" }),
+    .default({ taskViewMode: "board", projectOrderByOwner: {} }),
   updatedAt: timestamp("updated_at", { withTimezone: true })
     .default(sql`CURRENT_TIMESTAMP`)
     .notNull(),
diff --git a/src/lib/project-order.ts b/src/lib/project-order.ts
new file mode 100644
index 0000000..fe68fae
--- /dev/null
+++ b/src/lib/project-order.ts
@@ -0,0 +1,39 @@
+export function sortProjectsByUserOrder<
+  T extends { id: string; createdAt: Date },
+>(projects: T[], savedOrder?: string[]): T[] {
+  const byCreatedAt = [...projects].sort(
+    (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
+  );
+
+  if (!savedOrder?.length) {
+    return byCreatedAt;
+  }
+
+  const projectMap = new Map(projects.map((project) => [project.id, project]));
+  const result: T[] = [];
+  const seen = new Set<string>();
+
+  for (const id of savedOrder) {
+    const project = projectMap.get(id);
+    if (project) {
+      result.push(project);
+      seen.add(id);
+    }
+  }
+
+  for (const project of byCreatedAt) {
+    if (!seen.has(project.id)) {
+      result.push(project);
+    }
+  }
+
+  return result;
+}
+
+export function getSavedProjectOrder(
+  projectOrderByOwner: Record<string, string[]>,
+  ownerId: string
+): string[] | undefined {
+  const order = projectOrderByOwner[ownerId];
+  return order?.length ? order : undefined;
+}
diff --git a/src/lib/user-preferences.server.ts b/src/lib/user-preferences.server.ts
new file mode 100644
index 0000000..ab46385
--- /dev/null
+++ b/src/lib/user-preferences.server.ts
@@ -0,0 +1,21 @@
+import { eq } from "drizzle-orm";
+import { db } from "~/db";
+import {
+  userPreferences,
+  userPreferencesSchema,
+  type UserPreferences,
+} from "~/db/schema";
+
+export async function loadUserPreferences(
+  userId: string
+): Promise<UserPreferences> {
+  const row = await db.query.userPreferences.findFirst({
+    where: eq(userPreferences.userId, userId),
+  });
+
+  if (!row) {
+    return userPreferencesSchema.parse({});
+  }
+
+  return userPreferencesSchema.parse(row.preferences);
+}

From dc667e69100497990f723256a6693838fbc04283 Mon Sep 17 00:00:00 2001
From: Philipp Nowinski <mail@pno.dev>
Date: Mon, 1 Jun 2026 12:10:51 +0200
Subject: [PATCH 2/4] fix: address coderabbit findings

---
 src/components/nav-projects.tsx      | 76 ++++++++++++++++++----------
 src/db/mutations/user-preferences.ts | 19 +------
 2 files changed, 51 insertions(+), 44 deletions(-)

diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx
index 9984b0d..ac71e5e 100644
--- a/src/components/nav-projects.tsx
+++ b/src/components/nav-projects.tsx
@@ -44,24 +44,58 @@ import { cn, getOwningIdentity } from "~/lib/utils";
 
 const signedInRoute = getRouteApi("/_signed-in");
 
+type ProjectNavItemProps = {
+  project: Project;
+  pathname: string;
+  routeSearch: ReturnType<typeof signedInRoute.useSearch>;
+};
+
 function isNavPathActive(pathname: string, url: string) {
   if (url === "#") return false;
   return pathname === url || pathname.startsWith(`${url}/`);
 }
 
+function ProjectNavLink({ project }: { project: Project }) {
+  const url = `/projects/${project.id}/tasks`;
+  const logoUrl = project.logo ?? undefined;
+
+  return (
+    <Link to={url} className="flex min-w-0 items-center gap-2">
+      {logoUrl ? (
+        <img src={logoUrl} className="size-4 shrink-0 rounded-sm" alt="" />
+      ) : (
+        <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
+      )}
+      <span className="flex-1 truncate">{project.name}</span>
+    </Link>
+  );
+}
+
+function NonSortableProjectItem({
+  project,
+  pathname,
+  routeSearch,
+}: ProjectNavItemProps) {
+  const url = `/projects/${project.id}/tasks`;
+  const active = isFormSheetNavItemActive(pathname, url, undefined, routeSearch);
+
+  return (
+    <SidebarMenuSubItem>
+      <SidebarMenuSubButton asChild isActive={active} className="pl-9">
+        <ProjectNavLink project={project} />
+      </SidebarMenuSubButton>
+    </SidebarMenuSubItem>
+  );
+}
+
 function SortableProjectItem({
   project,
   pathname,
   routeSearch,
-}: {
-  project: Project;
-  pathname: string;
-  routeSearch: ReturnType<typeof signedInRoute.useSearch>;
-}) {
+}: ProjectNavItemProps) {
   const [isClient, setIsClient] = useState(false);
   const url = `/projects/${project.id}/tasks`;
   const active = isFormSheetNavItemActive(pathname, url, undefined, routeSearch);
-  const logoUrl = project.logo ?? undefined;
 
   useEffect(() => {
     setIsClient(true);
@@ -102,18 +136,7 @@ function SortableProjectItem({
           <GripVertical className="size-4" />
         </button>
         <SidebarMenuSubButton asChild isActive={active} className="min-w-0 flex-1 pl-2">
-          <Link to={url} className="flex min-w-0 items-center gap-2">
-            {logoUrl ? (
-              <img
-                src={logoUrl}
-                className="size-4 shrink-0 rounded-sm"
-                alt=""
-              />
-            ) : (
-              <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
-            )}
-            <span className="flex-1 truncate">{project.name}</span>
-          </Link>
+          <ProjectNavLink project={project} />
         </SidebarMenuSubButton>
       </div>
     </SidebarMenuSubItem>
@@ -163,16 +186,17 @@ function NavProjectsList({
     [ownerId, projects, reorderProjects]
   );
 
+  const itemProps = { pathname, routeSearch };
+
   return (
     <ClientOnly
       fallback={
         <>
           {projects.map((project) => (
-            <SortableProjectItem
+            <NonSortableProjectItem
               key={project.id}
               project={project}
-              pathname={pathname}
-              routeSearch={routeSearch}
+              {...itemProps}
             />
           ))}
         </>
@@ -191,8 +215,7 @@ function NavProjectsList({
             <SortableProjectItem
               key={project.id}
               project={project}
-              pathname={pathname}
-              routeSearch={routeSearch}
+              {...itemProps}
             />
           ))}
         </SortableContext>
@@ -216,6 +239,8 @@ export function NavProjects({ projects }: { projects: Project[] }) {
     routeSearch
   );
 
+  const itemProps = { pathname, routeSearch };
+
   return (
     <SidebarGroup className="py-1">
       <SidebarMenu>
@@ -243,11 +268,10 @@ export function NavProjects({ projects }: { projects: Project[] }) {
                   <NavProjectsList projects={projects} ownerId={ownerId} />
                 ) : (
                   projects.map((project) => (
-                    <SortableProjectItem
+                    <NonSortableProjectItem
                       key={project.id}
                       project={project}
-                      pathname={pathname}
-                      routeSearch={routeSearch}
+                      {...itemProps}
                     />
                   ))
                 )}
diff --git a/src/db/mutations/user-preferences.ts b/src/db/mutations/user-preferences.ts
index ad77818..5a58b46 100644
--- a/src/db/mutations/user-preferences.ts
+++ b/src/db/mutations/user-preferences.ts
@@ -1,10 +1,9 @@
 import { useQueryClient } from "@tanstack/react-query";
 import { createServerFn, useServerFn } from "@tanstack/react-start";
-import { and, eq, inArray, sql } from "drizzle-orm";
+import { eq, sql } from "drizzle-orm";
 import { useCallback } from "react";
 import { db } from "~/db";
 import {
-  projects,
   reorderProjectsValidator,
   userPreferences,
   userPreferencesSchema,
@@ -100,22 +99,6 @@ const reorderProjects = createServerFn({ method: "POST" })
       throw new Error("Project order must include all accessible projects");
     }
 
-    if (normalizedOrder.length > 0) {
-      const validRows = await db
-        .select({ id: projects.id })
-        .from(projects)
-        .where(
-          and(
-            eq(projects.owner, data.ownerId),
-            inArray(projects.id, normalizedOrder)
-          )
-        );
-
-      if (validRows.length !== normalizedOrder.length) {
-        throw new Error("Invalid project ids in reorder request");
-      }
-    }
-
     const current = await loadUserPreferences(userId);
     const merged = userPreferencesSchema.parse({
       ...current,

From 75f67a99a0692becf96ad1de2b7860b3672c6f33 Mon Sep 17 00:00:00 2001
From: Philipp Nowinski <mail@pno.dev>
Date: Mon, 1 Jun 2026 14:30:11 +0200
Subject: [PATCH 3/4] fix: resolve remaining issues

---
 src/components/app-sidebar.tsx  |  15 +-
 src/components/nav-main.tsx     |  30 ++--
 src/components/nav-projects.tsx | 242 ++++++++++++++++++++++----------
 3 files changed, 193 insertions(+), 94 deletions(-)

diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 1431018..30dbc00 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -4,8 +4,8 @@ import {
   Building2,
   Clock,
   ClockFading,
-  ClockPlus,
   Flag,
+  Plus,
   GitBranch,
   LayoutDashboard,
   LucideIcon,
@@ -21,6 +21,7 @@ import {
   Sidebar,
   SidebarContent,
   SidebarFooter,
+  SidebarGroup,
   SidebarHeader,
   SidebarMenu,
   SidebarMenuButton,
@@ -96,7 +97,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
         title: "Add Sprint",
         url: FORM_SHEET_CREATE_LINKS.sprint.to,
         search: FORM_SHEET_CREATE_LINKS.sprint.search,
-        icon: ClockPlus,
+        icon: Plus,
       },
     ];
 
@@ -129,9 +130,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
         </SidebarMenu>
       </SidebarHeader>
       <SidebarContent>
-        <NavMain items={dashboardNav} />
-        <NavProjects projects={projectsQuery.data} />
-        <NavMain items={sprintsNav} />
+        <SidebarGroup className="py-1">
+          <SidebarMenu>
+            <NavMain items={dashboardNav} embedded />
+            <NavProjects projects={projectsQuery.data} embedded />
+            <NavMain items={sprintsNav} embedded />
+          </SidebarMenu>
+        </SidebarGroup>
         <NavSettings settings={settingsNav} />
         {/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
       </SidebarContent>
diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx
index ca8dfec..6e5d88d 100644
--- a/src/components/nav-main.tsx
+++ b/src/components/nav-main.tsx
@@ -37,7 +37,9 @@ function isAddItem(title: string) {
 export function NavMain({
   title,
   items,
+  embedded = false,
 }: {
+  embedded?: boolean;
   title?: string;
   items: {
     title: string;
@@ -56,15 +58,7 @@ export function NavMain({
   const pathname = location.pathname;
   const routeSearch = signedInRoute.useSearch();
 
-  return (
-    <SidebarGroup className="py-1">
-      {title ? (
-        <SidebarGroupLabel className="text-sidebar-foreground/45 mb-1.5 px-4 text-[11px] font-medium tracking-wider uppercase">
-          {title}
-        </SidebarGroupLabel>
-      ) : null}
-      <SidebarMenu>
-        {items.map((item) => {
+  const menuItems = items.map((item) => {
           const hasChildren = Boolean(item.items?.length);
           if (!hasChildren) {
             return (
@@ -133,7 +127,7 @@ export function NavMain({
                                   className="size-4 shrink-0 rounded-sm"
                                   alt=""
                                 />
-                              ) : subItem.icon && !isAction ? (
+                              ) : subItem.icon ? (
                                 <subItem.icon className="text-sidebar-foreground/45 size-4 shrink-0" />
                               ) : null}
                               <span className="flex-1">{subItem.title}</span>
@@ -153,8 +147,20 @@ export function NavMain({
               </SidebarMenuItem>
             </Collapsible>
           );
-        })}
-      </SidebarMenu>
+        });
+
+  if (embedded) {
+    return menuItems;
+  }
+
+  return (
+    <SidebarGroup className="py-1">
+      {title ? (
+        <SidebarGroupLabel className="text-sidebar-foreground/45 mb-1.5 px-4 text-[11px] font-medium tracking-wider uppercase">
+          {title}
+        </SidebarGroupLabel>
+      ) : null}
+      <SidebarMenu>{menuItems}</SidebarMenu>
     </SidebarGroup>
   );
 }
diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx
index ac71e5e..aa34012 100644
--- a/src/components/nav-projects.tsx
+++ b/src/components/nav-projects.tsx
@@ -16,8 +16,15 @@ import {
   verticalListSortingStrategy,
 } from "@dnd-kit/sortable";
 import { CSS } from "@dnd-kit/utilities";
-import { ChevronRight, Folder, FolderPlus, GripVertical } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { ChevronRight, Folder, GripVertical, Plus } from "lucide-react";
+import {
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  type MutableRefObject,
+  type SyntheticEvent,
+} from "react";
 
 import {
   Collapsible,
@@ -55,19 +62,59 @@ function isNavPathActive(pathname: string, url: string) {
   return pathname === url || pathname.startsWith(`${url}/`);
 }
 
-function ProjectNavLink({ project }: { project: Project }) {
-  const url = `/projects/${project.id}/tasks`;
+function ProjectRowLabel({ project }: { project: Project }) {
   const logoUrl = project.logo ?? undefined;
 
   return (
-    <Link to={url} className="flex min-w-0 items-center gap-2">
+    <>
       {logoUrl ? (
         <img src={logoUrl} className="size-4 shrink-0 rounded-sm" alt="" />
       ) : (
         <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
       )}
-      <span className="flex-1 truncate">{project.name}</span>
-    </Link>
+      <span className="flex-1">{project.name}</span>
+    </>
+  );
+}
+
+function blockLinkIfDragging(
+  ignoreNavigationRef: MutableRefObject<boolean>,
+  event: SyntheticEvent
+) {
+  if (ignoreNavigationRef.current) {
+    event.preventDefault();
+    event.stopPropagation();
+  }
+}
+
+function ProjectNavLink({
+  project,
+  active,
+  ignoreNavigationRef,
+}: {
+  project: Project;
+  active: boolean;
+  ignoreNavigationRef?: MutableRefObject<boolean>;
+}) {
+  return (
+    <SidebarMenuSubButton asChild isActive={active}>
+      <Link
+        to="/projects/$projectId/tasks"
+        params={{ projectId: project.id }}
+        onClickCapture={
+          ignoreNavigationRef
+            ? (event) => blockLinkIfDragging(ignoreNavigationRef, event)
+            : undefined
+        }
+        onAuxClickCapture={
+          ignoreNavigationRef
+            ? (event) => blockLinkIfDragging(ignoreNavigationRef, event)
+            : undefined
+        }
+      >
+        <ProjectRowLabel project={project} />
+      </Link>
+    </SidebarMenuSubButton>
   );
 }
 
@@ -81,9 +128,7 @@ function NonSortableProjectItem({
 
   return (
     <SidebarMenuSubItem>
-      <SidebarMenuSubButton asChild isActive={active} className="pl-9">
-        <ProjectNavLink project={project} />
-      </SidebarMenuSubButton>
+      <ProjectNavLink project={project} active={active} />
     </SidebarMenuSubItem>
   );
 }
@@ -92,7 +137,10 @@ function SortableProjectItem({
   project,
   pathname,
   routeSearch,
-}: ProjectNavItemProps) {
+  ignoreNavigationRef,
+}: ProjectNavItemProps & {
+  ignoreNavigationRef: MutableRefObject<boolean>;
+}) {
   const [isClient, setIsClient] = useState(false);
   const url = `/projects/${project.id}/tasks`;
   const active = isFormSheetNavItemActive(pathname, url, undefined, routeSearch);
@@ -105,6 +153,7 @@ function SortableProjectItem({
     attributes,
     listeners,
     setNodeRef,
+    setActivatorNodeRef,
     transform,
     transition,
     isDragging,
@@ -121,24 +170,29 @@ function SortableProjectItem({
     <SidebarMenuSubItem
       ref={isClient ? setNodeRef : undefined}
       style={style}
-      className={cn(isClient && isDragging && "z-50 opacity-50")}
+      className={cn(
+        "group/project-item relative",
+        isClient && isDragging && "z-50 opacity-50"
+      )}
     >
-      <div className="flex min-w-0 items-center">
+      {isClient ? (
         <button
+          ref={setActivatorNodeRef}
           type="button"
-          className={cn(
-            "text-sidebar-foreground/45 ml-6 shrink-0 touch-none p-1",
-            isClient && "cursor-grab active:cursor-grabbing"
-          )}
+          className="absolute top-1/2 left-2 z-10 -translate-y-1/2 touch-none rounded-sm p-0.5 text-sidebar-foreground/45 opacity-0 transition-opacity cursor-grab group-hover/project-item:opacity-100 focus-visible:opacity-100 active:cursor-grabbing"
           aria-label={`Reorder ${project.name}`}
-          {...(isClient ? { ...attributes, ...listeners } : {})}
+          onClick={(event) => event.preventDefault()}
+          {...attributes}
+          {...listeners}
         >
           <GripVertical className="size-4" />
         </button>
-        <SidebarMenuSubButton asChild isActive={active} className="min-w-0 flex-1 pl-2">
-          <ProjectNavLink project={project} />
-        </SidebarMenuSubButton>
-      </div>
+      ) : null}
+      <ProjectNavLink
+        project={project}
+        active={active}
+        ignoreNavigationRef={ignoreNavigationRef}
+      />
     </SidebarMenuSubItem>
   );
 }
@@ -154,6 +208,14 @@ function NavProjectsList({
   const pathname = location.pathname;
   const routeSearch = signedInRoute.useSearch();
   const reorderProjects = useReorderProjectsMutation();
+  const ignoreNavigationRef = useRef(false);
+
+  const blockNavigationAfterDrag = useCallback(() => {
+    ignoreNavigationRef.current = true;
+    window.setTimeout(() => {
+      ignoreNavigationRef.current = false;
+    }, 500);
+  }, []);
 
   const sensors = useSensors(
     useSensor(PointerSensor, {
@@ -205,7 +267,16 @@ function NavProjectsList({
       <DndContext
         sensors={sensors}
         collisionDetection={closestCenter}
-        onDragEnd={handleDragEnd}
+        onDragStart={() => {
+          ignoreNavigationRef.current = true;
+        }}
+        onDragEnd={(event) => {
+          handleDragEnd(event);
+          blockNavigationAfterDrag();
+        }}
+        onDragCancel={() => {
+          ignoreNavigationRef.current = false;
+        }}
       >
         <SortableContext
           items={projects.map((project) => project.id)}
@@ -215,6 +286,7 @@ function NavProjectsList({
             <SortableProjectItem
               key={project.id}
               project={project}
+              ignoreNavigationRef={ignoreNavigationRef}
               {...itemProps}
             />
           ))}
@@ -224,7 +296,7 @@ function NavProjectsList({
   );
 }
 
-export function NavProjects({ projects }: { projects: Project[] }) {
+function ProjectsCollapsible({ projects }: { projects: Project[] }) {
   const { location } = useRouterState();
   const pathname = location.pathname;
   const routeSearch = signedInRoute.useSearch();
@@ -241,61 +313,77 @@ export function NavProjects({ projects }: { projects: Project[] }) {
 
   const itemProps = { pathname, routeSearch };
 
+  return (
+    <Collapsible defaultOpen className="group/collapsible">
+      <SidebarMenuItem>
+        <CollapsibleTrigger asChild>
+          <SidebarMenuButton className="pr-3 font-medium [&>svg:last-child]:transition-transform [&>svg:last-child]:duration-200 group-data-[state=open]/collapsible:[&>svg:last-child]:rotate-90">
+            <Folder className="text-sidebar-foreground/50" />
+            <span className="flex-1">Projects</span>
+            <ChevronRight className="text-sidebar-foreground/45 size-4 shrink-0" />
+          </SidebarMenuButton>
+        </CollapsibleTrigger>
+        <CollapsibleContent>
+          <SidebarMenuSub>
+            <SidebarMenuSubItem>
+              <SidebarMenuSubButton asChild isActive={allActive}>
+                <Link to="/tasks">
+                  <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
+                  <span className="flex-1">All</span>
+                </Link>
+              </SidebarMenuSubButton>
+            </SidebarMenuSubItem>
+
+            {ownerId ? (
+              <NavProjectsList projects={projects} ownerId={ownerId} />
+            ) : (
+              projects.map((project) => (
+                <NonSortableProjectItem
+                  key={project.id}
+                  project={project}
+                  {...itemProps}
+                />
+              ))
+            )}
+
+            <SidebarMenuSubItem>
+              <SidebarMenuSubButton
+                asChild
+                isActive={addActive}
+                className="font-normal text-muted-foreground hover:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:text-sidebar-primary"
+              >
+                <Link
+                  to={FORM_SHEET_CREATE_LINKS.project.to}
+                  search={FORM_SHEET_CREATE_LINKS.project.search}
+                >
+                  <Plus className="text-sidebar-foreground/45 size-4 shrink-0" />
+                  <span className="flex-1">Add Project</span>
+                </Link>
+              </SidebarMenuSubButton>
+            </SidebarMenuSubItem>
+          </SidebarMenuSub>
+        </CollapsibleContent>
+      </SidebarMenuItem>
+    </Collapsible>
+  );
+}
+
+export function NavProjects({
+  projects,
+  embedded = false,
+}: {
+  projects: Project[];
+  embedded?: boolean;
+}) {
+  const collapsible = <ProjectsCollapsible projects={projects} />;
+
+  if (embedded) {
+    return collapsible;
+  }
+
   return (
     <SidebarGroup className="py-1">
-      <SidebarMenu>
-        <Collapsible defaultOpen className="group/collapsible">
-          <SidebarMenuItem>
-            <CollapsibleTrigger asChild>
-              <SidebarMenuButton className="pr-3 font-medium [&>svg:last-child]:transition-transform [&>svg:last-child]:duration-200 group-data-[state=open]/collapsible:[&>svg:last-child]:rotate-90">
-                <Folder className="text-sidebar-foreground/50" />
-                <span className="flex-1">Projects</span>
-                <ChevronRight className="text-sidebar-foreground/45 size-4 shrink-0" />
-              </SidebarMenuButton>
-            </CollapsibleTrigger>
-            <CollapsibleContent>
-              <SidebarMenuSub>
-                <SidebarMenuSubItem>
-                  <SidebarMenuSubButton asChild isActive={allActive}>
-                    <Link to="/tasks">
-                      <Folder className="text-sidebar-foreground/45 size-4 shrink-0" />
-                      <span className="flex-1">All</span>
-                    </Link>
-                  </SidebarMenuSubButton>
-                </SidebarMenuSubItem>
-
-                {ownerId ? (
-                  <NavProjectsList projects={projects} ownerId={ownerId} />
-                ) : (
-                  projects.map((project) => (
-                    <NonSortableProjectItem
-                      key={project.id}
-                      project={project}
-                      {...itemProps}
-                    />
-                  ))
-                )}
-
-                <SidebarMenuSubItem>
-                  <SidebarMenuSubButton
-                    asChild
-                    isActive={addActive}
-                    className="font-normal text-muted-foreground hover:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:text-sidebar-primary"
-                  >
-                    <Link
-                      to={FORM_SHEET_CREATE_LINKS.project.to}
-                      search={FORM_SHEET_CREATE_LINKS.project.search}
-                    >
-                      <FolderPlus className="text-sidebar-foreground/45 size-4 shrink-0" />
-                      <span className="flex-1">Add Project</span>
-                    </Link>
-                  </SidebarMenuSubButton>
-                </SidebarMenuSubItem>
-              </SidebarMenuSub>
-            </CollapsibleContent>
-          </SidebarMenuItem>
-        </Collapsible>
-      </SidebarMenu>
+      <SidebarMenu>{collapsible}</SidebarMenu>
     </SidebarGroup>
   );
 }

From 7b6da37b5333666001d72b8e81694d0badcd3ffd Mon Sep 17 00:00:00 2001
From: Philipp Nowinski <mail@pno.dev>
Date: Mon, 1 Jun 2026 14:40:01 +0200
Subject: [PATCH 4/4] fix: address coderabbit findings

---
 src/components/app-sidebar.tsx | 19 ++++---------------
 src/components/nav-main.tsx    | 31 ++++++++++++++++---------------
 2 files changed, 20 insertions(+), 30 deletions(-)

diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 30dbc00..ece2927 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -8,12 +8,11 @@ import {
   Plus,
   GitBranch,
   LayoutDashboard,
-  LucideIcon,
   Tag,
   User,
 } from "lucide-react";
 
-import { NavMain } from "~/components/nav-main";
+import { NavMain, type NavSubItem } from "~/components/nav-main";
 import { NavProjects } from "~/components/nav-projects";
 import { NavOrganization } from "~/components/nav-organization";
 import { NavSettings } from "~/components/nav-settings";
@@ -29,18 +28,7 @@ import {
 } from "~/components/ui/sidebar";
 import { useProjectsQuery } from "~/db/queries/projects";
 import { useSprintsQuery } from "~/db/queries/sprints";
-import {
-  FORM_SHEET_CREATE_LINKS,
-  type FormSheetSearch,
-} from "~/lib/form-sheet-search";
-
-type NavItem = {
-  title: string;
-  url: string;
-  search?: FormSheetSearch;
-  icon?: LucideIcon | string;
-  highlighted?: boolean;
-};
+import { FORM_SHEET_CREATE_LINKS } from "~/lib/form-sheet-search";
 
 const settingsNav = [
   {
@@ -86,7 +74,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
   );
 
   const sprintsNav = React.useMemo(() => {
-    const sprintItems: NavItem[] = [
+    const sprintItems: NavSubItem[] = [
       ...sprintsQuery.data.map((sprint) => ({
         title: sprint.name,
         url: `/sprints/${sprint.id}/tasks`,
@@ -98,6 +86,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
         url: FORM_SHEET_CREATE_LINKS.sprint.to,
         search: FORM_SHEET_CREATE_LINKS.sprint.search,
         icon: Plus,
+        variant: "action",
       },
     ];
 
diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx
index 6e5d88d..24a65fd 100644
--- a/src/components/nav-main.tsx
+++ b/src/components/nav-main.tsx
@@ -25,14 +25,24 @@ import {
 
 const signedInRoute = getRouteApi("/_signed-in");
 
+export type NavSubItemVariant = "link" | "action";
+
+export type NavSubItem = {
+  title: string;
+  url: string;
+  search?: FormSheetSearch;
+  icon?: LucideIcon | string;
+  highlighted?: boolean;
+  variant?: NavSubItemVariant;
+};
+
 function isNavPathActive(pathname: string, url: string) {
   if (url === "#") return false;
   return pathname === url || pathname.startsWith(`${url}/`);
 }
 
-function isAddItem(title: string) {
-  return title.startsWith("Add ");
-}
+const navSubItemActionClassName =
+  "font-normal text-muted-foreground hover:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:text-sidebar-primary";
 
 export function NavMain({
   title,
@@ -45,13 +55,7 @@ export function NavMain({
     title: string;
     url: string;
     icon: LucideIcon;
-    items?: {
-      title: string;
-      url: string;
-      search?: FormSheetSearch;
-      icon?: LucideIcon | string;
-      highlighted?: boolean;
-    }[];
+    items?: NavSubItem[];
   }[];
 }) {
   const { location } = useRouterState();
@@ -106,7 +110,7 @@ export function NavMain({
                         subItem.search,
                         routeSearch
                       );
-                      const isAction = isAddItem(subItem.title);
+                      const isAction = subItem.variant === "action";
                       const logoUrl =
                         typeof subItem.icon === "string" ? subItem.icon : undefined;
 
@@ -115,10 +119,7 @@ export function NavMain({
                           <SidebarMenuSubButton
                             asChild
                             isActive={active}
-                            className={cn(
-                              isAction &&
-                                "font-normal text-muted-foreground hover:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:text-sidebar-primary"
-                            )}
+                            className={cn(isAction && navSubItemActionClassName)}
                           >
                             <Link to={subItem.url} search={subItem.search}>
                               {logoUrl ? (
