diff --git a/CHANGELOG.md b/CHANGELOG.md index b563f7a..4eb7398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Version format: `` covers the web app + dashboard. `` covers the Ladde --- +## app 0.5.32 / api 1.5.1 (2026-08-11) + +**Dashboard score history: list / grid view toggle (#471).** The list's thumbnail was too small to recognize a screen at a glance. A toggle in the top-right of the Score history header now switches between the existing **list** and a **grid** of cards with a larger thumbnail (a new 640px `md` thumbnail variant, resized on read and cached) plus name, score, tags, and time. The choice persists in localStorage; reveal-on-scroll windowing and delete work in both views. + +--- + ## app 0.5.31 / api 1.5.1 (2026-08-11) **Rename a scored screen (#469).** Scores get an auto-generated screen name that is sometimes wrong. A pencil icon next to the name on the score detail page now opens an inline editor (Enter saves, Esc cancels). Owner-only; preserves the surface suffix so the Web/Figma/Skill tag stays correct. Persists via the owner-scoped `PATCH /api/dashboard/scores/[id]/tags` route, extended to accept `screenName`. diff --git a/package.json b/package.json index 515004c..a5d8112 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "runladder", - "version": "0.5.31", + "version": "0.5.32", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/dashboard/scores/[id]/thumbnail/route.ts b/src/app/api/dashboard/scores/[id]/thumbnail/route.ts index 9ba41f3..5e136d8 100644 --- a/src/app/api/dashboard/scores/[id]/thumbnail/route.ts +++ b/src/app/api/dashboard/scores/[id]/thumbnail/route.ts @@ -54,13 +54,16 @@ export async function GET( // the dashboard list doesn't download full-size images. Detail views omit // `size` and keep the full-resolution blob. const size = new URL(req.url).searchParams.get("size"); - if (size === "sm") { + // sm = list rows (~48px), md = grid cards (#471). Both resize on read and + // cache per variant; detail views omit `size` and keep the full blob. + const box = size === "sm" ? 96 : size === "md" ? 640 : null; + if (box) { try { const input = Buffer.from(await new Response(result.stream).arrayBuffer()); const small = await sharp(input) .rotate() - .resize({ width: 96, height: 96, fit: "inside", withoutEnlargement: true }) - .jpeg({ quality: 72, mozjpeg: true }) + .resize({ width: box, height: box, fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: size === "sm" ? 72 : 80, mozjpeg: true }) .toBuffer(); return new NextResponse(new Uint8Array(small), { headers: { "Content-Type": "image/jpeg", "Cache-Control": cache }, diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c96fc99..b509b77 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -552,6 +552,25 @@ export default function DashboardPage() { 30, ); + // Score-history view: list (default) or grid of cards (#471). Remembered + // across visits so the choice sticks. + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + useEffect(() => { + const v = + typeof window !== "undefined" + ? window.localStorage.getItem("ladder:scoreView") + : null; + if (v === "grid" || v === "list") setViewMode(v); + }, []); + function chooseView(v: "list" | "grid") { + setViewMode(v); + try { + window.localStorage.setItem("ladder:scoreView", v); + } catch { + // localStorage unavailable — the choice just won't persist + } + } + if (!isLoaded) return null; if (!isSignedIn) return ; @@ -677,12 +696,126 @@ export default function DashboardPage() { ) : ( <> -
+
Score history - - {scores.length} score{scores.length !== 1 ? "s" : ""} - +
+ + {scores.length} score{scores.length !== 1 ? "s" : ""} + +
+ + +
+
+ {viewMode === "grid" ? ( +
+ {scores.slice(0, visibleCount).map((entry) => ( +
+ +
+ {entry.thumbnail ? ( + + ) : ( +
+ No preview +
+ )} +
+
+ +
+ + {entry.score.toFixed(1)} + + + + {entry.label} + + · + {timeAgo(entry.timestamp)} + + {typeof entry.uplift === "number" && ( + + + + )} +
+
+ + +
+ ))} +
+ ) : (
{scores.slice(0, visibleCount).map((entry) => (
))}
+ )} {/* Reveal the next page as this scrolls into view (#448). */} {hasMore && (
diff --git a/src/content/hq/features.mdx b/src/content/hq/features.mdx index 6a4e9f8..e115723 100644 --- a/src/content/hq/features.mdx +++ b/src/content/hq/features.mdx @@ -2,7 +2,7 @@ title: Feature Inventory updatedAt: 2026-08-11 updatedBy: Sara -lastPr: 470 +lastPr: 472 --- ## How to read this page @@ -32,7 +32,7 @@ When you ship a feature, add the row here in the same PR. Template: | Private / Internal scoring (paid feature; toggle defaults on for paid, free locked to public, off-warning, tier-aware "Private"/"Internal" label) | Pro / Team / Pulse (free = public only) | Live (v0.5.7, [#290](https://github.com/drawbackwards/runladder/issues/290) / [#301](https://github.com/drawbackwards/runladder/issues/301)) | `src/app/score/page.tsx`, `src/lib/score-scope.ts`, `src/app/api/score/route.ts`, `src/app/api/score/stream/route.ts` | | Personal dashboard (`/dashboard`) | All signed-in | Live | `src/app/dashboard/` | | Personal analytics (#467, Phase A): the dashboard has **Overview / Analytics** tabs; the Analytics tab reads score history client-side into charts (score-over-time with a 0-5 axis, rung profile, top recurring finding categories, portfolio distribution, plus a stat row) and filters them by **Industry** and **Tags** (the #429 tagging payoff), each a single-select dropdown. No new endpoints. Team-wide analytics, auto-suggested industry, and bulk tagging are later #467 phases. | Pro+ / Team | Live (#467 Phase A) | `src/components/dashboard/DashboardAnalytics.tsx`, `src/app/dashboard/page.tsx` | -| Dashboard score list windows the render (~30 rows, reveals more on scroll via IntersectionObserver) and list-row thumbnails load a small ~96px variant instead of the full 1400px image (#448, follow-up to #442). The full array stays client-side for the activity heatmap; server-side pagination is deferred. | All signed-in | Live (#448) | `src/app/dashboard/page.tsx`, `src/hooks/use-infinite-window.ts`, `src/app/api/dashboard/scores/[id]/thumbnail/route.ts` (`?size=sm`), `src/lib/scores.ts` (`scoreThumbnailUrl`) | +| Dashboard score list windows the render (~30 rows, reveals more on scroll via IntersectionObserver) and list-row thumbnails load a small ~96px variant instead of the full 1400px image (#448, follow-up to #442). The full array stays client-side for the activity heatmap; server-side pagination is deferred. **List / grid toggle (#471):** a header control switches between the list and a grid of cards with a larger 640px `md` thumbnail variant (resized on read + cached); the choice persists in localStorage. | All signed-in | Live (#448, #471) | `src/app/dashboard/page.tsx`, `src/hooks/use-infinite-window.ts`, `src/app/api/dashboard/scores/[id]/thumbnail/route.ts` (`?size=sm\|md`), `src/lib/scores.ts` (`scoreThumbnailUrl`) | | Design rhythm + activity heatmap on personal dashboard (paid only) | Pro / Team / Pulse | Live (#289) | `src/app/dashboard/page.tsx` (gated on `paid`) | | Designer detail page: per-score surface tag (Figma/Claude/Web/Skill/Pulse) | Team Lead | Live (#299) | `src/app/dashboard/team/members/[userId]/page.tsx`, `src/lib/surface.ts` | | Team Lead opens a member's score detail from the designer page | Team Lead | Live (#300) | `src/app/dashboard/team/members/[userId]/page.tsx`, `src/app/dashboard/scores/[id]/page.tsx`, `src/app/api/dashboard/scores/[id]/route.ts` |