feat(03-06): Hashtag search end-to-end — Phase 3 complete

- hashtag-helpers.ts: extractHashtags (regex+dedup) + saveHashtags (upsert)
- parse-caption.ts: parseCaption → CaptionSegment[] for client rendering
- search.byHashtag GREEN: hashtag.findUnique → postHashtag.findMany + pagination
- posts.create + reposts.create: extract+save hashtags after post creation
- /hashtag/[tag]: infinite scroll post grid + useInView sentinel
- /search Tags tab: byHashtag query wired, 3-col post grid
- PostCard: parseCaption replaces raw caption — hashtags are orange Link to /hashtag/[tag]
- STATE.md: Phase 3 complete, 3/7 phases done

Tests: 67 passing, 0 failing (5 todo), tsc clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 12:16:03 +02:00
1 parent 59a3be27b0
commit 847ac97bde
10 files changed
+353 -53

No files matched your search

+9 -10
View File
@@ -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
@@ -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 `<p>`
- `type === "hashtag"` → `<Link href="/hashtag/{tag}">#{tag}</Link>` (text-orange-500 hover:underline)
- `type === "text"` → `<span>{text}</span>`
## 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
+107
View File
@@ -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 (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-1">#{normalizedTag}</h1>
{totalApprox !== null && (
<p className="text-sm text-muted-foreground mb-6">
{totalApprox} posts
</p>
)}
{isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : posts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No posts tagged #{normalizedTag} yet
</p>
) : (
<>
<div className="grid grid-cols-3 gap-0.5">
{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 (
<div
key={p.id}
className="relative aspect-square overflow-hidden bg-muted"
>
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={p.caption ?? "Post"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
{isCarousel && (
<div className="absolute top-1.5 right-1.5">
<Layers2 className="h-4 w-4 text-white drop-shadow" aria-hidden="true" />
</div>
)}
</div>
);
})}
</div>
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-4" />
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
</>
)}
</div>
);
}
+64 -34
View File
@@ -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<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useState<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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 (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Search</h1>
@@ -82,7 +70,7 @@ export default function SearchPage() {
{/* Pets tab */}
<TabsContent value="pets">
{!enabled ? (
{!petsEnabled ? (
<p className="text-sm text-muted-foreground text-center py-10">
Type at least 2 characters to search
</p>
@@ -92,7 +80,7 @@ export default function SearchPage() {
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No pets found for &ldquo;{debouncedQuery}&rdquo;
No pets found for &ldquo;{trimmed}&rdquo;
</p>
) : (
<div className="space-y-2">
@@ -137,11 +125,53 @@ export default function SearchPage() {
)}
</TabsContent>
{/* Tags tab — implemented in Plan 03-06 */}
{/* Tags tab */}
<TabsContent value="tags">
<p className="text-sm text-muted-foreground text-center py-10">
Hashtag search coming soon
</p>
{!tagsEnabled ? (
<p className="text-sm text-muted-foreground text-center py-10">
Type a hashtag to search
</p>
) : loadingTags ? (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tagPosts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No posts tagged #{normalizedTag}
</p>
) : (
<div className="grid grid-cols-3 gap-0.5 mt-0.5">
{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 (
<div key={p.id} className="relative aspect-square overflow-hidden bg-muted">
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={p.caption ?? "Post"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
{isCarousel && (
<div className="absolute top-1.5 right-1.5">
<Layers2 className="h-4 w-4 text-white drop-shadow" aria-hidden="true" />
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</div>
+16 -2
View File
@@ -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) {
</div>
</div>
) : (
/* Caption display with 3-line clamp + more/less toggle */
/* Caption display with 3-line clamp + clickable hashtags + more/less toggle */
currentCaption && (
<div>
<p
@@ -401,7 +403,19 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
!isCaptionExpanded ? "line-clamp-3" : ""
}`}
>
{currentCaption}
{parseCaption(currentCaption).map((seg, i) =>
seg.type === "hashtag" ? (
<Link
key={i}
href={`/hashtag/${seg.tag}`}
className="text-orange-500 hover:underline"
>
#{seg.tag}
</Link>
) : (
<span key={i}>{seg.text}</span>
)
)}
</p>
{/* more/less toggle — instant, no animation (UI-SPEC) */}
{currentCaption.length > 140 && (
+41
View File
@@ -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<void> {
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: {},
});
})
);
}
+16
View File
@@ -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;
}
+5
View File
@@ -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;
}),
+5
View File
@@ -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 };
}),
+32 -7
View File
@@ -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<string, unknown>[]; 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,
};
}),
});