From facea55115fbe5d09b8d382b6f6599396bc67ceb Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 29 Jun 2026 17:30:59 +0300 Subject: [PATCH 1/4] feat: increase search default limit to 20 and recommendation threshold to 0.3 (max 6) --- src/app/api/ai/events/[eventId]/recommendations/route.ts | 2 +- src/app/api/ai/search/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/ai/events/[eventId]/recommendations/route.ts b/src/app/api/ai/events/[eventId]/recommendations/route.ts index 20c492a..5b43138 100644 --- a/src/app/api/ai/events/[eventId]/recommendations/route.ts +++ b/src/app/api/ai/events/[eventId]/recommendations/route.ts @@ -50,7 +50,7 @@ export async function GET( const otherVecs = await generateBatchEmbeddings(otherTexts); - const scored = findMostSimilar(sourceVec, otherVecs, 0.4).slice(0, 5); + const scored = findMostSimilar(sourceVec, otherVecs, 0.3).slice(0, 6); const results: SearchResultDto[] = scored.map((s) => { const e = otherEvents[s.index]; diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index 6a347a4..7cdda0e 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -34,7 +34,7 @@ export async function GET( try { const { searchParams } = new URL(request.url); const query = searchParams.get("q")?.trim(); - const limit = Math.min(50, parseInt(searchParams.get("limit") || "10")); + const limit = Math.min(50, parseInt(searchParams.get("limit") || "20")); const city = searchParams.get("city"); const dateFrom = searchParams.get("dateFrom"); const dateTo = searchParams.get("dateTo"); From 1f1aa4d6d65d8e339bafcfb74bc1e774b799f67a Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 29 Jun 2026 17:39:24 +0300 Subject: [PATCH 2/4] fix: lower search similarity threshold from 0.3 to 0.2 --- src/app/api/ai/search/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index 7cdda0e..2c06fef 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -114,7 +114,7 @@ export async function GET( item, score: cosineSimilarity(queryVec, item.embedding), })) - .filter((s) => s.score > 0.3) + .filter((s) => s.score > 0.2) .sort((a, b) => b.score - a.score) .slice(0, limit); From cbd7f07a12ca9b63d5392938f47319b262bc9ea1 Mon Sep 17 00:00:00 2001 From: Tafita Mathieu <199898191+Mathieu-bot@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:53:17 +0300 Subject: [PATCH 3/4] feat: notification recommendations (#45) * feat: add RecommendedEventDto with reasons field * feat: add global recommendations API endpoint with reasons (favorites, viewing, venue, trending) * feat: add getGlobalRecommendations to API client * feat: add NotificationContext for global recommendations state * feat: add NotificationBell icon with red dot * feat: add RecommendationPanel slide-in sidebar with reasons * feat: add notification bell and recommendation panel to Nav * feat: add NotificationProvider to app providers * feat: expose setCurrentEventId from NotificationContext * feat: register current event context for global recommendations * fix: wrap Nav in Fragment, fix useMemo syntax, remove unused import * fix: add missing closing div in Nav * fix: move NavSelector inside Providers to fix useNotifications prerender error * Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --------- Co-authored-by: Mathieu-bot Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../api/ai/recommendations/global/route.ts | 196 ++++++++++++++++++ src/app/events/[eventId]/page.tsx | 10 +- src/app/layout.tsx | 2 +- src/app/providers.tsx | 5 +- src/components/layouts/Nav.tsx | 10 +- src/components/layouts/NotificationBell.tsx | 21 ++ .../layouts/RecommendationPanel.tsx | 184 ++++++++++++++++ src/lib/api.ts | 10 + src/lib/hooks/NotificationContext.tsx | 140 +++++++++++++ src/types/dto/index.ts | 4 + 10 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 src/app/api/ai/recommendations/global/route.ts create mode 100644 src/components/layouts/NotificationBell.tsx create mode 100644 src/components/layouts/RecommendationPanel.tsx create mode 100644 src/lib/hooks/NotificationContext.tsx diff --git a/src/app/api/ai/recommendations/global/route.ts b/src/app/api/ai/recommendations/global/route.ts new file mode 100644 index 0000000..b745069 --- /dev/null +++ b/src/app/api/ai/recommendations/global/route.ts @@ -0,0 +1,196 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/db/prisma"; +import { + generateEmbedding, + generateBatchEmbeddings, + findMostSimilar, +} from "@/ai"; +import type { RecommendedEventDto } from "@/types/dto"; + +const MIN_SCORE = 0.2; +const MAX_RESULTS = 6; + +export async function GET( + request: NextRequest, +): Promise< + NextResponse<{ data: RecommendedEventDto[] } | { error: string }> +> { + try { + const { searchParams } = new URL(request.url); + const favoriteIds = searchParams + .get("favorites") + ?.split(",") + .filter(Boolean); + const currentEventId = searchParams.get("currentEventId"); + + const now = new Date(); + + // 1. Resolve favorite session IDs → parent event IDs + let favoriteEventIds: string[] = []; + if (favoriteIds && favoriteIds.length > 0) { + const sessions = await prisma.eventSession.findMany({ + where: { id: { in: favoriteIds } }, + select: { eventId: true }, + }); + favoriteEventIds = [...new Set(sessions.map((s) => s.eventId))]; + } + + // 2. Fetch source events (favorites + current page) + const sourceIds = [ + ...new Set([ + ...favoriteEventIds, + ...(currentEventId ? [currentEventId] : []), + ]), + ]; + + const sourceEvents = + sourceIds.length > 0 + ? await prisma.event.findMany({ where: { id: { in: sourceIds } } }) + : []; + + // 3. Candidate pool: upcoming/live events, excluding sources + const excludeIds = [...new Set(sourceIds)]; + const candidateEvents = await prisma.event.findMany({ + where: { + id: { notIn: excludeIds }, + endDate: { gte: now }, + }, + include: { venue: true }, + orderBy: { startDate: "asc" }, + }); + + if (candidateEvents.length === 0) { + return NextResponse.json({ data: [] }); + } + + // 4. Batch-embed candidates + const candidateTexts = candidateEvents.map((e) => + [e.title, e.description, e.venue?.name ?? ""] + .filter(Boolean) + .join(" "), + ); + const candidateVecs = await generateBatchEmbeddings(candidateTexts); + + // 5. Score each source against candidates + const scored = new Map< + string, + { score: number; reasons: Set } + >(); + + const addIfBetter = ( + eventId: string, + score: number, + reason: string, + ) => { + const existing = scored.get(eventId); + if (!existing || score > existing.score) { + scored.set(eventId, { score, reasons: new Set([reason]) }); + } else if (existing && score > 0) { + existing.reasons.add(reason); + } + }; + + for (const source of sourceEvents) { + const sourceText = [source.title, source.description] + .filter(Boolean) + .join(" "); + const sourceVec = await generateEmbedding(sourceText); + const similar = findMostSimilar(sourceVec, candidateVecs, MIN_SCORE); + + let reason = ""; + if (currentEventId === source.id) { + reason = `Similar to ${source.title} you're exploring`; + } else if (favoriteEventIds.includes(source.id)) { + reason = `Because you favorited a session in ${source.title}`; + } + + for (const s of similar) { + addIfBetter(candidateEvents[s.index].id, s.score, reason); + } + } + + // 6. Trending — events with most upvoted questions + const trending = await prisma.event.findMany({ + where: { + id: { notIn: excludeIds }, + endDate: { gte: now }, + }, + include: { + eventSessions: { + include: { questions: { select: { upvotes: true } } }, + }, + }, + }); + + const trendingUpvotes = trending.map((e) => { + const total = e.eventSessions.reduce( + (sum, s) => + sum + s.questions.reduce((qsum, q) => qsum + q.upvotes, 0), + 0, + ); + return { id: e.id, upvotes: total }; + }); + + const maxUpvotes = Math.max(...trendingUpvotes.map((t) => t.upvotes), 1); + for (const t of trendingUpvotes) { + if (t.upvotes === 0) continue; + const normalized = t.upvotes / maxUpvotes; + addIfBetter(t.id, normalized * 0.5, "Trending among attendees"); + } + + // 7. Also check same venue as a source + for (const source of sourceEvents) { + if (!source.venueId) continue; + for (const c of candidateEvents) { + if (c.venueId === source.venueId) { + addIfBetter( + c.id, + 0.25, + `Also happening at ${c.venue?.name ?? "the same venue"}`, + ); + } + } + } + + // 8. Sort and cap + const sorted = [...scored.entries()] + .sort((a, b) => b[1].score - a[1].score) + .slice(0, MAX_RESULTS); + + const data: RecommendedEventDto[] = sorted.map(([id, { score, reasons }]) => { + const event = candidateEvents.find((e) => e.id === id)!; + const v = event.venue; + return { + id: event.id, + title: event.title, + description: event.description, + startDate: event.startDate.toISOString(), + endDate: event.endDate.toISOString(), + isOnline: event.isOnline, + type: "event", + score: Math.round(score * 1000) / 1000, + match: { + venue: v + ? { + id: v.id, + name: v.name, + city: v.city, + neighborhood: v.neighborhood, + totalRooms: v.totalRooms, + } + : null, + speakers: [], + }, + reasons: [...new Set(reasons)], + }; + }); + + return NextResponse.json({ data }, { status: 200 }); + } catch (err) { + console.error("GET /api/ai/recommendations/global error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/events/[eventId]/page.tsx b/src/app/events/[eventId]/page.tsx index 772cb78..9919355 100644 --- a/src/app/events/[eventId]/page.tsx +++ b/src/app/events/[eventId]/page.tsx @@ -1,10 +1,11 @@ "use client"; import Link from "next/link"; -import { useState, useRef, useCallback, useMemo } from "react"; +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { useGetEvent } from "@/lib/hooks/useEvents"; +import { useNotifications } from "@/lib/hooks/NotificationContext"; import { api } from "@/lib/api"; import { EventHero } from "@/components/events/EventHero"; import { EventInfoGrid } from "@/components/events/EventInfoGrid"; @@ -32,6 +33,7 @@ function NotFound() { export default function EventDetailPage() { const { eventId } = useParams<{ eventId: string }>(); const { data: event, isLoading } = useGetEvent(eventId); + const { setCurrentEventId } = useNotifications(); const { data: recsData } = useQuery({ queryKey: ["recommendations", eventId], queryFn: () => api.getRecommendations(eventId), @@ -43,6 +45,12 @@ export default function EventDetailPage() { const [statusFilter, setStatusFilter] = useState("all"); const [tablePage, setTablePage] = useState(1); + // Register this event as the current context for global recommendations + useEffect(() => { + setCurrentEventId(eventId); + return () => setCurrentEventId(null); + }, [eventId, setCurrentEventId]); + const viewPillRefs = useRef<(HTMLButtonElement | null)[]>([]); const statusPillRefs = useRef<(HTMLButtonElement | null)[]>([]); const dayPillRefs = useRef<(HTMLButtonElement | null)[]>([]); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 238081a..77f6179 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -53,8 +53,8 @@ export default function RootLayout({ - + {children} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 7597420..59c38c8 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; import { ToastProvider } from "@/components/ui/Toast"; import { FavoritesProvider } from "@/lib/hooks/FavoritesContext"; +import { NotificationProvider } from "@/lib/hooks/NotificationContext"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -21,7 +22,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); diff --git a/src/components/layouts/Nav.tsx b/src/components/layouts/Nav.tsx index 53c392e..0ea123c 100644 --- a/src/components/layouts/Nav.tsx +++ b/src/components/layouts/Nav.tsx @@ -5,6 +5,8 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { motion } from "motion/react"; +import { NotificationBell } from "./NotificationBell"; +import { RecommendationPanel } from "./RecommendationPanel"; export function Nav() { const pathname = usePathname(); @@ -61,7 +63,8 @@ export function Nav() { const isFavoritesActive = pathname.startsWith("/favorites"); return ( -