feat(home): portfolio home redesign — folder rail, starred view, grid/list

Client page driven by useQuery on /api/me/portfolio-home with optimistic
mutations (star, move-to-folder, folder create/rename/delete, drag or
menu-based reorder). TDD'd view-model in portfolioHomeView.ts (status
pill mapping, search over display language, sort incl needs-attention,
money/date formatting, optimistic config upsert);
groupPortfoliosForHome generalised over id type so the client re-groups
with wire-format string ids. List view is a semantic table; replaces
CardTiles/Card/AddNewCard (NewPortfolioModal retained).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 16:18:44 +00:00
parent 633c127620
commit cfa181f3c7
11 changed files with 1339 additions and 194 deletions

View file

@ -8,10 +8,9 @@ import {
userPortfolioConfig,
userPortfolioFolders,
} from "@/app/db/schema/user_portfolio_config";
import { groupPortfoliosForHome } from "@/app/lib/portfolioUserConfig";
// Everything the home page renders: the caller's portfolios grouped by their
// personal folders, with starred float and a most-recently-starred list.
// Everything the home page renders, flat: the client groups via
// groupPortfoliosForHome so optimistic mutations can re-group locally.
export async function GET() {
try {
const session = await getServerSession(AuthOptions);
@ -40,16 +39,7 @@ export async function GET() {
.where(eq(userPortfolioFolders.userId, userId)),
]);
const starredIds = new Set(
configs.filter((c) => c.starredAt !== null).map((c) => c.portfolioId),
);
const grouped = groupPortfoliosForHome({ portfolios, configs, folders });
const body = {
folders: grouped.folders,
unfiled: grouped.unfiled,
starred: grouped.starred,
starredIds: [...starredIds],
};
const body = { portfolios, configs, folders };
// bigint isn't JSON-serialisable; ids cross the wire as strings.
return new NextResponse(

View file

@ -1,49 +0,0 @@
import { useState } from "react";
import PlusIcon from "../PlusIcon";
import NewPortfolioModal from "./NewPortfolioModal";
const styles = {
wrapper:
"group bg-brandblue hover:bg-hoverblue shadow-xl hover:shadow-none cursor-pointer rounded-3xl flex flex-col items-center justify-center aspect-square",
header: "relative mt-2 mx-2 w-full",
imageWrapper:
"relative rounded-2xl overflow-hidden flex justify-center items-center",
wrapperAnime: "transition-all duration-500 ease-in-out",
image: "object-cover w-8/12 h-8/12 mx-auto fill-white",
textWrapper: "w-full flex justify-center items-center pt-6",
text: "pb-6 font-medium leading-none text-base tracking-wider text-gray-400",
};
const AddNewCard = () => {
const title = "New Portfolio";
const [isModalOpen, setModalIsOpen] = useState(false);
const openModal = () => {
setModalIsOpen(true);
};
return (
<a onClick={openModal}>
<div className="flex justify-center">
<div className={[styles.wrapper, styles.wrapperAnime].join(" ")}>
<div className={styles.header}>
<div className={styles.imageWrapper}>
<div className="w-1/4">
<PlusIcon color="white" />
<NewPortfolioModal
isOpen={isModalOpen}
setIsOpen={setModalIsOpen}
/>
</div>
</div>
</div>
<div className={styles.textWrapper}>
<h1 className={styles.text}>{`${title}`}</h1>
</div>
</div>
</div>
</a>
);
};
export default AddNewCard;

View file

@ -1,68 +0,0 @@
import React from "react";
import StatusBadge from "@/app/components/StatusBadge";
import { useRouter } from "next/navigation";
import { PortfolioStatus } from "@/app/db/schema/portfolio";
import { formatNumber } from "@/app/utils";
import Image from "next/image";
const styles = {
wrapper:
"group py-2 px-3 active:bg-brandmidblue font-medium leading-none text-base tracking-wider text-gray-400 hover:text-white-300 bg-white hover:bg-hoverblue shadow-2xl hover:shadow-none cursor-pointer aspect-square rounded-3xl flex flex-col items-center justify-center",
header: "relative mt-2 w-full border-brandblue",
budgetWrapper: "min-h-7 pr-4 flex justify-end w-full text-right max-h-16 my-auto text-gray-700 group-hover:text-white transition-all duration-500 ease-in-out relative",
imageWrapper: "rounded-2xl overflow-hidden flex justify-center items-center",
wrapperAnime: "transition-all duration-500 ease-in-out",
image: "object-cover mx-auto",
textWrapper: "pb-3 w-full px-4 flex justify-center items-center max-h-16 my-auto text-gray-700 text-center group-hover:text-white transition-all duration-500 ease-in-out",
};
interface CardProps {
id: bigint;
title: string;
image: string;
budget: number | null;
status: (typeof PortfolioStatus)[number];
};
const Card = ({ id, title, image, budget, status }: CardProps) => {
const router = useRouter();
function handleClick() {
router.push(`/portfolio/${id}`);
}
const budgetFormatted = budget ? "£" + formatNumber(budget) : "";
return (
<div>
<div
onClick={handleClick}
className={[styles.wrapper, styles.wrapperAnime].join(" ")}
>
<div className={styles.header}>
<div className={styles.budgetWrapper} >{budgetFormatted}</div>
<div className={styles.imageWrapper}>
<Image
src={image}
className={styles.image}
alt=""
width={80}
height={80}
/>
</div>
</div>
<div className={styles.textWrapper}>
<h1>{`${title}`}</h1>
</div>
<div className="mb-4 flex justify-end w-full">
<div className="flex justify-center w-full">
<StatusBadge status={status} />
</div>
</div>
</div>
</div>
);
};
export default Card;

View file

@ -1,35 +0,0 @@
"use client";
import Card from "./Card";
import AddNewCard from "./AddNewCard";
import type { Portfolio } from "@/app/db/schema/portfolio";
type PortfoliosType = Array<Portfolio>;
export default function CardTiles({
Portfolios,
}: {
Portfolios: PortfoliosType;
}) {
return (
<div className="flex justify-center">
<div className="grid grid-cols-1 gap-6 max-w-7xl sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<AddNewCard />
{Portfolios.map((portfolio, index) => {
const image_idx = index % 5;
return (
<Card
key={portfolio.id.toString()}
id={portfolio.id}
title={portfolio.name}
image={`house-icon.svg`}
budget={portfolio.budget}
status={portfolio.status}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,253 @@
"use client";
import { useState } from "react";
import { MoreVertical, Plus, Star } from "lucide-react";
import type { FolderWire } from "@/app/lib/portfolioHomeView";
export type RailView = "all" | "starred" | { folderId: string };
export type FolderRailProps = {
folders: FolderWire[]; // already ordered by position
view: RailView;
totalCount: number;
starredCount: number;
attentionTotal: number;
countOf: (folderId: string) => number;
attentionOf: (folderId: string) => number;
onSelect: (view: RailView) => void;
onReorder: (orderedIds: string[]) => void;
onCreate: (name: string) => void;
onRename: (folderId: string, name: string) => void;
onDelete: (folderId: string) => void;
};
function AttentionBadge({ count }: { count: number }) {
if (!count) return null;
return (
<span
title={`${count} need attention`}
className="rounded-full bg-red-100 px-2 py-px text-[11px] font-semibold text-red-800 tabular-nums"
>
{count}
</span>
);
}
function railItemClasses(active: boolean) {
return `flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm ${
active ? "bg-[#e4e9f1] font-semibold" : "hover:bg-[#e9edf3]"
}`;
}
export default function FolderRail(props: FolderRailProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const isFolderView = (folderId: string) =>
typeof props.view === "object" && props.view.folderId === folderId;
const commitDrop = () => {
if (
dragIndex !== null &&
dropIndex !== null &&
dropIndex !== dragIndex
) {
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(dragIndex, 1);
ids.splice(dropIndex, 0, moved);
props.onReorder(ids);
}
setDragIndex(null);
setDropIndex(null);
};
const nameForm = (
defaultValue: string,
onSubmit: (name: string) => void,
onDone: () => void,
) => (
<form
className="flex-1 px-1"
onSubmit={(e) => {
e.preventDefault();
const name = new FormData(e.currentTarget).get("name");
if (typeof name === "string" && name.trim()) onSubmit(name.trim());
onDone();
}}
>
<input
name="name"
defaultValue={defaultValue}
autoFocus
maxLength={120}
aria-label="Folder name"
onBlur={onDone}
onKeyDown={(e) => e.key === "Escape" && onDone()}
className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none"
/>
</form>
);
return (
<nav aria-label="Portfolio folders" className="w-full shrink-0 lg:sticky lg:top-5 lg:w-[230px]">
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => props.onSelect("starred")}
className={railItemClasses(props.view === "starred")}
>
<Star size={14} className="shrink-0" fill="#f1bb06" strokeWidth={0} aria-hidden />
<span className="flex-1 truncate">Starred</span>
<span className="text-[13px] text-gray-500 tabular-nums">
{props.starredCount}
</span>
</button>
<button
type="button"
onClick={() => props.onSelect("all")}
className={railItemClasses(props.view === "all")}
>
<span className="flex-1 truncate">All portfolios</span>
<AttentionBadge count={props.attentionTotal} />
<span className="text-[13px] text-gray-500 tabular-nums">
{props.totalCount}
</span>
</button>
</div>
<hr className="my-2.5 border-[#e2e7ee]" />
<div className="mb-1.5 flex items-center px-2.5 text-xs font-semibold text-gray-500">
Your folders
<button
type="button"
onClick={() => setCreating(true)}
className="ml-auto flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-semibold text-brandmidblue hover:bg-[#eceefb]"
>
<Plus size={13} aria-hidden /> New
</button>
</div>
<ul className="flex flex-col gap-0.5">
{props.folders.map((folder, index) => (
<li
key={folder.id}
draggable={editingId !== folder.id}
onDragStart={() => setDragIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (dragIndex !== null) setDropIndex(index);
}}
onDrop={commitDrop}
onDragEnd={() => {
setDragIndex(null);
setDropIndex(null);
}}
className={`group flex items-center rounded-lg ${
dragIndex === index ? "opacity-40" : ""
} ${
dropIndex === index && dragIndex !== null && dragIndex !== index
? dropIndex < dragIndex
? "shadow-[0_-2px_0_#3943b7]"
: "shadow-[0_2px_0_#3943b7]"
: ""
} ${editingId === folder.id ? "" : "cursor-grab"}`}
>
{editingId === folder.id ? (
nameForm(
folder.name,
(name) => props.onRename(folder.id, name),
() => setEditingId(null),
)
) : (
<>
<button
type="button"
onClick={() => props.onSelect({ folderId: folder.id })}
className={railItemClasses(isFolderView(folder.id))}
>
<span className="flex-1 truncate">{folder.name}</span>
<AttentionBadge count={props.attentionOf(folder.id)} />
<span className="text-[13px] text-gray-500 tabular-nums">
{props.countOf(folder.id)}
</span>
</button>
<details className="relative shrink-0">
<summary
aria-label={`Folder options for ${folder.name}`}
className="grid cursor-pointer list-none place-items-center rounded-lg p-1 text-gray-500 opacity-0 hover:bg-gray-100 focus-visible:opacity-100 group-hover:opacity-100 [&::-webkit-details-marker]:hidden [details[open]>&]:opacity-100"
>
<MoreVertical size={15} aria-hidden />
</summary>
<div className="absolute right-0 top-full z-20 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white p-1 shadow-md">
{(
[
["Move up", index > 0, -1],
["Move down", index < props.folders.length - 1, 1],
] as const
).map(([label, enabled, delta]) => (
<button
key={label}
type="button"
disabled={!enabled}
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(index, 1);
ids.splice(index + delta, 0, moved);
props.onReorder(ids);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50 disabled:cursor-default disabled:text-gray-400 disabled:hover:bg-transparent"
>
{label}
</button>
))}
<div className="my-1 border-t border-gray-200" />
<button
type="button"
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
setEditingId(folder.id);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
Rename
</button>
<button
type="button"
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
props.onDelete(folder.id);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm text-red-700 hover:bg-red-50"
>
Remove folder
</button>
</div>
</details>
</>
)}
</li>
))}
{creating && (
<li className="flex items-center rounded-lg">
{nameForm("", props.onCreate, () => setCreating(false))}
</li>
)}
</ul>
<p className="hidden px-2.5 pt-2 text-xs text-gray-500 lg:block">
Folders are personal to you teammates organise their own. Drag to
reorder.
</p>
</nav>
);
}

View file

@ -0,0 +1,297 @@
"use client";
import Link from "next/link";
import { MoreVertical, Star } from "lucide-react";
import {
formatMoney,
formatUpdatedAgo,
statusDisplay,
type FolderWire,
type PortfolioWire,
type StatusTone,
} from "@/app/lib/portfolioHomeView";
const PILL_TONES: Record<StatusTone, string> = {
early: "bg-slate-100 text-slate-600",
tender: "bg-indigo-100 text-indigo-800",
underway: "bg-blue-100 text-blue-800",
ontrack: "bg-green-100 text-green-800",
delayed: "bg-amber-100 text-amber-800",
atrisk: "bg-red-100 text-red-800",
done: "bg-emerald-100 text-emerald-900",
review: "bg-yellow-100 text-yellow-800",
};
export type CardActions = {
isStarred: (portfolioId: string) => boolean;
folderIdOf: (portfolioId: string) => string | null;
onToggleStar: (portfolio: PortfolioWire) => void;
onMoveToFolder: (portfolio: PortfolioWire, folderId: string | null) => void;
folders: FolderWire[];
};
function StatusPill({ status }: { status: string }) {
const display = statusDisplay(status);
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${PILL_TONES[display.tone]}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
{display.label}
</span>
);
}
function StarButton({
portfolio,
actions,
}: {
portfolio: PortfolioWire;
actions: CardActions;
}) {
const starred = actions.isStarred(portfolio.id);
return (
<button
type="button"
aria-pressed={starred}
aria-label={`${starred ? "Unstar" : "Star"} ${portfolio.name}`}
title={starred ? "Unstar" : "Star"}
onClick={() => actions.onToggleStar(portfolio)}
className="rounded-lg p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-brandblue focus-visible:outline focus-visible:outline-2 focus-visible:outline-brandmidblue"
>
<Star
size={17}
className={starred ? "stroke-brandblue" : ""}
fill={starred ? "#f1bb06" : "none"}
aria-hidden
/>
</button>
);
}
function CardMenu({
portfolio,
actions,
openUpward,
}: {
portfolio: PortfolioWire;
actions: CardActions;
openUpward?: boolean;
}) {
const currentFolderId = actions.folderIdOf(portfolio.id);
return (
<details className="relative">
<summary
aria-label={`Actions for ${portfolio.name}`}
className="grid cursor-pointer list-none place-items-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-brandblue [&::-webkit-details-marker]:hidden"
>
<MoreVertical size={16} aria-hidden />
</summary>
<div
className={`absolute right-0 z-20 min-w-[160px] rounded-lg border border-gray-200 bg-white p-1 shadow-md ${
openUpward ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
<div className="px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Move to folder
</div>
{actions.folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={(e) => {
e.currentTarget.closest("details")?.removeAttribute("open");
actions.onMoveToFolder(portfolio, folder.id);
}}
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
{folder.name}
{currentFolderId === folder.id && (
<span className="text-brandmidblue" aria-label="current folder">
</span>
)}
</button>
))}
<button
type="button"
onClick={(e) => {
e.currentTarget.closest("details")?.removeAttribute("open");
actions.onMoveToFolder(portfolio, null);
}}
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
No folder
{currentFolderId === null && (
<span className="text-brandmidblue" aria-label="current folder">
</span>
)}
</button>
</div>
</details>
);
}
function BudgetLine({ portfolio }: { portfolio: PortfolioWire }) {
const { budget, cost } = portfolio;
if (!budget) {
return <div className="text-[13px] text-gray-500">No budget set</div>;
}
const pct = cost != null ? Math.round((100 * cost) / budget) : null;
const over = cost != null && cost > budget;
return (
<div className="text-[13px]">
<div className="mb-1 flex justify-between text-gray-500">
{cost != null ? (
<>
<span>
<span className="font-semibold text-gray-900 tabular-nums">
{formatMoney(cost)}
</span>{" "}
of <span className="tabular-nums">{formatMoney(budget)}</span>{" "}
budget
</span>
<span
className={`tabular-nums ${over ? "font-semibold text-red-700" : ""}`}
>
{pct}%{over ? " over" : ""}
</span>
</>
) : (
<>
<span>
Budget{" "}
<span className="font-semibold text-gray-900 tabular-nums">
{formatMoney(budget)}
</span>
</span>
<span>no spend yet</span>
</>
)}
</div>
<div className="h-1 overflow-hidden rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${over ? "bg-red-700" : "bg-brandmidblue"}`}
style={{ width: `${Math.min(100, pct ?? 0)}%` }}
/>
</div>
</div>
);
}
export function PortfolioGridCard({
portfolio,
actions,
folderName,
}: {
portfolio: PortfolioWire;
actions: CardActions;
folderName?: string | null;
}) {
return (
<article className="flex flex-col gap-2.5 rounded-xl border border-[#e2e7ee] bg-white p-4 transition-colors hover:border-[#c3ccd8]">
<div className="flex items-start justify-between gap-2">
<Link
href={`/portfolio/${portfolio.id}`}
className="text-[17px] font-semibold leading-snug text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
>
{portfolio.name}
</Link>
<StarButton portfolio={portfolio} actions={actions} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-gray-500">
<StatusPill status={portfolio.status} />
<span className="font-medium text-gray-900">
{portfolio.goal === "None" ? "No goal set" : portfolio.goal}
</span>
{portfolio.numberOfProperties != null && (
<span>
<span className="tabular-nums">
{portfolio.numberOfProperties.toLocaleString()}
</span>{" "}
properties
</span>
)}
</div>
<BudgetLine portfolio={portfolio} />
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
{folderName ? `${folderName} · ` : ""}
Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())}
</span>
<CardMenu portfolio={portfolio} actions={actions} openUpward />
</div>
</article>
);
}
export function PortfolioTable({
portfolios,
actions,
folderNameOf,
}: {
portfolios: PortfolioWire[];
actions: CardActions;
folderNameOf: (portfolioId: string) => string | null;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-[#e2e7ee] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e2e7ee] text-left text-[11px] font-semibold text-gray-500">
<th className="w-10 px-3 py-2" aria-label="Starred" />
<th className="px-3 py-2">Portfolio</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Goal</th>
<th className="px-3 py-2 text-right">Properties</th>
<th className="px-3 py-2">Budget</th>
<th className="px-3 py-2 text-right">Updated</th>
<th className="w-10 px-3 py-2" aria-label="Actions" />
</tr>
</thead>
<tbody>
{portfolios.map((portfolio) => (
<tr
key={portfolio.id}
className="border-b border-[#eef1f6] last:border-b-0 hover:bg-[#f8fafc]"
>
<td className="px-3 py-2">
<StarButton portfolio={portfolio} actions={actions} />
</td>
<td className="max-w-[280px] truncate px-3 py-2">
<Link
href={`/portfolio/${portfolio.id}`}
className="font-semibold text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
>
{portfolio.name}
</Link>
</td>
<td className="px-3 py-2">
<StatusPill status={portfolio.status} />
</td>
<td className="max-w-[180px] truncate px-3 py-2 text-gray-700">
{portfolio.goal === "None" ? "—" : portfolio.goal}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{portfolio.numberOfProperties?.toLocaleString() ?? "—"}
</td>
<td className="min-w-[170px] px-3 py-2">
<BudgetLine portfolio={portfolio} />
</td>
<td className="whitespace-nowrap px-3 py-2 text-right text-gray-500">
{folderNameOf(portfolio.id)
? `${folderNameOf(portfolio.id)} · `
: ""}
{formatUpdatedAgo(portfolio.updatedAt, new Date())}
</td>
<td className="px-3 py-2">
<CardMenu portfolio={portfolio} actions={actions} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,458 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LayoutGrid, List, Plus, Search } from "lucide-react";
import {
matchesSearch,
sortPortfolios,
statusDisplay,
upsertLocalConfig,
type HomeData,
type HomeSort,
type PortfolioWire,
} from "@/app/lib/portfolioHomeView";
import FolderRail, { type RailView } from "./FolderRail";
import {
PortfolioGridCard,
PortfolioTable,
type CardActions,
} from "./PortfolioCard";
import NewPortfolioModal from "./NewPortfolioModal";
const HOME_KEY = ["portfolio-home"];
async function fetchHome(): Promise<HomeData> {
const res = await fetch("/api/me/portfolio-home");
if (!res.ok) throw new Error("Failed to load portfolios");
return res.json();
}
async function send(url: string, method: string, body?: unknown) {
const res = await fetch(url, {
method,
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "Request failed");
}
}
// Shared optimistic-mutation plumbing: apply the local update immediately,
// roll back on error, reconcile with the server afterwards.
function useOptimisticHomeMutation<V>(
mutationFn: (vars: V) => Promise<void>,
localUpdate: (home: HomeData, vars: V) => HomeData,
announceError: (message: string) => void,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onMutate: async (vars: V) => {
await queryClient.cancelQueries(HOME_KEY);
const previous = queryClient.getQueryData<HomeData>(HOME_KEY);
if (previous) {
queryClient.setQueryData(HOME_KEY, localUpdate(previous, vars));
}
return { previous };
},
onError: (_err: unknown, _vars: V, context?: { previous?: HomeData }) => {
if (context?.previous) {
queryClient.setQueryData(HOME_KEY, context.previous);
}
announceError("Something went wrong — change undone");
},
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
});
}
export default function PortfolioHome() {
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome);
const [view, setView] = useState<RailView>("all");
const [display, setDisplay] = useState<"grid" | "list">("grid");
const [sort, setSort] = useState<HomeSort>("updated");
const [query, setQuery] = useState("");
const [announcement, setAnnouncement] = useState("");
const [newPortfolioOpen, setNewPortfolioOpen] = useState(false);
const configMutation = useOptimisticHomeMutation(
(vars: {
portfolioId: string;
change: { starred?: boolean; folderId?: string | null };
}) =>
send(`/api/me/portfolios/${vars.portfolioId}/config`, "PATCH", vars.change),
(home, vars) => ({
...home,
configs: upsertLocalConfig(home.configs, vars.portfolioId, {
...(vars.change.starred !== undefined && {
starredAt: vars.change.starred ? new Date().toISOString() : null,
}),
...(vars.change.folderId !== undefined && {
folderId: vars.change.folderId,
}),
}),
}),
setAnnouncement,
);
const reorderMutation = useOptimisticHomeMutation(
(orderedIds: string[]) =>
send("/api/me/folders/reorder", "PATCH", { orderedIds }),
(home, orderedIds) => ({
...home,
folders: home.folders.map((f) => ({
...f,
position: orderedIds.indexOf(f.id),
})),
}),
setAnnouncement,
);
const renameMutation = useOptimisticHomeMutation(
(vars: { folderId: string; name: string }) =>
send(`/api/me/folders/${vars.folderId}`, "PATCH", { name: vars.name }),
(home, vars) => ({
...home,
folders: home.folders.map((f) =>
f.id === vars.folderId ? { ...f, name: vars.name } : f,
),
}),
setAnnouncement,
);
const deleteMutation = useOptimisticHomeMutation(
(folderId: string) => send(`/api/me/folders/${folderId}`, "DELETE"),
(home, folderId) => ({
...home,
folders: home.folders.filter((f) => f.id !== folderId),
configs: home.configs.map((c) =>
c.folderId === folderId ? { ...c, folderId: null } : c,
),
}),
setAnnouncement,
);
const createMutation = useMutation({
mutationFn: (name: string) => send("/api/me/folders", "POST", { name }),
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
});
if (isLoading) {
return (
<div className="mx-auto flex max-w-[1320px] gap-7 px-6 py-8" aria-busy>
<div className="hidden w-[230px] shrink-0 animate-pulse rounded-xl bg-gray-200/60 lg:block lg:h-64" />
<div className="grid flex-1 grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="h-44 animate-pulse rounded-xl bg-gray-200/60" />
))}
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="mx-auto max-w-[1320px] px-6 py-16 text-center text-gray-600">
Couldnt load your portfolios.{" "}
<button
type="button"
onClick={() => refetch()}
className="font-semibold text-brandmidblue hover:underline"
>
Try again
</button>
</div>
);
}
// ---- derived render state (all plain derivation, no memoization needed
// at this scale) ----
const configOf = new Map(data.configs.map((c) => [c.portfolioId, c]));
const folderById = new Map(data.folders.map((f) => [f.id, f]));
const orderedFolders = [...data.folders].sort(
(a, b) => a.position - b.position,
);
const isStarred = (id: string) => configOf.get(id)?.starredAt != null;
const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null;
const folderNameOf = (id: string) => {
const folderId = folderIdOf(id);
return folderId ? (folderById.get(folderId)?.name ?? null) : null;
};
const needsAttention = (p: PortfolioWire) =>
statusDisplay(p.status).needsAttention;
const inView = (p: PortfolioWire) =>
view === "all"
? true
: view === "starred"
? isStarred(p.id)
: folderIdOf(p.id) === view.folderId;
const scope = data.portfolios.filter(inView);
const filtered = scope.filter((p) =>
matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query),
);
let rows = sortPortfolios(filtered, sort);
if (view === "starred") {
rows = [...rows].sort(
(a, b) =>
new Date(configOf.get(b.id)!.starredAt!).getTime() -
new Date(configOf.get(a.id)!.starredAt!).getTime(),
);
} else {
rows = [...rows].sort(
(a, b) => Number(isStarred(b.id)) - Number(isStarred(a.id)),
);
}
const viewTitle =
view === "all"
? "All portfolios"
: view === "starred"
? "Starred"
: (folderById.get(view.folderId)?.name ?? "Folder");
const attentionCount = rows.filter(needsAttention).length;
const propertiesSum = rows.reduce(
(sum, p) => sum + (p.numberOfProperties ?? 0),
0,
);
const cardActions: CardActions = {
isStarred,
folderIdOf,
folders: orderedFolders,
onToggleStar: (p) => {
const starred = !isStarred(p.id);
configMutation.mutate({ portfolioId: p.id, change: { starred } });
setAnnouncement(`${starred ? "Starred" : "Unstarred"} ${p.name}`);
},
onMoveToFolder: (p, folderId) => {
configMutation.mutate({ portfolioId: p.id, change: { folderId } });
setAnnouncement(
folderId
? `Moved ${p.name} to ${folderById.get(folderId)?.name}`
: `Removed ${p.name} from its folder`,
);
},
};
return (
<div className="mx-auto max-w-[1320px] px-6 py-7">
<div className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
Portfolios
</h1>
<div className="ml-auto flex flex-wrap items-center gap-2.5">
<label className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
aria-hidden
/>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search portfolios"
aria-label="Search portfolios by name, status, goal or folder"
className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm"
/>
</label>
<select
value={sort}
onChange={(e) => setSort(e.target.value as HomeSort)}
aria-label="Sort portfolios"
className="rounded-lg border border-[#c3ccd8] bg-white px-2.5 py-2 text-sm"
>
<option value="updated">Recently updated</option>
<option value="attention">Needs attention first</option>
<option value="name">Name AZ</option>
<option value="props">Most properties</option>
</select>
<div
role="group"
aria-label="View"
className="flex overflow-hidden rounded-lg border border-[#c3ccd8] bg-white"
>
{(
[
["grid", LayoutGrid, "Grid view"],
["list", List, "List view"],
] as const
).map(([value, Icon, label]) => (
<button
key={value}
type="button"
aria-pressed={display === value}
aria-label={label}
title={label}
onClick={() => setDisplay(value)}
className={`grid place-items-center px-2.5 py-2 ${
display === value
? "bg-brandblue text-white"
: "text-gray-500 hover:text-gray-900"
}`}
>
<Icon size={15} aria-hidden />
</button>
))}
</div>
<button
type="button"
onClick={() => setNewPortfolioOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-brandblue px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#232866]"
>
<Plus size={15} aria-hidden /> New portfolio
</button>
</div>
</div>
<div className="flex flex-col gap-4 lg:flex-row lg:gap-7">
<FolderRail
folders={orderedFolders}
view={view}
totalCount={data.portfolios.length}
starredCount={data.portfolios.filter((p) => isStarred(p.id)).length}
attentionTotal={data.portfolios.filter(needsAttention).length}
countOf={(folderId) =>
data.portfolios.filter((p) => folderIdOf(p.id) === folderId).length
}
attentionOf={(folderId) =>
data.portfolios.filter(
(p) => folderIdOf(p.id) === folderId && needsAttention(p),
).length
}
onSelect={setView}
onReorder={(orderedIds) => {
reorderMutation.mutate(orderedIds);
setAnnouncement("Folder order saved");
}}
onCreate={(name) => createMutation.mutate(name)}
onRename={(folderId, name) =>
renameMutation.mutate({ folderId, name })
}
onDelete={(folderId) => {
deleteMutation.mutate(folderId);
setAnnouncement("Folder removed — its portfolios are unfiled");
}}
/>
<main className="min-w-0 flex-1">
<div className="mb-4">
<h2 className="text-[17px] font-semibold tracking-tight text-gray-900">
{viewTitle}
</h2>
<div className="text-[13px] text-gray-500">
{query.trim() ? (
<>
Showing <span className="tabular-nums">{rows.length}</span> of{" "}
<span className="tabular-nums">{scope.length}</span> for
{query.trim()}
</>
) : (
<>
<span className="tabular-nums">{rows.length}</span> portfolios
{propertiesSum > 0 && (
<>
{" "}
· <span className="tabular-nums">
{propertiesSum.toLocaleString()}
</span>{" "}
properties
</>
)}
{attentionCount > 0 && (
<>
{" "}
·{" "}
<b className="font-semibold text-red-700">
{attentionCount} need attention
</b>
</>
)}
</>
)}
</div>
</div>
{rows.length === 0 ? (
query.trim() ? (
<div className="py-12 text-center text-sm text-gray-500">
No portfolios in {viewTitle} match
<b className="text-gray-900">{query.trim()}</b>.{" "}
<button
type="button"
onClick={() => setQuery("")}
className="font-semibold text-brandmidblue hover:underline"
>
Clear search
</button>
</div>
) : (
<div className="rounded-xl border border-dashed border-[#c3ccd8] px-5 py-6 text-sm text-gray-500">
{view === "starred" ? (
<>
<b className="font-semibold text-gray-900">
Nothing starred yet.
</b>{" "}
Star a portfolio to keep your day-to-day work one click
away.
</>
) : view === "all" ? (
<>
<b className="font-semibold text-gray-900">
No portfolios yet.
</b>{" "}
Create your first portfolio to get started.
</>
) : (
<>
<b className="font-semibold text-gray-900">
This folder is empty.
</b>{" "}
Move portfolios here with the menu on any card.
</>
)}
</div>
)
) : display === "grid" ? (
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((portfolio) => (
<PortfolioGridCard
key={portfolio.id}
portfolio={portfolio}
actions={cardActions}
folderName={
view === "all" || view === "starred"
? folderNameOf(portfolio.id)
: null
}
/>
))}
</div>
) : (
<PortfolioTable
portfolios={rows}
actions={cardActions}
folderNameOf={
view === "all" || view === "starred" ? folderNameOf : () => null
}
/>
)}
</main>
</div>
<div role="status" aria-live="polite" className="sr-only">
{announcement}
</div>
<NewPortfolioModal
isOpen={newPortfolioOpen}
setIsOpen={setNewPortfolioOpen}
/>
</div>
);
}

View file

@ -1,30 +1,15 @@
import CardTiles from "../components/home/CardTiles";
import { getPortfolios } from "./utils";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import PortfolioHome from "../components/home/PortfolioHome";
const Home = async () => {
const user = await getServerSession(AuthOptions);
if (!user?.user) {
console.error("User not found");
redirect("/");
}
const portfolios = await getPortfolios(user.user.dbId);
return (
<>
<div className="flex justify-center">
<h1 className="text-3xl font-bold mt-3 mb-5 text-gray-700">
{" "}
Your Portfolios{" "}
</h1>
</div>
<div className="px-5">
<CardTiles Portfolios={portfolios} />
</div>
</>
);
return <PortfolioHome />;
};
export default Home;

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { PortfolioStatus } from "@/app/db/schema/portfolio";
import {
formatMoney,
formatUpdatedAgo,
matchesSearch,
sortPortfolios,
statusDisplay,
upsertLocalConfig,
} from "./portfolioHomeView";
describe("statusDisplay", () => {
it("maps lifecycle enum values to clean pill labels", () => {
expect(statusDisplay("scoping")).toEqual({
label: "Scoping",
tone: "early",
needsAttention: false,
});
expect(statusDisplay("completion; status: at risk")).toEqual({
label: "At risk",
tone: "atrisk",
needsAttention: true,
});
expect(statusDisplay("needs review")).toEqual({
label: "Needs review",
tone: "review",
needsAttention: true,
});
});
it("covers every status the schema allows", () => {
for (const status of PortfolioStatus) {
const display = statusDisplay(status);
expect(display.label, status).toBeTruthy();
expect(display.label).not.toContain(";");
}
});
});
describe("formatMoney", () => {
it("renders millions with one decimal and thousands as whole k", () => {
expect(formatMoney(6500000)).toBe("£6.5M");
expect(formatMoney(4000000)).toBe("£4M");
expect(formatMoney(745000)).toBe("£745k");
expect(formatMoney(0)).toBe("£0");
});
});
describe("matchesSearch", () => {
const lambeth = {
name: "Lambeth Regeneration",
status: "completion; status: at risk",
goal: "Increasing EPC",
folderName: "SHDF Wave 3",
};
it("matches on name, display status, goal, and folder name, case-insensitively", () => {
expect(matchesSearch(lambeth, "lambeth")).toBe(true);
expect(matchesSearch(lambeth, "at risk")).toBe(true);
expect(matchesSearch(lambeth, "epc")).toBe(true);
expect(matchesSearch(lambeth, "shdf")).toBe(true);
expect(matchesSearch(lambeth, "peabody")).toBe(false);
});
it("matches everything on an empty query and unfoldered portfolios don't match folder terms", () => {
expect(matchesSearch(lambeth, " ")).toBe(true);
expect(
matchesSearch({ ...lambeth, folderName: null }, "shdf"),
).toBe(false);
});
});
describe("sortPortfolios", () => {
const rows = [
{ name: "B", status: "scoping", numberOfProperties: 10, updatedAt: new Date("2026-07-01") },
{ name: "A", status: "completion; status: delayed", numberOfProperties: 30, updatedAt: new Date("2026-07-06") },
{ name: "C", status: "completion; status: at risk", numberOfProperties: 20, updatedAt: new Date("2026-07-03") },
];
const names = (sorted: typeof rows) => sorted.map((r) => r.name);
it("orders by the chosen key without mutating the input", () => {
expect(names(sortPortfolios(rows, "updated"))).toEqual(["A", "C", "B"]);
expect(names(sortPortfolios(rows, "attention"))).toEqual(["C", "A", "B"]);
expect(names(sortPortfolios(rows, "name"))).toEqual(["A", "B", "C"]);
expect(names(sortPortfolios(rows, "props"))).toEqual(["A", "C", "B"]);
expect(names(rows)).toEqual(["B", "A", "C"]);
});
});
describe("upsertLocalConfig", () => {
it("creates a config on first star, like the lazy DB upsert", () => {
const next = upsertLocalConfig([], "7", { starredAt: "2026-07-07T12:00:00Z" });
expect(next).toEqual([
{ portfolioId: "7", folderId: null, starredAt: "2026-07-07T12:00:00Z" },
]);
});
it("changing one field preserves the other", () => {
const configs = [
{ portfolioId: "7", folderId: "10", starredAt: "2026-07-01T00:00:00Z" },
];
expect(upsertLocalConfig(configs, "7", { starredAt: null })).toEqual([
{ portfolioId: "7", folderId: "10", starredAt: null },
]);
expect(upsertLocalConfig(configs, "7", { folderId: null })).toEqual([
{ portfolioId: "7", folderId: null, starredAt: "2026-07-01T00:00:00Z" },
]);
});
});
describe("formatUpdatedAgo", () => {
const now = new Date("2026-07-07T12:00:00Z");
it("reads naturally at every distance", () => {
expect(formatUpdatedAgo("2026-07-07T09:00:00Z", now)).toBe("today");
expect(formatUpdatedAgo("2026-07-06T09:00:00Z", now)).toBe("yesterday");
expect(formatUpdatedAgo("2026-07-02T12:00:00Z", now)).toBe("5 days ago");
expect(formatUpdatedAgo("2026-05-01T00:00:00Z", now)).toBe("2 months ago");
});
});

View file

@ -0,0 +1,191 @@
// View-model logic for the portfolio home page: pure functions the client
// components derive their render state from. Kept free of React and drizzle
// so it tests in node and survives refactors on either side.
export type StatusTone =
| "early"
| "tender"
| "underway"
| "ontrack"
| "delayed"
| "atrisk"
| "done"
| "review";
export type StatusDisplay = {
label: string;
tone: StatusTone;
needsAttention: boolean;
};
// The lifecycle enum's raw values ("completion; status: at risk") are storage
// strings, not UI copy; this is the single place they become pill text.
const STATUS_DISPLAY: Record<string, StatusDisplay> = {
scoping: { label: "Scoping", tone: "early", needsAttention: false },
survey: { label: "Survey", tone: "early", needsAttention: false },
assessment: { label: "Assessment", tone: "early", needsAttention: false },
tendering: { label: "Tendering", tone: "tender", needsAttention: false },
"project underway": {
label: "Underway",
tone: "underway",
needsAttention: false,
},
"completion; status: on track": {
label: "On track",
tone: "ontrack",
needsAttention: false,
},
"completion; status: delayed": {
label: "Delayed",
tone: "delayed",
needsAttention: true,
},
"completion; status: at risk": {
label: "At risk",
tone: "atrisk",
needsAttention: true,
},
"completion; status: completed": {
label: "Completed",
tone: "done",
needsAttention: false,
},
"needs review": { label: "Needs review", tone: "review", needsAttention: true },
};
const UNKNOWN_STATUS: StatusDisplay = {
label: "Unknown",
tone: "early",
needsAttention: false,
};
export function statusDisplay(status: string): StatusDisplay {
return STATUS_DISPLAY[status] ?? UNKNOWN_STATUS;
}
export type HomeSort = "updated" | "attention" | "name" | "props";
// Wire-format rows (ids and dates as strings — see the portfolio-home
// route's bigint serialisation).
export type LocalConfig = {
portfolioId: string;
folderId: string | null;
starredAt: string | null;
};
export type PortfolioWire = {
id: string;
name: string;
status: string;
goal: string;
budget: number | null;
cost: number | null;
numberOfProperties: number | null;
updatedAt: string;
};
export type FolderWire = { id: string; name: string; position: number };
export type HomeData = {
portfolios: PortfolioWire[];
configs: LocalConfig[];
folders: FolderWire[];
};
// Client-side mirror of the lazy DB upsert, used for optimistic cache
// updates: no row yet means "all defaults", and a change touches only the
// fields it names.
export function upsertLocalConfig(
configs: LocalConfig[],
portfolioId: string,
change: Partial<Pick<LocalConfig, "folderId" | "starredAt">>,
): LocalConfig[] {
const existing = configs.find((c) => c.portfolioId === portfolioId);
if (!existing) {
return [
...configs,
{ portfolioId, folderId: null, starredAt: null, ...change },
];
}
return configs.map((c) =>
c.portfolioId === portfolioId ? { ...c, ...change } : c,
);
}
// Severity for "needs attention first": worst state leads, healthy and
// finished work trails.
const ATTENTION_RANK: Record<StatusTone, number> = {
atrisk: 0,
delayed: 1,
review: 2,
underway: 3,
tender: 4,
early: 5,
ontrack: 6,
done: 7,
};
export function sortPortfolios<
P extends {
name: string;
status: string;
numberOfProperties: number | null;
updatedAt: Date | string;
},
>(portfolios: P[], sort: HomeSort): P[] {
const by: Record<HomeSort, (a: P, b: P) => number> = {
updated: (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
attention: (a, b) =>
ATTENTION_RANK[statusDisplay(a.status).tone] -
ATTENTION_RANK[statusDisplay(b.status).tone],
name: (a, b) => a.name.localeCompare(b.name),
props: (a, b) => (b.numberOfProperties ?? 0) - (a.numberOfProperties ?? 0),
};
return [...portfolios].sort(by[sort]);
}
// Users search in display language ("at risk"), never in enum storage
// strings, so matching goes through statusDisplay.
export function matchesSearch(
portfolio: {
name: string;
status: string;
goal: string;
folderName: string | null;
},
query: string,
): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
portfolio.name,
statusDisplay(portfolio.status).label,
portfolio.goal,
portfolio.folderName ?? "",
]
.join(" ")
.toLowerCase()
.includes(q);
}
export function formatUpdatedAgo(updatedAt: string, now: Date): string {
const days = Math.floor(
(now.getTime() - new Date(updatedAt).getTime()) / 86_400_000,
);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 28) return `${days} days ago`;
const months = Math.round(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
export function formatMoney(amount: number): string {
if (amount >= 1_000_000) {
return "£" + (amount / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (amount >= 1_000) {
return "£" + Math.round(amount / 1_000) + "k";
}
return "£" + Math.round(amount);
}

View file

@ -60,33 +60,34 @@ export function planFolderDeletion({
};
}
type ConfigRow = {
portfolioId: bigint;
folderId: bigint | null;
starredAt: Date | null;
type ConfigRow<Id> = {
portfolioId: Id;
folderId: Id | null;
starredAt: Date | string | null;
};
type FolderRow = { id: bigint; name: string; position: number };
type FolderRow<Id> = { id: Id; name: string; position: number };
// Assembles the home page's render model from the three per-user reads.
// Generic over the portfolio payload so it doesn't care which columns the
// page selects.
export function groupPortfoliosForHome<P extends { id: bigint }>({
// Generic over the portfolio payload and the id type: the server groups with
// bigints; the client re-groups optimistically with wire-format string ids.
export function groupPortfoliosForHome<Id, P extends { id: Id }>({
portfolios,
configs,
folders,
}: {
portfolios: P[];
configs: ConfigRow[];
folders: FolderRow[];
configs: Array<ConfigRow<Id>>;
folders: Array<FolderRow<Id>>;
}): {
folders: Array<{ id: bigint; name: string; portfolios: P[] }>;
folders: Array<{ id: Id; name: string; portfolios: P[] }>;
unfiled: P[];
starred: P[];
} {
const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c]));
const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null;
const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null;
const starredTime = (p: P) => new Date(starredAt(p)!).getTime();
// Starred float above unstarred; original relative order is otherwise kept.
const floatStarred = (group: P[]) =>
@ -104,6 +105,6 @@ export function groupPortfoliosForHome<P extends { id: bigint }>({
unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)),
starred: portfolios
.filter((p) => starredAt(p) !== null)
.sort((a, b) => starredAt(b)!.getTime() - starredAt(a)!.getTime()),
.sort((a, b) => starredTime(b) - starredTime(a)),
};
}