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 + {p.caption + ) : ( +
+ )} + {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 + {p.caption + ) : ( +
+ )} + {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, + }; + }), });