# Story Archive Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Let users browse all their past stories (beyond 24h) in a thumbnail grid on their own profile page. **Architecture:** New XRPC endpoint mirrors getStories without the 24-hour cutoff, with cursor pagination. A StoryArchive grid component renders thumbnails inline on the profile page (own profile only). StoryViewer gains a single-story mode for opening archived stories. **Tech Stack:** hatk (XRPC server framework), Svelte 5, TanStack Query, existing StoryView types. --- ### Task 1: Lexicon + Server Endpoint **Files:** - Create: `lexicons/social/grain/unspecced/getStoryArchive.json` - Create: `server/xrpc/getStoryArchive.ts` **Step 1: Create the lexicon file** Create `lexicons/social/grain/unspecced/getStoryArchive.json`: ```json { "lexicon": 1, "id": "social.grain.unspecced.getStoryArchive", "defs": { "main": { "type": "query", "description": "Get all stories for an actor, including expired ones. For archive browsing.", "parameters": { "type": "params", "required": ["actor"], "properties": { "actor": { "type": "string", "format": "did" }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, "cursor": { "type": "string" } } }, "output": { "encoding": "application/json", "schema": { "type": "object", "required": ["stories"], "properties": { "stories": { "type": "array", "items": { "type": "ref", "ref": "social.grain.story.defs#storyView" } }, "cursor": { "type": "string" } } } } } } } ``` **Step 2: Create the server endpoint** Create `server/xrpc/getStoryArchive.ts`. This mirrors `server/xrpc/getStories.ts` with these differences: - No 24-hour cutoff - `ORDER BY created_at DESC` (newest first, not ASC) - Cursor-based pagination using `created_at` timestamp ```typescript import { defineQuery } from "$hatk"; import { views } from "$hatk"; import type { GrainActorProfile, Story, Label } from "$hatk"; import { HIDE_LABELS } from "../labels/_hidden.ts"; import { lookupCrossPosts } from "../feeds/_hydrate.ts"; export default defineQuery("social.grain.unspecced.getStoryArchive", async (ctx) => { const { db, ok } = ctx; const actor = ctx.params.actor; if (!actor) return ok({ stories: [] }); const limit = Math.min(Number(ctx.params.limit) || 50, 100); const cursor = ctx.params.cursor as string | undefined; const queryParams: (string | number)[] = [actor, limit + 1]; let cursorClause = ""; if (cursor) { cursorClause = ` AND s.created_at < $3`; queryParams.push(cursor); } const rows = (await db.query( `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at FROM "social.grain.story" s LEFT JOIN _repos r ON s.did = r.did WHERE s.did = $1 AND (r.status IS NULL OR r.status != 'takendown') ${cursorClause} ORDER BY s.created_at DESC LIMIT $2`, queryParams, )) as { uri: string; cid: string; did: string; media: string; aspect_ratio: string; location: string | null; address: string | null; created_at: string; }[]; const hasMore = rows.length > limit; const pageRows = hasMore ? rows.slice(0, limit) : rows; // Resolve author profile const profiles = await ctx.lookup("social.grain.actor.profile", "did", [ actor, ]); const author = profiles.get(actor); const profileView = author ? views.grainActorDefsProfileView({ cid: author.cid, did: author.did, handle: author.handle ?? author.did, displayName: author.value.displayName, avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, }) : views.grainActorDefsProfileView({ cid: "", did: actor, handle: actor, }); // Hydrate external labels const storyUris = pageRows.map((r) => r.uri); const labelsByUri = storyUris.length > 0 ? ((await ctx.labels(storyUris)) as Map) : new Map(); // Filter stories with hide-severity labels const visibleRows = pageRows.filter((row) => { const labels = labelsByUri.get(row.uri); if (!labels) return true; const latestByVal = new Map(); for (const l of labels) { const prev = latestByVal.get(l.val); if (!prev || l.cts > prev.cts) latestByVal.set(l.val, l); } return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg); }); // Cross-post lookup const crossPosts = await lookupCrossPosts(db, visibleRows, "story"); const stories = visibleRows.map((row) => { let blobRef: any; try { blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; } catch { blobRef = row.media; } let aspectRatio: { width: number; height: number }; try { aspectRatio = typeof row.aspect_ratio === "string" ? JSON.parse(row.aspect_ratio) : row.aspect_ratio; } catch { aspectRatio = { width: 4, height: 3 }; } let location: Story["location"] | null = null; if (row.location) { try { location = typeof row.location === "string" ? JSON.parse(row.location) : row.location; } catch { location = null; } } let address: Story["address"] | null = null; if (row.address) { try { address = typeof row.address === "string" ? JSON.parse(row.address) : row.address; } catch { address = null; } } return views.storyView({ uri: row.uri, cid: row.cid, creator: profileView, thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "", fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "", aspectRatio, ...(location ? { location: { name: location.name, value: location.value }, ...(address ? { address } : {}), } : {}), createdAt: row.created_at, ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), }); }); const nextCursor = hasMore ? pageRows[pageRows.length - 1].created_at : undefined; return ok({ stories, ...(nextCursor ? { cursor: nextCursor } : {}) }); }); ``` **Step 3: Regenerate types** Run: `npx hatk generate types` Expected: Output includes `GetStoryArchive` in the generated types list. **Step 4: Commit** ```bash git add lexicons/social/grain/unspecced/getStoryArchive.json server/xrpc/getStoryArchive.ts hatk.generated.ts hatk.generated.client.ts git commit -m "feat: add getStoryArchive endpoint (no 24h cutoff, cursor pagination)" ``` --- ### Task 2: Client Query **Files:** - Modify: `app/lib/queries.ts` (add after `storiesQuery` around line 89) **Step 1: Add `storyArchiveQuery` to queries.ts** Add after the existing `storiesQuery` definition: ```typescript export const storyArchiveQuery = (did: string, cursor?: string, f?: Fetch) => queryOptions({ queryKey: ["stories", "archive", did, cursor], queryFn: () => callXrpc( "social.grain.unspecced.getStoryArchive", { actor: did, ...(cursor ? { cursor } : {}) }, f, ).then((r) => r ?? { stories: [], cursor: undefined }), staleTime: 60_000, }); ``` **Step 2: Verify types** Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` Expected: 0 ERRORS **Step 3: Commit** ```bash git add app/lib/queries.ts git commit -m "feat: add storyArchiveQuery client query" ``` --- ### Task 3: StoryViewer — Single-Story Mode + Date Formatting **Files:** - Modify: `app/lib/components/organisms/StoryViewer.svelte` **Step 1: Add `singleStory` prop** Add a new optional prop to the `$props()` destructuring at lines 12-18: ```typescript let { initialDid, onclose, singleStory, }: { initialDid: string; onclose: () => void; singleStory?: { uri: string } | null; } = $props(); ``` **Step 2: Override story loading in single-story mode** When `singleStory` is provided, use `storyQuery` instead of `storiesQuery` and disable author swiping. Add a new import at line 6: ```typescript import { storiesQuery, storyAuthorsQuery, storyQuery } from "$lib/queries"; ``` Add after the `storyAuthors` query (around line 23): ```typescript // Single-story mode: load just one story by URI const singleStoryData = createQuery(() => ({ ...storyQuery(singleStory?.uri ?? ""), enabled: !!singleStory, })); ``` Override `stories` and `authorDids` derivations. Replace the existing `stories` line (line 55) and `totalStories` (line 58) with: ```typescript const stories = createQuery(() => singleStory ? { ...storyQuery(singleStory.uri), enabled: true } : storiesQuery(currentDid), ); const currentStory = $derived( singleStory ? (singleStoryData.data ?? undefined) : stories.data?.[currentStoryIndex], ); const totalStories = $derived(singleStory ? 1 : (stories.data?.length ?? 0)); ``` **Step 3: Disable author swiping in single-story mode** In the `next()` function (line 148), wrap the author-advance logic: ```typescript function next() { if (currentStoryIndex < totalStories - 1) { currentStoryIndex++; progress = 0; } else if (!singleStory && currentAuthorIndex < authorDids.length - 1) { currentAuthorIndex++; } else { onclose(); } } ``` In the `prev()` function (line 160): ```typescript function prev() { if (currentStoryIndex > 0) { currentStoryIndex--; progress = 0; } else if (!singleStory && currentAuthorIndex > 0) { currentAuthorIndex--; } } ``` **Step 4: Update `timeAgo` for old stories** Replace the `timeAgo` function (lines 119-127) with: ```typescript function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const hours = Math.floor(diff / (1000 * 60 * 60)); if (hours < 1) { const mins = Math.floor(diff / (1000 * 60)); return `${mins}m`; } if (hours < 24) { return `${hours}h`; } return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } ``` **Step 5: Verify types** Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` Expected: 0 ERRORS **Step 6: Commit** ```bash git add app/lib/components/organisms/StoryViewer.svelte git commit -m "feat: add single-story mode and full date display to StoryViewer" ``` --- ### Task 4: StoryArchive Grid Component **Files:** - Create: `app/lib/components/molecules/StoryArchive.svelte` **Step 1: Create the component** This component shows a 3-column grid of square story thumbnails with date overlays. It uses cursor pagination with a "Load more" button. Tapping a thumbnail opens StoryViewer in single-story mode. ```svelte {#if initial.isLoading}
{#each { length: 6 } as _}
{/each}
{:else if allStories.length === 0}
No stories yet.
{:else}
{#each allStories as story (story.uri)} {/each}
{#if cursor}
{/if} {/if} {#if viewingStory} (viewingStory = null)} /> {/if} ``` **Step 2: Verify types** Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` Expected: 0 ERRORS **Step 3: Commit** ```bash git add app/lib/components/molecules/StoryArchive.svelte git commit -m "feat: add StoryArchive thumbnail grid component" ``` --- ### Task 5: Wire Into Profile Page **Files:** - Modify: `app/routes/profile/[did]/+page.svelte` **Step 1: Add imports and state** Add to the imports (after the StoryViewer import, line 15): ```typescript import StoryArchive from "$lib/components/molecules/StoryArchive.svelte"; import { Archive } from "lucide-svelte"; ``` Add state variable (after `showStoryViewer` at line 21): ```typescript let showArchive = $state(false); ``` Add derived (after `hasStory` at line 30): ```typescript const isOwnProfile = $derived(viewerDid === did); ``` **Step 2: Add archive button to the profile header** Insert after the `.links-row` div (after line 83, before the knownFollowers block): ```svelte {#if isOwnProfile} {/if} ``` **Step 3: Add archive section to the page body** Insert after the view-toggle section (after line 116, before the grid/list content): ```svelte {#if showArchive && isOwnProfile}

Story Archive

{/if} ``` **Step 4: Add styles** Add to the `