diff --git a/src/app/api/me/portfolio-home/route.ts b/src/app/api/me/portfolio-home/route.ts index 4fb62a7c..3f4095ef 100644 --- a/src/app/api/me/portfolio-home/route.ts +++ b/src/app/api/me/portfolio-home/route.ts @@ -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( diff --git a/src/app/components/home/AddNewCard.tsx b/src/app/components/home/AddNewCard.tsx deleted file mode 100644 index 6eb76ba3..00000000 --- a/src/app/components/home/AddNewCard.tsx +++ /dev/null @@ -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 ( - -
-
-
-
-
- - -
-
-
-
-

{`${title}`}

-
-
-
-
- ); -}; - -export default AddNewCard; diff --git a/src/app/components/home/Card.tsx b/src/app/components/home/Card.tsx deleted file mode 100644 index be2c72c5..00000000 --- a/src/app/components/home/Card.tsx +++ /dev/null @@ -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 ( -
-
-
-
{budgetFormatted}
-
- -
-
-
-

{`${title}`}

-
-
-
- -
-
-
-
- ); -}; - -export default Card; diff --git a/src/app/components/home/CardTiles.tsx b/src/app/components/home/CardTiles.tsx deleted file mode 100644 index 467045e9..00000000 --- a/src/app/components/home/CardTiles.tsx +++ /dev/null @@ -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; - -export default function CardTiles({ - Portfolios, -}: { - Portfolios: PortfoliosType; -}) { - return ( -
-
- - {Portfolios.map((portfolio, index) => { - const image_idx = index % 5; - return ( - - ); - })} -
-
- ); -} diff --git a/src/app/components/home/FolderRail.tsx b/src/app/components/home/FolderRail.tsx new file mode 100644 index 00000000..e215e846 --- /dev/null +++ b/src/app/components/home/FolderRail.tsx @@ -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 ( + + ⚠ {count} + + ); +} + +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(null); + const [dropIndex, setDropIndex] = useState(null); + const [creating, setCreating] = useState(false); + const [editingId, setEditingId] = useState(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, + ) => ( +
{ + e.preventDefault(); + const name = new FormData(e.currentTarget).get("name"); + if (typeof name === "string" && name.trim()) onSubmit(name.trim()); + onDone(); + }} + > + e.key === "Escape" && onDone()} + className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none" + /> +
+ ); + + return ( + + ); +} diff --git a/src/app/components/home/PortfolioCard.tsx b/src/app/components/home/PortfolioCard.tsx new file mode 100644 index 00000000..35df8e81 --- /dev/null +++ b/src/app/components/home/PortfolioCard.tsx @@ -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 = { + 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 ( + + + {display.label} + + ); +} + +function StarButton({ + portfolio, + actions, +}: { + portfolio: PortfolioWire; + actions: CardActions; +}) { + const starred = actions.isStarred(portfolio.id); + return ( + + ); +} + +function CardMenu({ + portfolio, + actions, + openUpward, +}: { + portfolio: PortfolioWire; + actions: CardActions; + openUpward?: boolean; +}) { + const currentFolderId = actions.folderIdOf(portfolio.id); + return ( +
+ + + +
+
+ Move to folder +
+ {actions.folders.map((folder) => ( + + ))} + +
+
+ ); +} + +function BudgetLine({ portfolio }: { portfolio: PortfolioWire }) { + const { budget, cost } = portfolio; + if (!budget) { + return
No budget set
; + } + const pct = cost != null ? Math.round((100 * cost) / budget) : null; + const over = cost != null && cost > budget; + return ( +
+
+ {cost != null ? ( + <> + + + {formatMoney(cost)} + {" "} + of {formatMoney(budget)}{" "} + budget + + + {pct}%{over ? " over" : ""} + + + ) : ( + <> + + Budget{" "} + + {formatMoney(budget)} + + + no spend yet + + )} +
+
+
+
+
+ ); +} + +export function PortfolioGridCard({ + portfolio, + actions, + folderName, +}: { + portfolio: PortfolioWire; + actions: CardActions; + folderName?: string | null; +}) { + return ( +
+
+ + {portfolio.name} + + +
+
+ + + {portfolio.goal === "None" ? "No goal set" : portfolio.goal} + + {portfolio.numberOfProperties != null && ( + + + {portfolio.numberOfProperties.toLocaleString()} + {" "} + properties + + )} +
+ +
+ + {folderName ? `${folderName} · ` : ""} + Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())} + + +
+
+ ); +} + +export function PortfolioTable({ + portfolios, + actions, + folderNameOf, +}: { + portfolios: PortfolioWire[]; + actions: CardActions; + folderNameOf: (portfolioId: string) => string | null; +}) { + return ( +
+ + + + + + + + + + + + + {portfolios.map((portfolio) => ( + + + + + + + + + + + ))} + +
+ PortfolioStatusGoalPropertiesBudgetUpdated +
+ + + + {portfolio.name} + + + + + {portfolio.goal === "None" ? "—" : portfolio.goal} + + {portfolio.numberOfProperties?.toLocaleString() ?? "—"} + + + + {folderNameOf(portfolio.id) + ? `${folderNameOf(portfolio.id)} · ` + : ""} + {formatUpdatedAgo(portfolio.updatedAt, new Date())} + + +
+
+ ); +} diff --git a/src/app/components/home/PortfolioHome.tsx b/src/app/components/home/PortfolioHome.tsx new file mode 100644 index 00000000..14758f1a --- /dev/null +++ b/src/app/components/home/PortfolioHome.tsx @@ -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 { + 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( + mutationFn: (vars: V) => Promise, + 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(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("all"); + const [display, setDisplay] = useState<"grid" | "list">("grid"); + const [sort, setSort] = useState("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 ( +
+
+
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+
+ ); + } + if (isError || !data) { + return ( +
+ Couldn’t load your portfolios.{" "} + +
+ ); + } + + // ---- 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 ( +
+
+

+ Portfolios +

+
+ + +
+ {( + [ + ["grid", LayoutGrid, "Grid view"], + ["list", List, "List view"], + ] as const + ).map(([value, Icon, label]) => ( + + ))} +
+ +
+
+ +
+ 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"); + }} + /> + +
+
+

+ {viewTitle} +

+
+ {query.trim() ? ( + <> + Showing {rows.length} of{" "} + {scope.length} for “ + {query.trim()}” + + ) : ( + <> + {rows.length} portfolios + {propertiesSum > 0 && ( + <> + {" "} + · + {propertiesSum.toLocaleString()} + {" "} + properties + + )} + {attentionCount > 0 && ( + <> + {" "} + ·{" "} + + {attentionCount} need attention + + + )} + + )} +
+
+ + {rows.length === 0 ? ( + query.trim() ? ( +
+ 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

({ 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)), }; }