Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Version format: `<app>` covers the web app + dashboard. `<api>` 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`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "runladder",
"version": "0.5.31",
"version": "0.5.32",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
9 changes: 6 additions & 3 deletions src/app/api/dashboard/scores/[id]/thumbnail/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
142 changes: 138 additions & 4 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RedirectToSignIn />;

Expand Down Expand Up @@ -677,12 +696,126 @@ export default function DashboardPage() {
) : (
<>
<ScoreCTACard />
<div className="mt-6 mb-3 flex items-center justify-between">
<div className="mt-6 mb-3 flex items-center justify-between gap-3">
<SectionLabel>Score history</SectionLabel>
<span className="text-[10px] text-muted">
{scores.length} score{scores.length !== 1 ? "s" : ""}
</span>
<div className="flex items-center gap-3">
<span className="text-[10px] text-muted">
{scores.length} score{scores.length !== 1 ? "s" : ""}
</span>
<div className="flex items-center border border-[#2a2a2a]">
<button
type="button"
onClick={() => chooseView("list")}
aria-label="List view"
aria-pressed={viewMode === "list"}
title="List view"
className={`p-1.5 transition-colors ${
viewMode === "list"
? "bg-[#2a2a2a] text-foreground"
: "text-muted hover:text-foreground"
}`}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
onClick={() => chooseView("grid")}
aria-label="Grid view"
aria-pressed={viewMode === "grid"}
title="Grid view"
className={`p-1.5 transition-colors ${
viewMode === "grid"
? "bg-[#2a2a2a] text-foreground"
: "text-muted hover:text-foreground"
}`}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="5" height="5" />
<rect x="9" y="2" width="5" height="5" />
<rect x="2" y="9" width="5" height="5" />
<rect x="9" y="9" width="5" height="5" />
</svg>
</button>
</div>
</div>
</div>
{viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{scores.slice(0, visibleCount).map((entry) => (
<div
key={entry.id}
className="border border-[#2a2a2a] bg-[#1a1a1a] hover:border-[#3a3a3a] transition-colors group relative"
>
<Link
href={`/dashboard/scores/${entry.id}`}
className="block"
>
<div className="aspect-[4/3] border-b border-[#2a2a2a] bg-[#111] overflow-hidden">
{entry.thumbnail ? (
<img
src={`/api/dashboard/scores/${entry.id}/thumbnail?size=md`}
alt=""
loading="lazy"
className="w-full h-full object-cover object-top"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[#333] text-xs">
No preview
</div>
)}
</div>
<div className="p-3">
<ScoreRowTitle
label={entry.screenName || entry.source}
isPublic={entry.isPublic}
isTeam={isTeamScope(tier)}
/>
<div className="flex items-center gap-2 mt-1.5">
<span
className="text-lg font-bold tabular-nums"
style={{ color: getScoreColor(entry.score) }}
>
{entry.score.toFixed(1)}
</span>
<span className="text-[10px] text-muted font-sans truncate">
<span style={{ color: getScoreColor(entry.score) }}>
{entry.label}
</span>
<span className="text-[#444] mx-1.5">·</span>
{timeAgo(entry.timestamp)}
</span>
{typeof entry.uplift === "number" && (
<span className="ml-auto flex-shrink-0">
<UpliftBadge uplift={entry.uplift} />
</span>
)}
</div>
</div>
</Link>
<button
onClick={() => deleteScore(entry.id)}
disabled={deleting === entry.id}
className="absolute top-2 right-2 text-[#888] hover:text-ladder-red bg-[#111]/80 border border-[#2a2a2a] p-1 transition-all disabled:opacity-30 opacity-0 group-hover:opacity-100"
title="Delete score"
aria-label="Delete score"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14" />
</svg>
</button>
</div>
))}
</div>
) : (
<div className="space-y-1.5">
{scores.slice(0, visibleCount).map((entry) => (
<div
Expand Down Expand Up @@ -766,6 +899,7 @@ export default function DashboardPage() {
</div>
))}
</div>
)}
{/* Reveal the next page as this scrolls into view (#448). */}
{hasMore && (
<div ref={sentinelRef} aria-hidden className="h-1" />
Expand Down
4 changes: 2 additions & 2 deletions src/content/hq/features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Feature Inventory
updatedAt: 2026-08-11
updatedBy: Sara
lastPr: 470
lastPr: 472
---

## How to read this page
Expand Down Expand Up @@ -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` |
Expand Down
Loading