+ No portfolios in {viewTitle} match “
+ {query.trim()}”.{" "}
+
+
+ ) : (
+
+ {view === "starred" ? (
+ <>
+
+ Nothing starred yet.
+ {" "}
+ Star a portfolio to keep your day-to-day work one click
+ away.
+ >
+ ) : view === "all" ? (
+ <>
+
+ No portfolios yet.
+ {" "}
+ Create your first portfolio to get started.
+ >
+ ) : (
+ <>
+
+ This folder is empty.
+ {" "}
+ Move portfolios here with the ⋯ menu on any card.
+ >
+ )}
+
+ )
+ ) : display === "grid" ? (
+
+ {rows.map((portfolio) => (
+
+ ))}
+
+ ) : (
+ null
+ }
+ />
+ )}
+
+
+
+
+ {announcement}
+
+
+
+ );
+}
diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx
index f82a9829..85ce4c92 100644
--- a/src/app/home/page.tsx
+++ b/src/app/home/page.tsx
@@ -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 (
- <>
-
-
- {" "}
- Your Portfolios{" "}
-
-
-
-
-
- >
- );
+ return ;
};
export default Home;
diff --git a/src/app/lib/portfolioHomeView.test.ts b/src/app/lib/portfolioHomeView.test.ts
new file mode 100644
index 00000000..2a4801ed
--- /dev/null
+++ b/src/app/lib/portfolioHomeView.test.ts
@@ -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");
+ });
+});
diff --git a/src/app/lib/portfolioHomeView.ts b/src/app/lib/portfolioHomeView.ts
new file mode 100644
index 00000000..512ca9e0
--- /dev/null
+++ b/src/app/lib/portfolioHomeView.ts
@@ -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 = {
+ 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>,
+): 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 = {
+ 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 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);
+}
diff --git a/src/app/lib/portfolioUserConfig.ts b/src/app/lib/portfolioUserConfig.ts
index 37203e64..74194da8 100644
--- a/src/app/lib/portfolioUserConfig.ts
+++ b/src/app/lib/portfolioUserConfig.ts
@@ -60,33 +60,34 @@ export function planFolderDeletion({
};
}
-type ConfigRow = {
- portfolioId: bigint;
- folderId: bigint | null;
- starredAt: Date | null;
+type ConfigRow = {
+ portfolioId: Id;
+ folderId: Id | null;
+ starredAt: Date | string | null;
};
-type FolderRow = { id: bigint; name: string; position: number };
+type FolderRow = { 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
({
+// 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({
portfolios,
configs,
folders,
}: {
portfolios: P[];
- configs: ConfigRow[];
- folders: FolderRow[];
+ configs: Array>;
+ folders: Array>;
}): {
- 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