From 37560d011f93b0056a4376f4f1521c49c507353a Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Fri, 10 Jul 2026 17:35:02 -0400 Subject: [PATCH 1/5] feat(user): add updates feed for followed players (issue #28) Replace the homepage "Activity feed coming soon" placeholder with a feed of recent revisions authored by followed users on public decks. Attribution is by editor (DeckRevision.userId), so a followed user's collaborator edits on other people's public decks surface too. Scoped to visibility=PUBLIC, kind=DECK, matching the discovery convention. Key changes: - getFollowingUpdates cached under userFollowingTag so follow/unfollow refreshes instantly; deck edits ride minutes-level staleness - summarizeDeltas helper for the +N/-M/K-changes row summary - (user_id, updated_at DESC) index on deck_revision for the feed query - UpdatesFeed server component with zero-follow and no-updates empty states plus a CLS-safe skeleton --- app/_components/home/home-view.tsx | 15 +- app/_components/home/updates-feed.tsx | 114 ++++++++++ lib/deck/__tests__/revision.test.ts | 26 +++ lib/deck/revision.ts | 18 ++ lib/user/__tests__/feed.test.ts | 196 ++++++++++++++++++ lib/user/feed.ts | 102 +++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 8 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 app/_components/home/updates-feed.tsx create mode 100644 lib/user/__tests__/feed.test.ts create mode 100644 lib/user/feed.ts create mode 100644 prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql diff --git a/app/_components/home/home-view.tsx b/app/_components/home/home-view.tsx index 1bfe466..4fb270e 100644 --- a/app/_components/home/home-view.tsx +++ b/app/_components/home/home-view.tsx @@ -4,6 +4,7 @@ import { type Format, type Visibility } from "@/lib/generated/prisma/enums"; import { HomeGreeting } from "./home-greeting"; import { DeckStrip, type DeckStripItem } from "./deck-strip"; import { FeaturedDecks, FeaturedDecksSkeleton } from "./featured-decks"; +import { UpdatesFeed, UpdatesFeedSkeleton } from "./updates-feed"; interface HomeViewProps { userId: string; @@ -44,17 +45,11 @@ export async function HomeView({ userId, username }: HomeViewProps) { - {/* Two-column: Trending cards + placeholder activity */} + {/* Two-column: updates feed + reserved slot for trending cards */}
- {/* Activity feed placeholder — TODO: implement when activity query exists */} -
-
- Activity -
-
- Activity feed coming soon. -
-
+ }> + +
); diff --git a/app/_components/home/updates-feed.tsx b/app/_components/home/updates-feed.tsx new file mode 100644 index 0000000..8f9b19d --- /dev/null +++ b/app/_components/home/updates-feed.tsx @@ -0,0 +1,114 @@ +import Link from "@/app/_components/link"; +import { Eyebrow } from "@/components/ui/eyebrow"; +import { getFollowingUpdates, type FeedItem } from "@/lib/user/feed"; +import { summarizeDeltas } from "@/lib/deck/revision"; +import { type Format } from "@/lib/generated/prisma/enums"; +import { TimeAgo } from "./time-ago"; + +function formatLabel(format: Format): string { + return format.charAt(0) + format.slice(1).toLowerCase(); +} + +function Header() { + return ( +
+ Updates +
+ ); +} + +function EmptyTile({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function FeedRow({ item }: { item: FeedItem }) { + const { added, removed, count } = summarizeDeltas(item.changes); + const editorLabel = item.editor.displayUsername ?? item.editor.username; + + return ( +
+
+ + + {editorLabel} + {" "} + updated{" "} + + {item.deck.name} + + + + + +
+
+ {formatLabel(item.deck.format)} ·{" "} + {added > 0 && ( + + +{added} + + )} + {added > 0 && removed > 0 && " "} + {removed > 0 && ( + + −{removed} + + )}{" "} + · {count} change{count === 1 ? "" : "s"} +
+
+ ); +} + +export async function UpdatesFeed({ userId }: { userId: string }) { + const { followingCount, items } = await getFollowingUpdates(userId); + + return ( +
+
+ {followingCount === 0 ? ( + + + Follow players + {" "} + to see their deck updates here. + + ) : items.length === 0 ? ( + No recent updates from players you follow. + ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export function UpdatesFeedSkeleton() { + return ( +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/lib/deck/__tests__/revision.test.ts b/lib/deck/__tests__/revision.test.ts index 3a77a3c..2c6ded6 100644 --- a/lib/deck/__tests__/revision.test.ts +++ b/lib/deck/__tests__/revision.test.ts @@ -5,6 +5,7 @@ import { deltasToBulkChanges, invertDeltas, mergeDeltas, + summarizeDeltas, type RevisionDelta, } from "@/lib/deck/revision"; import type { ExistingDeckCard } from "@/lib/deck/mutation/diff"; @@ -94,6 +95,31 @@ describe("mergeDeltas", () => { }); }); +describe("summarizeDeltas", () => { + it("splits mixed deltas into added/removed totals with the raw entry count", () => { + const result = summarizeDeltas([ + delta(1, "Sol Ring", 2), + delta(2, "Counterspell", -3), + delta(3, "Island", 4), + ]); + + expect(result).toEqual({ added: 6, removed: 3, count: 3 }); + }); + + it("reports zero removed when every delta is positive", () => { + const result = summarizeDeltas([ + delta(1, "Sol Ring", 1), + delta(2, "Counterspell", 2), + ]); + + expect(result).toEqual({ added: 3, removed: 0, count: 2 }); + }); + + it("returns all zeros for an empty list", () => { + expect(summarizeDeltas([])).toEqual({ added: 0, removed: 0, count: 0 }); + }); +}); + describe("invertDeltas", () => { it("flips every sign", () => { const inverted = invertDeltas([ diff --git a/lib/deck/revision.ts b/lib/deck/revision.ts index 347c069..b9c970e 100644 --- a/lib/deck/revision.ts +++ b/lib/deck/revision.ts @@ -55,6 +55,24 @@ export function mergeDeltas( return [...byKey.values()].filter((d) => d.delta !== 0); } +export interface DeltaSummary { + added: number; + removed: number; + count: number; +} + +export function summarizeDeltas( + deltas: readonly RevisionDelta[], +): DeltaSummary { + let added = 0; + let removed = 0; + for (const d of deltas) { + if (d.delta > 0) added += d.delta; + else removed -= d.delta; + } + return { added, removed, count: deltas.length }; +} + export function invertDeltas(deltas: readonly RevisionDelta[]): RevisionDelta[] { return deltas.map((d) => ({ ...d, delta: -d.delta })); } diff --git a/lib/user/__tests__/feed.test.ts b/lib/user/__tests__/feed.test.ts new file mode 100644 index 0000000..ea2fc38 --- /dev/null +++ b/lib/user/__tests__/feed.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/cache", () => ({ + cacheLife: vi.fn(), + cacheTag: vi.fn(), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + follow: { + findMany: vi.fn(), + }, + deckRevision: { + findMany: vi.fn(), + }, + user: { + findMany: vi.fn(), + }, + }, +})); + +import { cacheTag } from "next/cache"; +import { prisma } from "@/lib/db"; +import { FEED_PAGE_SIZE, getFollowingUpdates } from "../feed"; + +const mockFollowFindMany = vi.mocked(prisma.follow.findMany); +const mockRevisionFindMany = vi.mocked(prisma.deckRevision.findMany); +const mockUserFindMany = vi.mocked(prisma.user.findMany); +const mockCacheTag = vi.mocked(cacheTag); + +const VIEWER_ID = "viewer-1"; +const EDITOR_ID = "editor-1"; + +const EDITOR = { + id: EDITOR_ID, + username: "bob", + displayUsername: "Bob", + name: "Bob Builder", + image: null, +}; + +function revision( + id: string, + overrides: Partial<{ + userId: string; + updatedAt: Date; + changes: unknown; + deck: { id: string; name: string; format: string }; + }> = {}, +) { + return { + id, + userId: EDITOR_ID, + updatedAt: new Date("2026-07-01T12:00:00Z"), + changes: [ + { + cardId: 1, + cardName: "Sol Ring", + zone: "MAINBOARD", + category: null, + delta: 1, + }, + ], + deck: { id: "deck-1", name: "My Deck", format: "COMMANDER" }, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getFollowingUpdates", () => { + it("returns the zero-follow state without querying revisions or users", async () => { + mockFollowFindMany.mockResolvedValue([] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(result).toEqual({ followingCount: 0, items: [] }); + expect(mockRevisionFindMany).not.toHaveBeenCalled(); + expect(mockUserFindMany).not.toHaveBeenCalled(); + }); + + it("tags the viewer's following cache", async () => { + mockFollowFindMany.mockResolvedValue([] as never); + + await getFollowingUpdates(VIEWER_ID); + + expect(mockCacheTag).toHaveBeenCalledWith(`user:${VIEWER_ID}:following`); + }); + + it("queries public DECK revisions by followed editors, newest first, one page", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + { followingId: "editor-2" }, + ] as never); + mockRevisionFindMany.mockResolvedValue([revision("rev-1")] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(mockRevisionFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: { in: [EDITOR_ID, "editor-2"] }, + deck: { visibility: "PUBLIC", kind: "DECK" }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + take: FEED_PAGE_SIZE, + }), + ); + expect(result.followingCount).toBe(2); + expect(result.items).toEqual([ + { + revisionId: "rev-1", + updatedAt: new Date("2026-07-01T12:00:00Z"), + deck: { id: "deck-1", name: "My Deck", format: "COMMANDER" }, + editor: { + username: "bob", + displayUsername: "Bob", + name: "Bob Builder", + image: null, + }, + changes: [ + { + cardId: 1, + cardName: "Sol Ring", + zone: "MAINBOARD", + category: null, + delta: 1, + }, + ], + }, + ]); + }); + + it("dedupes editor ids before the user lookup", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1"), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + await getFollowingUpdates(VIEWER_ID); + + expect(mockUserFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: [EDITOR_ID] } } }), + ); + }); + + it("skips revisions whose editor row is missing", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1", { userId: "deleted-user" }), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const { items } = await getFollowingUpdates(VIEWER_ID); + + expect(items).toHaveLength(1); + expect(items[0]!.revisionId).toBe("rev-2"); + }); + + it("skips revisions with malformed changes payloads", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1", { changes: { not: "an array" } }), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const { items } = await getFollowingUpdates(VIEWER_ID); + + expect(items).toHaveLength(1); + expect(items[0]!.revisionId).toBe("rev-2"); + }); + + it("skips the user lookup when no revisions match", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(result).toEqual({ followingCount: 1, items: [] }); + expect(mockUserFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/user/feed.ts b/lib/user/feed.ts new file mode 100644 index 0000000..1f80d39 --- /dev/null +++ b/lib/user/feed.ts @@ -0,0 +1,102 @@ +import "server-only"; +import { cacheLife, cacheTag } from "next/cache"; +import { prisma } from "@/lib/db"; +import { userFollowingTag } from "@/lib/deck/cache-tags"; +import { parseRevisionDeltas, type RevisionDelta } from "@/lib/deck/revision"; +import type { Format } from "@/lib/generated/prisma/enums"; + +export const FEED_PAGE_SIZE = 10; + +export interface FeedItem { + revisionId: string; + updatedAt: Date; + deck: { id: string; name: string; format: Format }; + editor: { + username: string; + displayUsername: string | null; + name: string; + image: string | null; + }; + changes: RevisionDelta[]; +} + +export interface FollowingUpdates { + followingCount: number; + items: FeedItem[]; +} + +/** + * Recent revisions authored by users the viewer follows, on PUBLIC decks of + * `kind=DECK`. Attribution is by editor (`DeckRevision.userId`), so a + * followed user's collaborator edits on someone else's public deck surface + * too. Cached under the viewer's following tag — follow/unfollow refreshes + * immediately; deck edits ride the minutes-level staleness. + */ +export async function getFollowingUpdates( + viewerId: string, +): Promise { + "use cache"; + cacheLife("minutes"); + cacheTag(userFollowingTag(viewerId)); + + const follows = await prisma.follow.findMany({ + where: { followerId: viewerId }, + select: { followingId: true }, + }); + if (follows.length === 0) return { followingCount: 0, items: [] }; + + const followedIds = follows.map((f) => f.followingId); + const revisions = await prisma.deckRevision.findMany({ + where: { + userId: { in: followedIds }, + deck: { visibility: "PUBLIC", kind: "DECK" }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + take: FEED_PAGE_SIZE, + select: { + id: true, + userId: true, + updatedAt: true, + changes: true, + deck: { select: { id: true, name: true, format: true } }, + }, + }); + + const editorIds = [...new Set(revisions.map((r) => r.userId))]; + const editors = + editorIds.length === 0 + ? [] + : await prisma.user.findMany({ + where: { id: { in: editorIds } }, + select: { + id: true, + username: true, + displayUsername: true, + name: true, + image: true, + }, + }); + const editorById = new Map(editors.map((e) => [e.id, e])); + + const items: FeedItem[] = []; + for (const r of revisions) { + const editor = editorById.get(r.userId); + if (!editor) continue; + const changes = parseRevisionDeltas(r.changes); + if (changes.length === 0) continue; + items.push({ + revisionId: r.id, + updatedAt: r.updatedAt, + deck: r.deck, + editor: { + username: editor.username, + displayUsername: editor.displayUsername, + name: editor.name, + image: editor.image, + }, + changes, + }); + } + + return { followingCount: follows.length, items }; +} diff --git a/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql new file mode 100644 index 0000000..e71db7e --- /dev/null +++ b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "deck_revision_user_id_updated_at_idx" ON "deck_revision"("user_id", "updated_at" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a31b375..00a3158 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -338,6 +338,7 @@ model DeckRevision { deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) @@index([deckId, updatedAt(sort: Desc)]) + @@index([userId, updatedAt(sort: Desc)]) @@map("deck_revision") } From 21bcc455124dca95441095617580e77da1f460ae Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Fri, 10 Jul 2026 21:14:12 -0400 Subject: [PATCH 2/5] fix(user): address updates feed review findings (PR #83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope the 5-minute revision merge window per editor so collaborator edits are attributed to their own author instead of being merged into another editor's revision — the feed attributes by DeckRevision.userId, so cross-editor merges mis-attributed or hid collaborator activity. Also: - Match the feed skeleton to the real list (10 rows from FEED_PAGE_SIZE, 64px rows, same bordered divide-y container) to avoid CLS while streaming. - Rewrite the deck_revision index migration to the repo's CONCURRENTLY convention (IF NOT EXISTS inline, manual CONCURRENTLY rollout notes for production). - Drop unused editor.name/image from FeedItem and its query. - Update history page copy to reflect the per-editor merge window. --- app/(ui)/deck/[id]/history/page.tsx | 4 +-- app/_components/home/updates-feed.tsx | 16 ++++++------ lib/deck/mutation/__tests__/revision.test.ts | 25 +++++++++++++++++++ lib/deck/mutation/revision.ts | 2 +- lib/user/__tests__/feed.test.ts | 4 --- lib/user/feed.ts | 6 ----- .../migration.sql | 19 ++++++++++++-- 7 files changed, 53 insertions(+), 23 deletions(-) diff --git a/app/(ui)/deck/[id]/history/page.tsx b/app/(ui)/deck/[id]/history/page.tsx index b90db39..ce0a7af 100644 --- a/app/(ui)/deck/[id]/history/page.tsx +++ b/app/(ui)/deck/[id]/history/page.tsx @@ -40,8 +40,8 @@ async function DeckHistoryContent({ id }: { id: string }) { {deck.name} · History

- Changes to this deck, newest first. Edits within 5 minutes of each - other are grouped into a single revision. + Changes to this deck, newest first. Edits by the same player within + 5 minutes are grouped into a single revision.

diff --git a/app/_components/home/updates-feed.tsx b/app/_components/home/updates-feed.tsx index 8f9b19d..fb1e366 100644 --- a/app/_components/home/updates-feed.tsx +++ b/app/_components/home/updates-feed.tsx @@ -1,6 +1,10 @@ import Link from "@/app/_components/link"; import { Eyebrow } from "@/components/ui/eyebrow"; -import { getFollowingUpdates, type FeedItem } from "@/lib/user/feed"; +import { + FEED_PAGE_SIZE, + getFollowingUpdates, + type FeedItem, +} from "@/lib/user/feed"; import { summarizeDeltas } from "@/lib/deck/revision"; import { type Format } from "@/lib/generated/prisma/enums"; import { TimeAgo } from "./time-ago"; @@ -100,13 +104,9 @@ export function UpdatesFeedSkeleton() { return (
-
- {Array.from({ length: 5 }).map((_, i) => ( -
+
+ {Array.from({ length: FEED_PAGE_SIZE }).map((_, i) => ( +
))}
diff --git a/lib/deck/mutation/__tests__/revision.test.ts b/lib/deck/mutation/__tests__/revision.test.ts index 48a4ae1..8e27ab3 100644 --- a/lib/deck/mutation/__tests__/revision.test.ts +++ b/lib/deck/mutation/__tests__/revision.test.ts @@ -60,6 +60,9 @@ describe("recordDeckRevisionTx", () => { await recordDeckRevisionTx(tx, DECK_ID, USER_ID, [delta]); + expect(tx.deckRevision.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { deckId: DECK_ID, userId: USER_ID } }), + ); expect(tx.deckRevision.create).toHaveBeenCalledWith({ data: { deckId: DECK_ID, @@ -69,6 +72,28 @@ describe("recordDeckRevisionTx", () => { }); }); + it("scopes the merge window per editor: a second editor gets their own revision", async () => { + // First editor has a recent revision; the merge lookup is scoped by + // userId, so the second editor's findFirst returns null and a fresh + // revision is created under their own userId. + const tx = makeTx({ findFirst: null }); + + await recordDeckRevisionTx(tx, DECK_ID, "user-2", [delta]); + + expect(tx.deckRevision.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { deckId: DECK_ID, userId: "user-2" } }), + ); + expect(tx.deckRevision.create).toHaveBeenCalledWith({ + data: { + deckId: DECK_ID, + userId: "user-2", + changes: [delta], + }, + }); + expect(tx.deckRevision.update).not.toHaveBeenCalled(); + expect(tx.deckRevision.delete).not.toHaveBeenCalled(); + }); + it("creates a new revision when outside the merge window", async () => { const staleDate = new Date(Date.now() - 10 * 60 * 1000); // 10 min ago const tx = makeTx({ diff --git a/lib/deck/mutation/revision.ts b/lib/deck/mutation/revision.ts index 239c3d1..2049584 100644 --- a/lib/deck/mutation/revision.ts +++ b/lib/deck/mutation/revision.ts @@ -33,7 +33,7 @@ export async function recordDeckRevisionTx( } const latest = await tx.deckRevision.findFirst({ - where: { deckId }, + where: { deckId, userId }, orderBy: { updatedAt: "desc" }, select: { id: true, updatedAt: true, changes: true }, }); diff --git a/lib/user/__tests__/feed.test.ts b/lib/user/__tests__/feed.test.ts index ea2fc38..2f9e086 100644 --- a/lib/user/__tests__/feed.test.ts +++ b/lib/user/__tests__/feed.test.ts @@ -34,8 +34,6 @@ const EDITOR = { id: EDITOR_ID, username: "bob", displayUsername: "Bob", - name: "Bob Builder", - image: null, }; function revision( @@ -117,8 +115,6 @@ describe("getFollowingUpdates", () => { editor: { username: "bob", displayUsername: "Bob", - name: "Bob Builder", - image: null, }, changes: [ { diff --git a/lib/user/feed.ts b/lib/user/feed.ts index 1f80d39..5090f4b 100644 --- a/lib/user/feed.ts +++ b/lib/user/feed.ts @@ -14,8 +14,6 @@ export interface FeedItem { editor: { username: string; displayUsername: string | null; - name: string; - image: string | null; }; changes: RevisionDelta[]; } @@ -72,8 +70,6 @@ export async function getFollowingUpdates( id: true, username: true, displayUsername: true, - name: true, - image: true, }, }); const editorById = new Map(editors.map((e) => [e.id, e])); @@ -91,8 +87,6 @@ export async function getFollowingUpdates( editor: { username: editor.username, displayUsername: editor.displayUsername, - name: editor.name, - image: editor.image, }, changes, }); diff --git a/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql index e71db7e..3706a81 100644 --- a/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql +++ b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql @@ -1,2 +1,17 @@ --- CreateIndex -CREATE INDEX "deck_revision_user_id_updated_at_idx" ON "deck_revision"("user_id", "updated_at" DESC); +-- NOTE ON PRODUCTION ROLLOUT: +-- Prisma Migrate wraps every migration in a transaction, so CONCURRENTLY +-- cannot be used inline here (Postgres rejects it inside a transaction block). +-- For production, apply the CONCURRENTLY variant manually BEFORE running +-- `prisma migrate deploy`, then mark this migration as applied: +-- +-- psql "$DATABASE_URL" <<'SQL' +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "deck_revision_user_id_updated_at_idx" +-- ON "deck_revision" ("user_id", "updated_at" DESC); +-- SQL +-- pnpm prisma migrate resolve --applied 20260710212937_deck_revision_user_updated_at_idx +-- +-- The non-concurrent statement below applies in dev/shadow databases without issue. + +-- Supports the updates feed: recent revisions by followed editors, newest first. +CREATE INDEX IF NOT EXISTS "deck_revision_user_id_updated_at_idx" + ON "deck_revision" ("user_id", "updated_at" DESC); From be44574256e633a3c40972ee5436ea7328798d7c Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Sat, 11 Jul 2026 12:35:40 -0400 Subject: [PATCH 3/5] feat(user): link feed items to deck history with revision highlight Clicking an updates feed row now opens the deck's history page deep-linked to that revision, which scrolls into view with a persistent highlight ring. Key changes: - History page accepts ?revision=, awaited inside the existing Suspense boundary and threaded down to DeckHistoryList - Matching RevisionCard scrolls into view (smooth, centered) and shows a ring-2 ring-primary highlight; unknown ids are ignored - Feed rows use a stretched-link overlay to the revision URL with pointer cursor and hover background; inner editor/deck links stay independently clickable - Fix pre-existing hydration mismatch on the history
); } -export default function DeckHistoryPage({ params }: DeckHistoryPageProps) { +export default function DeckHistoryPage({ + params, + searchParams, +}: DeckHistoryPageProps) { return (
} > - +
); @@ -73,9 +84,11 @@ export default function DeckHistoryPage({ params }: DeckHistoryPageProps) { async function HistoryLoader({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: Promise<{ revision?: string }>; }) { - const { id } = await params; - return ; + const [{ id }, { revision }] = await Promise.all([params, searchParams]); + return ; } diff --git a/app/_components/deck/deck-history-list.tsx b/app/_components/deck/deck-history-list.tsx index ab297ad..980200e 100644 --- a/app/_components/deck/deck-history-list.tsx +++ b/app/_components/deck/deck-history-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -15,6 +15,7 @@ interface DeckHistoryListProps { deckId: string; revisions: RevisionView[]; isOwner: boolean; + highlightId?: string | undefined; } const ZONE_LABEL: Record = { @@ -29,6 +30,7 @@ export function DeckHistoryList({ deckId, revisions, isOwner, + highlightId, }: DeckHistoryListProps) { if (revisions.length === 0) { return ( @@ -46,6 +48,7 @@ export function DeckHistoryList({ deckId={deckId} revision={r} isOwner={isOwner} + isHighlighted={r.id === highlightId} /> ))} @@ -56,11 +59,20 @@ function RevisionCard({ deckId, revision, isOwner, + isHighlighted, }: { deckId: string; revision: RevisionView; isOwner: boolean; + isHighlighted: boolean; }) { + const cardRef = useRef(null); + + useEffect(() => { + if (!isHighlighted) return; + cardRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [isHighlighted]); + const updatedAt = useMemo( () => new Date(revision.updatedAt), [revision.updatedAt], @@ -83,12 +95,20 @@ function RevisionCard({ const clearSelection = () => setSelected(new Set()); return ( -
  • +
  • -
    - {grouped.map(({ zone, deltas }) => ( -
    - {grouped.length > 1 && ( -
    - {ZONE_LABEL[zone]} -
    - )} -
      - {deltas.map((d) => { - const key = deltaKey(d); - return ( -
    • - {isOwner && ( - toggle(key, checked)} - /> - )} - 0 - ? "text-emerald-600 dark:text-emerald-400 font-medium" - : "text-red-600 dark:text-red-400 font-medium" - } - > - {d.delta > 0 ? `+${d.delta}` : d.delta} - - {d.cardName || `Card #${d.cardId}`} - {d.category && ( - - ({d.category}) - - )} -
    • - ); - })} -
    -
    - ))} +
    + ( + toggle(key, checked)} + /> + ) + : undefined + } + />
  • ); diff --git a/app/_components/deck/revision-diff.tsx b/app/_components/deck/revision-diff.tsx new file mode 100644 index 0000000..998ab7d --- /dev/null +++ b/app/_components/deck/revision-diff.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { deltaKey, type RevisionDelta } from "@/lib/deck/revision"; +import { groupDeltasByZone } from "@/lib/deck/group-deltas"; +import type { Zone } from "@/lib/generated/prisma/enums"; + +const ZONE_LABEL: Record = { + MAINBOARD: "Mainboard", + SIDEBOARD: "Sideboard", + CONSIDERING: "Considering", + COMMANDER: "Commander", + COMPANION: "Companion", +}; + +export function RevisionDiff({ + deltas, + renderRowStart, +}: { + deltas: readonly RevisionDelta[]; + renderRowStart?: ((delta: RevisionDelta, key: string) => ReactNode) | undefined; +}) { + const grouped = groupDeltasByZone(deltas); + + return ( +
    + {grouped.map(({ zone, deltas: zoneDeltas }) => ( +
    + {grouped.length > 1 && ( +
    + {ZONE_LABEL[zone]} +
    + )} +
      + {zoneDeltas.map((d) => { + const key = deltaKey(d); + return ( +
    • + {renderRowStart?.(d, key)} + 0 + ? "text-emerald-600 dark:text-emerald-400 font-medium" + : "text-red-600 dark:text-red-400 font-medium" + } + > + {d.delta > 0 ? `+${d.delta}` : d.delta} + + {d.cardName || `Card #${d.cardId}`} + {d.category && ( + + ({d.category}) + + )} +
    • + ); + })} +
    +
    + ))} +
    + ); +} diff --git a/app/_components/home/feed-row-expand.tsx b/app/_components/home/feed-row-expand.tsx new file mode 100644 index 0000000..26fb36e --- /dev/null +++ b/app/_components/home/feed-row-expand.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { + Collapsible, + CollapsiblePanel, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +export function FeedRowExpand({ + deckName, + summary, + children, +}: { + deckName: string; + summary: ReactNode; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( + +
    + {summary} + + + +
    + +
    {children}
    +
    +
    + ); +} diff --git a/app/_components/home/updates-feed.tsx b/app/_components/home/updates-feed.tsx index 05c1638..7e3a441 100644 --- a/app/_components/home/updates-feed.tsx +++ b/app/_components/home/updates-feed.tsx @@ -7,6 +7,8 @@ import { } from "@/lib/user/feed"; import { summarizeDeltas } from "@/lib/deck/revision"; import { type Format } from "@/lib/generated/prisma/enums"; +import { RevisionDiff } from "@/app/_components/deck/revision-diff"; +import { FeedRowExpand } from "./feed-row-expand"; import { TimeAgo } from "./time-ago"; function formatLabel(format: Format): string { @@ -60,21 +62,28 @@ function FeedRow({ item }: { item: FeedItem }) {
    -
    - {formatLabel(item.deck.format)} ·{" "} - {added > 0 && ( - - +{added} - - )} - {added > 0 && removed > 0 && " "} - {removed > 0 && ( - - −{removed} - - )}{" "} - · {count} change{count === 1 ? "" : "s"} -
    + + {formatLabel(item.deck.format)} ·{" "} + {added > 0 && ( + + +{added} + + )} + {added > 0 && removed > 0 && " "} + {removed > 0 && ( + + −{removed} + + )}{" "} + · {count} change{count === 1 ? "" : "s"} + + } + > + +
    ); } diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..ead948a --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsiblePanel({ ...props }: CollapsiblePrimitive.Panel.Props) { + return +} + +export { Collapsible, CollapsiblePanel, CollapsibleTrigger }