diff --git a/.planning/STATE.md b/.planning/STATE.md
index 9d3277b..12776da 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
-stopped_at: Phase 3 plan 01 starting
+stopped_at: Phase 3 complete (6/6)
last_updated: "2026-06-13T00:00:00.000Z"
-last_activity: 2026-06-13 -- Phase 2 complete (6/6); Phase 3 planning done; ready to execute 03-01
+last_activity: 2026-06-13 -- Phase 3 complete; 67 tests passing; ready for Phase 4
progress:
total_phases: 7
- completed_phases: 2
+ completed_phases: 3
total_plans: 16
- completed_plans: 10
- percent: 29
+ completed_plans: 16
+ percent: 57
---
# Project State
@@ -25,12 +25,11 @@ See: .planning/PROJECT.md (updated 2026-06-05)
## Current Position
-Phase: 3 (Engagement and Discovery) — READY TO EXECUTE
-Plan: 1 of 6 — 03-01 (Schema Foundation + Stubs)
-Status: Planning complete; execution starting
-Last activity: 2026-06-13 -- Phase 3 planning artifacts created (CONTEXT, RESEARCH, UI-SPEC, VALIDATION, 6 PLANs)
+Phase: 3 (Engagement and Discovery) — COMPLETE
+Next: Phase 4 — Pet Profiles & Discovery
+Status: Phase 3 all 6 plans done; 67 tests passing
-Progress: [███░░░░░░░] 29%
+Progress: [█████░░░░░] 57%
## Phase 2 — Completed 2026-06-13
diff --git a/.planning/phases/03-engagement-discovery/03-06-SUMMARY.md b/.planning/phases/03-engagement-discovery/03-06-SUMMARY.md
new file mode 100644
index 0000000..a54171f
--- /dev/null
+++ b/.planning/phases/03-engagement-discovery/03-06-SUMMARY.md
@@ -0,0 +1,58 @@
+---
+phase: 03-engagement-discovery
+plan: 06
+status: complete
+completed: 2026-06-13
+---
+
+# Plan 03-06 Summary — Hashtag Search (SRCH-01)
+
+## What Was Built
+
+**hashtag-helpers.ts (server-only):**
+- `extractHashtags(text)`: /#(\w{1,50})/g regex, lowercase, deduplicate, max 30; lastIndex reset before+after (Pitfall 3)
+- `saveHashtags(prisma, postId, tags)`: upsert Hashtag + PostHashtag junction per tag; wrapped in caller's `.catch(()=>{})` so hashtag failure never blocks post creation
+
+**parse-caption.ts (client-safe):**
+- `parseCaption(caption)`: splits on `/(#\w{1,50})/g`, returns `CaptionSegment[]` (`{type:"text"}` | `{type:"hashtag"}`)
+
+**search.ts — `byHashtag` GREEN:**
+- `hashtag.findUnique({ where: { tag } })` → early-return `{ posts: [], nextCursor: null }` if not found
+- `postHashtag.findMany` with `post.{ pet, images(take:1) }` include + `postId { lt: cursor }` pagination
+- Returns `{ posts, nextCursor }`
+
+**posts.ts:** `extractHashtags(input.caption)` + `saveHashtags(...).catch(()=>{})` after `post.create`
+
+**reposts.ts:** same hashtag extraction from `originalPost.caption` after repost `post.create`
+
+**/hashtag/[tag]/page.tsx:**
+- `use(params)` for async Next.js 15 params; normalizes tag (strip #, lowercase)
+- `useInfiniteQuery(trpc.search.byHashtag.infiniteQueryOptions)` + `useInView` sentinel
+- `#{tag}` heading + approximate post count + 3-col grid (aspect-square, Layers2 on carousel)
+- Empty state: "No posts tagged #{tag} yet"
+
+**/search/page.tsx (Tags tab wired):**
+- Shared query input; normalizes to tag (strip #, lowercase) for Tags tab
+- `useQuery(trpc.search.byHashtag)` enabled when `normalizedTag.length >= 1`
+- Tags tab: same 3-col grid as hashtag page + empty/loading states
+
+**PostCard.tsx — clickable hashtags:**
+- `parseCaption(currentCaption).map(seg => ...)` replaces raw string in caption `
`
+- `type === "hashtag"` → `#{tag}` (text-orange-500 hover:underline)
+- `type === "text"` → `{text}`
+
+## Test Results
+
+- search.test.ts: 4/4 GREEN (2 pets + 2 byHashtag)
+- Full suite: 67 passing, 0 failing (5 todo), 1 pre-existing env failure (media-upload.test.ts — Supabase URL not set in test env)
+- `npx tsc --noEmit`: clean
+
+## Phase 3 Complete
+
+All 6 plans executed:
+- 03-01: Schema foundation (Reaction, Comment, Repost, Hashtag, PostHashtag)
+- 03-02: Paw Reactions GREEN + PawButton
+- 03-03: Comments GREEN + CommentSheet
+- 03-04: Paw-Back/Repost GREEN + RepostCard + PawBackButton
+- 03-05: Explore + pet search GREEN + /explore + /search + nav
+- 03-06: Hashtag search GREEN + /hashtag/[tag] + clickable captions
diff --git a/src/app/(app)/hashtag/[tag]/page.tsx b/src/app/(app)/hashtag/[tag]/page.tsx
new file mode 100644
index 0000000..9a324ba
--- /dev/null
+++ b/src/app/(app)/hashtag/[tag]/page.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { use } from "react";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useInView } from "react-intersection-observer";
+import { useEffect } from "react";
+import { Layers2, Loader2 } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { getMediaUrl } from "@/lib/media-url";
+
+interface PageProps {
+ params: Promise<{ tag: string }>;
+}
+
+export default function HashtagPage({ params }: PageProps) {
+ const { tag } = use(params);
+ const normalizedTag = tag.replace(/^#/, "").toLowerCase();
+ const trpc = useTRPC();
+
+ const { ref: sentinelRef, inView } = useInView({ threshold: 0 });
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ useInfiniteQuery(
+ trpc.search.byHashtag.infiniteQueryOptions(
+ { tag: normalizedTag, limit: 24 },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ }
+ )
+ );
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const posts = data?.pages.flatMap((p) => p.posts) ?? [];
+ const totalApprox = data?.pages[0] ? posts.length + (hasNextPage ? "+" : "") : null;
+
+ return (
+
+
#{normalizedTag}
+ {totalApprox !== null && (
+
+ {totalApprox} posts
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : posts.length === 0 ? (
+
+ No posts tagged #{normalizedTag} yet
+
+ ) : (
+ <>
+
+ {posts.map((post) => {
+ const p = post as {
+ id: string;
+ caption: string | null;
+ images: Array<{ storageKey: string; position?: number }>;
+ };
+ const firstImage = p.images?.[0];
+ const isCarousel = (p.images?.length ?? 0) > 1;
+ const mediaUrl = firstImage ? getMediaUrl(firstImage.storageKey) : null;
+
+ return (
+
+ {mediaUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+ {isCarousel && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {/* Infinite scroll sentinel */}
+
+ {isFetchingNextPage && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx
index 5b0c148..a11c67c 100644
--- a/src/app/(app)/search/page.tsx
+++ b/src/app/(app)/search/page.tsx
@@ -2,7 +2,7 @@
import { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
-import { Search, Loader2 } from "lucide-react";
+import { Search, Layers2, Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Input } from "@/components/ui/input";
@@ -11,27 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FollowButton } from "@/components/social/FollowButton";
import { getAvatarUrl } from "@/lib/avatar-url";
-
-function useDebounce(value: T, delay: number): T {
- const [debounced, setDebounced] = useState(value);
- const timeoutRef = useState | null>(null);
-
- const update = useCallback(
- (v: T) => {
- if (timeoutRef[0]) clearTimeout(timeoutRef[0]);
- timeoutRef[1](setTimeout(() => setDebounced(v), delay));
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [delay]
- );
-
- // Update when value changes
- useState(() => {
- update(value);
- });
-
- return debounced;
-}
+import { getMediaUrl } from "@/lib/media-url";
export default function SearchPage() {
const trpc = useTRPC();
@@ -40,7 +20,6 @@ export default function SearchPage() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
- // Simple debounce via timeout
const handleQueryChange = useCallback((e: React.ChangeEvent) => {
const val = e.target.value;
setQuery(val);
@@ -48,16 +27,25 @@ export default function SearchPage() {
return () => clearTimeout(timer);
}, []);
- const enabled = debouncedQuery.trim().length >= 2;
+ const trimmed = debouncedQuery.trim();
+ const petsEnabled = trimmed.length >= 2;
+ // Hashtag: strip leading # then require at least 1 char
+ const normalizedTag = trimmed.replace(/^#/, "").toLowerCase();
+ const tagsEnabled = normalizedTag.length >= 1;
const { data: petResults, isLoading: loadingPets } = useQuery(
- trpc.search.pets.queryOptions(
- { query: debouncedQuery.trim() },
- { enabled }
- )
+ trpc.search.pets.queryOptions({ query: trimmed }, { enabled: petsEnabled })
);
const pets = petResults?.pets ?? [];
+ const { data: tagResults, isLoading: loadingTags } = useQuery(
+ trpc.search.byHashtag.queryOptions(
+ { tag: normalizedTag },
+ { enabled: tagsEnabled }
+ )
+ );
+ const tagPosts = tagResults?.posts ?? [];
+
return (
Search
@@ -82,7 +70,7 @@ export default function SearchPage() {
{/* Pets tab */}
- {!enabled ? (
+ {!petsEnabled ? (
Type at least 2 characters to search
@@ -92,7 +80,7 @@ export default function SearchPage() {
) : pets.length === 0 ? (
- No pets found for “{debouncedQuery}”
+ No pets found for “{trimmed}”
) : (
@@ -137,11 +125,53 @@ export default function SearchPage() {
)}
- {/* Tags tab — implemented in Plan 03-06 */}
+ {/* Tags tab */}
-
- Hashtag search coming soon
-
+ {!tagsEnabled ? (
+
+ Type a hashtag to search
+
+ ) : loadingTags ? (
+
+
+
+ ) : tagPosts.length === 0 ? (
+
+ No posts tagged #{normalizedTag}
+
+ ) : (
+
+ {tagPosts.map((post) => {
+ const p = post as {
+ id: string;
+ caption: string | null;
+ images: Array<{ storageKey: string }>;
+ };
+ const firstImage = p.images?.[0];
+ const isCarousel = (p.images?.length ?? 0) > 1;
+ const mediaUrl = firstImage ? getMediaUrl(firstImage.storageKey) : null;
+ return (
+
+ {mediaUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+ {isCarousel && (
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx
index 20c2246..502ef4a 100644
--- a/src/components/feed/PostCard.tsx
+++ b/src/components/feed/PostCard.tsx
@@ -34,8 +34,10 @@ import {
} from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
+import Link from "next/link";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
+import { parseCaption } from "@/lib/parse-caption";
import { PawRing } from "@/components/stories/PawRing";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import { PawButton } from "@/components/feed/PawButton";
@@ -393,7 +395,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
) : (
- /* Caption display with 3-line clamp + more/less toggle */
+ /* Caption display with 3-line clamp + clickable hashtags + more/less toggle */
currentCaption && (
- {currentCaption}
+ {parseCaption(currentCaption).map((seg, i) =>
+ seg.type === "hashtag" ? (
+
+ #{seg.tag}
+
+ ) : (
+ {seg.text}
+ )
+ )}
{/* more/less toggle — instant, no animation (UI-SPEC) */}
{currentCaption.length > 140 && (
diff --git a/src/lib/hashtag-helpers.ts b/src/lib/hashtag-helpers.ts
new file mode 100644
index 0000000..9431cda
--- /dev/null
+++ b/src/lib/hashtag-helpers.ts
@@ -0,0 +1,41 @@
+import "server-only";
+import type { PrismaClient } from "@prisma/client";
+
+// g-flag regex is stateful — module-scoped so we reset lastIndex after each use
+const HASHTAG_REGEX = /#(\w{1,50})/g;
+const MAX_HASHTAGS = 30;
+
+export function extractHashtags(text: string | null | undefined): string[] {
+ if (!text) return [];
+ HASHTAG_REGEX.lastIndex = 0; // reset before use (Pitfall 3)
+ const matches: string[] = [];
+ let match: RegExpExecArray | null;
+ while ((match = HASHTAG_REGEX.exec(text)) !== null) {
+ matches.push(match[1]!.toLowerCase());
+ if (matches.length >= MAX_HASHTAGS) break;
+ }
+ HASHTAG_REGEX.lastIndex = 0; // reset after use
+ return [...new Set(matches)];
+}
+
+export async function saveHashtags(
+ prisma: PrismaClient,
+ postId: string,
+ tags: string[]
+): Promise
{
+ if (tags.length === 0) return;
+ await Promise.all(
+ tags.map(async (tag) => {
+ const hashtag = await prisma.hashtag.upsert({
+ where: { tag },
+ create: { tag },
+ update: {},
+ });
+ await prisma.postHashtag.upsert({
+ where: { postId_hashtagId: { postId, hashtagId: hashtag.id } },
+ create: { postId, hashtagId: hashtag.id },
+ update: {},
+ });
+ })
+ );
+}
diff --git a/src/lib/parse-caption.ts b/src/lib/parse-caption.ts
new file mode 100644
index 0000000..c2b5178
--- /dev/null
+++ b/src/lib/parse-caption.ts
@@ -0,0 +1,16 @@
+export type CaptionSegment =
+ | { type: "text"; text: string }
+ | { type: "hashtag"; tag: string };
+
+export function parseCaption(caption: string): CaptionSegment[] {
+ const segments: CaptionSegment[] = [];
+ const parts = caption.split(/(#\w{1,50})/g);
+ for (const part of parts) {
+ if (part.startsWith("#")) {
+ segments.push({ type: "hashtag", tag: part.slice(1).toLowerCase() });
+ } else if (part) {
+ segments.push({ type: "text", text: part });
+ }
+ }
+ return segments;
+}
diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts
index fe1c8f8..710e12a 100644
--- a/src/trpc/routers/posts.ts
+++ b/src/trpc/routers/posts.ts
@@ -11,6 +11,7 @@ import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { randomUUID } from "crypto";
import type { PrismaClient } from "@prisma/client";
+import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers";
/**
* Posts tRPC router — photo post creation, caption edit, deletion, feed hydration.
@@ -134,6 +135,10 @@ export const postsRouter = router({
// Fan-out to all followers' Redis feed inboxes (PHOTO-01)
await fanOutPost(redis, post.id, input.petId, post.createdAt, ctx.prisma);
+ // Index hashtags — never fails post creation
+ const tags = extractHashtags(input.caption ?? null);
+ await saveHashtags(ctx.prisma, post.id, tags).catch(() => {});
+
return post;
}),
diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts
index 5c35203..a32d6a9 100644
--- a/src/trpc/routers/reposts.ts
+++ b/src/trpc/routers/reposts.ts
@@ -4,6 +4,7 @@ import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
+import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers";
import type { PrismaClient } from "@prisma/client";
async function assertPetOwnership(
@@ -73,6 +74,10 @@ export const repostsRouter = router({
await fanOutPost(redis, post.id, input.reposterPetId, post.createdAt, ctx.prisma);
+ // Index hashtags from original post's caption — never fails repost creation
+ const tags = extractHashtags(originalPost.caption);
+ await saveHashtags(ctx.prisma, post.id, tags).catch(() => {});
+
return { postId: post.id };
}),
diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts
index e57bfaa..28c400b 100644
--- a/src/trpc/routers/search.ts
+++ b/src/trpc/routers/search.ts
@@ -1,6 +1,5 @@
import "server-only";
import { z } from "zod";
-import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
export const searchRouter = router({
@@ -47,12 +46,38 @@ export const searchRouter = router({
.max(50)
.transform((s) => s.replace(/^#/, "").toLowerCase()),
limit: z.number().int().min(1).max(30).default(20),
- cursor: z.string().optional(),
+ cursor: z.string().optional(), // postId cursor
})
)
- .query(
- async (): Promise<{ posts: Record[]; nextCursor: string | null }> => {
- throw new TRPCError({ code: "NOT_IMPLEMENTED" });
- }
- ),
+ .query(async ({ ctx, input }) => {
+ const hashtag = await ctx.prisma.hashtag.findUnique({
+ where: { tag: input.tag },
+ });
+ if (!hashtag) return { posts: [], nextCursor: null };
+
+ const rows = await ctx.prisma.postHashtag.findMany({
+ where: {
+ hashtagId: hashtag.id,
+ ...(input.cursor ? { postId: { lt: input.cursor } } : {}),
+ },
+ take: input.limit + 1,
+ orderBy: { post: { createdAt: "desc" } },
+ include: {
+ post: {
+ include: {
+ pet: { select: { id: true, name: true, avatarKey: true } },
+ images: { orderBy: { position: "asc" }, take: 1 },
+ },
+ },
+ },
+ });
+
+ const hasNextPage = rows.length > input.limit;
+ if (hasNextPage) rows.pop();
+ const posts = rows.map((r) => r.post);
+ return {
+ posts,
+ nextCursor: hasNextPage ? posts[posts.length - 1]!.id : null,
+ };
+ }),
});