From f9a688fab6485352496fc7e72afb3a9ffbc4a515 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 15:59:09 +0300 Subject: [PATCH 01/19] feat(web): add mobile routing groundwork --- apps/web/src/routeTree.gen.ts | 169 ++++ apps/web/src/routes/activitate.tsx | 931 +++++++++++++++++ apps/web/src/routes/contact.tsx | 17 + apps/web/src/routes/cum-functioneaza.tsx | 17 + apps/web/src/routes/dashboard.tsx | 932 +----------------- apps/web/src/routes/despre.tsx | 17 + apps/web/src/routes/index.tsx | 379 +------ apps/web/src/routes/parteneri.tsx | 17 + .../src/routes/politica-confidentialitate.tsx | 19 + apps/web/src/routes/sursele-noastre.tsx | 17 + apps/web/src/routes/termeni.tsx | 17 + 11 files changed, 1229 insertions(+), 1303 deletions(-) create mode 100644 apps/web/src/routes/activitate.tsx create mode 100644 apps/web/src/routes/contact.tsx create mode 100644 apps/web/src/routes/cum-functioneaza.tsx create mode 100644 apps/web/src/routes/despre.tsx create mode 100644 apps/web/src/routes/parteneri.tsx create mode 100644 apps/web/src/routes/politica-confidentialitate.tsx create mode 100644 apps/web/src/routes/sursele-noastre.tsx create mode 100644 apps/web/src/routes/termeni.tsx diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 842d2dd..45707ae 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,12 +10,20 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' +import { Route as TermeniRouteImport } from './routes/termeni' +import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as ResetPasswordRouteImport } from './routes/reset-password' +import { Route as PoliticaConfidentialitateRouteImport } from './routes/politica-confidentialitate' +import { Route as ParteneriRouteImport } from './routes/parteneri' import { Route as FeedRouteImport } from './routes/feed' +import { Route as DespreRouteImport } from './routes/despre' import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as CumFunctioneazaRouteImport } from './routes/cum-functioneaza' +import { Route as ContactRouteImport } from './routes/contact' import { Route as BookmarksRouteImport } from './routes/bookmarks' +import { Route as ActivitateRouteImport } from './routes/activitate' import { Route as IndexRouteImport } from './routes/index' import { Route as SourceSourceIdRouteImport } from './routes/source.$sourceId' import { Route as EventSlugRouteImport } from './routes/event.$slug' @@ -26,6 +34,16 @@ const UnsubscribeRoute = UnsubscribeRouteImport.update({ path: '/unsubscribe', getParentRoute: () => rootRouteImport, } as any) +const TermeniRoute = TermeniRouteImport.update({ + id: '/termeni', + path: '/termeni', + getParentRoute: () => rootRouteImport, +} as any) +const SurseleNoastreRoute = SurseleNoastreRouteImport.update({ + id: '/sursele-noastre', + path: '/sursele-noastre', + getParentRoute: () => rootRouteImport, +} as any) const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ id: '/sitemap.xml', path: '/sitemap.xml', @@ -41,21 +59,52 @@ const ResetPasswordRoute = ResetPasswordRouteImport.update({ path: '/reset-password', getParentRoute: () => rootRouteImport, } as any) +const PoliticaConfidentialitateRoute = + PoliticaConfidentialitateRouteImport.update({ + id: '/politica-confidentialitate', + path: '/politica-confidentialitate', + getParentRoute: () => rootRouteImport, + } as any) +const ParteneriRoute = ParteneriRouteImport.update({ + id: '/parteneri', + path: '/parteneri', + getParentRoute: () => rootRouteImport, +} as any) const FeedRoute = FeedRouteImport.update({ id: '/feed', path: '/feed', getParentRoute: () => rootRouteImport, } as any) +const DespreRoute = DespreRouteImport.update({ + id: '/despre', + path: '/despre', + getParentRoute: () => rootRouteImport, +} as any) const DashboardRoute = DashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', getParentRoute: () => rootRouteImport, } as any) +const CumFunctioneazaRoute = CumFunctioneazaRouteImport.update({ + id: '/cum-functioneaza', + path: '/cum-functioneaza', + getParentRoute: () => rootRouteImport, +} as any) +const ContactRoute = ContactRouteImport.update({ + id: '/contact', + path: '/contact', + getParentRoute: () => rootRouteImport, +} as any) const BookmarksRoute = BookmarksRouteImport.update({ id: '/bookmarks', path: '/bookmarks', getParentRoute: () => rootRouteImport, } as any) +const ActivitateRoute = ActivitateRouteImport.update({ + id: '/activitate', + path: '/activitate', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -79,12 +128,20 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/activitate': typeof ActivitateRoute '/bookmarks': typeof BookmarksRoute + '/contact': typeof ContactRoute + '/cum-functioneaza': typeof CumFunctioneazaRoute '/dashboard': typeof DashboardRoute + '/despre': typeof DespreRoute '/feed': typeof FeedRoute + '/parteneri': typeof ParteneriRoute + '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/sursele-noastre': typeof SurseleNoastreRoute + '/termeni': typeof TermeniRoute '/unsubscribe': typeof UnsubscribeRoute '/event/$slug': typeof EventSlugRoute '/source/$sourceId': typeof SourceSourceIdRoute @@ -92,12 +149,20 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/activitate': typeof ActivitateRoute '/bookmarks': typeof BookmarksRoute + '/contact': typeof ContactRoute + '/cum-functioneaza': typeof CumFunctioneazaRoute '/dashboard': typeof DashboardRoute + '/despre': typeof DespreRoute '/feed': typeof FeedRoute + '/parteneri': typeof ParteneriRoute + '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/sursele-noastre': typeof SurseleNoastreRoute + '/termeni': typeof TermeniRoute '/unsubscribe': typeof UnsubscribeRoute '/event/$slug': typeof EventSlugRoute '/source/$sourceId': typeof SourceSourceIdRoute @@ -106,12 +171,20 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/activitate': typeof ActivitateRoute '/bookmarks': typeof BookmarksRoute + '/contact': typeof ContactRoute + '/cum-functioneaza': typeof CumFunctioneazaRoute '/dashboard': typeof DashboardRoute + '/despre': typeof DespreRoute '/feed': typeof FeedRoute + '/parteneri': typeof ParteneriRoute + '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/sursele-noastre': typeof SurseleNoastreRoute + '/termeni': typeof TermeniRoute '/unsubscribe': typeof UnsubscribeRoute '/event/$slug': typeof EventSlugRoute '/source/$sourceId': typeof SourceSourceIdRoute @@ -121,12 +194,20 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/activitate' | '/bookmarks' + | '/contact' + | '/cum-functioneaza' | '/dashboard' + | '/despre' | '/feed' + | '/parteneri' + | '/politica-confidentialitate' | '/reset-password' | '/robots.txt' | '/sitemap.xml' + | '/sursele-noastre' + | '/termeni' | '/unsubscribe' | '/event/$slug' | '/source/$sourceId' @@ -134,12 +215,20 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/activitate' | '/bookmarks' + | '/contact' + | '/cum-functioneaza' | '/dashboard' + | '/despre' | '/feed' + | '/parteneri' + | '/politica-confidentialitate' | '/reset-password' | '/robots.txt' | '/sitemap.xml' + | '/sursele-noastre' + | '/termeni' | '/unsubscribe' | '/event/$slug' | '/source/$sourceId' @@ -147,12 +236,20 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/activitate' | '/bookmarks' + | '/contact' + | '/cum-functioneaza' | '/dashboard' + | '/despre' | '/feed' + | '/parteneri' + | '/politica-confidentialitate' | '/reset-password' | '/robots.txt' | '/sitemap.xml' + | '/sursele-noastre' + | '/termeni' | '/unsubscribe' | '/event/$slug' | '/source/$sourceId' @@ -161,12 +258,20 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ActivitateRoute: typeof ActivitateRoute BookmarksRoute: typeof BookmarksRoute + ContactRoute: typeof ContactRoute + CumFunctioneazaRoute: typeof CumFunctioneazaRoute DashboardRoute: typeof DashboardRoute + DespreRoute: typeof DespreRoute FeedRoute: typeof FeedRoute + ParteneriRoute: typeof ParteneriRoute + PoliticaConfidentialitateRoute: typeof PoliticaConfidentialitateRoute ResetPasswordRoute: typeof ResetPasswordRoute RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute + SurseleNoastreRoute: typeof SurseleNoastreRoute + TermeniRoute: typeof TermeniRoute UnsubscribeRoute: typeof UnsubscribeRoute EventSlugRoute: typeof EventSlugRoute SourceSourceIdRoute: typeof SourceSourceIdRoute @@ -182,6 +287,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UnsubscribeRouteImport parentRoute: typeof rootRouteImport } + '/termeni': { + id: '/termeni' + path: '/termeni' + fullPath: '/termeni' + preLoaderRoute: typeof TermeniRouteImport + parentRoute: typeof rootRouteImport + } + '/sursele-noastre': { + id: '/sursele-noastre' + path: '/sursele-noastre' + fullPath: '/sursele-noastre' + preLoaderRoute: typeof SurseleNoastreRouteImport + parentRoute: typeof rootRouteImport + } '/sitemap.xml': { id: '/sitemap.xml' path: '/sitemap.xml' @@ -203,6 +322,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResetPasswordRouteImport parentRoute: typeof rootRouteImport } + '/politica-confidentialitate': { + id: '/politica-confidentialitate' + path: '/politica-confidentialitate' + fullPath: '/politica-confidentialitate' + preLoaderRoute: typeof PoliticaConfidentialitateRouteImport + parentRoute: typeof rootRouteImport + } + '/parteneri': { + id: '/parteneri' + path: '/parteneri' + fullPath: '/parteneri' + preLoaderRoute: typeof ParteneriRouteImport + parentRoute: typeof rootRouteImport + } '/feed': { id: '/feed' path: '/feed' @@ -210,6 +343,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FeedRouteImport parentRoute: typeof rootRouteImport } + '/despre': { + id: '/despre' + path: '/despre' + fullPath: '/despre' + preLoaderRoute: typeof DespreRouteImport + parentRoute: typeof rootRouteImport + } '/dashboard': { id: '/dashboard' path: '/dashboard' @@ -217,6 +357,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardRouteImport parentRoute: typeof rootRouteImport } + '/cum-functioneaza': { + id: '/cum-functioneaza' + path: '/cum-functioneaza' + fullPath: '/cum-functioneaza' + preLoaderRoute: typeof CumFunctioneazaRouteImport + parentRoute: typeof rootRouteImport + } + '/contact': { + id: '/contact' + path: '/contact' + fullPath: '/contact' + preLoaderRoute: typeof ContactRouteImport + parentRoute: typeof rootRouteImport + } '/bookmarks': { id: '/bookmarks' path: '/bookmarks' @@ -224,6 +378,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BookmarksRouteImport parentRoute: typeof rootRouteImport } + '/activitate': { + id: '/activitate' + path: '/activitate' + fullPath: '/activitate' + preLoaderRoute: typeof ActivitateRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -257,12 +418,20 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ActivitateRoute: ActivitateRoute, BookmarksRoute: BookmarksRoute, + ContactRoute: ContactRoute, + CumFunctioneazaRoute: CumFunctioneazaRoute, DashboardRoute: DashboardRoute, + DespreRoute: DespreRoute, FeedRoute: FeedRoute, + ParteneriRoute: ParteneriRoute, + PoliticaConfidentialitateRoute: PoliticaConfidentialitateRoute, ResetPasswordRoute: ResetPasswordRoute, RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, + SurseleNoastreRoute: SurseleNoastreRoute, + TermeniRoute: TermeniRoute, UnsubscribeRoute: UnsubscribeRoute, EventSlugRoute: EventSlugRoute, SourceSourceIdRoute: SourceSourceIdRoute, diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx new file mode 100644 index 0000000..a04a22d --- /dev/null +++ b/apps/web/src/routes/activitate.tsx @@ -0,0 +1,931 @@ +import SignInForm from "@/components/sign-in-form"; +import SignUpForm from "@/components/sign-up-form"; +import UserMenu from "@/components/user-menu"; +import BiasBalanceMeter from "@/components/bias-balance-meter"; +import StreakActivityCalendar from "@/components/streak-activity-calendar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { api } from "@news-app/backend/convex/_generated/api"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { + Authenticated, + AuthLoading, + Unauthenticated, + useMutation as useConvexMutationHook, + useQuery, +} from "convex/react"; +import type { FormEvent } from "react"; +import { useEffect, useRef, useState } from "react"; +import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect"; +import { formatRelativeTimestamp } from "@/lib/dates"; +import { toast } from "sonner"; +import { z } from "zod"; +import { + Bookmark, + ChevronRight, + Flame, + Newspaper, + Sparkles, +} from "lucide-react"; + +const searchSchema = z.object({ + mode: z.enum(["signin", "signup"]).optional(), + redirect: z.string().optional(), + verified: z.union([z.string(), z.number()]).optional(), +}); + +function formatReadDuration(seconds?: number) { + if (!seconds || seconds <= 0) return null; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + return `${minutes} min`; +} + +function formatScrollDepth(percentage?: number) { + if (percentage === undefined) return null; + return `${Math.round(percentage * 100)}% depth`; +} + +function getBiasSnapshotLabel(balance: number) { + const absolute = Math.abs(balance); + if (absolute < 15) return "Balanced mix this week"; + if (balance < 0) { + return absolute >= 60 + ? "Strongly left-leaning this week" + : "Leaning left this week"; + } + return absolute >= 60 + ? "Strongly right-leaning this week" + : "Leaning right this week"; +} + +function getNextStreakMilestone(streak: number) { + const milestones = [7, 30, 100, 365]; + return milestones.find((milestone) => milestone > streak) ?? null; +} + +export const Route = createFileRoute("/activitate")({ + validateSearch: searchSchema, + head: () => ({ + meta: [ + { title: "Dashboard — Biviant" }, + { name: "robots", content: "noindex, nofollow" }, + ], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const search = Route.useSearch(); + const navigate = useNavigate({ from: "/activitate" }); + const redirectTo: AuthRedirectPath = + search.redirect && isAuthRedirectPath(search.redirect) + ? search.redirect + : "/feed"; + const hasShownVerifiedToastRef = useRef(false); + const isVerified = String(search.verified) === "1"; + const showSignIn = search.mode !== "signup" || isVerified; + + useEffect(() => { + if (!isVerified || hasShownVerifiedToastRef.current) { + return; + } + + hasShownVerifiedToastRef.current = true; + toast.message("If you recently verified, you can now log in."); + void navigate({ + search: (current) => ({ + ...current, + mode: "signin", + verified: undefined, + }), + replace: true, + }); + }, [isVerified, navigate]); + + return ( + <> + + + + +
+
+
+
+
+

+ Biviant Account +

+

+ {showSignIn ? "Welcome back" : "Create your free account"} +

+

+ Read every event, source profile, and search result without + an account. Sign in when you want synced bookmarks, + personalized ranking, and notifications. +

+
+ +
+ {showSignIn ? ( + { + void navigate({ + search: (current) => ({ + ...current, + mode: "signup", + }), + }); + }} + /> + ) : ( + { + void navigate({ + search: (current) => ({ + ...current, + mode: "signin", + }), + }); + }} + /> + )} +
+
+ +
+
+

+ What accounts unlock +

+

+ The content stays open. Accounts are only for personal, + persistent features. +

+
    +
  1. + + 1 + + Save bookmarks and keep them synced across devices. +
  2. +
  3. + + 2 + + Personalize ranking using your reading habits and + interests. +
  4. +
  5. + + 3 + + Get notifications, digests, and future account-only tools. +
  6. +
+
+ +
+

+ Verification matters +

+

+ New email/password signups require email verification before + first sign-in. Google sign-in continues to work as a + one-step path when the provider has already verified the + address. +

+
+
+
+
+
+
+ + + + + ); +} + +function AuthorizedDashboard() { + const currentUser = useQuery(api.user.getCurrentUser); + const dashboardOverview = useQuery(api.interactions.getDashboardOverview); + const isAdmin = useQuery(api.user.isCurrentUserAdmin); + const topicDiagnostics = useQuery( + api.clustering.getRecentTopicInferenceDiagnosticsForAdmin, + isAdmin ? { limit: 10 } : "skip", + ); + const setConfig = useConvexMutationHook(api.config.set); + const [minScoreInput, setMinScoreInput] = useState(""); + const [confidenceRatioInput, setConfidenceRatioInput] = useState(""); + const [maxTopicsInput, setMaxTopicsInput] = useState(""); + const [configMessage, setConfigMessage] = useState(""); + const [isSavingConfig, setIsSavingConfig] = useState(false); + const currentSettings = topicDiagnostics?.[0]?.settings; + + useEffect(() => { + if (!currentSettings) return; + setMinScoreInput(String(currentSettings.minScore)); + setConfidenceRatioInput(String(currentSettings.confidenceRatio)); + setMaxTopicsInput(String(currentSettings.maxTopics)); + }, [ + currentSettings?.minScore, + currentSettings?.confidenceRatio, + currentSettings?.maxTopics, + ]); + + if ( + currentUser === undefined || + dashboardOverview === undefined || + isAdmin === undefined + ) { + return ( + + ); + } + + const hasConfigChanges = + !!currentSettings && + (minScoreInput !== String(currentSettings.minScore) || + confidenceRatioInput !== String(currentSettings.confidenceRatio) || + maxTopicsInput !== String(currentSettings.maxTopics)); + + const handleResetConfig = () => { + if (!currentSettings) return; + setMinScoreInput(String(currentSettings.minScore)); + setConfidenceRatioInput(String(currentSettings.confidenceRatio)); + setMaxTopicsInput(String(currentSettings.maxTopics)); + setConfigMessage(""); + }; + + const handleSaveConfig = async (event: FormEvent) => { + event.preventDefault(); + if (!currentSettings || isSavingConfig) return; + + const minScore = Number(minScoreInput); + const confidenceRatio = Number(confidenceRatioInput); + const maxTopics = Number(maxTopicsInput); + + if (!Number.isFinite(minScore) || minScore < 1 || minScore > 20) { + setConfigMessage("Min score must be a number between 1 and 20."); + return; + } + if ( + !Number.isFinite(confidenceRatio) || + confidenceRatio < 0.1 || + confidenceRatio > 1 + ) { + setConfigMessage("Confidence ratio must be between 0.1 and 1."); + return; + } + if (!Number.isInteger(maxTopics) || maxTopics < 1 || maxTopics > 5) { + setConfigMessage("Max topics must be a whole number between 1 and 5."); + return; + } + + setIsSavingConfig(true); + setConfigMessage(""); + + try { + await Promise.all([ + setConfig({ + key: "topic_inference_min_score", + value: JSON.stringify(minScore), + description: + "Minimum weighted lexical score required before a topic is attached to a clustered event.", + }), + setConfig({ + key: "topic_inference_confidence_ratio", + value: JSON.stringify(confidenceRatio), + description: + "Relative score threshold for keeping additional inferred topics alongside the top-scoring topic.", + }), + setConfig({ + key: "topic_inference_max_topics", + value: JSON.stringify(maxTopics), + description: + "Maximum number of inferred topics attached to an event during clustering.", + }), + ]); + setConfigMessage("Topic inference settings saved."); + } catch (error) { + console.error("Failed to save topic inference settings:", error); + setConfigMessage("Could not save settings. Please try again."); + } finally { + setIsSavingConfig(false); + } + }; + + const userName = currentUser?.profile?.name || currentUser?.email || "User"; + const readingStreak = + dashboardOverview?.stats.currentStreak ?? + currentUser?.stats.currentStreak ?? + 0; + const longestStreak = + dashboardOverview?.stats.longestStreak ?? + currentUser?.stats.longestStreak ?? + 0; + const articlesRead = + dashboardOverview?.stats.articlesRead ?? + currentUser?.stats.articlesRead ?? + 0; + const biasBalance = + dashboardOverview?.stats.biasBalance ?? currentUser?.stats.biasBalance ?? 0; + const bookmarkCount = dashboardOverview?.stats.bookmarkCount ?? 0; + const eventsExplored = dashboardOverview?.stats.eventsExplored ?? 0; + const recentHistory = dashboardOverview?.recentHistory ?? []; + const recentBookmarks = dashboardOverview?.recentBookmarks ?? []; + const streakDays = dashboardOverview?.streakCalendar.days ?? []; + const activeDays = dashboardOverview?.streakCalendar.activeDays ?? 0; + const weeklyBiasReads = dashboardOverview?.weeklyBiasSummary.reads ?? 0; + const weeklyBiasBalance = dashboardOverview?.weeklyBiasSummary.balance ?? 0; + const nextStreakMilestone = getNextStreakMilestone(readingStreak); + + return ( +
+
+
+ {/* Header - Clean and minimal */} +
+
+
+ {userName.charAt(0).toUpperCase()} +
+
+

Welcome back

+

+ {userName.split(" ")[0]} +

+
+
+ +
+ + {/* Bento Grid - Main Stats */} +
+ {/* Streak - Prominent */} +
+
+
+ +
+
+

+ {readingStreak} +

+

day streak

+
+
+ {nextStreakMilestone && readingStreak > 0 && ( +

+ {nextStreakMilestone - readingStreak} days to{" "} + {nextStreakMilestone} +

+ )} +
+ + {/* Articles Read */} +
+
+
+ +
+
+

+ {articlesRead} +

+

articles read

+
+
+
+ + {/* Events Explored */} +
+
+
+ +
+
+

+ {eventsExplored} +

+

+ events explored +

+
+
+
+ + {/* Bookmarks */} +
+
+
+ +
+
+

+ {bookmarkCount} +

+

bookmarked

+
+
+
+
+ + {/* Activity + Bias Row */} +
+ {/* Activity Calendar */} +
+
+
+

Activity

+

+ Last 12 weeks of reading +

+
+
+
+

+ {readingStreak} +

+

+ Current +

+
+
+

+ {longestStreak} +

+

+ Best +

+
+
+

+ {activeDays} +

+

+ Active +

+
+
+
+
+ +
+
+ + {/* Bias Balance */} +
+

Bias Balance

+

+ Your reading perspective mix +

+
+ +
+ {weeklyBiasReads > 0 && ( +

+ {getBiasSnapshotLabel(weeklyBiasBalance)} across{" "} + {weeklyBiasReads} reads +

+ )} +
+
+ + {/* Recent Activity */} +
+ {/* Reading History */} +
+
+
+

Recent Reading

+

+ Events you spent time with +

+
+ +
+
+ {recentHistory.length === 0 ? ( +
+ Your reading history will appear here +
+ ) : ( +
+ {recentHistory.slice(0, 4).map((entry) => { + const durationLabel = formatReadDuration( + entry.metadata.timeSpentSeconds, + ); + const scrollLabel = formatScrollDepth( + entry.metadata.scrollDepthPercentage, + ); + const detailBits = [durationLabel, scrollLabel].filter( + Boolean, + ); + + return ( + +
+ {entry.event.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {entry.event.title} +

+
+ + {formatRelativeTimestamp(entry.lastViewedAt)} + + {detailBits.length > 0 && ( + <> + · + {detailBits.join(" · ")} + + )} +
+
+ + ); + })} +
+ )} +
+
+ + {/* Bookmarks */} +
+
+
+

Bookmarks

+

+ Saved for later +

+
+ +
+
+ {recentBookmarks.length === 0 ? ( +
+ Bookmark events from the feed +
+ ) : ( +
+ {recentBookmarks.slice(0, 4).map((entry) => ( + +
+ {entry.event.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {entry.event.title} +

+
+ + {formatRelativeTimestamp(entry.bookmarkedAt)} + + · + + {(entry.event.sourceCount ?? 0)} source + {(entry.event.sourceCount ?? 0) === 1 ? "" : "s"} + +
+
+ + ))} +
+ )} +
+
+
+ + {/* Quick Actions */} +
+ +
+ +
+
+

Browse Feed

+

+ See today's stories +

+
+ + + +
+ +
+
+

Bookmarks

+

+ {bookmarkCount} saved{" "} + {bookmarkCount === 1 ? "story" : "stories"} +

+
+ +
+ + {/* Admin: Topic Diagnostics */} + {isAdmin && topicDiagnostics && topicDiagnostics.length > 0 && ( +
+
+

+ Topic Inference Diagnostics +

+

+ Review recent event topic assignments +

+
+ +
+ + + +
+ + {currentSettings && ( +
+
+
+ + setMinScoreInput(e.target.value)} + disabled={isSavingConfig} + /> +
+
+ + + setConfidenceRatioInput(e.target.value) + } + disabled={isSavingConfig} + /> +
+
+ + setMaxTopicsInput(e.target.value)} + disabled={isSavingConfig} + /> +
+
+
+ + + {configMessage && ( +

+ {configMessage} +

+ )} +
+
+ )} + +
+ {topicDiagnostics.map((event) => ( +
+
+
+

{event.eventTitle}

+

+ {event.articleCount} article + {event.articleCount === 1 ? "" : "s"} +

+
+
+ t.displayName, + )} + /> + t.displayName, + )} + /> +
+
+ +
+
+
+

+ Inference Input +

+
+

{event.inferenceInput.title}

+ {event.inferenceInput.summary && ( +

{event.inferenceInput.summary}

+ )} +
+
+ + {event.inferenceInput.atomicFacts.length > 0 && ( +
+

+ Facts +

+
+ {event.inferenceInput.atomicFacts.map((fact) => ( + + {fact} + + ))} +
+
+ )} +
+ +
+

+ Top Candidates +

+
+ {event.topCandidates.map((candidate) => ( +
+
+

+ {candidate.displayName} +

+

+ {candidate.signalCount} signals +

+
+

+ {candidate.score} +

+
+ ))} +
+
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} + +function TopicChipList({ label, topics }: { label: string; topics: string[] }) { + return ( +
+ {label}: + {topics.length > 0 ? ( + topics.map((topic) => ( + + {topic} + + )) + ) : ( + None + )} +
+ ); +} diff --git a/apps/web/src/routes/contact.tsx b/apps/web/src/routes/contact.tsx new file mode 100644 index 0000000..5ead34f --- /dev/null +++ b/apps/web/src/routes/contact.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/contact")({ + component: ContactPage, +}); + +function ContactPage() { + return ( +
+

Contact

+

+ Pagină în construcție. Vom adăuga în curând detaliile de contact și + canalele prin care ne poți scrie. +

+
+ ); +} diff --git a/apps/web/src/routes/cum-functioneaza.tsx b/apps/web/src/routes/cum-functioneaza.tsx new file mode 100644 index 0000000..4dd8f07 --- /dev/null +++ b/apps/web/src/routes/cum-functioneaza.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/cum-functioneaza")({ + component: CumFunctioneazaPage, +}); + +function CumFunctioneazaPage() { + return ( +
+

Cum funcționează

+

+ Pagină în construcție. Pregătim o explicație simplă despre feed, + analiza de bias și comparația dintre surse. +

+
+ ); +} diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index 066f966..30f516b 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,931 +1,7 @@ -import SignInForm from "@/components/sign-in-form"; -import SignUpForm from "@/components/sign-up-form"; -import UserMenu from "@/components/user-menu"; -import BiasBalanceMeter from "@/components/bias-balance-meter"; -import StreakActivityCalendar from "@/components/streak-activity-calendar"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { PageLoadingState } from "@/components/ui/page-loading-state"; -import { api } from "@news-app/backend/convex/_generated/api"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { - Authenticated, - AuthLoading, - Unauthenticated, - useMutation as useConvexMutationHook, - useQuery, -} from "convex/react"; -import type { FormEvent } from "react"; -import { useEffect, useRef, useState } from "react"; -import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect"; -import { formatRelativeTimestamp } from "@/lib/dates"; -import { toast } from "sonner"; -import { z } from "zod"; -import { - Bookmark, - ChevronRight, - Flame, - Newspaper, - Sparkles, -} from "lucide-react"; - -const searchSchema = z.object({ - mode: z.enum(["signin", "signup"]).optional(), - redirect: z.string().optional(), - verified: z.union([z.string(), z.number()]).optional(), -}); - -function formatReadDuration(seconds?: number) { - if (!seconds || seconds <= 0) return null; - if (seconds < 60) return `${seconds}s`; - const minutes = Math.round(seconds / 60); - return `${minutes} min`; -} - -function formatScrollDepth(percentage?: number) { - if (percentage === undefined) return null; - return `${Math.round(percentage * 100)}% depth`; -} - -function getBiasSnapshotLabel(balance: number) { - const absolute = Math.abs(balance); - if (absolute < 15) return "Balanced mix this week"; - if (balance < 0) { - return absolute >= 60 - ? "Strongly left-leaning this week" - : "Leaning left this week"; - } - return absolute >= 60 - ? "Strongly right-leaning this week" - : "Leaning right this week"; -} - -function getNextStreakMilestone(streak: number) { - const milestones = [7, 30, 100, 365]; - return milestones.find((milestone) => milestone > streak) ?? null; -} +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/dashboard")({ - validateSearch: searchSchema, - head: () => ({ - meta: [ - { title: "Dashboard — Biviant" }, - { name: "robots", content: "noindex, nofollow" }, - ], - }), - component: RouteComponent, + beforeLoad: () => { + throw redirect({ to: "/activitate", replace: true }); + }, }); - -function RouteComponent() { - const search = Route.useSearch(); - const navigate = useNavigate({ from: "/dashboard" }); - const redirectTo: AuthRedirectPath = - search.redirect && isAuthRedirectPath(search.redirect) - ? search.redirect - : "/feed"; - const hasShownVerifiedToastRef = useRef(false); - const isVerified = String(search.verified) === "1"; - const showSignIn = search.mode !== "signup" || isVerified; - - useEffect(() => { - if (!isVerified || hasShownVerifiedToastRef.current) { - return; - } - - hasShownVerifiedToastRef.current = true; - toast.message("If you recently verified, you can now log in."); - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - verified: undefined, - }), - replace: true, - }); - }, [isVerified, navigate]); - - return ( - <> - - - - -
-
-
-
-
-

- Biviant Account -

-

- {showSignIn ? "Welcome back" : "Create your free account"} -

-

- Read every event, source profile, and search result without - an account. Sign in when you want synced bookmarks, - personalized ranking, and notifications. -

-
- -
- {showSignIn ? ( - { - void navigate({ - search: (current) => ({ - ...current, - mode: "signup", - }), - }); - }} - /> - ) : ( - { - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - }), - }); - }} - /> - )} -
-
- -
-
-

- What accounts unlock -

-

- The content stays open. Accounts are only for personal, - persistent features. -

-
    -
  1. - - 1 - - Save bookmarks and keep them synced across devices. -
  2. -
  3. - - 2 - - Personalize ranking using your reading habits and - interests. -
  4. -
  5. - - 3 - - Get notifications, digests, and future account-only tools. -
  6. -
-
- -
-

- Verification matters -

-

- New email/password signups require email verification before - first sign-in. Google sign-in continues to work as a - one-step path when the provider has already verified the - address. -

-
-
-
-
-
-
- - - - - ); -} - -function AuthorizedDashboard() { - const currentUser = useQuery(api.user.getCurrentUser); - const dashboardOverview = useQuery(api.interactions.getDashboardOverview); - const isAdmin = useQuery(api.user.isCurrentUserAdmin); - const topicDiagnostics = useQuery( - api.clustering.getRecentTopicInferenceDiagnosticsForAdmin, - isAdmin ? { limit: 10 } : "skip", - ); - const setConfig = useConvexMutationHook(api.config.set); - const [minScoreInput, setMinScoreInput] = useState(""); - const [confidenceRatioInput, setConfidenceRatioInput] = useState(""); - const [maxTopicsInput, setMaxTopicsInput] = useState(""); - const [configMessage, setConfigMessage] = useState(""); - const [isSavingConfig, setIsSavingConfig] = useState(false); - const currentSettings = topicDiagnostics?.[0]?.settings; - - useEffect(() => { - if (!currentSettings) return; - setMinScoreInput(String(currentSettings.minScore)); - setConfidenceRatioInput(String(currentSettings.confidenceRatio)); - setMaxTopicsInput(String(currentSettings.maxTopics)); - }, [ - currentSettings?.minScore, - currentSettings?.confidenceRatio, - currentSettings?.maxTopics, - ]); - - if ( - currentUser === undefined || - dashboardOverview === undefined || - isAdmin === undefined - ) { - return ( - - ); - } - - const hasConfigChanges = - !!currentSettings && - (minScoreInput !== String(currentSettings.minScore) || - confidenceRatioInput !== String(currentSettings.confidenceRatio) || - maxTopicsInput !== String(currentSettings.maxTopics)); - - const handleResetConfig = () => { - if (!currentSettings) return; - setMinScoreInput(String(currentSettings.minScore)); - setConfidenceRatioInput(String(currentSettings.confidenceRatio)); - setMaxTopicsInput(String(currentSettings.maxTopics)); - setConfigMessage(""); - }; - - const handleSaveConfig = async (event: FormEvent) => { - event.preventDefault(); - if (!currentSettings || isSavingConfig) return; - - const minScore = Number(minScoreInput); - const confidenceRatio = Number(confidenceRatioInput); - const maxTopics = Number(maxTopicsInput); - - if (!Number.isFinite(minScore) || minScore < 1 || minScore > 20) { - setConfigMessage("Min score must be a number between 1 and 20."); - return; - } - if ( - !Number.isFinite(confidenceRatio) || - confidenceRatio < 0.1 || - confidenceRatio > 1 - ) { - setConfigMessage("Confidence ratio must be between 0.1 and 1."); - return; - } - if (!Number.isInteger(maxTopics) || maxTopics < 1 || maxTopics > 5) { - setConfigMessage("Max topics must be a whole number between 1 and 5."); - return; - } - - setIsSavingConfig(true); - setConfigMessage(""); - - try { - await Promise.all([ - setConfig({ - key: "topic_inference_min_score", - value: JSON.stringify(minScore), - description: - "Minimum weighted lexical score required before a topic is attached to a clustered event.", - }), - setConfig({ - key: "topic_inference_confidence_ratio", - value: JSON.stringify(confidenceRatio), - description: - "Relative score threshold for keeping additional inferred topics alongside the top-scoring topic.", - }), - setConfig({ - key: "topic_inference_max_topics", - value: JSON.stringify(maxTopics), - description: - "Maximum number of inferred topics attached to an event during clustering.", - }), - ]); - setConfigMessage("Topic inference settings saved."); - } catch (error) { - console.error("Failed to save topic inference settings:", error); - setConfigMessage("Could not save settings. Please try again."); - } finally { - setIsSavingConfig(false); - } - }; - - const userName = currentUser?.profile?.name || currentUser?.email || "User"; - const readingStreak = - dashboardOverview?.stats.currentStreak ?? - currentUser?.stats.currentStreak ?? - 0; - const longestStreak = - dashboardOverview?.stats.longestStreak ?? - currentUser?.stats.longestStreak ?? - 0; - const articlesRead = - dashboardOverview?.stats.articlesRead ?? - currentUser?.stats.articlesRead ?? - 0; - const biasBalance = - dashboardOverview?.stats.biasBalance ?? currentUser?.stats.biasBalance ?? 0; - const bookmarkCount = dashboardOverview?.stats.bookmarkCount ?? 0; - const eventsExplored = dashboardOverview?.stats.eventsExplored ?? 0; - const recentHistory = dashboardOverview?.recentHistory ?? []; - const recentBookmarks = dashboardOverview?.recentBookmarks ?? []; - const streakDays = dashboardOverview?.streakCalendar.days ?? []; - const activeDays = dashboardOverview?.streakCalendar.activeDays ?? 0; - const weeklyBiasReads = dashboardOverview?.weeklyBiasSummary.reads ?? 0; - const weeklyBiasBalance = dashboardOverview?.weeklyBiasSummary.balance ?? 0; - const nextStreakMilestone = getNextStreakMilestone(readingStreak); - - return ( -
-
-
- {/* Header - Clean and minimal */} -
-
-
- {userName.charAt(0).toUpperCase()} -
-
-

Welcome back

-

- {userName.split(" ")[0]} -

-
-
- -
- - {/* Bento Grid - Main Stats */} -
- {/* Streak - Prominent */} -
-
-
- -
-
-

- {readingStreak} -

-

day streak

-
-
- {nextStreakMilestone && readingStreak > 0 && ( -

- {nextStreakMilestone - readingStreak} days to{" "} - {nextStreakMilestone} -

- )} -
- - {/* Articles Read */} -
-
-
- -
-
-

- {articlesRead} -

-

articles read

-
-
-
- - {/* Events Explored */} -
-
-
- -
-
-

- {eventsExplored} -

-

- events explored -

-
-
-
- - {/* Bookmarks */} -
-
-
- -
-
-

- {bookmarkCount} -

-

bookmarked

-
-
-
-
- - {/* Activity + Bias Row */} -
- {/* Activity Calendar */} -
-
-
-

Activity

-

- Last 12 weeks of reading -

-
-
-
-

- {readingStreak} -

-

- Current -

-
-
-

- {longestStreak} -

-

- Best -

-
-
-

- {activeDays} -

-

- Active -

-
-
-
-
- -
-
- - {/* Bias Balance */} -
-

Bias Balance

-

- Your reading perspective mix -

-
- -
- {weeklyBiasReads > 0 && ( -

- {getBiasSnapshotLabel(weeklyBiasBalance)} across{" "} - {weeklyBiasReads} reads -

- )} -
-
- - {/* Recent Activity */} -
- {/* Reading History */} -
-
-
-

Recent Reading

-

- Events you spent time with -

-
- -
-
- {recentHistory.length === 0 ? ( -
- Your reading history will appear here -
- ) : ( -
- {recentHistory.slice(0, 4).map((entry) => { - const durationLabel = formatReadDuration( - entry.metadata.timeSpentSeconds, - ); - const scrollLabel = formatScrollDepth( - entry.metadata.scrollDepthPercentage, - ); - const detailBits = [durationLabel, scrollLabel].filter( - Boolean, - ); - - return ( - -
- {entry.event.imageUrl ? ( - - ) : ( -
- -
- )} -
-
-

- {entry.event.title} -

-
- - {formatRelativeTimestamp(entry.lastViewedAt)} - - {detailBits.length > 0 && ( - <> - · - {detailBits.join(" · ")} - - )} -
-
- - ); - })} -
- )} -
-
- - {/* Bookmarks */} -
-
-
-

Bookmarks

-

- Saved for later -

-
- -
-
- {recentBookmarks.length === 0 ? ( -
- Bookmark events from the feed -
- ) : ( -
- {recentBookmarks.slice(0, 4).map((entry) => ( - -
- {entry.event.imageUrl ? ( - - ) : ( -
- -
- )} -
-
-

- {entry.event.title} -

-
- - {formatRelativeTimestamp(entry.bookmarkedAt)} - - · - - {(entry.event.sourceCount ?? 0)} source - {(entry.event.sourceCount ?? 0) === 1 ? "" : "s"} - -
-
- - ))} -
- )} -
-
-
- - {/* Quick Actions */} -
- -
- -
-
-

Browse Feed

-

- See today's stories -

-
- - - -
- -
-
-

Bookmarks

-

- {bookmarkCount} saved{" "} - {bookmarkCount === 1 ? "story" : "stories"} -

-
- -
- - {/* Admin: Topic Diagnostics */} - {isAdmin && topicDiagnostics && topicDiagnostics.length > 0 && ( -
-
-

- Topic Inference Diagnostics -

-

- Review recent event topic assignments -

-
- -
- - - -
- - {currentSettings && ( -
-
-
- - setMinScoreInput(e.target.value)} - disabled={isSavingConfig} - /> -
-
- - - setConfidenceRatioInput(e.target.value) - } - disabled={isSavingConfig} - /> -
-
- - setMaxTopicsInput(e.target.value)} - disabled={isSavingConfig} - /> -
-
-
- - - {configMessage && ( -

- {configMessage} -

- )} -
-
- )} - -
- {topicDiagnostics.map((event) => ( -
-
-
-

{event.eventTitle}

-

- {event.articleCount} article - {event.articleCount === 1 ? "" : "s"} -

-
-
- t.displayName, - )} - /> - t.displayName, - )} - /> -
-
- -
-
-
-

- Inference Input -

-
-

{event.inferenceInput.title}

- {event.inferenceInput.summary && ( -

{event.inferenceInput.summary}

- )} -
-
- - {event.inferenceInput.atomicFacts.length > 0 && ( -
-

- Facts -

-
- {event.inferenceInput.atomicFacts.map((fact) => ( - - {fact} - - ))} -
-
- )} -
- -
-

- Top Candidates -

-
- {event.topCandidates.map((candidate) => ( -
-
-

- {candidate.displayName} -

-

- {candidate.signalCount} signals -

-
-

- {candidate.score} -

-
- ))} -
-
-
-
- ))} -
-
- )} -
-
-
- ); -} - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
-

- {label} -

-

{value}

-
- ); -} - -function TopicChipList({ label, topics }: { label: string; topics: string[] }) { - return ( -
- {label}: - {topics.length > 0 ? ( - topics.map((topic) => ( - - {topic} - - )) - ) : ( - None - )} -
- ); -} diff --git a/apps/web/src/routes/despre.tsx b/apps/web/src/routes/despre.tsx new file mode 100644 index 0000000..36bcf7f --- /dev/null +++ b/apps/web/src/routes/despre.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/despre")({ + component: DesprePage, +}); + +function DesprePage() { + return ( +
+

Despre noi

+

+ Pagină în construcție. Pregătim o prezentare scurtă despre Biviant și + misiunea noastră. +

+
+ ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 1dbc16e..3d8ce5b 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,378 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { createFileRoute } from "@tanstack/react-router"; -import { api } from "@news-app/backend/convex/_generated/api"; -import { SITE } from "@/lib/seo"; -import { useConvexAuth, useQuery } from "convex/react"; -import { Link } from "@tanstack/react-router"; -import EventCard from "@/components/feed/event-card"; -import { Button } from "@/components/ui/button"; -import { ArrowRight, Globe, Newspaper, Shield, Sparkles } from "lucide-react"; - -function LandingActions({ - className, - isAuthenticated, -}: { - className?: string; - isAuthenticated: boolean; -}) { - return ( -
-
- - {!isAuthenticated && ( - - )} -
- {!isAuthenticated && ( -

- Reading stays open to everyone. Accounts unlock bookmarks, - personalized ranking, and notifications. -

- )} -
- ); -} - -// Fallback events for when the feed is empty - intentionally minimal -// These are demo placeholders; EventCard will render them without images/badges -const FALLBACK_BASE_TIMESTAMP = Date.now(); -const FALLBACK_EVENTS = [ - { - _id: "demo-1" as unknown as import("@news-app/backend/convex/_generated/dataModel").Id<"events">, - slug: "demo-federal-reserve-rate-changes", - _creationTime: FALLBACK_BASE_TIMESTAMP - 3600000, - title: - "Federal Reserve Signals Potential Rate Changes Amid Economic Uncertainty", - globalImpact: - "The Federal Reserve indicated possible adjustments to interest rates as economic indicators show mixed signals across different sectors.", - firstPublishedAt: FALLBACK_BASE_TIMESTAMP - 3600000, - lastUpdatedAt: FALLBACK_BASE_TIMESTAMP - 3600000, - sourceCount: 12, - topicIds: [], - status: "published" as const, - extractionQuality: 0.85, - imageUrl: - "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800&q=80", - }, - { - _id: "demo-2" as unknown as import("@news-app/backend/convex/_generated/dataModel").Id<"events">, - slug: "demo-tech-earnings-beat-expectations", - _creationTime: FALLBACK_BASE_TIMESTAMP - 7200000, - title: - "Major Tech Companies Report Quarterly Earnings Exceeding Expectations", - globalImpact: - "Several leading technology firms announced better-than-expected quarterly results, driving market optimism despite ongoing regulatory scrutiny.", - firstPublishedAt: FALLBACK_BASE_TIMESTAMP - 7200000, - lastUpdatedAt: FALLBACK_BASE_TIMESTAMP - 7200000, - sourceCount: 8, - topicIds: [], - status: "published" as const, - extractionQuality: 0.92, - imageUrl: - "https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&q=80", - }, - { - _id: "demo-3" as unknown as import("@news-app/backend/convex/_generated/dataModel").Id<"events">, - slug: "demo-climate-summit-new-agreements", - _creationTime: FALLBACK_BASE_TIMESTAMP - 14400000, - title: "Climate Summit Concludes with New International Agreements", - globalImpact: - "World leaders reached consensus on several key environmental initiatives following intense negotiations at the annual climate conference.", - firstPublishedAt: FALLBACK_BASE_TIMESTAMP - 14400000, - lastUpdatedAt: FALLBACK_BASE_TIMESTAMP - 14400000, - sourceCount: 15, - topicIds: [], - status: "published" as const, - extractionQuality: 0.88, - imageUrl: - "https://images.unsplash.com/photo-1569163139599-0f4517e36f51?w=800&q=80", - }, -]; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - head: () => ({ - meta: [ - { - title: `${SITE.name} — Every Story Has Three Sides`, - }, - { - name: "description", - content: - "Every story has a left version, a right version, and what actually happened. Biviant shows you all three, scores every source for bias and reliability, and tells you exactly how it affects your life.", - }, - { - property: "og:title", - content: `${SITE.name} — Every Story Has Three Sides`, - }, - { - property: "og:description", - content: - "Every story has a left version, a right version, and what actually happened. Biviant shows you all three.", - }, - { property: "og:url", content: SITE.url }, - ], - links: [{ rel: "canonical", href: SITE.url }], - scripts: [ - { - type: "application/ld+json", - children: JSON.stringify({ - "@context": "https://schema.org", - "@type": "WebApplication", - name: SITE.name, - url: SITE.url, - description: - "Every story has a left version, a right version, and what actually happened. Biviant shows you all three, scores every source for bias and reliability, and tells you exactly how it affects your life.", - applicationCategory: "NewsApplication", - operatingSystem: "Web", - }), - }, - ], - }), - component: LandingPage, + beforeLoad: () => { + throw redirect({ to: "/feed", replace: true }); + }, }); - -function LandingPage() { - const { isAuthenticated } = useConvexAuth(); - const [hasAnimated, setHasAnimated] = useState(false); - - useEffect(() => { - // Only animate on first mount - setHasAnimated(true); - }, []); - - const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); - const rawPreview = Number(runtimeConfig?.landingPreviewCount); - const MAX_LANDING_PREVIEW_COUNT = 20; - // Show 5 events by default for more impact - const previewCount = Number.isFinite(rawPreview) - ? Math.min(MAX_LANDING_PREVIEW_COUNT, Math.max(1, Math.floor(rawPreview))) - : 5; - - const rawMaxSources = Number(runtimeConfig?.eventCardMaxSources); - const MAX_EVENT_CARD_SOURCES = 10; - const maxSources = Number.isFinite(rawMaxSources) - ? Math.min(MAX_EVENT_CARD_SOURCES, Math.max(0, Math.floor(rawMaxSources))) - : 5; - - // Optimistic loading - use default value immediately, don't wait for config - const events = useQuery(api.events.getPublicPublishedEventsPreview, { - limit: previewCount, - }); - - const topics = useQuery( - api.topics.getTopics, - events && events.length > 0 ? undefined : "skip", - ); - - const topicNamesById = useMemo(() => { - const map: Record = {}; - topics?.forEach((topic) => { - map[topic._id] = topic.displayName; - }); - return map; - }, [topics]); - - // Use fallback events if feed is empty - const displayEvents = - events && events.length > 0 - ? events - : events !== undefined - ? FALLBACK_EVENTS - : undefined; - const isUsingFallback = events !== undefined && events.length === 0; - - return ( -
- {/* EVENTS-FIRST HERO - Events ARE the landing page */} -
- {/* Single subtle ambient glow */} -
-
-
- - {/* Main content */} -
-
- {/* Headline - the sharp value prop */} -
-

- Every story has a left version, a right version, -
- - {" "} - and what actually happened. - -

-

- See all three, with bias scores and impact analysis. -

-
- - {/* THE EVENTS - No decorative frame, trust the cards */} -
- {displayEvents === undefined ? ( - // Loading skeleton - match previewCount - <> - {Array.from({ length: previewCount }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} - - ) : ( - displayEvents.map((event, index) => ( -
- -
- )) - )} -
- - {/* Demo indicator if using fallback */} - {isUsingFallback && ( -
- - - Demo events shown - live feed coming soon - -
- )} - - {/* Scroll hint */} - {!isAuthenticated && displayEvents && displayEvents.length > 0 && ( -
- - Create a free account - - -
- )} -
-
-
- - {/* How It Works - Ultra compact, lead with Your Impact */} -
-
-
- {[ - { - icon: Sparkles, - title: "Your Impact", - desc: "See exactly how each story affects your life, finances, and community.", - }, - { - icon: Newspaper, - title: "Every Angle", - desc: "One story from left, right, and center sources side by side.", - }, - { - icon: Shield, - title: "Trust Scores", - desc: "We score every claim, not just every source.", - }, - ].map(({ icon: Icon, title, desc }) => ( -
-
- -
-
-

{title}

-

- {desc} -

-
-
- ))} -
-
-
- - {/* Final CTA - Single focused message */} -
-
-
- {/* Specificity flex instead of low number */} -
- - - Tracking 50+ news sources across the political spectrum - -
- -

- Stop reading the news blind -

- -

- {isAuthenticated - ? "Read the full feed, save what matters, and keep your perspective sharp." - : "Read the full feed right now, then create a free account when you want bookmarks, personalized ranking, and alerts."} -

- - -
-
-
-
- ); -} diff --git a/apps/web/src/routes/parteneri.tsx b/apps/web/src/routes/parteneri.tsx new file mode 100644 index 0000000..c767918 --- /dev/null +++ b/apps/web/src/routes/parteneri.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/parteneri")({ + component: ParteneriPage, +}); + +function ParteneriPage() { + return ( +
+

Parteneri

+

+ Pagină în construcție. Aici vor apărea partenerii, colaborările și + inițiativele susținute de Biviant. +

+
+ ); +} diff --git a/apps/web/src/routes/politica-confidentialitate.tsx b/apps/web/src/routes/politica-confidentialitate.tsx new file mode 100644 index 0000000..e49e2c2 --- /dev/null +++ b/apps/web/src/routes/politica-confidentialitate.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/politica-confidentialitate")({ + component: PoliticaConfidentialitatePage, +}); + +function PoliticaConfidentialitatePage() { + return ( +
+

+ Politica de confidențialitate +

+

+ Pagină în construcție. Vom publica aici informațiile despre datele + colectate, utilizare și drepturile tale. +

+
+ ); +} diff --git a/apps/web/src/routes/sursele-noastre.tsx b/apps/web/src/routes/sursele-noastre.tsx new file mode 100644 index 0000000..9f3062b --- /dev/null +++ b/apps/web/src/routes/sursele-noastre.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/sursele-noastre")({ + component: SurseleNoastrePage, +}); + +function SurseleNoastrePage() { + return ( +
+

Sursele noastre

+

+ Pagină în construcție. În curând vei vedea cum selectăm sursele și cum + construim perspectivele din feed. +

+
+ ); +} diff --git a/apps/web/src/routes/termeni.tsx b/apps/web/src/routes/termeni.tsx new file mode 100644 index 0000000..1f8af71 --- /dev/null +++ b/apps/web/src/routes/termeni.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/termeni")({ + component: TermeniPage, +}); + +function TermeniPage() { + return ( +
+

Termeni

+

+ Pagină în construcție. Urmează să adăugăm termenii și condițiile de + utilizare pentru Biviant. +

+
+ ); +} From f24e1537efeabef0388e7485c19f5380d6810a70 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 16:02:52 +0300 Subject: [PATCH 02/19] feat(web): add responsive shell for mobile tabs --- apps/web/src/components/layout/Footer.tsx | 62 +++++++++++ .../src/components/layout/MobileTabBar.tsx | 103 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++++ apps/web/src/routes/__root.tsx | 10 +- apps/web/src/routes/profil.tsx | 17 +++ 5 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/layout/Footer.tsx create mode 100644 apps/web/src/components/layout/MobileTabBar.tsx create mode 100644 apps/web/src/routes/profil.tsx diff --git a/apps/web/src/components/layout/Footer.tsx b/apps/web/src/components/layout/Footer.tsx new file mode 100644 index 0000000..8158b86 --- /dev/null +++ b/apps/web/src/components/layout/Footer.tsx @@ -0,0 +1,62 @@ +import { Link } from "@tanstack/react-router"; + +const footerSections = [ + { + title: "Despre", + links: [ + { to: "/despre", label: "Despre noi" }, + { to: "/contact", label: "Contact" }, + { to: "/parteneri", label: "Parteneri" }, + ], + }, + { + title: "Resurse", + links: [ + { to: "/cum-functioneaza", label: "Cum funcționează" }, + { to: "/sursele-noastre", label: "Sursele noastre" }, + ], + }, + { + title: "Legal", + links: [ + { + to: "/politica-confidentialitate", + label: "Confidențialitate", + }, + { to: "/termeni", label: "Termeni" }, + ], + }, +] as const; + +export function Footer() { + return ( +
+
+
+ {footerSections.map((section) => ( +
+

+ {section.title} +

+
+ {section.links.map((link) => ( + + {link.label} + + ))} +
+
+ ))} +
+ +
+ © {new Date().getFullYear()} Biviant +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx new file mode 100644 index 0000000..3e0f5d0 --- /dev/null +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -0,0 +1,103 @@ +import { Bookmark, BarChart3, Newspaper, User } from "lucide-react"; +import { Link, useRouterState } from "@tanstack/react-router"; +import type { ComponentType, MouseEvent } from "react"; +import { cn } from "@/lib/utils"; + +type TabItem = { + to: string; + label: string; + icon: ComponentType<{ className?: string; strokeWidth?: number }>; + isActive?: (pathname: string) => boolean; +}; + +const tabs: TabItem[] = [ + { + to: "/feed", + label: "Feed", + icon: Newspaper, + isActive: (pathname) => + pathname === "/feed" || + pathname.startsWith("/feed/") || + pathname.startsWith("/event/"), + }, + { + to: "/bookmarks", + label: "Salvate", + icon: Bookmark, + }, + { + to: "/activitate", + label: "Activitate", + icon: BarChart3, + }, + { + to: "/profil", + label: "Profil", + icon: User, + }, +]; + +function matchesPath(pathname: string, to: string) { + return pathname === to || pathname.startsWith(`${to}/`); +} + +export function MobileTabBar() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + const handleTabClick = + (to: string, isActive: boolean) => (event: MouseEvent) => { + if (!isActive) { + return; + } + + event.preventDefault(); + + if (typeof window !== "undefined") { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }; + + return ( + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 45707ae..383bf81 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as ResetPasswordRouteImport } from './routes/reset-password' +import { Route as ProfilRouteImport } from './routes/profil' import { Route as PoliticaConfidentialitateRouteImport } from './routes/politica-confidentialitate' import { Route as ParteneriRouteImport } from './routes/parteneri' import { Route as FeedRouteImport } from './routes/feed' @@ -59,6 +60,11 @@ const ResetPasswordRoute = ResetPasswordRouteImport.update({ path: '/reset-password', getParentRoute: () => rootRouteImport, } as any) +const ProfilRoute = ProfilRouteImport.update({ + id: '/profil', + path: '/profil', + getParentRoute: () => rootRouteImport, +} as any) const PoliticaConfidentialitateRoute = PoliticaConfidentialitateRouteImport.update({ id: '/politica-confidentialitate', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/feed': typeof FeedRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute + '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute @@ -158,6 +165,7 @@ export interface FileRoutesByTo { '/feed': typeof FeedRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute + '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/feed': typeof FeedRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute + '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute @@ -203,6 +212,7 @@ export interface FileRouteTypes { | '/feed' | '/parteneri' | '/politica-confidentialitate' + | '/profil' | '/reset-password' | '/robots.txt' | '/sitemap.xml' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/feed' | '/parteneri' | '/politica-confidentialitate' + | '/profil' | '/reset-password' | '/robots.txt' | '/sitemap.xml' @@ -245,6 +256,7 @@ export interface FileRouteTypes { | '/feed' | '/parteneri' | '/politica-confidentialitate' + | '/profil' | '/reset-password' | '/robots.txt' | '/sitemap.xml' @@ -267,6 +279,7 @@ export interface RootRouteChildren { FeedRoute: typeof FeedRoute ParteneriRoute: typeof ParteneriRoute PoliticaConfidentialitateRoute: typeof PoliticaConfidentialitateRoute + ProfilRoute: typeof ProfilRoute ResetPasswordRoute: typeof ResetPasswordRoute RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute @@ -322,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResetPasswordRouteImport parentRoute: typeof rootRouteImport } + '/profil': { + id: '/profil' + path: '/profil' + fullPath: '/profil' + preLoaderRoute: typeof ProfilRouteImport + parentRoute: typeof rootRouteImport + } '/politica-confidentialitate': { id: '/politica-confidentialitate' path: '/politica-confidentialitate' @@ -427,6 +447,7 @@ const rootRouteChildren: RootRouteChildren = { FeedRoute: FeedRoute, ParteneriRoute: ParteneriRoute, PoliticaConfidentialitateRoute: PoliticaConfidentialitateRoute, + ProfilRoute: ProfilRoute, ResetPasswordRoute: ResetPasswordRoute, RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2d3cd98..2601142 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,7 @@ import { Toaster } from "@/components/ui/sonner"; import { SITE } from "@/lib/seo"; +import { Footer } from "@/components/layout/Footer"; +import { MobileTabBar } from "@/components/layout/MobileTabBar"; import { HeadContent, @@ -122,10 +124,14 @@ function RootDocument() { -
-
+
+
+
+
+
+ {import.meta.env.DEV && ( diff --git a/apps/web/src/routes/profil.tsx b/apps/web/src/routes/profil.tsx new file mode 100644 index 0000000..ddb658b --- /dev/null +++ b/apps/web/src/routes/profil.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/profil")({ + component: ProfilPage, +}); + +function ProfilPage() { + return ( +
+

Profil

+

+ Pagină în construcție. Aici vor apărea setările contului și opțiunile + personale. +

+
+ ); +} From cdc55fb2cdb326a07b1085cdd4d9fd2ea7783a86 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 16:05:08 +0300 Subject: [PATCH 03/19] feat(web): rename dashboard navigation to activitate --- apps/web/src/components/auth-prompt-banner.tsx | 4 ++-- apps/web/src/components/bookmark-button.tsx | 2 +- apps/web/src/components/header.tsx | 6 +++--- apps/web/src/components/sign-in-form.tsx | 2 +- apps/web/src/components/sign-up-form.tsx | 4 ++-- apps/web/src/lib/auth-redirect.ts | 2 ++ apps/web/src/routes/activitate.tsx | 10 +++++----- apps/web/src/routes/bookmarks.tsx | 2 +- apps/web/src/routes/reset-password.tsx | 6 +++--- 9 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/auth-prompt-banner.tsx b/apps/web/src/components/auth-prompt-banner.tsx index 34110b4..17b55dc 100644 --- a/apps/web/src/components/auth-prompt-banner.tsx +++ b/apps/web/src/components/auth-prompt-banner.tsx @@ -26,12 +26,12 @@ export default function AuthPromptBanner({
diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx index 1dcccb3..bd87d9c 100644 --- a/apps/web/src/components/bookmark-button.tsx +++ b/apps/web/src/components/bookmark-button.tsx @@ -67,7 +67,7 @@ export default function BookmarkButton({ action: { label: "Sign in", onClick: () => { - window.location.href = `/dashboard?mode=signin&redirect=${encodeURIComponent(redirectTo)}`; + window.location.href = `/activitate?mode=signin&redirect=${encodeURIComponent(redirectTo)}`; }, }, }); diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index b044351..e93a733 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -10,10 +10,10 @@ import { } from "@/components/ui/sheet"; const links = [ - { to: "/", label: "Home", icon: Home }, + { to: "/", label: "Acasă", icon: Home }, { to: "/feed", label: "Feed", icon: Newspaper }, - { to: "/bookmarks", label: "Bookmarks", icon: Bookmark }, - { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { to: "/bookmarks", label: "Salvate", icon: Bookmark }, + { to: "/activitate", label: "Activitate", icon: LayoutDashboard }, ] as const; export default function Header() { diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index 8f6ba63..982ca79 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -25,7 +25,7 @@ export default function SignInForm({ onSwitchToSignUp, initialEmail = "", emailLocked = false, - redirectTo = "/dashboard", + redirectTo = "/activitate", title = "Welcome Back", subtitle = "Sign in to your Biviant account", submitLabel = "Sign In", diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index 2b14a41..40a087c 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -17,14 +17,14 @@ function getVerificationCallbackURL(redirectTo: AuthRedirectPath) { verified: "1", redirect: redirectTo, }); - return `/dashboard?${params.toString()}`; + return `/activitate?${params.toString()}`; } export default function SignUpForm({ onSwitchToSignIn, initialEmail = "", emailLocked = false, - redirectTo = "/dashboard", + redirectTo = "/activitate", title = "Create Account", subtitle = "Join Biviant and see the whole story", submitLabel = "Create Account", diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts index 685fc7c..e277a1e 100644 --- a/apps/web/src/lib/auth-redirect.ts +++ b/apps/web/src/lib/auth-redirect.ts @@ -1,5 +1,6 @@ export type AuthRedirectPath = | "/" + | "/activitate" | "/bookmarks" | "/dashboard" | "/feed" @@ -15,6 +16,7 @@ export function isAuthRedirectPath(value: string): value is AuthRedirectPath { return ( value === "/" || + value === "/activitate" || value === "/bookmarks" || value === "/dashboard" || value === "/feed" || diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx index a04a22d..381fb54 100644 --- a/apps/web/src/routes/activitate.tsx +++ b/apps/web/src/routes/activitate.tsx @@ -69,7 +69,7 @@ export const Route = createFileRoute("/activitate")({ validateSearch: searchSchema, head: () => ({ meta: [ - { title: "Dashboard — Biviant" }, + { title: "Activitate — Biviant" }, { name: "robots", content: "noindex, nofollow" }, ], }), @@ -212,8 +212,8 @@ function RouteComponent() { @@ -255,8 +255,8 @@ function AuthorizedDashboard() { ) { return ( ); diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 3f535e9..79f9be3 100644 --- a/apps/web/src/routes/bookmarks.tsx +++ b/apps/web/src/routes/bookmarks.tsx @@ -54,7 +54,7 @@ function BookmarksPage() {

diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index 44f0264..929668d 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -72,7 +72,7 @@ function ResetPasswordRoute() { onSuccess: () => { toast.success("Your password has been reset. You can sign in now."); void navigate({ - to: "/dashboard", + to: "/activitate", search: { mode: "signin" }, }); }, @@ -112,7 +112,7 @@ function ResetPasswordRoute() { "This password reset link is missing a token. Request a new one from the sign-in form."}

@@ -198,7 +198,7 @@ function ResetPasswordRoute() {
From 5569861ee4b1861c4957678584346f680d1c9acd Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 16:09:45 +0300 Subject: [PATCH 04/19] feat(web): split profile view by auth state --- .../components/profile/AnonymousProfile.tsx | 110 +++++++++++ .../profile/AuthenticatedProfile.tsx | 177 ++++++++++++++++++ apps/web/src/routes/profil.tsx | 35 +++- 3 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/profile/AnonymousProfile.tsx create mode 100644 apps/web/src/components/profile/AuthenticatedProfile.tsx diff --git a/apps/web/src/components/profile/AnonymousProfile.tsx b/apps/web/src/components/profile/AnonymousProfile.tsx new file mode 100644 index 0000000..ad38b2b --- /dev/null +++ b/apps/web/src/components/profile/AnonymousProfile.tsx @@ -0,0 +1,110 @@ +import { BarChart3, Bookmark, Scale, Sparkles } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; + +const features = [ + { + title: "Bias per articol", + description: + "Vezi rapid cum se poziționează sursele și ce nuanțe adaugă fiecare.", + icon: Scale, + }, + { + title: "Comparație de perspective", + description: + "Pune față în față relatări diferite pentru același eveniment, fără să schimbi aplicația.", + icon: BarChart3, + }, + { + title: "Divergențe factuale", + description: + "Observă unde apar discrepanțe reale între afirmațiile făcute de surse.", + icon: Sparkles, + }, +] as const; + +export function AnonymousProfile() { + return ( +
+
+
+
+
+

+ Profil +

+

+ Înțelege știrile din mai multe perspective, fără zgomot inutil. +

+

+ Biviant te ajută să urmărești biasul, comparațiile între surse + și divergențele factuale, iar contul gratuit îți păstrează + salvările și preferințele sincronizate. +

+
+ +
+ + +
+
+ +
+ {features.map(({ title, description, icon: Icon }) => ( +
+
+ +
+

{title}

+

+ {description} +

+
+ ))} +
+ +
+
+
+

Află mai multe

+

+ Explorează cum funcționează produsul și de unde provin + perspectivele din feed. +

+
+
+ + + Cum funcționează + + + Sursele noastre + + + Despre noi + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/profile/AuthenticatedProfile.tsx b/apps/web/src/components/profile/AuthenticatedProfile.tsx new file mode 100644 index 0000000..0e0c99e --- /dev/null +++ b/apps/web/src/components/profile/AuthenticatedProfile.tsx @@ -0,0 +1,177 @@ +import { Bell, LogOut, MoonStar, ShieldCheck, User2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; + +type AuthenticatedProfileUser = { + email: string; + image?: string | null; + name?: string | null; + profile?: { + avatar?: string; + name?: string; + }; +}; + +function getInitials(name: string) { + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join(""); +} + +export function AuthenticatedProfile({ + user, +}: { + user: AuthenticatedProfileUser; +}) { + const displayName = user.profile?.name || user.name || user.email; + const avatarSrc = user.profile?.avatar || user.image || undefined; + const initials = getInitials(displayName); + + const handleSignOut = async () => { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + location.reload(); + }, + }, + }); + }; + + return ( +
+
+
+
+
+ {avatarSrc ? ( + {displayName} + ) : ( +
+ {initials || "B"} +
+ )} + +
+

+ Contul tău +

+

+ {displayName} +

+

+ {user.email} +

+
+
+
+ +
+
+ +
+

Setări

+

+ Opțiuni rapide pentru preferințele tale. +

+
+
+ +
+
+
+ + Temă +
+

+ Selectorul de temă va fi disponibil în curând. +

+ +
+ +
+
+ + Notificări +
+

+ Preferințele pentru alerte și rezumate vor apărea aici. +

+ +
+
+
+ +
+
+ +
+

Cont

+

+ Informațiile tale de autentificare și opțiunile sensibile ale + contului. +

+
+
+ +
+
+

Email

+

{user.email}

+
+ +
+ + +
+ +
+
+ + Siguranța contului +
+

+ Dacă vrei modificări mai sensibile, ne poți scrie din pagina + de contact și te ajutăm manual. +

+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/routes/profil.tsx b/apps/web/src/routes/profil.tsx index ddb658b..8307586 100644 --- a/apps/web/src/routes/profil.tsx +++ b/apps/web/src/routes/profil.tsx @@ -1,17 +1,34 @@ +import { AnonymousProfile } from "@/components/profile/AnonymousProfile"; +import { AuthenticatedProfile } from "@/components/profile/AuthenticatedProfile"; +import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { api } from "@news-app/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; +import { useConvexAuth, useQuery } from "convex/react"; export const Route = createFileRoute("/profil")({ + head: () => ({ + meta: [{ title: "Profil — Biviant" }], + }), component: ProfilPage, }); function ProfilPage() { - return ( -
-

Profil

-

- Pagină în construcție. Aici vor apărea setările contului și opțiunile - personale. -

-
- ); + const { isAuthenticated, isLoading } = useConvexAuth(); + const user = useQuery(api.user.getCurrentUser); + + if (isLoading || (isAuthenticated && user === undefined)) { + return ( + + ); + } + + if (isAuthenticated && user) { + return ; + } + + return ; } From 467c26635ff23d561881bc8eaeb90887cfaad6c9 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 16:14:48 +0300 Subject: [PATCH 05/19] feat(web): gate activity tabs and add saved route --- apps/web/src/components/SignInPrompt.tsx | 46 +++++ .../web/src/components/auth-prompt-banner.tsx | 12 +- apps/web/src/components/bookmark-button.tsx | 2 +- apps/web/src/components/header.tsx | 2 +- .../src/components/layout/MobileTabBar.tsx | 2 +- .../components/profile/AnonymousProfile.tsx | 4 +- apps/web/src/components/sign-up-form.tsx | 2 +- apps/web/src/lib/auth-redirect.ts | 2 + apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/activitate.tsx | 172 ++---------------- apps/web/src/routes/bookmarks.tsx | 167 +---------------- apps/web/src/routes/dashboard.tsx | 169 ++++++++++++++++- apps/web/src/routes/reset-password.tsx | 6 +- apps/web/src/routes/salvate.tsx | 166 +++++++++++++++++ 14 files changed, 439 insertions(+), 334 deletions(-) create mode 100644 apps/web/src/components/SignInPrompt.tsx create mode 100644 apps/web/src/routes/salvate.tsx diff --git a/apps/web/src/components/SignInPrompt.tsx b/apps/web/src/components/SignInPrompt.tsx new file mode 100644 index 0000000..465abb7 --- /dev/null +++ b/apps/web/src/components/SignInPrompt.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import type { AuthRedirectPath } from "@/lib/auth-redirect"; +import { Button } from "@/components/ui/button"; + +type SignInPromptProps = { + title: string; + description: string; + redirectTo: AuthRedirectPath; + illustration?: ReactNode; +}; + +export function SignInPrompt({ + title, + description, + redirectTo, + illustration, +}: SignInPromptProps) { + return ( +
+
+
+ {illustration ? ( +
{illustration}
+ ) : null} +

{title}

+

+ {description} +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/auth-prompt-banner.tsx b/apps/web/src/components/auth-prompt-banner.tsx index 17b55dc..a7d69ad 100644 --- a/apps/web/src/components/auth-prompt-banner.tsx +++ b/apps/web/src/components/auth-prompt-banner.tsx @@ -11,8 +11,8 @@ type AuthPromptBannerProps = { export default function AuthPromptBanner({ redirectTo, - title = "Create a free account to save and personalize your news", - description = "Reading stays open to everyone. Accounts unlock bookmarks, personalized ranking, and notifications.", + title = "Creează un cont gratuit ca să salvezi și să personalizezi știrile", + description = "Poți citi fără cont. Autentificarea adaugă salvări, recomandări personale și funcții viitoare.", compact = false, }: AuthPromptBannerProps) { return ( @@ -26,13 +26,13 @@ export default function AuthPromptBanner({
diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx index bd87d9c..1dcccb3 100644 --- a/apps/web/src/components/bookmark-button.tsx +++ b/apps/web/src/components/bookmark-button.tsx @@ -67,7 +67,7 @@ export default function BookmarkButton({ action: { label: "Sign in", onClick: () => { - window.location.href = `/activitate?mode=signin&redirect=${encodeURIComponent(redirectTo)}`; + window.location.href = `/dashboard?mode=signin&redirect=${encodeURIComponent(redirectTo)}`; }, }, }); diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index e93a733..b219528 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -12,7 +12,7 @@ import { const links = [ { to: "/", label: "Acasă", icon: Home }, { to: "/feed", label: "Feed", icon: Newspaper }, - { to: "/bookmarks", label: "Salvate", icon: Bookmark }, + { to: "/salvate", label: "Salvate", icon: Bookmark }, { to: "/activitate", label: "Activitate", icon: LayoutDashboard }, ] as const; diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 3e0f5d0..9a934aa 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -21,7 +21,7 @@ const tabs: TabItem[] = [ pathname.startsWith("/event/"), }, { - to: "/bookmarks", + to: "/salvate", label: "Salvate", icon: Bookmark, }, diff --git a/apps/web/src/components/profile/AnonymousProfile.tsx b/apps/web/src/components/profile/AnonymousProfile.tsx index ad38b2b..6ae0ab6 100644 --- a/apps/web/src/components/profile/AnonymousProfile.tsx +++ b/apps/web/src/components/profile/AnonymousProfile.tsx @@ -45,12 +45,12 @@ export function AnonymousProfile() {
diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index 40a087c..1c104be 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -17,7 +17,7 @@ function getVerificationCallbackURL(redirectTo: AuthRedirectPath) { verified: "1", redirect: redirectTo, }); - return `/activitate?${params.toString()}`; + return `/dashboard?${params.toString()}`; } export default function SignUpForm({ diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts index e277a1e..9f8a313 100644 --- a/apps/web/src/lib/auth-redirect.ts +++ b/apps/web/src/lib/auth-redirect.ts @@ -1,6 +1,7 @@ export type AuthRedirectPath = | "/" | "/activitate" + | "/salvate" | "/bookmarks" | "/dashboard" | "/feed" @@ -17,6 +18,7 @@ export function isAuthRedirectPath(value: string): value is AuthRedirectPath { return ( value === "/" || value === "/activitate" || + value === "/salvate" || value === "/bookmarks" || value === "/dashboard" || value === "/feed" || diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 383bf81..41de44a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' import { Route as TermeniRouteImport } from './routes/termeni' import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as SalvateRouteImport } from './routes/salvate' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ProfilRouteImport } from './routes/profil' @@ -50,6 +51,11 @@ const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ path: '/sitemap.xml', getParentRoute: () => rootRouteImport, } as any) +const SalvateRoute = SalvateRouteImport.update({ + id: '/salvate', + path: '/salvate', + getParentRoute: () => rootRouteImport, +} as any) const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ id: '/robots.txt', path: '/robots.txt', @@ -146,6 +152,7 @@ export interface FileRoutesByFullPath { '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/sursele-noastre': typeof SurseleNoastreRoute '/termeni': typeof TermeniRoute @@ -168,6 +175,7 @@ export interface FileRoutesByTo { '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/sursele-noastre': typeof SurseleNoastreRoute '/termeni': typeof TermeniRoute @@ -191,6 +199,7 @@ export interface FileRoutesById { '/profil': typeof ProfilRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/sursele-noastre': typeof SurseleNoastreRoute '/termeni': typeof TermeniRoute @@ -215,6 +224,7 @@ export interface FileRouteTypes { | '/profil' | '/reset-password' | '/robots.txt' + | '/salvate' | '/sitemap.xml' | '/sursele-noastre' | '/termeni' @@ -237,6 +247,7 @@ export interface FileRouteTypes { | '/profil' | '/reset-password' | '/robots.txt' + | '/salvate' | '/sitemap.xml' | '/sursele-noastre' | '/termeni' @@ -259,6 +270,7 @@ export interface FileRouteTypes { | '/profil' | '/reset-password' | '/robots.txt' + | '/salvate' | '/sitemap.xml' | '/sursele-noastre' | '/termeni' @@ -282,6 +294,7 @@ export interface RootRouteChildren { ProfilRoute: typeof ProfilRoute ResetPasswordRoute: typeof ResetPasswordRoute RobotsDottxtRoute: typeof RobotsDottxtRoute + SalvateRoute: typeof SalvateRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute SurseleNoastreRoute: typeof SurseleNoastreRoute TermeniRoute: typeof TermeniRoute @@ -321,6 +334,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapDotxmlRouteImport parentRoute: typeof rootRouteImport } + '/salvate': { + id: '/salvate' + path: '/salvate' + fullPath: '/salvate' + preLoaderRoute: typeof SalvateRouteImport + parentRoute: typeof rootRouteImport + } '/robots.txt': { id: '/robots.txt' path: '/robots.txt' @@ -450,6 +470,7 @@ const rootRouteChildren: RootRouteChildren = { ProfilRoute: ProfilRoute, ResetPasswordRoute: ResetPasswordRoute, RobotsDottxtRoute: RobotsDottxtRoute, + SalvateRoute: SalvateRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, SurseleNoastreRoute: SurseleNoastreRoute, TermeniRoute: TermeniRoute, diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx index 381fb54..62fbbaf 100644 --- a/apps/web/src/routes/activitate.tsx +++ b/apps/web/src/routes/activitate.tsx @@ -1,5 +1,4 @@ -import SignInForm from "@/components/sign-in-form"; -import SignUpForm from "@/components/sign-up-form"; +import { SignInPrompt } from "@/components/SignInPrompt"; import UserMenu from "@/components/user-menu"; import BiasBalanceMeter from "@/components/bias-balance-meter"; import StreakActivityCalendar from "@/components/streak-activity-calendar"; @@ -7,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PageLoadingState } from "@/components/ui/page-loading-state"; import { api } from "@news-app/backend/convex/_generated/api"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { Authenticated, AuthLoading, @@ -16,11 +15,8 @@ import { useQuery, } from "convex/react"; import type { FormEvent } from "react"; -import { useEffect, useRef, useState } from "react"; -import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect"; +import { useEffect, useState } from "react"; import { formatRelativeTimestamp } from "@/lib/dates"; -import { toast } from "sonner"; -import { z } from "zod"; import { Bookmark, ChevronRight, @@ -29,12 +25,6 @@ import { Sparkles, } from "lucide-react"; -const searchSchema = z.object({ - mode: z.enum(["signin", "signup"]).optional(), - redirect: z.string().optional(), - verified: z.union([z.string(), z.number()]).optional(), -}); - function formatReadDuration(seconds?: number) { if (!seconds || seconds <= 0) return null; if (seconds < 60) return `${seconds}s`; @@ -66,7 +56,6 @@ function getNextStreakMilestone(streak: number) { } export const Route = createFileRoute("/activitate")({ - validateSearch: searchSchema, head: () => ({ meta: [ { title: "Activitate — Biviant" }, @@ -77,138 +66,17 @@ export const Route = createFileRoute("/activitate")({ }); function RouteComponent() { - const search = Route.useSearch(); - const navigate = useNavigate({ from: "/activitate" }); - const redirectTo: AuthRedirectPath = - search.redirect && isAuthRedirectPath(search.redirect) - ? search.redirect - : "/feed"; - const hasShownVerifiedToastRef = useRef(false); - const isVerified = String(search.verified) === "1"; - const showSignIn = search.mode !== "signup" || isVerified; - - useEffect(() => { - if (!isVerified || hasShownVerifiedToastRef.current) { - return; - } - - hasShownVerifiedToastRef.current = true; - toast.message("If you recently verified, you can now log in."); - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - verified: undefined, - }), - replace: true, - }); - }, [isVerified, navigate]); - return ( <> -
-
-
-
-
-

- Biviant Account -

-

- {showSignIn ? "Welcome back" : "Create your free account"} -

-

- Read every event, source profile, and search result without - an account. Sign in when you want synced bookmarks, - personalized ranking, and notifications. -

-
- -
- {showSignIn ? ( - { - void navigate({ - search: (current) => ({ - ...current, - mode: "signup", - }), - }); - }} - /> - ) : ( - { - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - }), - }); - }} - /> - )} -
-
- -
-
-

- What accounts unlock -

-

- The content stays open. Accounts are only for personal, - persistent features. -

-
    -
  1. - - 1 - - Save bookmarks and keep them synced across devices. -
  2. -
  3. - - 2 - - Personalize ranking using your reading habits and - interests. -
  4. -
  5. - - 3 - - Get notifications, digests, and future account-only tools. -
  6. -
-
- -
-

- Verification matters -

-

- New email/password signups require email verification before - first sign-in. Google sign-in continues to work as a - one-step path when the provider has already verified the - address. -

-
-
-
-
-
+
- {/* Bookmarks */} + {/* Salvate */}
-

Bookmarks

+

Salvate

- Saved for later + Păstrate pentru mai târziu

@@ -611,7 +479,7 @@ function AuthorizedDashboard() {
{recentBookmarks.length === 0 ? (
- Bookmark events from the feed + Salvează evenimente din feed ca să apară aici
) : (
@@ -669,25 +537,25 @@ function AuthorizedDashboard() {
-

Browse Feed

+

Explorează feed-ul

- See today's stories + Vezi subiectele de astăzi

-

Bookmarks

+

Salvate

- {bookmarkCount} saved{" "} - {bookmarkCount === 1 ? "story" : "stories"} + {bookmarkCount}{" "} + {bookmarkCount === 1 ? "articol salvat" : "articole salvate"}

diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 79f9be3..75f46aa 100644 --- a/apps/web/src/routes/bookmarks.tsx +++ b/apps/web/src/routes/bookmarks.tsx @@ -1,166 +1,7 @@ -import { useMemo } from "react"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { api } from "@news-app/backend/convex/_generated/api"; -import { - Authenticated, - AuthLoading, - Unauthenticated, - useQuery, -} from "convex/react"; -import EventCard from "@/components/feed/event-card"; -import { Button } from "@/components/ui/button"; -import { PageLoadingState } from "@/components/ui/page-loading-state"; -import { SITE } from "@/lib/seo"; - -/** Validate and clamp a config value to a non-negative integer. */ -function safePositiveInt(raw: unknown, fallback: number): number { - const n = Number(raw); - return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : fallback; -} +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/bookmarks")({ - head: () => ({ - meta: [ - { title: `Bookmarks — ${SITE.name}` }, - { name: "robots", content: "noindex, nofollow" }, - ], - }), - component: BookmarksPage, + beforeLoad: () => { + throw redirect({ to: "/salvate", replace: true }); + }, }); - -function BookmarksPage() { - return ( - <> - - - - -
-
-
-
-
-
- - - -
-
-

- Sign in to see your bookmarks -

-

- Bookmark events to read later from any device. Your bookmarks sync across all your sessions. -

-
- -
-
-
-
-
-
- - - - - ); -} - -function BookmarksContent() { - const bookmarks = useQuery(api.interactions.getBookmarkedEvents); - const topics = useQuery(api.topics.getTopics); - const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); - const maxSources = safePositiveInt(runtimeConfig?.eventCardMaxSources, 5); - - const topicNamesById = useMemo(() => { - const map: Record = {}; - topics?.forEach((topic) => { - map[topic._id] = topic.displayName; - }); - return map; - }, [topics]); - - if (bookmarks === undefined) { - return ( - - ); - } - - return ( -
-
-
- {/* Header */} -
-
-
-

- Saved for Later -

-
-

- Your Bookmarks -

-

- {bookmarks.length === 0 - ? "Events you save will appear here. Bookmark any story to read it later." - : `You have ${bookmarks.length} ${bookmarks.length === 1 ? "event" : "events"} saved for later.` - } -

-
-
-
-
- - {/* Content */} - {bookmarks.length === 0 ? ( -
-
-
- - - -
-
-

No bookmarks yet

-

- When you find an interesting story, tap the bookmark icon to save it here for later reading. -

-
- -
-
- ) : ( -
- {bookmarks.map((event) => ( - - ))} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index 30f516b..a9a0b3d 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,7 +1,168 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import SignInForm from "@/components/sign-in-form"; +import SignUpForm from "@/components/sign-up-form"; +import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useConvexAuth } from "convex/react"; +import { useEffect, useRef } from "react"; +import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect"; +import { toast } from "sonner"; +import { z } from "zod"; + +const searchSchema = z.object({ + mode: z.enum(["signin", "signup"]).optional(), + redirect: z.string().optional(), + verified: z.union([z.string(), z.number()]).optional(), +}); export const Route = createFileRoute("/dashboard")({ - beforeLoad: () => { - throw redirect({ to: "/activitate", replace: true }); - }, + validateSearch: searchSchema, + head: () => ({ + meta: [ + { title: "Cont — Biviant" }, + { name: "robots", content: "noindex, nofollow" }, + ], + }), + component: DashboardAuthPage, }); + +function DashboardAuthPage() { + const search = Route.useSearch(); + const navigate = useNavigate({ from: "/dashboard" }); + const { isAuthenticated, isLoading } = useConvexAuth(); + const redirectTo: AuthRedirectPath = + search.redirect && isAuthRedirectPath(search.redirect) + ? search.redirect + : "/activitate"; + const hasShownVerifiedToastRef = useRef(false); + const isVerified = String(search.verified) === "1"; + const showSignIn = search.mode !== "signup" || isVerified; + + useEffect(() => { + if (isAuthenticated) { + void navigate({ to: "/activitate", replace: true }); + } + }, [isAuthenticated, navigate]); + + useEffect(() => { + if (!isVerified || hasShownVerifiedToastRef.current) { + return; + } + + hasShownVerifiedToastRef.current = true; + toast.message("Dacă ți-ai verificat contul, te poți conecta acum."); + void navigate({ + search: (current) => ({ + ...current, + mode: "signin", + verified: undefined, + }), + replace: true, + }); + }, [isVerified, navigate]); + + if (isLoading || isAuthenticated) { + return ( + + ); + } + + return ( +
+
+
+
+
+

+ Cont Biviant +

+

+ {showSignIn ? "Bine ai revenit" : "Creează un cont gratuit"} +

+

+ Poți citi feed-ul fără cont. Conectarea îți oferă salvări + sincronizate, preferințe personale și acces rapid la activitatea + ta. +

+
+ +
+ {showSignIn ? ( + { + void navigate({ + search: (current) => ({ + ...current, + mode: "signup", + }), + }); + }} + /> + ) : ( + { + void navigate({ + search: (current) => ({ + ...current, + mode: "signin", + }), + }); + }} + /> + )} +
+
+ +
+
+

Ce deblochează contul

+

+ Conținutul rămâne deschis. Contul adaugă doar funcții personale + și persistente. +

+
    +
  1. + + 1 + + Salvezi articole și evenimente pentru mai târziu. +
  2. +
  3. + + 2 + + Îți personalizezi experiența pe baza intereselor tale. +
  4. +
  5. + + 3 + + Primești acces la notificări și funcții noi pe măsură ce apar. +
  6. +
+
+ +
+

Verificarea contează

+

+ Conturile create cu email și parolă necesită verificarea adresei + înainte de prima conectare. Pentru autentificarea cu Google, + verificarea furnizorului rămâne suficientă. +

+
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index 929668d..44f0264 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -72,7 +72,7 @@ function ResetPasswordRoute() { onSuccess: () => { toast.success("Your password has been reset. You can sign in now."); void navigate({ - to: "/activitate", + to: "/dashboard", search: { mode: "signin" }, }); }, @@ -112,7 +112,7 @@ function ResetPasswordRoute() { "This password reset link is missing a token. Request a new one from the sign-in form."}

@@ -198,7 +198,7 @@ function ResetPasswordRoute() {
diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx new file mode 100644 index 0000000..b043b50 --- /dev/null +++ b/apps/web/src/routes/salvate.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { api } from "@news-app/backend/convex/_generated/api"; +import { + Authenticated, + AuthLoading, + Unauthenticated, + useQuery, +} from "convex/react"; +import { SignInPrompt } from "@/components/SignInPrompt"; +import EventCard from "@/components/feed/event-card"; +import { Button } from "@/components/ui/button"; +import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { SITE } from "@/lib/seo"; + +function safePositiveInt(raw: unknown, fallback: number): number { + const n = Number(raw); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : fallback; +} + +export const Route = createFileRoute("/salvate")({ + head: () => ({ + meta: [ + { title: `Salvate — ${SITE.name}` }, + { name: "robots", content: "noindex, nofollow" }, + ], + }), + component: SalvatePage, +}); + +function SalvatePage() { + return ( + <> + + + + + + + + +
+ } + /> + + + + + + ); +} + +function SalvateContent() { + const bookmarks = useQuery(api.interactions.getBookmarkedEvents); + const topics = useQuery(api.topics.getTopics); + const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); + const maxSources = safePositiveInt(runtimeConfig?.eventCardMaxSources, 5); + + const topicNamesById = useMemo(() => { + const map: Record = {}; + topics?.forEach((topic) => { + map[topic._id] = topic.displayName; + }); + return map; + }, [topics]); + + if (bookmarks === undefined) { + return ( + + ); + } + + return ( +
+
+
+
+
+
+

+ Salvate +

+
+

+ Articolele tale salvate +

+

+ {bookmarks.length === 0 + ? "Articolele și evenimentele salvate vor apărea aici." + : `Ai ${bookmarks.length} ${bookmarks.length === 1 ? "articol salvat" : "articole salvate"} pentru mai târziu.`} +

+
+
+
+
+ + {bookmarks.length === 0 ? ( +
+
+
+ + + +
+
+

Nu ai salvări încă

+

+ Când găsești o poveste importantă, folosește iconița de + salvare ca să o păstrezi aici. +

+
+ +
+
+ ) : ( +
+ {bookmarks.map((event) => ( + + ))} +
+ )} +
+
+
+ ); +} From 1e703b72876079e0df79768315d2db5ab49008e9 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 16:19:35 +0300 Subject: [PATCH 06/19] fix(web): polish auth redirects and tab interactions --- apps/web/src/components/bookmark-button.tsx | 9 +++- .../src/components/layout/MobileTabBar.tsx | 4 +- apps/web/src/routes/dashboard.tsx | 50 +++++++++---------- apps/web/src/routes/reset-password.tsx | 8 +-- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx index 1dcccb3..daaf74e 100644 --- a/apps/web/src/components/bookmark-button.tsx +++ b/apps/web/src/components/bookmark-button.tsx @@ -43,7 +43,12 @@ export default function BookmarkButton({ const toggle = useMutation({ mutationFn: useConvexMutation(api.interactions.toggleBookmark), onSuccess: (data) => { - if (data.bookmarked) { + if ( + typeof data === "object" && + data !== null && + "bookmarked" in data && + data.bookmarked === true + ) { toast.success("Bookmarked"); } else { toast("Bookmark removed"); @@ -105,7 +110,7 @@ export default function BookmarkButton({ bookmarked ? "text-primary bg-primary/10 hover:bg-primary/20" : "text-muted-foreground hover:text-primary hover:bg-primary/10", - className + className, )} disabled={toggle.isPending} onClick={handleClick} diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 9a934aa..f0c76d0 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -47,7 +47,7 @@ export function MobileTabBar() { }); const handleTabClick = - (to: string, isActive: boolean) => (event: MouseEvent) => { + (isActive: boolean) => (event: MouseEvent) => { if (!isActive) { return; } @@ -75,7 +75,7 @@ export function MobileTabBar() { { if (isAuthenticated) { - void navigate({ to: "/activitate", replace: true }); + window.location.replace("/activitate"); } - }, [isAuthenticated, navigate]); + }, [isAuthenticated]); + + const replaceDashboardSearch = ( + mode: "signin" | "signup", + verified?: string | number, + ) => { + const params = new URLSearchParams(); + params.set("mode", mode); + + if (search.redirect) { + params.set("redirect", search.redirect); + } + + if (verified !== undefined) { + params.set("verified", String(verified)); + } + + window.location.replace(`/dashboard?${params.toString()}`); + }; useEffect(() => { if (!isVerified || hasShownVerifiedToastRef.current) { @@ -50,15 +67,8 @@ function DashboardAuthPage() { hasShownVerifiedToastRef.current = true; toast.message("Dacă ți-ai verificat contul, te poți conecta acum."); - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - verified: undefined, - }), - replace: true, - }); - }, [isVerified, navigate]); + replaceDashboardSearch("signin"); + }, [isVerified, search.redirect]); if (isLoading || isAuthenticated) { return ( @@ -96,12 +106,7 @@ function DashboardAuthPage() { title="Conectează-te în cont" subtitle="Accesează salvările, preferințele și experiența ta personalizată." onSwitchToSignUp={() => { - void navigate({ - search: (current) => ({ - ...current, - mode: "signup", - }), - }); + replaceDashboardSearch("signup", search.verified); }} /> ) : ( @@ -111,12 +116,7 @@ function DashboardAuthPage() { subtitle="Contul gratuit deblochează salvările, feed-ul personalizat și notificările viitoare." submitLabel="Creează cont" onSwitchToSignIn={() => { - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - }), - }); + replaceDashboardSearch("signin", search.verified); }} /> )} diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index 44f0264..b2d67fc 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { useForm } from "@tanstack/react-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -43,7 +43,6 @@ export const Route = createFileRoute("/reset-password")({ function ResetPasswordRoute() { const search = Route.useSearch(); - const navigate = useNavigate({ from: "/reset-password" }); const token = search.token?.trim(); const errorMessage = getResetErrorMessage(search.error); @@ -71,10 +70,7 @@ function ResetPasswordRoute() { { onSuccess: () => { toast.success("Your password has been reset. You can sign in now."); - void navigate({ - to: "/dashboard", - search: { mode: "signin" }, - }); + window.location.assign("/dashboard?mode=signin"); }, onError: (error) => { const message = From 85a53717c6e47fbc7c90729bfd4f85b55b09e47a Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 18:17:29 +0300 Subject: [PATCH 07/19] align desktop and mobile nav items --- apps/web/src/components/header.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index b219528..9fa0b87 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Link, useRouterState } from "@tanstack/react-router"; -import { Menu, Newspaper, Bookmark, LayoutDashboard, Home } from "lucide-react"; +import { Menu, Newspaper, Bookmark, LayoutDashboard, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -10,10 +10,10 @@ import { } from "@/components/ui/sheet"; const links = [ - { to: "/", label: "Acasă", icon: Home }, { to: "/feed", label: "Feed", icon: Newspaper }, { to: "/salvate", label: "Salvate", icon: Bookmark }, { to: "/activitate", label: "Activitate", icon: LayoutDashboard }, + { to: "/profil", label: "Profil", icon: User }, ] as const; export default function Header() { @@ -26,7 +26,7 @@ export default function Header() {
{/* Logo */} - +
B
From 2532c44ca292cf80e53560726780c0aa6df86c5d Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 18:36:01 +0300 Subject: [PATCH 08/19] feat(web): add server resolved locale infrastructure --- apps/web/src/components/LanguagePicker.tsx | 77 ++++ apps/web/src/lib/i18n/LocaleContext.tsx | 28 ++ apps/web/src/lib/i18n/getServerLocale.ts | 44 ++ apps/web/src/lib/i18n/resolveLocale.ts | 43 ++ apps/web/src/lib/i18n/strings.ts | 463 +++++++++++++++++++++ apps/web/src/router.tsx | 7 +- apps/web/src/routes/__root.tsx | 83 ++-- packages/backend/convex/schema.ts | 3 + packages/backend/convex/user.ts | 30 ++ 9 files changed, 749 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/components/LanguagePicker.tsx create mode 100644 apps/web/src/lib/i18n/LocaleContext.tsx create mode 100644 apps/web/src/lib/i18n/getServerLocale.ts create mode 100644 apps/web/src/lib/i18n/resolveLocale.ts create mode 100644 apps/web/src/lib/i18n/strings.ts diff --git a/apps/web/src/components/LanguagePicker.tsx b/apps/web/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..109e1ca --- /dev/null +++ b/apps/web/src/components/LanguagePicker.tsx @@ -0,0 +1,77 @@ +import { createServerFn } from "@tanstack/react-start"; +import { useRouter } from "@tanstack/react-router"; +import { setCookie } from "@tanstack/react-start/server"; +import { useConvexAuth, useMutation } from "convex/react"; +import { api } from "@news-app/backend/convex/_generated/api"; +import { useLocale, useT } from "@/lib/i18n/LocaleContext"; +import type { Locale } from "@/lib/i18n/strings"; +import { Button } from "@/components/ui/button"; + +function validateLocale(input: unknown): Locale { + if (input === "ro" || input === "en") { + return input; + } + + throw new Error("Invalid locale"); +} + +const setLocaleCookie = createServerFn({ method: "POST" }) + .inputValidator(validateLocale) + .handler(({ data }) => { + setCookie("bv_locale", data, { + path: "/", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 365, + }); + + return { locale: data }; + }); + +export function LanguagePicker() { + const router = useRouter(); + const t = useT(); + const currentLocale = useLocale(); + const { isAuthenticated } = useConvexAuth(); + const updatePreferredLanguage = useMutation(api.user.updatePreferredLanguage); + + const handleChange = async (locale: Locale) => { + if (locale === currentLocale) { + return; + } + + await setLocaleCookie({ data: locale }); + + if (isAuthenticated) { + try { + await updatePreferredLanguage({ language: locale }); + } catch { + // Cookie persistence is still enough for anonymous/fallback usage. + } + } + + await router.invalidate(); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/lib/i18n/LocaleContext.tsx b/apps/web/src/lib/i18n/LocaleContext.tsx new file mode 100644 index 0000000..bae14ca --- /dev/null +++ b/apps/web/src/lib/i18n/LocaleContext.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { STRINGS, type Locale, type StringKey } from "./strings"; + +const LocaleContext = createContext("en"); + +export function LocaleProvider({ + locale, + children, +}: { + locale: Locale; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useLocale(): Locale { + return useContext(LocaleContext); +} + +export function useT() { + const locale = useLocale(); + + return (key: StringKey, fallback?: string): string => { + return STRINGS[locale][key] ?? STRINGS.en[key] ?? fallback ?? key; + }; +} diff --git a/apps/web/src/lib/i18n/getServerLocale.ts b/apps/web/src/lib/i18n/getServerLocale.ts new file mode 100644 index 0000000..1eeb29e --- /dev/null +++ b/apps/web/src/lib/i18n/getServerLocale.ts @@ -0,0 +1,44 @@ +import { createServerFn } from "@tanstack/react-start"; +import { + getCookie, + getRequestHeader, + getRequestUrl, +} from "@tanstack/react-start/server"; +import { resolveLocale } from "./resolveLocale"; +import type { Locale } from "./strings"; + +type GetServerLocaleInput = { + userPreference?: string | null; +}; + +function validateInput(input: unknown): GetServerLocaleInput { + if (!input || typeof input !== "object") { + return {}; + } + + const record = input as Record; + return { + userPreference: + typeof record.userPreference === "string" ? record.userPreference : null, + }; +} + +export const getServerLocale = createServerFn({ method: "GET" }) + .inputValidator(validateInput) + .handler(({ data }): Locale => { + const requestUrl = getRequestUrl({ + xForwardedHost: true, + xForwardedProto: true, + }); + + return resolveLocale({ + searchParam: requestUrl.searchParams.get("lang"), + cookieValue: getCookie("bv_locale") ?? null, + userPreference: data.userPreference ?? null, + countryCode: + getRequestHeader("x-vercel-ip-country") ?? + getRequestHeader("cf-ipcountry") ?? + null, + acceptLanguage: getRequestHeader("accept-language") ?? null, + }); + }); diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts new file mode 100644 index 0000000..c401dea --- /dev/null +++ b/apps/web/src/lib/i18n/resolveLocale.ts @@ -0,0 +1,43 @@ +import type { Locale } from "./strings"; + +const SUPPORTED: readonly Locale[] = ["ro", "en"] as const; +const DEFAULT_LOCALE: Locale = "en"; + +export interface LocaleResolutionInput { + searchParam?: string | null; + cookieValue?: string | null; + userPreference?: string | null; + countryCode?: string | null; + acceptLanguage?: string | null; +} + +export function resolveLocale(input: LocaleResolutionInput): Locale { + if (isSupported(input.searchParam)) return input.searchParam; + if (isSupported(input.cookieValue)) return input.cookieValue; + if (isSupported(input.userPreference)) return input.userPreference; + if (input.countryCode?.toUpperCase() === "RO") return "ro"; + const fromAccept = parseAcceptLanguage(input.acceptLanguage); + if (fromAccept) return fromAccept; + return DEFAULT_LOCALE; +} + +function isSupported(value: string | null | undefined): value is Locale { + return value === "ro" || value === "en"; +} + +function parseAcceptLanguage(header: string | null | undefined): Locale | null { + if (!header) return null; + + const codes = header + .split(",") + .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]) + .filter(Boolean); + + for (const code of codes) { + if (code && SUPPORTED.includes(code as Locale)) { + return code as Locale; + } + } + + return null; +} diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts new file mode 100644 index 0000000..fda21e8 --- /dev/null +++ b/apps/web/src/lib/i18n/strings.ts @@ -0,0 +1,463 @@ +export type Locale = "ro" | "en"; + +const ro = { + "tabs.feed": "Feed", + "tabs.saved": "Salvate", + "tabs.activity": "Activitate", + "tabs.profile": "Profil", + "nav.mobile": "Navigație mobilă", + "auth.signUp": "Înregistrare gratuită", + "auth.signIn": "Conectare", + "auth.signOut": "Deconectare", + "auth.account": "Cont Biviant", + "auth.welcomeBack": "Bine ai revenit", + "auth.createFreeAccount": "Creează un cont gratuit", + "auth.accountIntro": + "Poți citi feed-ul fără cont. Conectarea îți oferă salvări sincronizate, preferințe personale și acces rapid la activitatea ta.", + "auth.signInTitle": "Conectează-te în cont", + "auth.signInSubtitle": + "Accesează salvările, preferințele și experiența ta personalizată.", + "auth.signUpTitle": "Creează-ți contul", + "auth.signUpSubtitle": + "Contul gratuit deblochează salvările, feed-ul personalizat și notificările viitoare.", + "auth.createAccount": "Creează cont", + "auth.orContinueWith": "Sau continuă cu", + "auth.googleError": "Autentificarea cu Google a eșuat. Încearcă din nou.", + "auth.promptTitle": + "Creează un cont gratuit ca să salvezi și să personalizezi știrile", + "auth.promptBody": + "Poți citi fără cont. Autentificarea adaugă salvări, recomandări personale și funcții viitoare.", + "auth.unlockTitle": "Ce deblochează contul", + "auth.unlockBody": + "Conținutul rămâne deschis. Contul adaugă doar funcții personale și persistente.", + "auth.unlockOne": "Salvezi articole și evenimente pentru mai târziu.", + "auth.unlockTwo": "Îți personalizezi experiența pe baza intereselor tale.", + "auth.unlockThree": + "Primești acces la notificări și funcții noi pe măsură ce apar.", + "auth.verificationTitle": "Verificarea contează", + "auth.verificationBody": + "Conturile create cu email și parolă necesită verificarea adresei înainte de prima conectare. Pentru autentificarea cu Google, verificarea furnizorului rămâne suficientă.", + "auth.verifiedToast": "Dacă ți-ai verificat contul, te poți conecta acum.", + "saved.empty.title": "Salvează articole pentru mai târziu", + "saved.empty.body": + "Fă-ți o listă personală cu tot ce vrei să citești sau să recitești.", + "saved.loading.title": "Se încarcă salvările", + "saved.loading.body": + "Pregătim articolele salvate și contextul aferent.", + "saved.checking.title": "Verificăm salvările", + "saved.checking.body": + "Confirmăm sesiunea înainte să încărcăm articolele tale salvate.", + "saved.section": "Salvate", + "saved.heading": "Articolele tale salvate", + "saved.none": "Nu ai salvări încă", + "saved.noneBody": + "Când găsești o poveste importantă, folosește iconița de salvare ca să o păstrezi aici.", + "saved.browseFeed": "Explorează feed-ul", + "saved.summary.empty": "Articolele și evenimentele salvate vor apărea aici.", + "saved.summary.one": "Ai 1 articol salvat pentru mai târziu.", + "saved.summary.many": "Ai {count} articole salvate pentru mai târziu.", + "activity.empty.title": "Urmărește-ți obiceiurile de citire", + "activity.empty.body": + "Vezi balanța de bias, streak-urile de citire și statisticile tale într-un singur loc.", + "activity.loading.title": "Se încarcă activitatea", + "activity.loading.body": + "Pregătim salvările, streak-urile și statisticile tale.", + "activity.checking.title": "Verificăm sesiunea", + "activity.checking.body": + "Pregătim activitatea ta și verificăm starea contului.", + "activity.savedLabel": "Salvate", + "activity.savedSub": "Păstrate pentru mai târziu", + "activity.savedAll": "Toate", + "activity.savedEmpty": "Salvează evenimente din feed ca să apară aici", + "activity.welcomeBack": "Bine ai revenit", + "activity.dayStreak": "zile consecutive", + "activity.daysToGoal": "{count} zile până la {milestone}", + "activity.articlesRead": "articole citite", + "activity.eventsExplored": "evenimente explorate", + "activity.bookmarked": "salvate", + "activity.section": "Activitate", + "activity.last12Weeks": "Ultimele 12 săptămâni de lectură", + "activity.current": "Curent", + "activity.best": "Record", + "activity.active": "Active", + "activity.biasBalance": "Balanță de bias", + "activity.biasMix": "Mixul perspectivelor din lectura ta", + "activity.recentReading": "Lectură recentă", + "activity.recentReadingBody": "Evenimente la care ai petrecut timp", + "activity.readingHistoryEmpty": "Istoricul tău de lectură va apărea aici", + "activity.sourcesOne": "1 sursă", + "activity.sourcesMany": "{count} surse", + "activity.feedCard": "Explorează feed-ul", + "activity.feedCardBody": "Vezi subiectele de astăzi", + "activity.savedCard": "Salvate", + "activity.savedOne": "1 articol salvat", + "activity.savedMany": "{count} articole salvate", + "activity.biasSnapshot.balanced": "Săptămâna aceasta ai avut un mix echilibrat", + "activity.biasSnapshot.left": "Săptămâna aceasta ai înclinat spre stânga", + "activity.biasSnapshot.leftStrong": + "Săptămâna aceasta ai înclinat puternic spre stânga", + "activity.biasSnapshot.right": "Săptămâna aceasta ai înclinat spre dreapta", + "activity.biasSnapshot.rightStrong": + "Săptămâna aceasta ai înclinat puternic spre dreapta", + "activity.biasReads": "{count} lecturi", + "profile.preparing.title": "Se pregătește profilul", + "profile.preparing.body": "Încărcăm preferințele și detaliile contului tău.", + "profile.account": "Contul tău", + "profile.settings": "Setări", + "profile.settingsBody": "Opțiuni rapide pentru preferințele tale.", + "profile.theme": "Temă", + "profile.themeBody": "Selectorul de temă va fi disponibil în curând.", + "profile.notifications": "Notificări", + "profile.notificationsBody": + "Preferințele pentru alerte și rezumate vor apărea aici.", + "profile.comingSoon": "În curând", + "profile.accountSection": "Cont", + "profile.accountBody": + "Informațiile tale de autentificare și opțiunile sensibile ale contului.", + "profile.changePassword": "Schimbă parola", + "profile.deleteAccount": "Solicită ștergerea contului", + "profile.security": "Siguranța contului", + "profile.securityBody": + "Dacă vrei modificări mai sensibile, ne poți scrie din pagina de contact și te ajutăm manual.", + "profile.hero": + "Înțelege știrile din mai multe perspective, fără zgomot inutil.", + "profile.heroBody": + "Biviant te ajută să urmărești biasul, comparațiile între surse și divergențele factuale, iar contul gratuit îți păstrează salvările și preferințele sincronizate.", + "profile.learnMore": "Află mai multe", + "profile.learnMoreBody": + "Explorează cum funcționează produsul și de unde provin perspectivele din feed.", + "profile.feature.bias": "Bias per articol", + "profile.feature.biasBody": + "Vezi rapid cum se poziționează sursele și ce nuanțe adaugă fiecare.", + "profile.feature.compare": "Comparație de perspective", + "profile.feature.compareBody": + "Pune față în față relatări diferite pentru același eveniment, fără să schimbi aplicația.", + "profile.feature.facts": "Divergențe factuale", + "profile.feature.factsBody": + "Observă unde apar discrepanțe reale între afirmațiile făcute de surse.", + "settings.title": "Setări", + "settings.language": "Limbă", + "settings.language.ro": "Română", + "settings.language.en": "Engleză", + "user.menu": "Meniul utilizatorului", + "user.account": "Cont", + "user.streakOne": "1 zi consecutivă", + "user.streakMany": "{count} zile consecutive", + "user.profileSoon": "Profilul complet apare în curând", + "footer.aboutSection": "Despre", + "footer.resourcesSection": "Resurse", + "footer.legalSection": "Legal", + "footer.about": "Despre noi", + "footer.contact": "Contact", + "footer.partners": "Parteneri", + "footer.privacy": "Confidențialitate", + "footer.terms": "Termeni", + "footer.howItWorks": "Cum funcționează", + "footer.sources": "Sursele noastre", + "placeholder.inProgress": "Pagină în construcție.", + "page.about.title": "Despre noi", + "page.about.body": + "Pagină în construcție. Pregătim o prezentare scurtă despre Biviant și misiunea noastră.", + "page.contact.title": "Contact", + "page.contact.body": + "Pagină în construcție. Vom adăuga în curând detaliile de contact și canalele prin care ne poți scrie.", + "page.partners.title": "Parteneri", + "page.partners.body": + "Pagină în construcție. Aici vor apărea partenerii, colaborările și inițiativele susținute de Biviant.", + "page.how.title": "Cum funcționează", + "page.how.body": + "Pagină în construcție. Pregătim o explicație simplă despre feed, analiza de bias și comparația dintre surse.", + "page.privacy.title": "Politica de confidențialitate", + "page.privacy.body": + "Pagină în construcție. Vom publica aici informațiile despre datele colectate, utilizare și drepturile tale.", + "page.terms.title": "Termeni", + "page.terms.body": + "Pagină în construcție. Urmează să adăugăm termenii și condițiile de utilizare pentru Biviant.", + "page.sources.title": "Sursele noastre", + "page.sources.body": + "Pagină în construcție. În curând vei vedea cum selectăm sursele și cum construim perspectivele din feed.", + "feed.meta.title": "Feed — Biviant", + "feed.meta.description": + "Explorează subiectele importante ale zilei din perspective multiple. Filtrează după topic și urmărește același eveniment în mai multe surse.", + "feed.topic.search": "Caută topicuri...", + "feed.topic.empty": "Nu am găsit topicuri.", + "feed.topic.all": "Toate topicurile", + "feed.topic.single": "Topic", + "feed.topic.filter": "Filtrează după topic", + "feed.topic.drawerTitle": "Filtrează după topic", + "feed.topic.drawerBody": "Selectează un topic pentru a filtra poveștile", + "feed.close": "Închide", + "feed.search.placeholder": "Caută evenimente după titlu...", + "feed.search.label": "Caută evenimente", + "feed.search.clear": "Șterge căutarea", + "feed.sort.recent": "Recente", + "feed.sort.trending": "Trending", + "feed.sort.recentHint": "Evenimentele publicate cel mai recent apar primele.", + "feed.sort.trendingHint": + "Clasate după diversitatea surselor, volumul articolelor și recență.", + "feed.search.threshold": "Scrie cel puțin 2 caractere pentru căutare.", + "feed.filter.clear": "Elimină filtrul", + "event.general": "General", + "event.summaryPending": "Rezumatul este în pregătire...", + "event.coveragePreview": + "Acoperire agregată din mai multe surse. Deschide evenimentul pentru a compara articolele.", + "event.updated": "Actualizat {time}", + "event.sources": "{count} surse", + "event.articles.one": "1 articol", + "event.articles.many": "{count} articole", + "event.follow": "Urmărește evenimentul", + "event.biasDistribution": + "Distribuția biasului surselor: {left} stânga, {center} centru, {right} dreapta", + "event.bias.left": "{count} stânga", + "event.bias.center": "{count} centru", + "event.bias.right": "{count} dreapta", + "bias.label.balanced": "Echilibrat", + "bias.label.left": "Înclină spre stânga", + "bias.label.leftStrong": "Mai ales stânga", + "bias.label.right": "Înclină spre dreapta", + "bias.label.rightStrong": "Mai ales dreapta", + "bias.copy.balanced": "Citești un mix sănătos de perspective.", + "bias.copy.left": "Lectura recentă înclină spre stânga.", + "bias.copy.leftStrong": "Lectura recentă este puternic orientată spre stânga.", + "bias.copy.right": "Lectura recentă înclină spre dreapta.", + "bias.copy.rightStrong": + "Lectura recentă este puternic orientată spre dreapta.", + "bias.balance": "Balanță de bias", + "bias.leftHeavy": "Mai mult stânga", + "bias.center": "Echilibrat", + "bias.rightHeavy": "Mai mult dreapta", +} as const; + +type BaseStrings = typeof ro; + +const en: { [K in keyof BaseStrings]: string } = { + "tabs.feed": "Feed", + "tabs.saved": "Saved", + "tabs.activity": "Activity", + "tabs.profile": "Profile", + "nav.mobile": "Mobile navigation", + "auth.signUp": "Sign up free", + "auth.signIn": "Sign in", + "auth.signOut": "Sign out", + "auth.account": "Biviant account", + "auth.welcomeBack": "Welcome back", + "auth.createFreeAccount": "Create a free account", + "auth.accountIntro": + "You can read the feed without an account. Signing in gives you synced saves, personal preferences, and quick access to your activity.", + "auth.signInTitle": "Sign in to your account", + "auth.signInSubtitle": + "Access your saved items, preferences, and personalized experience.", + "auth.signUpTitle": "Create your account", + "auth.signUpSubtitle": + "A free account unlocks saved items, a personalized feed, and future notifications.", + "auth.createAccount": "Create account", + "auth.orContinueWith": "Or continue with", + "auth.googleError": "Google sign-in failed. Please try again.", + "auth.promptTitle": + "Create a free account to save and personalize your news", + "auth.promptBody": + "You can read without an account. Signing in adds saved items, recommendations, and upcoming features.", + "auth.unlockTitle": "What an account unlocks", + "auth.unlockBody": + "The content stays open. An account only adds personal, persistent features.", + "auth.unlockOne": "Save articles and events for later.", + "auth.unlockTwo": "Personalize the experience around your interests.", + "auth.unlockThree": "Get access to notifications and new features as they roll out.", + "auth.verificationTitle": "Verification matters", + "auth.verificationBody": + "Accounts created with email and password require address verification before the first sign-in. For Google sign-in, the provider verification remains sufficient.", + "auth.verifiedToast": "If you verified your account, you can sign in now.", + "saved.empty.title": "Save articles to read later", + "saved.empty.body": + "Build a personal list of everything you want to read or revisit.", + "saved.loading.title": "Loading saved items", + "saved.loading.body": + "We are preparing your saved articles and their context.", + "saved.checking.title": "Checking saved items", + "saved.checking.body": + "We are confirming your session before loading your saved articles.", + "saved.section": "Saved", + "saved.heading": "Your saved articles", + "saved.none": "No saved items yet", + "saved.noneBody": + "When you find an important story, use the save icon to keep it here.", + "saved.browseFeed": "Browse the feed", + "saved.summary.empty": "Saved articles and events will appear here.", + "saved.summary.one": "You have 1 saved article for later.", + "saved.summary.many": "You have {count} saved articles for later.", + "activity.empty.title": "Track your reading habits", + "activity.empty.body": + "See your bias balance, reading streaks, and stats in one place.", + "activity.loading.title": "Loading activity", + "activity.loading.body": + "We are preparing your saved items, streaks, and stats.", + "activity.checking.title": "Checking session", + "activity.checking.body": + "We are preparing your activity and checking your account state.", + "activity.savedLabel": "Saved", + "activity.savedSub": "Kept for later", + "activity.savedAll": "All", + "activity.savedEmpty": "Save events from the feed to see them here", + "activity.welcomeBack": "Welcome back", + "activity.dayStreak": "day streak", + "activity.daysToGoal": "{count} days to {milestone}", + "activity.articlesRead": "articles read", + "activity.eventsExplored": "events explored", + "activity.bookmarked": "bookmarked", + "activity.section": "Activity", + "activity.last12Weeks": "Last 12 weeks of reading", + "activity.current": "Current", + "activity.best": "Best", + "activity.active": "Active", + "activity.biasBalance": "Bias balance", + "activity.biasMix": "Your reading perspective mix", + "activity.recentReading": "Recent reading", + "activity.recentReadingBody": "Events you spent time with", + "activity.readingHistoryEmpty": "Your reading history will appear here", + "activity.sourcesOne": "1 source", + "activity.sourcesMany": "{count} sources", + "activity.feedCard": "Browse the feed", + "activity.feedCardBody": "See today’s stories", + "activity.savedCard": "Saved", + "activity.savedOne": "1 saved article", + "activity.savedMany": "{count} saved articles", + "activity.biasSnapshot.balanced": "Balanced mix this week", + "activity.biasSnapshot.left": "Leaning left this week", + "activity.biasSnapshot.leftStrong": "Strongly left-leaning this week", + "activity.biasSnapshot.right": "Leaning right this week", + "activity.biasSnapshot.rightStrong": "Strongly right-leaning this week", + "activity.biasReads": "{count} reads", + "profile.preparing.title": "Preparing profile", + "profile.preparing.body": + "We are loading your preferences and account details.", + "profile.account": "Your account", + "profile.settings": "Settings", + "profile.settingsBody": "Quick options for your preferences.", + "profile.theme": "Theme", + "profile.themeBody": "The theme selector will be available soon.", + "profile.notifications": "Notifications", + "profile.notificationsBody": + "Preferences for alerts and digests will appear here.", + "profile.comingSoon": "Coming soon", + "profile.accountSection": "Account", + "profile.accountBody": + "Your authentication details and the sensitive options for your account.", + "profile.changePassword": "Change password", + "profile.deleteAccount": "Request account deletion", + "profile.security": "Account security", + "profile.securityBody": + "If you need more sensitive changes, contact us from the contact page and we will help manually.", + "profile.hero": + "Understand the news from multiple perspectives, without the noise.", + "profile.heroBody": + "Biviant helps you follow bias, compare sources, and spot factual divergence, while a free account keeps your saved items and preferences in sync.", + "profile.learnMore": "Learn more", + "profile.learnMoreBody": + "Explore how the product works and where the perspectives in the feed come from.", + "profile.feature.bias": "Article bias", + "profile.feature.biasBody": + "Quickly see how sources lean and what nuance each one adds.", + "profile.feature.compare": "Perspective comparison", + "profile.feature.compareBody": + "Put different coverage of the same event side by side without leaving the app.", + "profile.feature.facts": "Factual divergence", + "profile.feature.factsBody": + "Notice where real discrepancies appear between claims from different sources.", + "settings.title": "Settings", + "settings.language": "Language", + "settings.language.ro": "Romanian", + "settings.language.en": "English", + "user.menu": "User menu", + "user.account": "Account", + "user.streakOne": "1 day streak", + "user.streakMany": "{count} day streak", + "user.profileSoon": "Full profile coming soon", + "footer.aboutSection": "About", + "footer.resourcesSection": "Resources", + "footer.legalSection": "Legal", + "footer.about": "About us", + "footer.contact": "Contact", + "footer.partners": "Partners", + "footer.privacy": "Privacy", + "footer.terms": "Terms", + "footer.howItWorks": "How it works", + "footer.sources": "Our sources", + "placeholder.inProgress": "Page under construction.", + "page.about.title": "About us", + "page.about.body": + "Page under construction. We are preparing a short introduction to Biviant and our mission.", + "page.contact.title": "Contact", + "page.contact.body": + "Page under construction. We will add contact details and ways to reach us soon.", + "page.partners.title": "Partners", + "page.partners.body": + "Page under construction. This is where Biviant partners, collaborations, and supported initiatives will appear.", + "page.how.title": "How it works", + "page.how.body": + "Page under construction. We are preparing a simple explanation of the feed, bias analysis, and source comparison.", + "page.privacy.title": "Privacy policy", + "page.privacy.body": + "Page under construction. We will publish information here about collected data, usage, and your rights.", + "page.terms.title": "Terms", + "page.terms.body": + "Page under construction. We will add Biviant’s terms and conditions here soon.", + "page.sources.title": "Our sources", + "page.sources.body": + "Page under construction. Soon you will see how we select sources and build perspectives in the feed.", + "feed.meta.title": "Feed — Biviant", + "feed.meta.description": + "Browse the day’s important stories from multiple perspectives. Filter by topic and follow the same event across sources.", + "feed.topic.search": "Search topics...", + "feed.topic.empty": "No topics found.", + "feed.topic.all": "All topics", + "feed.topic.single": "Topic", + "feed.topic.filter": "Filter by topic", + "feed.topic.drawerTitle": "Filter by topic", + "feed.topic.drawerBody": "Select a topic to filter stories", + "feed.close": "Close", + "feed.search.placeholder": "Search events by title...", + "feed.search.label": "Search events", + "feed.search.clear": "Clear search", + "feed.sort.recent": "Recent", + "feed.sort.trending": "Trending", + "feed.sort.recentHint": "Newest published events first.", + "feed.sort.trendingHint": + "Ranked by source diversity, article volume, and recency.", + "feed.search.threshold": "Type 2+ characters to search.", + "feed.filter.clear": "Clear filter", + "event.general": "General", + "event.summaryPending": "Summary pending...", + "event.coveragePreview": + "Coverage grouped from multiple sources. Open the event to compare articles.", + "event.updated": "Updated {time}", + "event.sources": "{count} sources", + "event.articles.one": "1 article", + "event.articles.many": "{count} articles", + "event.follow": "Follow the event", + "event.biasDistribution": + "Source bias distribution: {left} left, {center} center, {right} right", + "event.bias.left": "{count} left", + "event.bias.center": "{count} center", + "event.bias.right": "{count} right", + "bias.label.balanced": "Balanced", + "bias.label.left": "Leaning left", + "bias.label.leftStrong": "Mostly left", + "bias.label.right": "Leaning right", + "bias.label.rightStrong": "Mostly right", + "bias.copy.balanced": "You’re reading a healthy mix of perspectives.", + "bias.copy.left": "Recent reading tilts left-leaning.", + "bias.copy.leftStrong": "Recent reading skews left-leaning.", + "bias.copy.right": "Recent reading tilts right-leaning.", + "bias.copy.rightStrong": "Recent reading skews right-leaning.", + "bias.balance": "Bias balance", + "bias.leftHeavy": "Left-heavy", + "bias.center": "Balanced", + "bias.rightHeavy": "Right-heavy", +} satisfies { [K in keyof BaseStrings]: string }; + +export const STRINGS = { + ro, + en, +} as const satisfies Record>; + +export type StringKey = keyof typeof STRINGS.ro; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 33c8bd3..c596f2a 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -1,4 +1,7 @@ -import { createRouter as createTanStackRouter } from "@tanstack/react-router"; +import { + createRouter as createTanStackRouter, + type AnyRouter, +} from "@tanstack/react-router"; import { QueryClient } from "@tanstack/react-query"; import { routerWithQueryClient } from "@tanstack/react-router-with-query"; import { ConvexQueryClient } from "@convex-dev/react-query"; @@ -7,7 +10,7 @@ import { routeTree } from "./routeTree.gen"; import Loader from "./components/loader"; import "./index.css"; -export function getRouter() { +export function getRouter(): AnyRouter { const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!; if (!CONVEX_URL) { throw new Error( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2601142..9557199 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,6 +2,10 @@ import { Toaster } from "@/components/ui/sonner"; import { SITE } from "@/lib/seo"; import { Footer } from "@/components/layout/Footer"; import { MobileTabBar } from "@/components/layout/MobileTabBar"; +import { LocaleProvider } from "@/lib/i18n/LocaleContext"; +import { getServerLocale } from "@/lib/i18n/getServerLocale"; +import type { Locale } from "@/lib/i18n/strings"; +import { api } from "@news-app/backend/convex/_generated/api"; import { HeadContent, @@ -29,10 +33,7 @@ const fetchAuth = createServerFn({ method: "GET" }).handler(async () => { function getExistingAuthContext( matches: Array<{ context: unknown }>, -): { - token?: string; - isAuthenticated?: boolean; -} { +): RootContextState { const rootContext = matches[0]?.context; if (!rootContext || typeof rootContext !== "object") { return {}; @@ -47,10 +48,21 @@ function getExistingAuthContext( typeof rootContext.isAuthenticated === "boolean" ? rootContext.isAuthenticated : undefined; + const locale = + "locale" in rootContext && + (rootContext.locale === "ro" || rootContext.locale === "en") + ? rootContext.locale + : undefined; - return { token, isAuthenticated }; + return { token, isAuthenticated, locale }; } +type RootContextState = { + token?: string; + isAuthenticated?: boolean; + locale?: Locale; +}; + export interface RouterAppContext { queryClient: QueryClient; convexClient: ConvexReactClient; @@ -92,7 +104,7 @@ export const Route = createRootRouteWithContext()({ }), component: RootDocument, - beforeLoad: async (ctx) => { + beforeLoad: async (ctx): Promise => { // Only fetch the auth token during SSR. On the client, auth state is // already maintained by ConvexBetterAuthProvider and intent preloading // would otherwise hit this server function on every hover. @@ -101,44 +113,61 @@ export const Route = createRootRouteWithContext()({ } const { token } = await fetchAuth(); + let userPreference: string | null = null; if (token) { ctx.context.convexQueryClient.serverHttpClient?.setAuth(token); + try { + const currentUser = + await ctx.context.convexQueryClient.serverHttpClient?.query( + api.user.getCurrentUser, + {}, + ); + userPreference = currentUser?.profile?.preferredLanguage ?? null; + } catch { + userPreference = null; + } } + const locale = await getServerLocale({ data: { userPreference } }); return { token, isAuthenticated: !!token, + locale, }; }, }); function RootDocument() { - const context = useRouteContext({ from: Route.id }); + const context = useRouteContext({ strict: false }) as RouterAppContext & + RootContextState; + const locale = context.locale ?? "en"; return ( - - - - - -
-
-
-
- -
-
- - - {import.meta.env.DEV && ( - - )} - - - + + + + + + +
+
+
+
+ +
+
+ + + {import.meta.env.DEV && ( + + )} + + + + ); } diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 0aa14a6..43329b0 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -449,6 +449,9 @@ export default defineSchema({ avatar: v.optional(v.string()), job: v.optional(v.string()), location: v.optional(v.string()), + preferredLanguage: v.optional( + v.union(v.literal("ro"), v.literal("en")), + ), }), }) .index("by_email", ["email"]) diff --git a/packages/backend/convex/user.ts b/packages/backend/convex/user.ts index 1bd1256..54a2914 100644 --- a/packages/backend/convex/user.ts +++ b/packages/backend/convex/user.ts @@ -95,6 +95,9 @@ export const updateProfile = mutation({ avatar: v.optional(v.string()), job: v.optional(v.string()), location: v.optional(v.string()), + preferredLanguage: v.optional( + v.union(v.literal("ro"), v.literal("en")), + ), }), }, handler: async (ctx, args) => { @@ -120,6 +123,33 @@ export const updateProfile = mutation({ }, }); +export const updatePreferredLanguage = mutation({ + args: { + language: v.union(v.literal("ro"), v.literal("en")), + }, + handler: async (ctx, { language }) => { + const authUser = await authComponent.safeGetAuthUser(ctx); + if (!authUser) { + throw new ConvexError("Not authenticated"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_auth_user_id", (q) => q.eq("authUserId", authUser._id)) + .unique(); + + if (!user) { + throw new ConvexError("User not found - please refresh and try again"); + } + + await ctx.db.patch(user._id, { + profile: { ...user.profile, preferredLanguage: language }, + }); + + return { ok: true }; + }, +}); + /** * Update the current user's private context (for personalized insights). * Writes to the separate userPrivateContext table. From cf85fb47742f0141d6f18ae33f13891568e03926 Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 18:36:15 +0300 Subject: [PATCH 09/19] feat(web): translate shared ui and account surfaces --- apps/web/src/components/SignInPrompt.tsx | 7 +- .../web/src/components/auth-prompt-banner.tsx | 17 ++-- apps/web/src/components/auth-social.tsx | 11 ++- .../web/src/components/bias-balance-meter.tsx | 40 +++++--- apps/web/src/components/feed/event-card.tsx | 47 ++++++--- apps/web/src/components/feed/event.tsx | 8 +- apps/web/src/components/header.tsx | 23 +++-- apps/web/src/components/layout/Footer.tsx | 29 +++--- .../src/components/layout/MobileTabBar.tsx | 40 ++++---- .../components/profile/AnonymousProfile.tsx | 49 +++++----- .../profile/AuthenticatedProfile.tsx | 97 +++++++++++-------- apps/web/src/components/user-menu.tsx | 20 +++- 12 files changed, 233 insertions(+), 155 deletions(-) diff --git a/apps/web/src/components/SignInPrompt.tsx b/apps/web/src/components/SignInPrompt.tsx index 465abb7..2637cdf 100644 --- a/apps/web/src/components/SignInPrompt.tsx +++ b/apps/web/src/components/SignInPrompt.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Link } from "@tanstack/react-router"; import type { AuthRedirectPath } from "@/lib/auth-redirect"; +import { useT } from "@/lib/i18n/LocaleContext"; import { Button } from "@/components/ui/button"; type SignInPromptProps = { @@ -16,6 +17,8 @@ export function SignInPrompt({ redirectTo, illustration, }: SignInPromptProps) { + const t = useT(); + return (
@@ -30,12 +33,12 @@ export function SignInPrompt({
diff --git a/apps/web/src/components/auth-prompt-banner.tsx b/apps/web/src/components/auth-prompt-banner.tsx index a7d69ad..22df8b5 100644 --- a/apps/web/src/components/auth-prompt-banner.tsx +++ b/apps/web/src/components/auth-prompt-banner.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import type { AuthRedirectPath } from "@/lib/auth-redirect"; +import { useT } from "@/lib/i18n/LocaleContext"; type AuthPromptBannerProps = { redirectTo: AuthRedirectPath; @@ -11,28 +12,32 @@ type AuthPromptBannerProps = { export default function AuthPromptBanner({ redirectTo, - title = "Creează un cont gratuit ca să salvezi și să personalizezi știrile", - description = "Poți citi fără cont. Autentificarea adaugă salvări, recomandări personale și funcții viitoare.", + title, + description, compact = false, }: AuthPromptBannerProps) { + const t = useT(); + const resolvedTitle = title ?? t("auth.promptTitle"); + const resolvedDescription = description ?? t("auth.promptBody"); + return (
-

{title}

+

{resolvedTitle}

- {description} + {resolvedDescription}

diff --git a/apps/web/src/components/auth-social.tsx b/apps/web/src/components/auth-social.tsx index f449f44..d3b4ea6 100644 --- a/apps/web/src/components/auth-social.tsx +++ b/apps/web/src/components/auth-social.tsx @@ -1,9 +1,12 @@ import { authClient } from "@/lib/auth-client"; +import { useT } from "@/lib/i18n/LocaleContext"; import { toast } from "sonner"; import type { AuthRedirectPath } from "@/lib/auth-redirect"; import { Button } from "./ui/button"; export function AuthDivider() { + const t = useT(); + return (
@@ -11,7 +14,7 @@ export function AuthDivider() {
- Or continue with + {t("auth.orContinueWith")}
@@ -25,6 +28,8 @@ export function GoogleSignInButton({ callbackURL: AuthRedirectPath; onBeforeSignIn?: () => void; }) { + const t = useT(); + return ( ); } diff --git a/apps/web/src/components/bias-balance-meter.tsx b/apps/web/src/components/bias-balance-meter.tsx index f579dbf..6b30718 100644 --- a/apps/web/src/components/bias-balance-meter.tsx +++ b/apps/web/src/components/bias-balance-meter.tsx @@ -1,40 +1,48 @@ import { cn } from "@/lib/utils"; +import { useT } from "@/lib/i18n/LocaleContext"; type BiasBalanceMeterProps = { value: number; className?: string; }; -function getBiasBalanceLabel(value: number) { +function getBiasBalanceLabel( + value: number, + t: ReturnType, +) { const absolute = Math.abs(value); if (absolute < 15) { - return "Balanced"; + return t("bias.label.balanced"); } if (value < 0) { - return absolute >= 60 ? "Mostly left" : "Leaning left"; + return absolute >= 60 ? t("bias.label.leftStrong") : t("bias.label.left"); } - return absolute >= 60 ? "Mostly right" : "Leaning right"; + return absolute >= 60 ? t("bias.label.rightStrong") : t("bias.label.right"); } -function getBiasBalanceCopy(value: number) { +function getBiasBalanceCopy( + value: number, + t: ReturnType, +) { const absolute = Math.abs(value); if (absolute < 15) { - return "You’re reading a healthy mix of perspectives."; + return t("bias.copy.balanced"); } if (value < 0) { return absolute >= 60 - ? "Recent reading skews left-leaning." - : "Recent reading tilts left-leaning."; + ? t("bias.copy.leftStrong") + : t("bias.copy.left"); } return absolute >= 60 - ? "Recent reading skews right-leaning." - : "Recent reading tilts right-leaning."; + ? t("bias.copy.rightStrong") + : t("bias.copy.right"); } export default function BiasBalanceMeter({ value, className, }: BiasBalanceMeterProps) { + const t = useT(); const clampedValue = Math.max(-100, Math.min(100, Math.round(value))); const indicatorPosition = ((clampedValue + 100) / 200) * 100; const indicatorStyle = @@ -52,8 +60,8 @@ export default function BiasBalanceMeter({ left: `${indicatorPosition}%`, transform: "translate(-50%, -50%)", }; - const label = getBiasBalanceLabel(clampedValue); - const copy = getBiasBalanceCopy(clampedValue); + const label = getBiasBalanceLabel(clampedValue, t); + const copy = getBiasBalanceCopy(clampedValue, t); return (
@@ -68,7 +76,7 @@ export default function BiasBalanceMeter({ {clampedValue}

- Bias Balance + {t("bias.balance")}

@@ -85,9 +93,9 @@ export default function BiasBalanceMeter({ />
- Left-heavy - Balanced - Right-heavy + {t("bias.leftHeavy")} + {t("bias.center")} + {t("bias.rightHeavy")}
diff --git a/apps/web/src/components/feed/event-card.tsx b/apps/web/src/components/feed/event-card.tsx index 943f20c..86cbbaf 100644 --- a/apps/web/src/components/feed/event-card.tsx +++ b/apps/web/src/components/feed/event-card.tsx @@ -5,6 +5,7 @@ import BookmarkButton from "@/components/bookmark-button"; import ShareEventButton from "@/components/share-event-button"; import { formatAbsoluteTimestamp, formatRelativeTimestamp } from "@/lib/dates"; import { buildInteractionContextFromSources } from "@/lib/interaction-tracking"; +import { useT } from "@/lib/i18n/LocaleContext"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -113,14 +114,15 @@ const EventCard = ({ returnToFeed = false, interactive = true, }: EventCardProps) => { + const t = useT(); const topics = (event.topicIds ?? []) .map((id) => topicNamesById[id]) .filter(Boolean); - const primaryTopic = topics[0] ?? "General"; + const primaryTopic = topics[0] ?? t("event.general"); const summaryPreview = event.perspectiveSummaries?.center ?? event.globalImpact ?? - "Coverage grouped from multiple sources. Open the event to compare articles."; + t("event.coveragePreview"); const isFeature = variant === "feature"; const lastUpdatedAt = event.lastUpdatedAt ?? event.firstPublishedAt; const lastUpdatedLabel = formatRelativeTimestamp(lastUpdatedAt); @@ -161,7 +163,7 @@ const EventCard = ({ )} >
- {(topics.length > 0 ? topics : ["General"]) + {(topics.length > 0 ? topics : [t("event.general")]) .slice(0, isFeature ? 3 : 2) .map((topic) => ( - Updated {lastUpdatedLabel} + {t("event.updated").replace("{time}", lastUpdatedLabel)}

{interactive && (
@@ -269,12 +271,17 @@ const EventCard = ({ )}

- {totalSources} sources + {t("event.sources").replace("{count}", String(totalSources))}

{event.articleCount !== undefined - ? `${event.articleCount} ${event.articleCount === 1 ? "article" : "articles"}` - : "Follow the event"} + ? event.articleCount === 1 + ? t("event.articles.one") + : t("event.articles.many").replace( + "{count}", + String(event.articleCount), + ) + : t("event.follow")}

@@ -283,7 +290,10 @@ const EventCard = ({
{(["left", "center", "right"] as BiasBucket[]).map( @@ -303,9 +313,24 @@ const EventCard = ({ )}
- {biasDistribution.left} left - {biasDistribution.center} center - {biasDistribution.right} right + + {t("event.bias.left").replace( + "{count}", + String(biasDistribution.left), + )} + + + {t("event.bias.center").replace( + "{count}", + String(biasDistribution.center), + )} + + + {t("event.bias.right").replace( + "{count}", + String(biasDistribution.right), + )} +
)} diff --git a/apps/web/src/components/feed/event.tsx b/apps/web/src/components/feed/event.tsx index fa98306..e03f970 100644 --- a/apps/web/src/components/feed/event.tsx +++ b/apps/web/src/components/feed/event.tsx @@ -1,4 +1,5 @@ import type { Id } from "@news-app/backend/convex/_generated/dataModel"; +import { useT } from "@/lib/i18n/LocaleContext"; type EventCardProps = { event: { @@ -14,6 +15,7 @@ type EventCardProps = { }; const EventCard = ({ event, topicNamesById }: EventCardProps) => { + const t = useT(); const topics = event.topicIds.map((id) => topicNamesById[id]).filter(Boolean); return ( @@ -26,14 +28,16 @@ const EventCard = ({ event, topicNamesById }: EventCardProps) => {
)) ) : ( - General + + {t("event.general")} + )}

{event.title}

- {event.perspectiveSummaries?.center ?? "Summary pending…"} + {event.perspectiveSummaries?.center ?? t("event.summaryPending")}

); diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 9fa0b87..185aa29 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Link, useRouterState } from "@tanstack/react-router"; import { Menu, Newspaper, Bookmark, LayoutDashboard, User } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useT } from "@/lib/i18n/LocaleContext"; import { Sheet, SheetContent, @@ -10,14 +11,15 @@ import { } from "@/components/ui/sheet"; const links = [ - { to: "/feed", label: "Feed", icon: Newspaper }, - { to: "/salvate", label: "Salvate", icon: Bookmark }, - { to: "/activitate", label: "Activitate", icon: LayoutDashboard }, - { to: "/profil", label: "Profil", icon: User }, + { to: "/feed", key: "tabs.feed", icon: Newspaper }, + { to: "/salvate", key: "tabs.saved", icon: Bookmark }, + { to: "/activitate", key: "tabs.activity", icon: LayoutDashboard }, + { to: "/profil", key: "tabs.profile", icon: User }, ] as const; export default function Header() { const [open, setOpen] = useState(false); + const t = useT(); const routerState = useRouterState(); const currentPath = routerState.location.pathname; @@ -36,8 +38,8 @@ export default function Header() { {/* Desktop nav */}
@@ -296,7 +313,7 @@ function AuthorizedDashboard() { {eventsExplored}

- events explored + {t("activity.eventsExplored")}

@@ -312,7 +329,9 @@ function AuthorizedDashboard() {

{bookmarkCount}

-

bookmarked

+

+ {t("activity.bookmarked")} +

@@ -324,9 +343,9 @@ function AuthorizedDashboard() {
-

Activity

+

{t("activity.section")}

- Last 12 weeks of reading + {t("activity.last12Weeks")}

@@ -335,7 +354,7 @@ function AuthorizedDashboard() { {readingStreak}

- Current + {t("activity.current")}

@@ -343,7 +362,7 @@ function AuthorizedDashboard() { {longestStreak}

- Best + {t("activity.best")}

@@ -351,7 +370,7 @@ function AuthorizedDashboard() { {activeDays}

- Active + {t("activity.active")}

@@ -363,17 +382,20 @@ function AuthorizedDashboard() { {/* Bias Balance */}
-

Bias Balance

+

{t("activity.biasBalance")}

- Your reading perspective mix + {t("activity.biasMix")}

{weeklyBiasReads > 0 && (

- {getBiasSnapshotLabel(weeklyBiasBalance)} across{" "} - {weeklyBiasReads} reads + {getBiasSnapshotLabel(weeklyBiasBalance, t)}{" "} + {t("activity.biasReads").replace( + "{count}", + String(weeklyBiasReads), + )}

)}
@@ -385,9 +407,9 @@ function AuthorizedDashboard() {
-

Recent Reading

+

{t("activity.recentReading")}

- Events you spent time with + {t("activity.recentReadingBody")}

@@ -479,7 +501,7 @@ function AuthorizedDashboard() {
{recentBookmarks.length === 0 ? (
- Salvează evenimente din feed ca să apară aici + {t("activity.savedEmpty")}
) : (
@@ -514,8 +536,12 @@ function AuthorizedDashboard() { · - {(entry.event.sourceCount ?? 0)} source - {(entry.event.sourceCount ?? 0) === 1 ? "" : "s"} + {(entry.event.sourceCount ?? 0) === 1 + ? t("activity.sourcesOne") + : t("activity.sourcesMany").replace( + "{count}", + String(entry.event.sourceCount ?? 0), + )}
@@ -537,9 +563,9 @@ function AuthorizedDashboard() {
-

Explorează feed-ul

+

{t("activity.feedCard")}

- Vezi subiectele de astăzi + {t("activity.feedCardBody")}

@@ -552,10 +578,14 @@ function AuthorizedDashboard() {
-

Salvate

+

{t("activity.savedCard")}

- {bookmarkCount}{" "} - {bookmarkCount === 1 ? "articol salvat" : "articole salvate"} + {bookmarkCount === 1 + ? t("activity.savedOne") + : t("activity.savedMany").replace( + "{count}", + String(bookmarkCount), + )}

diff --git a/apps/web/src/routes/contact.tsx b/apps/web/src/routes/contact.tsx index 5ead34f..5ce00df 100644 --- a/apps/web/src/routes/contact.tsx +++ b/apps/web/src/routes/contact.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/contact")({ component: ContactPage, }); function ContactPage() { + const t = useT(); + return (
-

Contact

-

- Pagină în construcție. Vom adăuga în curând detaliile de contact și - canalele prin care ne poți scrie. -

+

+ {t("page.contact.title")} +

+

{t("page.contact.body")}

); } diff --git a/apps/web/src/routes/cum-functioneaza.tsx b/apps/web/src/routes/cum-functioneaza.tsx index 4dd8f07..ff5728f 100644 --- a/apps/web/src/routes/cum-functioneaza.tsx +++ b/apps/web/src/routes/cum-functioneaza.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/cum-functioneaza")({ component: CumFunctioneazaPage, }); function CumFunctioneazaPage() { + const t = useT(); + return (
-

Cum funcționează

-

- Pagină în construcție. Pregătim o explicație simplă despre feed, - analiza de bias și comparația dintre surse. -

+

+ {t("page.how.title")} +

+

{t("page.how.body")}

); } diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index fb01e3b..d486d01 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,6 +1,7 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { useT } from "@/lib/i18n/LocaleContext"; import { createFileRoute } from "@tanstack/react-router"; import { useConvexAuth } from "convex/react"; import { useEffect, useRef } from "react"; @@ -26,6 +27,7 @@ export const Route = createFileRoute("/dashboard")({ }); function DashboardAuthPage() { + const t = useT(); const search = Route.useSearch(); const { isAuthenticated, isLoading } = useConvexAuth(); const redirectTo: AuthRedirectPath = @@ -66,15 +68,15 @@ function DashboardAuthPage() { } hasShownVerifiedToastRef.current = true; - toast.message("Dacă ți-ai verificat contul, te poți conecta acum."); + toast.message(t("auth.verifiedToast")); replaceDashboardSearch("signin"); - }, [isVerified, search.redirect]); + }, [isVerified, search.redirect, t]); if (isLoading || isAuthenticated) { return ( ); @@ -87,15 +89,15 @@ function DashboardAuthPage() {

- Cont Biviant + {t("auth.account")}

- {showSignIn ? "Bine ai revenit" : "Creează un cont gratuit"} + {showSignIn + ? t("auth.welcomeBack") + : t("auth.createFreeAccount")}

- Poți citi feed-ul fără cont. Conectarea îți oferă salvări - sincronizate, preferințe personale și acces rapid la activitatea - ta. + {t("auth.accountIntro")}

@@ -103,8 +105,8 @@ function DashboardAuthPage() { {showSignIn ? ( { replaceDashboardSearch("signup", search.verified); }} @@ -112,9 +114,9 @@ function DashboardAuthPage() { ) : ( { replaceDashboardSearch("signin", search.verified); }} @@ -125,39 +127,38 @@ function DashboardAuthPage() {
-

Ce deblochează contul

+

{t("auth.unlockTitle")}

- Conținutul rămâne deschis. Contul adaugă doar funcții personale - și persistente. + {t("auth.unlockBody")}

  1. 1 - Salvezi articole și evenimente pentru mai târziu. + {t("auth.unlockOne")}
  2. 2 - Îți personalizezi experiența pe baza intereselor tale. + {t("auth.unlockTwo")}
  3. 3 - Primești acces la notificări și funcții noi pe măsură ce apar. + {t("auth.unlockThree")}
-

Verificarea contează

+

+ {t("auth.verificationTitle")} +

- Conturile create cu email și parolă necesită verificarea adresei - înainte de prima conectare. Pentru autentificarea cu Google, - verificarea furnizorului rămâne suficientă. + {t("auth.verificationBody")}

diff --git a/apps/web/src/routes/despre.tsx b/apps/web/src/routes/despre.tsx index 36bcf7f..cb75554 100644 --- a/apps/web/src/routes/despre.tsx +++ b/apps/web/src/routes/despre.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/despre")({ component: DesprePage, }); function DesprePage() { + const t = useT(); + return (
-

Despre noi

-

- Pagină în construcție. Pregătim o prezentare scurtă despre Biviant și - misiunea noastră. -

+

+ {t("page.about.title")} +

+

{t("page.about.body")}

); } diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index 34af7c7..bfd88a5 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -38,23 +38,24 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useIsMobile } from "@/components/ui/use-mobile"; +import { useT } from "@/lib/i18n/LocaleContext"; import { cn } from "@/lib/utils"; import { SITE } from "@/lib/seo"; export const Route = createFileRoute("/feed")({ head: () => ({ meta: [ - { title: `News Feed — ${SITE.name}` }, + { title: `Feed — ${SITE.name}` }, { name: "description", content: - "Browse today's top stories from multiple political perspectives. Filter by topic and track the same story across sources.", + "Browse the day’s important stories from multiple perspectives. Filter by topic and follow the same event across sources.", }, - { property: "og:title", content: `News Feed — ${SITE.name}` }, + { property: "og:title", content: `Feed — ${SITE.name}` }, { property: "og:description", content: - "Browse today's top stories from multiple political perspectives.", + "Browse the day’s important stories from multiple perspectives.", }, { property: "og:url", content: `${SITE.url}/feed` }, ], @@ -72,18 +73,20 @@ function TopicFilterContent({ selectedTopic: Id<"topics"> | "all"; onSelect: (topic: Id<"topics"> | "all") => void; }) { + const t = useT(); + return ( - + - No topics found. + {t("feed.topic.empty")} onSelect("all")} className="flex items-center justify-between gap-2" > - All Topics + {t("feed.topic.all")} {selectedTopic === "all" && ( )} @@ -116,13 +119,17 @@ function TopicFilter({ selectedTopic: Id<"topics"> | "all"; onSelect: (topic: Id<"topics"> | "all") => void; }) { + const t = useT(); const [open, setOpen] = useState(false); const isMobile = useIsMobile(); const selectedLabel = useMemo(() => { - if (selectedTopic === "all") return "All Topics"; - return topics?.find((t) => t._id === selectedTopic)?.displayName ?? "Topic"; - }, [selectedTopic, topics]); + if (selectedTopic === "all") return t("feed.topic.all"); + return ( + topics?.find((topic) => topic._id === selectedTopic)?.displayName ?? + t("feed.topic.single") + ); + }, [selectedTopic, t, topics]); const handleSelect = (topic: Id<"topics"> | "all") => { onSelect(topic); @@ -137,7 +144,7 @@ function TopicFilter({ "min-w-[140px] justify-between gap-2 rounded-full", selectedTopic !== "all" && "border-primary/50 bg-primary/5", )} - aria-label="Filter by topic" + aria-label={t("feed.topic.filter")} > @@ -156,9 +163,9 @@ function TopicFilter({
- Filter by Topic + {t("feed.topic.drawerTitle")} - Select a topic to filter stories + {t("feed.topic.drawerBody")}
@@ -168,7 +175,7 @@ function TopicFilter({ className="size-8 rounded-full" > - Close + {t("feed.close")}
@@ -189,7 +196,7 @@ function TopicFilter({ size="icon" className="size-8 rounded-full" onClick={() => onSelect("all")} - aria-label="Clear filter" + aria-label={t("feed.filter.clear")} > @@ -217,7 +224,7 @@ function TopicFilter({ size="icon" className="size-8 rounded-full" onClick={() => onSelect("all")} - aria-label="Clear filter" + aria-label={t("feed.filter.clear")} > @@ -231,6 +238,7 @@ function FeedComponent() { } function FeedContent() { + const t = useT(); const { isAuthenticated } = useConvexAuth(); const topics = useQuery(api.topics.getTopics); const currentUser = useQuery( @@ -424,9 +432,9 @@ function FeedContent() { onBlur={() => { window.setTimeout(() => setIsSearchFocused(false), 100); }} - placeholder="Search events by title..." + placeholder={t("feed.search.placeholder")} className="h-11 rounded-full border-border/80 bg-background/75 pr-12" - aria-label="Search events" + aria-label={t("feed.search.label")} /> {searchInput.length > 0 && ( @@ -468,7 +476,7 @@ function FeedContent() { aria-pressed={feedSort === "recent"} > - Recent + {t("feed.sort.recent")}

{feedSort === "recent" - ? "Newest published events first." - : "Ranked by source diversity, article volume, and recency."} + ? t("feed.sort.recentHint") + : t("feed.sort.trendingHint")}

)} {shouldShowThresholdHint && (

- Type 2+ characters to search. + {t("feed.search.threshold")}

)} {shouldShowRecentSearches && ( diff --git a/apps/web/src/routes/parteneri.tsx b/apps/web/src/routes/parteneri.tsx index c767918..9bab66a 100644 --- a/apps/web/src/routes/parteneri.tsx +++ b/apps/web/src/routes/parteneri.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/parteneri")({ component: ParteneriPage, }); function ParteneriPage() { + const t = useT(); + return (
-

Parteneri

-

- Pagină în construcție. Aici vor apărea partenerii, colaborările și - inițiativele susținute de Biviant. -

+

+ {t("page.partners.title")} +

+

{t("page.partners.body")}

); } diff --git a/apps/web/src/routes/politica-confidentialitate.tsx b/apps/web/src/routes/politica-confidentialitate.tsx index e49e2c2..c5c9044 100644 --- a/apps/web/src/routes/politica-confidentialitate.tsx +++ b/apps/web/src/routes/politica-confidentialitate.tsx @@ -1,19 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/politica-confidentialitate")({ component: PoliticaConfidentialitatePage, }); function PoliticaConfidentialitatePage() { + const t = useT(); + return (

- Politica de confidențialitate + {t("page.privacy.title")}

-

- Pagină în construcție. Vom publica aici informațiile despre datele - colectate, utilizare și drepturile tale. -

+

{t("page.privacy.body")}

); } diff --git a/apps/web/src/routes/profil.tsx b/apps/web/src/routes/profil.tsx index 8307586..fc432f1 100644 --- a/apps/web/src/routes/profil.tsx +++ b/apps/web/src/routes/profil.tsx @@ -1,6 +1,7 @@ import { AnonymousProfile } from "@/components/profile/AnonymousProfile"; import { AuthenticatedProfile } from "@/components/profile/AuthenticatedProfile"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { useT } from "@/lib/i18n/LocaleContext"; import { api } from "@news-app/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; import { useConvexAuth, useQuery } from "convex/react"; @@ -15,12 +16,13 @@ export const Route = createFileRoute("/profil")({ function ProfilPage() { const { isAuthenticated, isLoading } = useConvexAuth(); const user = useQuery(api.user.getCurrentUser); + const t = useT(); if (isLoading || (isAuthenticated && user === undefined)) { return ( ); diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx index b043b50..8c4d07d 100644 --- a/apps/web/src/routes/salvate.tsx +++ b/apps/web/src/routes/salvate.tsx @@ -11,6 +11,7 @@ import { SignInPrompt } from "@/components/SignInPrompt"; import EventCard from "@/components/feed/event-card"; import { Button } from "@/components/ui/button"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { useT } from "@/lib/i18n/LocaleContext"; import { SITE } from "@/lib/seo"; function safePositiveInt(raw: unknown, fallback: number): number { @@ -29,6 +30,8 @@ export const Route = createFileRoute("/salvate")({ }); function SalvatePage() { + const t = useT(); + return ( <> @@ -36,8 +39,8 @@ function SalvatePage() { @@ -60,8 +63,8 @@ function SalvatePage() { @@ -70,6 +73,7 @@ function SalvatePage() { } function SalvateContent() { + const t = useT(); const bookmarks = useQuery(api.interactions.getBookmarkedEvents); const topics = useQuery(api.topics.getTopics); const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); @@ -86,8 +90,8 @@ function SalvateContent() { if (bookmarks === undefined) { return ( ); @@ -101,16 +105,21 @@ function SalvateContent() {

- Salvate + {t("saved.section")}

- Articolele tale salvate + {t("saved.heading")}

{bookmarks.length === 0 - ? "Articolele și evenimentele salvate vor apărea aici." - : `Ai ${bookmarks.length} ${bookmarks.length === 1 ? "articol salvat" : "articole salvate"} pentru mai târziu.`} + ? t("saved.summary.empty") + : bookmarks.length === 1 + ? t("saved.summary.one") + : t("saved.summary.many").replace( + "{count}", + String(bookmarks.length), + )}

@@ -136,14 +145,13 @@ function SalvateContent() {
-

Nu ai salvări încă

+

{t("saved.none")}

- Când găsești o poveste importantă, folosește iconița de - salvare ca să o păstrezi aici. + {t("saved.noneBody")}

diff --git a/apps/web/src/routes/sursele-noastre.tsx b/apps/web/src/routes/sursele-noastre.tsx index 9f3062b..11ba474 100644 --- a/apps/web/src/routes/sursele-noastre.tsx +++ b/apps/web/src/routes/sursele-noastre.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/sursele-noastre")({ component: SurseleNoastrePage, }); function SurseleNoastrePage() { + const t = useT(); + return (
-

Sursele noastre

-

- Pagină în construcție. În curând vei vedea cum selectăm sursele și cum - construim perspectivele din feed. -

+

+ {t("page.sources.title")} +

+

{t("page.sources.body")}

); } diff --git a/apps/web/src/routes/termeni.tsx b/apps/web/src/routes/termeni.tsx index 1f8af71..6dea516 100644 --- a/apps/web/src/routes/termeni.tsx +++ b/apps/web/src/routes/termeni.tsx @@ -1,17 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; export const Route = createFileRoute("/termeni")({ component: TermeniPage, }); function TermeniPage() { + const t = useT(); + return (
-

Termeni

-

- Pagină în construcție. Urmează să adăugăm termenii și condițiile de - utilizare pentru Biviant. -

+

+ {t("page.terms.title")} +

+

{t("page.terms.body")}

); } From 74d6d6af19a0a845817280d9b00dea2c06d28fbb Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 18:38:54 +0300 Subject: [PATCH 11/19] feat(web): extend locale coverage across supporting flows --- .../web/src/components/feed/articles-list.tsx | 15 ++-- .../web/src/components/share-event-button.tsx | 8 ++- .../components/streak-activity-calendar.tsx | 40 +++++++++-- apps/web/src/lib/i18n/strings.ts | 72 ++++++++++++++++++- apps/web/src/routes/unsubscribe.tsx | 38 ++++++---- 5 files changed, 145 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/feed/articles-list.tsx b/apps/web/src/components/feed/articles-list.tsx index 99ad45c..3c6e7ae 100644 --- a/apps/web/src/components/feed/articles-list.tsx +++ b/apps/web/src/components/feed/articles-list.tsx @@ -7,6 +7,7 @@ import { useConvexMutation } from "@convex-dev/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import BiasIndicator from "@/components/bias-indicator"; import { getClientDeviceType } from "@/lib/interaction-tracking"; +import { useT } from "@/lib/i18n/LocaleContext"; type Article = { _id: Id<"articles">; @@ -42,6 +43,7 @@ function isNumberArray(value: unknown): value is number[] { } const ArticlesList = ({ eventId, articles }: ArticlesListProps) => { + const t = useT(); const { isAuthenticated } = useConvexAuth(); const logInteraction = useMutation({ mutationFn: useConvexMutation(api.interactions.logInteraction), @@ -73,7 +75,7 @@ const ArticlesList = ({ eventId, articles }: ArticlesListProps) => { - Original Reporting ({articles.length}) + {t("articles.originalReporting")} ({articles.length}) @@ -95,7 +97,7 @@ const ArticlesList = ({ eventId, articles }: ArticlesListProps) => { ) : (
- {article.source?.name ?? "Source"} + {article.source?.name ?? t("articles.source")}
)} @@ -109,7 +111,10 @@ const ArticlesList = ({ eventId, articles }: ArticlesListProps) => { to="/source/$sourceId" params={{ sourceId: article.source._id }} className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border border-border/80 bg-background transition-colors hover:bg-muted" - aria-label={`View ${article.source.name} source profile`} + aria-label={t("articles.viewSource").replace( + "{name}", + article.source.name, + )} onClick={() => logSourceClick(article)} > {article.source.logoUrl ? ( @@ -163,11 +168,11 @@ const ArticlesList = ({ eventId, articles }: ArticlesListProps) => { href={article.canonicalUrl} target="_blank" rel="noopener noreferrer" - aria-label="Read original (opens in a new tab)" + aria-label={t("articles.readOriginalAria")} className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => logSourceClick(article)} > - Read original + {t("articles.readOriginal")} diff --git a/apps/web/src/components/streak-activity-calendar.tsx b/apps/web/src/components/streak-activity-calendar.tsx index 15e4515..870d18a 100644 --- a/apps/web/src/components/streak-activity-calendar.tsx +++ b/apps/web/src/components/streak-activity-calendar.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { useT } from "@/lib/i18n/LocaleContext"; type StreakDay = { timestamp: number; @@ -30,6 +31,7 @@ export default function StreakActivityCalendar({ days, className, }: StreakActivityCalendarProps) { + const t = useT(); const sortedDays = [...days].sort((a, b) => a.timestamp - b.timestamp); const firstDay = sortedDays[0]; const leadingBlankCount = firstDay @@ -44,7 +46,15 @@ export default function StreakActivityCalendar({ const fullDateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", }); - const weekdayRows = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const weekdayRows = [ + t("calendar.weekday.mon"), + t("calendar.weekday.tue"), + t("calendar.weekday.wed"), + t("calendar.weekday.thu"), + t("calendar.weekday.fri"), + t("calendar.weekday.sat"), + t("calendar.weekday.sun"), + ]; const monthLabels = weeks.map((week, index) => { const firstRealDay = week.find((day) => day !== null); if (!firstRealDay) return ""; @@ -88,8 +98,28 @@ export default function StreakActivityCalendar({ day.isToday && "ring-1 ring-primary ring-offset-1 ring-offset-background", )} - title={`${fullDateFormatter.format(new Date(day.timestamp))}: ${day.readCount} ${day.readCount === 1 ? "event" : "events"} read`} - aria-label={`${fullDateFormatter.format(new Date(day.timestamp))}: ${day.readCount} ${day.readCount === 1 ? "event" : "events"} read`} + title={`${fullDateFormatter.format(new Date(day.timestamp))}: ${ + day.readCount === 1 + ? t("calendar.eventsRead.one").replace( + "{count}", + String(day.readCount), + ) + : t("calendar.eventsRead.many").replace( + "{count}", + String(day.readCount), + ) + }`} + aria-label={`${fullDateFormatter.format(new Date(day.timestamp))}: ${ + day.readCount === 1 + ? t("calendar.eventsRead.one").replace( + "{count}", + String(day.readCount), + ) + : t("calendar.eventsRead.many").replace( + "{count}", + String(day.readCount), + ) + }`} /> ) : (
- Less + {t("calendar.less")} - More + {t("calendar.more")}
); diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts index fda21e8..f944871 100644 --- a/apps/web/src/lib/i18n/strings.ts +++ b/apps/web/src/lib/i18n/strings.ts @@ -27,6 +27,9 @@ const ro = { "Creează un cont gratuit ca să salvezi și să personalizezi știrile", "auth.promptBody": "Poți citi fără cont. Autentificarea adaugă salvări, recomandări personale și funcții viitoare.", + "share.copied": "Linkul evenimentului a fost copiat", + "share.failed": "Nu am putut distribui acest eveniment. Încearcă din nou.", + "share.label": "Distribuie acest eveniment", "auth.unlockTitle": "Ce deblochează contul", "auth.unlockBody": "Conținutul rămâne deschis. Contul adaugă doar funcții personale și persistente.", @@ -226,7 +229,39 @@ const ro = { "bias.leftHeavy": "Mai mult stânga", "bias.center": "Echilibrat", "bias.rightHeavy": "Mai mult dreapta", -} as const; + "calendar.weekday.mon": "Lun", + "calendar.weekday.tue": "Mar", + "calendar.weekday.wed": "Mie", + "calendar.weekday.thu": "Joi", + "calendar.weekday.fri": "Vin", + "calendar.weekday.sat": "Sâm", + "calendar.weekday.sun": "Dum", + "calendar.eventsRead.one": "{count} eveniment citit", + "calendar.eventsRead.many": "{count} evenimente citite", + "calendar.less": "Mai puțin", + "calendar.more": "Mai mult", + "unsubscribe.meta.title": "Dezabonare — Biviant", + "unsubscribe.invalidTitle": "Link invalid", + "unsubscribe.invalidBody": + "Acest link de dezabonare nu conține adresa de email. Folosește linkul primit în email.", + "unsubscribe.goHome": "Mergi la început", + "unsubscribe.loading": "Te dezabonăm...", + "unsubscribe.errorTitle": "Ceva nu a mers bine", + "unsubscribe.errorBody": + "Nu am putut procesa cererea. Încearcă din nou sau răspunde la emailul nostru și te vom elimina manual.", + "unsubscribe.retry": "Încearcă din nou", + "unsubscribe.successTitle": "Te-ai dezabonat", + "unsubscribe.successBody": + "{email} a fost eliminat din toate emailurile Biviant. Nu te vom mai contacta.", + "unsubscribe.changedMind": "Te-ai răzgândit?", + "unsubscribe.rejoin": "Înscrie-te din nou pe lista de așteptare", + "articles.originalReporting": "Relatări originale", + "articles.source": "Sursă", + "articles.viewSource": "Vezi profilul sursei {name}", + "articles.readOriginal": "Citește originalul", + "articles.readOriginalAria": + "Citește articolul original (se deschide într-o filă nouă)", + } as const; type BaseStrings = typeof ro; @@ -257,6 +292,9 @@ const en: { [K in keyof BaseStrings]: string } = { "Create a free account to save and personalize your news", "auth.promptBody": "You can read without an account. Signing in adds saved items, recommendations, and upcoming features.", + "share.copied": "Event link copied", + "share.failed": "Could not share this event. Please try again.", + "share.label": "Share this event", "auth.unlockTitle": "What an account unlocks", "auth.unlockBody": "The content stays open. An account only adds personal, persistent features.", @@ -453,6 +491,38 @@ const en: { [K in keyof BaseStrings]: string } = { "bias.leftHeavy": "Left-heavy", "bias.center": "Balanced", "bias.rightHeavy": "Right-heavy", + "calendar.weekday.mon": "Mon", + "calendar.weekday.tue": "Tue", + "calendar.weekday.wed": "Wed", + "calendar.weekday.thu": "Thu", + "calendar.weekday.fri": "Fri", + "calendar.weekday.sat": "Sat", + "calendar.weekday.sun": "Sun", + "calendar.eventsRead.one": "{count} event read", + "calendar.eventsRead.many": "{count} events read", + "calendar.less": "Less", + "calendar.more": "More", + "unsubscribe.meta.title": "Unsubscribe — Biviant", + "unsubscribe.invalidTitle": "Invalid link", + "unsubscribe.invalidBody": + "This unsubscribe link is missing the email address. Please use the link from your email.", + "unsubscribe.goHome": "Go to home", + "unsubscribe.loading": "Unsubscribing...", + "unsubscribe.errorTitle": "Something went wrong", + "unsubscribe.errorBody": + "We couldn't process your request. Please try again or reply to our email and we'll remove you manually.", + "unsubscribe.retry": "Try again", + "unsubscribe.successTitle": "You're unsubscribed", + "unsubscribe.successBody": + "{email} has been removed from all Biviant emails. You won't hear from us again.", + "unsubscribe.changedMind": "Changed your mind?", + "unsubscribe.rejoin": "Re-join the waitlist", + "articles.originalReporting": "Original reporting", + "articles.source": "Source", + "articles.viewSource": "View {name} source profile", + "articles.readOriginal": "Read original", + "articles.readOriginalAria": + "Read original article (opens in a new tab)", } satisfies { [K in keyof BaseStrings]: string }; export const STRINGS = { diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx index 34e60c8..6ae00f9 100644 --- a/apps/web/src/routes/unsubscribe.tsx +++ b/apps/web/src/routes/unsubscribe.tsx @@ -6,6 +6,7 @@ import { api } from "@news-app/backend/convex/_generated/api"; import { z } from "zod"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useT } from "@/lib/i18n/LocaleContext"; import { AlertCircle, CheckCircle, @@ -30,6 +31,7 @@ export const Route = createFileRoute("/unsubscribe")({ }); function UnsubscribePage() { + const t = useT(); const { email } = Route.useSearch(); const [lastEmail, setLastEmail] = useState(undefined); @@ -65,13 +67,14 @@ function UnsubscribePage() { return ( -

Invalid Link

+

+ {t("unsubscribe.invalidTitle")} +

- This unsubscribe link is missing the email address. Please use the - link from your email. + {t("unsubscribe.invalidBody")}

); @@ -88,7 +91,7 @@ function UnsubscribePage() { role="status" aria-live="polite" > - Unsubscribing... + {t("unsubscribe.loading")}

); @@ -98,16 +101,17 @@ function UnsubscribePage() { return ( -

Something went wrong

+

+ {t("unsubscribe.errorTitle")} +

- We couldn't process your request. Please try again or reply to - our email and we'll remove you manually. + {t("unsubscribe.errorBody")}

); @@ -116,17 +120,23 @@ function UnsubscribePage() { return ( -

You're unsubscribed

+

+ {t("unsubscribe.successTitle")} +

- {email} has been removed - from all Biviant emails. You won't hear from us again. + {t("unsubscribe.successBody").replace( + "{email}", + email ? email : "", + )}

-

Changed your mind?

+

+ {t("unsubscribe.changedMind")} +

From 4b73e63067bd3e149e323a6f4dc3288297dd223e Mon Sep 17 00:00:00 2001 From: flavius Date: Sat, 9 May 2026 19:02:05 +0300 Subject: [PATCH 12/19] finish bilingual model --- apps/web/src/components/auth-social.tsx | 2 +- apps/web/src/components/bias-indicator.tsx | 15 +- apps/web/src/components/bookmark-button.tsx | 14 +- apps/web/src/components/feed/event-card.tsx | 7 +- .../feed/event-claim-comparison.tsx | 145 +++--- .../feed/source-coverage-summary.tsx | 24 +- apps/web/src/components/header.tsx | 12 +- .../profile/AuthenticatedProfile.tsx | 2 +- apps/web/src/components/sign-in-form.tsx | 57 ++- apps/web/src/components/sign-up-form.tsx | 62 +-- .../src/components/ui/page-loading-state.tsx | 10 +- apps/web/src/lib/dates.ts | 32 +- apps/web/src/lib/i18n/strings.ts | 462 +++++++++++++++++- apps/web/src/lib/seo.ts | 14 +- apps/web/src/router.tsx | 8 +- apps/web/src/routes/__root.tsx | 90 ++-- apps/web/src/routes/activitate.tsx | 97 ++-- apps/web/src/routes/dashboard.tsx | 23 +- apps/web/src/routes/event.$slug.tsx | 75 ++- apps/web/src/routes/feed.tsx | 92 ++-- apps/web/src/routes/profil.tsx | 17 +- apps/web/src/routes/reset-password.tsx | 91 ++-- apps/web/src/routes/salvate.tsx | 24 +- apps/web/src/routes/source.$sourceId.tsx | 134 +++-- apps/web/src/routes/unsubscribe.tsx | 23 +- 25 files changed, 1118 insertions(+), 414 deletions(-) diff --git a/apps/web/src/components/auth-social.tsx b/apps/web/src/components/auth-social.tsx index d3b4ea6..bfdd63a 100644 --- a/apps/web/src/components/auth-social.tsx +++ b/apps/web/src/components/auth-social.tsx @@ -71,7 +71,7 @@ export function GoogleSignInButton({ fill="#EA4335" /> - Google + {t("auth.continueGoogle")} ); } diff --git a/apps/web/src/components/bias-indicator.tsx b/apps/web/src/components/bias-indicator.tsx index 7ada191..15c14f4 100644 --- a/apps/web/src/components/bias-indicator.tsx +++ b/apps/web/src/components/bias-indicator.tsx @@ -1,3 +1,5 @@ +import { useT } from "@/lib/i18n/LocaleContext"; + type BiasIndicatorProps = { bias: number; // -5 (Left) to +5 (Right) size?: "sm" | "md" | "lg"; @@ -24,6 +26,7 @@ const BiasIndicator = ({ size = "md", thresholds: thresholdsProp, }: BiasIndicatorProps) => { + const t = useT(); const thresholds = validateThresholds(thresholdsProp ?? DEFAULT_THRESHOLDS); // Normalize bias to a 0-100 scale for positioning @@ -40,11 +43,11 @@ const BiasIndicator = ({ // Determine label const getLabel = (biasValue: number) => { - if (biasValue < thresholds[0]) return "Left"; - if (biasValue < thresholds[1]) return "Lean Left"; - if (biasValue <= thresholds[2]) return "Center"; - if (biasValue <= thresholds[3]) return "Lean Right"; - return "Right"; + if (biasValue < thresholds[0]) return t("bias.label.leftStrong"); + if (biasValue < thresholds[1]) return t("bias.label.left"); + if (biasValue <= thresholds[2]) return t("bias.center"); + if (biasValue <= thresholds[3]) return t("bias.label.right"); + return t("bias.label.rightStrong"); }; // Determine text color @@ -82,7 +85,7 @@ const BiasIndicator = ({ aria-valuenow={bias} aria-valuemin={-5} aria-valuemax={5} - aria-label={`Bias: ${getLabel(bias)}`} + aria-label={`${t("bias.balance")}: ${getLabel(bias)}`} > {/* Gradient track for visual interest */}
diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx index daaf74e..4eab408 100644 --- a/apps/web/src/components/bookmark-button.tsx +++ b/apps/web/src/components/bookmark-button.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { useRef, useCallback } from "react"; import { getClientDeviceType } from "@/lib/interaction-tracking"; +import { useT } from "@/lib/i18n/LocaleContext"; const BOOKMARK_DEBOUNCE_MS = 800; @@ -30,6 +31,7 @@ export default function BookmarkButton({ className, redirectTo = "/feed", }: BookmarkButtonProps) { + const t = useT(); const { isAuthenticated } = useConvexAuth(); const lastClickRef = useRef(0); @@ -49,14 +51,14 @@ export default function BookmarkButton({ "bookmarked" in data && data.bookmarked === true ) { - toast.success("Bookmarked"); + toast.success(t("bookmark.added")); } else { - toast("Bookmark removed"); + toast(t("bookmark.removed")); } }, onError: (error) => { console.error("Bookmark toggle failed:", error); - toast.error("Something went wrong. Please try again."); + toast.error(t("bookmark.error")); }, }); const { mutate } = toggle; @@ -68,9 +70,9 @@ export default function BookmarkButton({ e.stopPropagation(); if (!isAuthenticated) { - toast("Sign in to bookmark events", { + toast(t("bookmark.signInTitle"), { action: { - label: "Sign in", + label: t("bookmark.signInAction"), onClick: () => { window.location.href = `/dashboard?mode=signin&redirect=${encodeURIComponent(redirectTo)}`; }, @@ -114,7 +116,7 @@ export default function BookmarkButton({ )} disabled={toggle.isPending} onClick={handleClick} - aria-label={bookmarked ? "Remove bookmark" : "Bookmark this event"} + aria-label={bookmarked ? t("bookmark.remove") : t("bookmark.add")} aria-pressed={bookmarked} > { + const locale = useLocale(); const t = useT(); const topics = (event.topicIds ?? []) .map((id) => topicNamesById[id]) @@ -125,8 +126,8 @@ const EventCard = ({ t("event.coveragePreview"); const isFeature = variant === "feature"; const lastUpdatedAt = event.lastUpdatedAt ?? event.firstPublishedAt; - const lastUpdatedLabel = formatRelativeTimestamp(lastUpdatedAt); - const lastUpdatedTitle = formatAbsoluteTimestamp(lastUpdatedAt); + const lastUpdatedLabel = formatRelativeTimestamp(lastUpdatedAt, locale); + const lastUpdatedTitle = formatAbsoluteTimestamp(lastUpdatedAt, locale); const interactionContext = buildInteractionContextFromSources( event.sources ?? [], ); diff --git a/apps/web/src/components/feed/event-claim-comparison.tsx b/apps/web/src/components/feed/event-claim-comparison.tsx index 667f9c4..767b0da 100644 --- a/apps/web/src/components/feed/event-claim-comparison.tsx +++ b/apps/web/src/components/feed/event-claim-comparison.tsx @@ -11,6 +11,7 @@ import { ChevronDownIcon, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useT } from "@/lib/i18n/LocaleContext"; import { cn } from "@/lib/utils"; import { useState } from "react"; @@ -39,53 +40,13 @@ const STATUS_ORDER: ClaimStatus[] = [ "exclusive_center", ]; -const STATUS_COPY: Record< - ClaimStatus, - { - label: string; - heading: string; - description: string; - icon: typeof AlertTriangleIcon; - } -> = { - agreement: { - label: "Agreement", - heading: "Agreements", - description: "Claims confirmed by multiple sources.", - icon: CheckCircle2Icon, - }, - divergence: { - label: "Divergence", - heading: "Divergences", - description: "Claims where sources report materially different details.", - icon: AlertTriangleIcon, - }, - framing: { - label: "Framing", - heading: "Framing Differences", - description: "Shared facts described with meaningfully different language.", - icon: MessageSquareTextIcon, - }, - exclusive_left: { - label: "Left Exclusive", - heading: "Left-Side Exclusives", - description: - "Substantive claims only found in left or left-center coverage.", - icon: MinusCircleIcon, - }, - exclusive_right: { - label: "Right Exclusive", - heading: "Right-Side Exclusives", - description: - "Substantive claims only found in right or right-center coverage.", - icon: MinusCircleIcon, - }, - exclusive_center: { - label: "Center Exclusive", - heading: "Center Exclusives", - description: "Substantive claims only found in center coverage.", - icon: MinusCircleIcon, - }, +const STATUS_ICONS: Record = { + agreement: CheckCircle2Icon, + divergence: AlertTriangleIcon, + framing: MessageSquareTextIcon, + exclusive_left: MinusCircleIcon, + exclusive_right: MinusCircleIcon, + exclusive_center: MinusCircleIcon, }; function formatLean(value: string) { @@ -116,6 +77,7 @@ function ClaimVariantRow({ articlesById: Map; sourcesById: Map>; }) { + const t = useT(); const article = articlesById.get(String(variant.articleId)); const source = getVariantSource(variant, articlesById, sourcesById); @@ -149,7 +111,7 @@ function ClaimVariantRow({ ) : ( - Unknown source + {t("claim.unknownSource")} )} @@ -173,7 +135,7 @@ function ClaimVariantRow({ rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline" > - Read source article + {t("claim.readSourceArticle")} )} @@ -194,9 +156,9 @@ function ClaimCard({ sourcesById: Map>; status: ClaimStatus; }) { + const t = useT(); const [isExpanded, setIsExpanded] = useState(false); - const statusConfig = STATUS_COPY[status]; - const Icon = statusConfig.icon; + const Icon = STATUS_ICONS[status]; const sourceCount = new Set( claim.variants.map((variant) => String(variant.sourceId)), ).size; @@ -214,14 +176,26 @@ function ClaimCard({
- {statusConfig.label} + {status === "agreement" + ? t("claim.agreement") + : status === "divergence" + ? t("claim.divergence") + : status === "framing" + ? t("claim.framing") + : status === "exclusive_left" + ? t("claim.leftExclusive") + : status === "exclusive_right" + ? t("claim.rightExclusive") + : t("claim.centerExclusive")} - {claim.importance}/5 importance + {claim.importance}/5 {t("claim.importance")}
- {sourceCount} {sourceCount === 1 ? "source" : "sources"} + {sourceCount === 1 + ? t("claim.source.one") + : t("claim.source.many").replace("{count}", String(sourceCount))}
@@ -250,10 +224,13 @@ function ClaimCard({ className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-border bg-card py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" > {isExpanded - ? "Show less" - : `Show ${remainingVariantCount} more ${ - remainingVariantCount === 1 ? "source" : "sources" - }`} + ? t("claim.showLess") + : remainingVariantCount === 1 + ? t("claim.showMore.one") + : t("claim.showMore.many").replace( + "{count}", + String(remainingVariantCount), + )} ; articles: ClaimArticle[]; }) { + const t = useT(); const [activeFilter, setActiveFilter] = useState(null); const claims = useQuery(api.claimDivergence.getEventClaims, { @@ -329,14 +307,14 @@ export default function EventClaimComparison({ - Claim Breakdown + {t("claim.title")}
- Loading claim analysis... + {t("claim.loading")}
@@ -349,18 +327,16 @@ export default function EventClaimComparison({ - Claim Breakdown + {t("claim.title")}

- Claim analysis is not available yet + {t("claim.unavailable")}

- This view appears once enough articles have atomic facts and the - event-level claim worker has analyzed agreements, divergences, and - exclusives. + {t("claim.unavailableBody")}

@@ -421,10 +397,10 @@ export default function EventClaimComparison({
- Claim Breakdown + {t("claim.title")}

- Compare how different sources report this story + {t("claim.subtitle")}

@@ -434,25 +410,25 @@ export default function EventClaimComparison({
toggleFilter("agreements")} /> toggleFilter("divergences")} /> toggleFilter("framing")} /> toggleFilter("exclusives")} @@ -465,7 +441,7 @@ export default function EventClaimComparison({ onClick={() => setActiveFilter(null)} className="mt-3 text-xs font-medium text-primary hover:underline" > - Clear filter + {t("claim.clearFilter")} )}
@@ -475,15 +451,34 @@ export default function EventClaimComparison({
{visibleStatuses.map((status) => { const statusClaims = claimsByStatus.get(status) ?? []; - const copy = STATUS_COPY[status]; return (

- {copy.heading} + {status === "agreement" + ? t("claim.agreements") + : status === "divergence" + ? t("claim.divergences") + : status === "framing" + ? t("claim.framings") + : status === "exclusive_left" + ? t("claim.leftExclusives") + : status === "exclusive_right" + ? t("claim.rightExclusives") + : t("claim.centerExclusives")}

- {copy.description} + {status === "agreement" + ? t("claim.agreementBody") + : status === "divergence" + ? t("claim.divergenceBody") + : status === "framing" + ? t("claim.framingBody") + : status === "exclusive_left" + ? t("claim.leftExclusiveBody") + : status === "exclusive_right" + ? t("claim.rightExclusiveBody") + : t("claim.centerExclusiveBody")}

diff --git a/apps/web/src/components/feed/source-coverage-summary.tsx b/apps/web/src/components/feed/source-coverage-summary.tsx index b170296..77adb9b 100644 --- a/apps/web/src/components/feed/source-coverage-summary.tsx +++ b/apps/web/src/components/feed/source-coverage-summary.tsx @@ -2,6 +2,7 @@ import type { Id } from "@news-app/backend/convex/_generated/dataModel"; import { Link } from "@tanstack/react-router"; import BiasIndicator from "@/components/bias-indicator"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useT } from "@/lib/i18n/LocaleContext"; type SourceCoverageArticle = { _id: Id<"articles">; @@ -32,10 +33,10 @@ function getCoverageBucket( } function getCoverageLabel(bucket: CoverageBucket) { - if (bucket === "left") return "Left / left-center"; - if (bucket === "right") return "Right / right-center"; - if (bucket === "center") return "Center"; - return "Unknown"; + if (bucket === "left") return "coverage.left"; + if (bucket === "right") return "coverage.right"; + if (bucket === "center") return "coverage.center"; + return "coverage.unknown"; } function bucketClass(bucket: CoverageBucket) { @@ -50,6 +51,7 @@ export default function SourceCoverageSummary({ }: { articles: SourceCoverageArticle[]; }) { + const t = useT(); const sources = Array.from( new Map( articles @@ -76,7 +78,7 @@ export default function SourceCoverageSummary({ - Source Coverage + {t("coverage.title")} @@ -90,7 +92,7 @@ export default function SourceCoverageSummary({ key={bucket} className={bucketClass(bucket)} style={{ width: `${(count / total) * 100}%` }} - title={`${getCoverageLabel(bucket)}: ${count}`} + title={`${t(getCoverageLabel(bucket))}: ${count}`} /> ); })} @@ -104,7 +106,7 @@ export default function SourceCoverageSummary({ className="rounded-lg border border-border/70 bg-background/55 px-3 py-2" >

- {getCoverageLabel(bucket)} + {t(getCoverageLabel(bucket))}

{counts[bucket]} @@ -149,12 +151,12 @@ export default function SourceCoverageSummary({ {(source.mbfcFactual || source.mbfcCredibility) && (

- {[ - source.mbfcFactual - ? `Factual: ${source.mbfcFactual}` + {[ + source.mbfcFactual + ? `${t("coverage.factual")}: ${source.mbfcFactual}` : null, source.mbfcCredibility - ? `Credibility: ${source.mbfcCredibility}` + ? `${t("coverage.credibility")}: ${source.mbfcCredibility}` : null, ] .filter(Boolean) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 185aa29..b776e65 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -37,7 +37,10 @@ export default function Header() { {/* Desktop nav */} -

-
{/* Bento Grid - Main Stats */} -
+
{/* Streak - Prominent */} -
+
@@ -299,7 +299,7 @@ function AuthorizedDashboard() {
{/* Articles Read */} -
+
@@ -316,7 +316,7 @@ function AuthorizedDashboard() {
{/* Events Explored */} -
+
@@ -333,7 +333,7 @@ function AuthorizedDashboard() {
{/* Bookmarks */} -
+
diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx index 475d279..e81456c 100644 --- a/apps/web/src/routes/event.$slug.tsx +++ b/apps/web/src/routes/event.$slug.tsx @@ -386,22 +386,28 @@ function EventDetailPage() { > {hasPerspectives ? ( - + {t("event.multiplePerspectives")} - + )[tabCount] ?? "grid-cols-3"}`} > {event.perspectiveSummaries?.left && ( - {t("event.left")} + + {t("event.left")} + )} - {t("event.centerTab")} + + {t("event.centerTab")} + {event.perspectiveSummaries?.right && ( - {t("event.right")} + + {t("event.right")} + )} @@ -432,12 +438,12 @@ function EventDetailPage() { ) : ( - + {t("event.summary")} - +

{event.perspectiveSummaries?.center ?? t("event.compareOriginal")} @@ -448,12 +454,12 @@ function EventDetailPage() { {event.globalImpact && ( - + {t("event.meaning")} - +

{event.globalImpact}

diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index cc52635..afe1689 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -154,16 +154,16 @@ function TopicFilter({ variant="outline" size="sm" className={cn( - "min-w-[140px] justify-between gap-2 rounded-full", + "w-full max-w-[13rem] min-w-0 justify-between gap-1.5 rounded-full px-3 sm:max-w-[15rem] md:min-w-[140px] md:max-w-[16rem] md:gap-2", selectedTopic !== "all" && "border-primary/50 bg-primary/5", )} aria-label={t("feed.topic.filter")} > - - - {selectedLabel} + + + {selectedLabel} - + ); @@ -280,6 +280,8 @@ function FeedContent() { const [isSearchFocused, setIsSearchFocused] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const searchInputRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + const isLoadingMoreRef = useRef(false); useEffect(() => { try { @@ -288,7 +290,9 @@ function FeedContent() { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { setRecentSearches( - parsed.filter((value): value is string => typeof value === "string").slice(0, 5), + parsed + .filter((value): value is string => typeof value === "string") + .slice(0, 5), ); } } catch { @@ -350,23 +354,26 @@ function FeedContent() { } : "skip", ); + const canLoadMore = !isSearching && status === "CanLoadMore"; + const isLoadingMore = !isSearching && status === "LoadingMore"; const preferredTopicIds = useMemo(() => { if (!topics || !currentUser?.privateContext?.interests?.length) { return []; } - const preferredNames = currentUser.privateContext.interests.map((interest) => - interest.trim().toLowerCase(), + const preferredNames = currentUser.privateContext.interests.map( + (interest) => interest.trim().toLowerCase(), ); return topics .filter((topic) => { - const candidates = [ - topic.displayName, - ...(topic.aliases ?? []), - ].map((value) => value.trim().toLowerCase()); - return candidates.some((candidate) => preferredNames.includes(candidate)); + const candidates = [topic.displayName, ...(topic.aliases ?? [])].map( + (value) => value.trim().toLowerCase(), + ); + return candidates.some((candidate) => + preferredNames.includes(candidate), + ); }) .map((topic) => topic._id); }, [currentUser?.privateContext?.interests, topics]); @@ -381,6 +388,42 @@ function FeedContent() { : "skip", ); + useEffect(() => { + if (status !== "LoadingMore") { + isLoadingMoreRef.current = false; + } + }, [status]); + + useEffect(() => { + if (!canLoadMore) { + return; + } + + const target = loadMoreTriggerRef.current; + if (!target) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry?.isIntersecting || isLoadingMoreRef.current) { + return; + } + + isLoadingMoreRef.current = true; + loadMore(pageSize); + }, + { + rootMargin: "1200px 0px", + }, + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [canLoadMore, loadMore, pageSize]); + useEffect(() => { if ( debouncedSearch.length < 2 || @@ -423,18 +466,21 @@ function FeedContent() { const remainingSearchEvents = featuredSearchEvent ? searchResults.slice(1) : searchResults; - const shouldShowThresholdHint = isSearchFocused && searchInput.trim().length < 2; + const shouldShowThresholdHint = + isSearchFocused && searchInput.trim().length < 2; const shouldShowRecentSearches = - isSearchFocused && searchInput.trim().length === 0 && recentSearches.length > 0; + isSearchFocused && + searchInput.trim().length === 0 && + recentSearches.length > 0; return (
-
-
-
-
-
-
+
+
+
+
+
+
setIsSearchFocused(false), 100); }} placeholder={t("feed.search.placeholder")} - className="h-11 rounded-full border-border/80 bg-background/75 pr-12" + className="h-10 rounded-full border-border/80 bg-background/75 pr-11 text-base sm:h-11 sm:pr-12" aria-label={t("feed.search.label")} /> {searchInput.length > 0 && ( @@ -454,7 +500,7 @@ function FeedContent() { type="button" variant="ghost" size="icon" - className="absolute right-1 top-1 size-9 rounded-full" + className="absolute right-1 top-1 size-8 rounded-full sm:size-9" onClick={() => { setSearchInput(""); setDebouncedSearch(""); @@ -465,55 +511,52 @@ function FeedContent() { )}
-
- -
- {!isSearching && ( -
-
- - +
+
+
+
-

- {feedSort === "recent" - ? t("feed.sort.recentHint") - : t("feed.sort.trendingHint")} -

+ {!isSearching && ( +
+ + +
+ )}
- )} +
{shouldShowThresholdHint && (

{t("feed.search.threshold")} @@ -589,9 +632,6 @@ function FeedContent() { ? t("feed.leadStory") : t("feed.trendingStory")} -

- {feedSort === "recent" ? t("feed.featured") : t("feed.ranked")} -

) : null} - {((!isSearching && remainingEvents && remainingEvents.length > 0) || - (isSearching && remainingSearchEvents && remainingSearchEvents.length > 0)) ? ( + {(!isSearching && remainingEvents && remainingEvents.length > 0) || + (isSearching && + remainingSearchEvents && + remainingSearchEvents.length > 0) ? (

{isSearching ? t("feed.moreSearch") : t("feed.moreEvents")}

-

- {isSearching - ? t("feed.bestMatches") - : feedSort === "recent" - ? t("feed.latestCoverage") - : t("feed.rankedCoverage")} -

+ {isSearching ? ( +

+ {t("feed.bestMatches")} +

+ ) : null}
- {(isSearching ? remainingSearchEvents : remainingEvents)?.map((event) => ( - - ))} + {(isSearching ? remainingSearchEvents : remainingEvents)?.map( + (event) => ( + + ), + )}
) : null} @@ -695,16 +737,22 @@ function FeedContent() { )}
- {!isSearching && status === "CanLoadMore" && ( -
- + {!isSearching && (canLoadMore || isLoadingMore) && ( +
+ )}
From 86e6392418a98b136bdb5ab7268f1a3cf8d6ac8c Mon Sep 17 00:00:00 2001 From: flavius Date: Sun, 10 May 2026 13:40:07 +0300 Subject: [PATCH 15/19] optimize vector search across the whole codebase --- packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/clustering.ts | 1025 +++++++++++++++-- packages/backend/convex/config.ts | 42 + packages/backend/convex/ingestion.ts | 96 +- packages/backend/convex/schema.ts | 66 +- packages/backend/convex/seeds.ts | 12 +- packages/backend/convex/vectorSearchBudget.ts | 334 ++++++ 7 files changed, 1456 insertions(+), 121 deletions(-) create mode 100644 packages/backend/convex/vectorSearchBudget.ts diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 705b374..f83e5e2 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -47,6 +47,7 @@ import type * as summarizationNode from "../summarizationNode.js"; import type * as topicCatalog from "../topicCatalog.js"; import type * as topics from "../topics.js"; import type * as user from "../user.js"; +import type * as vectorSearchBudget from "../vectorSearchBudget.js"; import type * as waitlist from "../waitlist.js"; import type { @@ -95,6 +96,7 @@ declare const fullApi: ApiFromModules<{ topicCatalog: typeof topicCatalog; topics: typeof topics; user: typeof user; + vectorSearchBudget: typeof vectorSearchBudget; waitlist: typeof waitlist; }>; diff --git a/packages/backend/convex/clustering.ts b/packages/backend/convex/clustering.ts index 0dde046..9e03b19 100644 --- a/packages/backend/convex/clustering.ts +++ b/packages/backend/convex/clustering.ts @@ -35,6 +35,7 @@ import { syncPublicEventPreview, } from "./lib/publicEventPreviews"; import { buildEventShareRenderSignature } from "./shareAssets"; +import { estimateVectorSearchQgbRead } from "./vectorSearchBudget"; const CLUSTER_LOCK_KEY = "clusterEnrichedArticles"; const CLUSTER_LOCK_TTL_MS = 20 * 60 * 1000; @@ -47,7 +48,7 @@ const MAX_CANDIDATE_EVENTS = 250; const VECTOR_SEARCH_LIMIT = 40; const EVENT_PRESENTATION_ARTICLE_LIMIT = 10; const CANDIDACY_TOKEN_CAP = 200; -const EVENT_EMBEDDING_DIMENSIONS = 1536; +const EVENT_EMBEDDING_DIMENSIONS = 512; const DEFAULT_MIN_CLUSTER_SIMILARITY = 0.74; const DEFAULT_STRONG_CLUSTER_SIMILARITY = 0.84; const DEFAULT_MIN_TITLE_TOKEN_OVERLAP = 2; @@ -67,6 +68,35 @@ const DEFAULT_MERGE_MIN_TITLE_JACCARD = 0.45; const DEFAULT_MERGE_MAX_TIME_DELTA_HOURS = 48; const MERGE_VECTOR_SEARCH_LIMIT = 24; const RECLUSTER_VECTOR_SEARCH_LIMIT = 24; +const MERGE_RECENT_BUCKET = "recent_2d"; +const MERGE_STALE_BUCKET = "stale"; +const SINGLETON_BUCKET = "singleton"; +const MULTI_ARTICLE_BUCKET = "multi"; + +type ClusteringJobName = + | "clusterEnrichedArticles" + | "mergeNearDuplicateEvents" + | "reclusterRecentSingletonEvents"; + +type JobMetrics = { + jobName: ClusteringJobName; + runId: string; + budgetAllowed: boolean; + usedFallbackMode: boolean; + vectorSearches: number; + vectorMatchesReturned: number; + vectorMatchesHydrated: number; + vectorMatchesDiscardedPostFetch: number; + vectorSearchesPerArticle: number; + vectorSearchesPerCandidateEvent: number; + batchArticles: number; + candidateCacheSize: number; + mergeSeedEvents: number; + reclusterSeedEvents: number; + qgbRead: number; + elapsedMs: number; + stageMs: Record; +}; const STOPWORDS = new Set([ "a", @@ -144,6 +174,144 @@ function toEventEmbedding(articleEmbedding: number[]): number[] { return padded; } +function formatUtcDayBucket(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10); +} + +function buildRecentWindowBucket( + lastArticleAt: number, + referenceTime: number = Date.now(), +): string { + return referenceTime - lastArticleAt <= RECENT_EVENT_WINDOW_MS + ? MERGE_RECENT_BUCKET + : MERGE_STALE_BUCKET; +} + +function buildSingletonBucket(articleCount: number): string { + return articleCount <= 2 ? SINGLETON_BUCKET : MULTI_ARTICLE_BUCKET; +} + +function buildEventEmbeddingFilterFields(args: { + status?: "processing" | "published"; + lastArticleAt: number; + articleCount: number; + referenceTime?: number; +}) { + const recentWindowBucket = buildRecentWindowBucket( + args.lastArticleAt, + args.referenceTime, + ); + const singletonBucket = buildSingletonBucket(args.articleCount); + const updatedDayBucket = formatUtcDayBucket(args.lastArticleAt); + const status = args.status ?? "processing"; + + return { + recentWindowBucket, + singletonBucket, + updatedDayBucket, + mergeSearchBucket: `${status}::${recentWindowBucket}::${updatedDayBucket}`, + singletonSearchBucket: `${status}::${singletonBucket}::${updatedDayBucket}`, + }; +} + +function collectRecentDayBuckets(windowHours: number, now: number = Date.now()) { + const dayCount = Math.max(1, Math.ceil(windowHours / 24) + 1); + const buckets: string[] = []; + for (let offset = 0; offset < dayCount; offset++) { + buckets.push(formatUtcDayBucket(now - offset * 24 * 60 * 60 * 1000)); + } + return buckets; +} + +function buildMergeSearchBuckets(dayBuckets: string[]) { + return dayBuckets.flatMap((dayBucket) => [ + `published::${MERGE_RECENT_BUCKET}::${dayBucket}`, + `processing::${MERGE_RECENT_BUCKET}::${dayBucket}`, + ]); +} + +function buildSingletonSearchBuckets(dayBuckets: string[]) { + return dayBuckets.flatMap((dayBucket) => [ + `published::${SINGLETON_BUCKET}::${dayBucket}`, + `processing::${SINGLETON_BUCKET}::${dayBucket}`, + ]); +} + +function buildRunId(jobName: ClusteringJobName): string { + return `${jobName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function createJobMetrics(jobName: ClusteringJobName): JobMetrics { + return { + jobName, + runId: buildRunId(jobName), + budgetAllowed: true, + usedFallbackMode: false, + vectorSearches: 0, + vectorMatchesReturned: 0, + vectorMatchesHydrated: 0, + vectorMatchesDiscardedPostFetch: 0, + vectorSearchesPerArticle: 0, + vectorSearchesPerCandidateEvent: 0, + batchArticles: 0, + candidateCacheSize: 0, + mergeSeedEvents: 0, + reclusterSeedEvents: 0, + qgbRead: 0, + elapsedMs: 0, + stageMs: {}, + }; +} + +function markStageDuration( + metrics: JobMetrics, + stage: string, + startedAt: number, +): number { + const elapsed = Date.now() - startedAt; + metrics.stageMs[stage] = (metrics.stageMs[stage] ?? 0) + elapsed; + return elapsed; +} + +async function flushJobMetrics( + ctx: any, + metrics: JobMetrics, + startedAt: number, +): Promise { + metrics.elapsedMs = Date.now() - startedAt; + metrics.qgbRead = estimateVectorSearchQgbRead({ + vectorSearches: metrics.vectorSearches, + averageMatchesReturned: + metrics.vectorSearches > 0 + ? metrics.vectorMatchesReturned / metrics.vectorSearches + : 0, + }); + + console.log( + `[clustering] ${metrics.jobName} metrics ${JSON.stringify(metrics)}`, + ); + + await ctx.runMutation(internal.vectorSearchBudget.recordUsage, { + jobName: metrics.jobName, + runId: metrics.runId, + qgbRead: metrics.qgbRead, + vectorSearches: metrics.vectorSearches, + vectorMatchesReturned: metrics.vectorMatchesReturned, + vectorMatchesHydrated: metrics.vectorMatchesHydrated, + vectorMatchesDiscardedPostFetch: metrics.vectorMatchesDiscardedPostFetch, + usedFallbackMode: metrics.usedFallbackMode, + budgetAllowed: metrics.budgetAllowed, + elapsedMs: metrics.elapsedMs, + metricsJson: JSON.stringify(metrics), + }); +} + +async function getVectorSearchBudgetState( + ctx: any, +) { + return await ctx.runQuery(internal.vectorSearchBudget.checkBudget, {}); +} + function appendArticleEmbeddingToEventMean( existingEventEmbedding: number[], currentArticleCount: number, @@ -532,6 +700,23 @@ type ClusterCandidateVectorResult = ClusterCandidateQueryResult & { similarity: number; }; +function hydrateClusterCandidate( + candidate: ClusterCandidateQueryResult | ClusterCandidateVectorResult, +): ClusterCandidate { + return { + ...candidate, + embeddingId: candidate.embeddingId, + lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt, + sourceCount: candidate.sourceCount ?? candidate.sourceIds?.length ?? 0, + titleTokens: normalizeTitleTokens(candidate.title), + evidenceTokens: new Set(candidate.evidenceTokens ?? []), + factTokens: new Set(candidate.factTokens ?? []), + entityTokens: new Set(candidate.entityTokens ?? []), + topicSlugs: new Set(candidate.topicSlugs ?? []), + sourceIds: new Set(candidate.sourceIds ?? []), + }; +} + type ClusterSettings = { minSimilarity: number; strongSimilarity: number; @@ -1561,6 +1746,88 @@ function findBestCandidate( return best?.candidate ?? null; } +function findBatchLocalCandidate( + article: { + articleId: Id<"articles">; + title: string; + rssSnippet: string; + summary: string; + atomicFacts: string[]; + entities: string[]; + topicSlugs: string[]; + extractionQuality: "strong" | "weak"; + publishedAt: number; + embedding: number[]; + sourceId: Id<"sources">; + }, + candidates: ClusterCandidate[], + settings: ClusterSettings, +): ClusterCandidate | null { + const enrichedCandidates = candidates.map((candidate) => ({ + ...candidate, + similarity: cosineSimilarity( + toEventEmbedding(article.embedding), + toEventEmbedding(candidate.embedding), + ), + })); + + return findBestCandidate(article, enrichedCandidates, settings); +} + +function findHeuristicCandidate( + article: { + title: string; + rssSnippet: string; + summary: string; + atomicFacts: string[]; + entities: string[]; + topicSlugs: string[]; + publishedAt: number; + sourceId: Id<"sources">; + }, + candidates: ClusterCandidate[], +): ClusterCandidate | null { + const titleTokens = normalizeTitleTokens(normalizeTitleForClustering(article.title)); + const evidenceTokens = mergeTokenSets( + normalizeTitleTokens(normalizeSnippetForClustering(article.rssSnippet)), + normalizeTitleTokens(normalizeSnippetForClustering(article.summary)), + ); + const factTokens = normalizeTitleTokens(article.atomicFacts.join(" ")); + const entityTokens = extractEntityTokens( + article.title, + article.rssSnippet, + article.summary, + article.entities.join(" "), + article.atomicFacts.join(" "), + ); + const topicTokens = new Set(article.topicSlugs); + + let best: { candidate: ClusterCandidate; score: number } | null = null; + for (const candidate of candidates) { + const timeDeltaMs = Math.abs( + article.publishedAt - candidate.firstPublishedAt, + ); + if (timeDeltaMs > RECENT_EVENT_WINDOW_MS) continue; + + const sameSourcePenalty = candidate.sourceIds.has(article.sourceId) ? -1.5 : 0; + const score = + countTokenOverlap(titleTokens, candidate.titleTokens) * 4 + + jaccardSimilarity(titleTokens, candidate.titleTokens) * 6 + + countTokenOverlap(evidenceTokens, candidate.evidenceTokens) * 1.8 + + countTokenOverlap(factTokens, candidate.factTokens) * 2 + + countTokenOverlap(entityTokens, candidate.entityTokens) * 2.2 + + countTokenOverlap(topicTokens, candidate.topicSlugs) * 2.5 + + sameSourcePenalty; + + if (score < 4) continue; + if (!best || score > best.score) { + best = { candidate, score }; + } + } + + return best?.candidate ?? null; +} + function shouldPublishCluster( articleCount: number, uniqueSourceCount: number, @@ -1820,6 +2087,8 @@ export const backfillEventCandidacy = internalAction({ { eventId: event._id, status: event.status, + lastArticleAt, + articleCount, }, ); @@ -1839,6 +2108,62 @@ export const backfillEventCandidacy = internalAction({ }, }); +export const backfillEventEmbeddingBuckets = internalAction({ + args: { + cursor: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async ( + ctx, + { cursor, limit }, + ): Promise<{ + processed: number; + continueCursor: string | null; + isDone: boolean; + }> => { + const pageSize = Math.min(Math.max(Math.floor(limit ?? 100), 1), 200); + const page: PaginationResult> = await ctx.runQuery( + internal.clustering.getEventsForCandidacyBackfill, + { + paginationOpts: { + cursor: cursor ?? null, + numItems: pageSize, + }, + }, + ); + + for (const event of page.page) { + const fallbackArticles = + event.articleCount === undefined || event.lastArticleAt === undefined + ? await ctx.runQuery(internal.clustering.getArticlesForEventBackfill, { + eventId: event._id, + }) + : null; + const articleCount = event.articleCount ?? fallbackArticles?.length ?? 0; + const lastArticleAt = + event.lastArticleAt ?? + (fallbackArticles && fallbackArticles.length > 0 + ? fallbackArticles.reduce( + (max, article) => Math.max(max, article.publishedAt), + event.firstPublishedAt, + ) + : event.firstPublishedAt); + await ctx.runMutation(internal.clustering.syncEmbeddingStatusForBackfill, { + eventId: event._id, + status: event.status, + lastArticleAt, + articleCount, + }); + } + + return { + processed: page.page.length, + continueCursor: page.continueCursor ?? null, + isDone: page.isDone, + }; + }, +}); + export const getArticlesForEventBackfill = internalQuery({ args: { eventId: v.id("events") }, handler: async (ctx, { eventId }) => { @@ -1941,14 +2266,23 @@ export const syncEmbeddingStatusForBackfill = internalMutation({ args: { eventId: v.id("events"), status: v.union(v.literal("processing"), v.literal("published")), + lastArticleAt: v.number(), + articleCount: v.number(), }, - handler: async (ctx, { eventId, status }) => { + handler: async (ctx, { eventId, status, lastArticleAt, articleCount }) => { const embeddingRow = await ctx.db .query("eventEmbeddings") .withIndex("by_event", (q) => q.eq("eventId", eventId)) .first(); - if (embeddingRow && embeddingRow.status !== status) { - await ctx.db.patch(embeddingRow._id, { status }); + if (embeddingRow) { + await ctx.db.patch(embeddingRow._id, { + status, + ...buildEventEmbeddingFilterFields({ + status, + lastArticleAt, + articleCount, + }), + }); } }, }); @@ -2059,6 +2393,93 @@ export const getRecentClusterCandidates = internalQuery({ }, }); +export const getChangedClusterCandidates = internalQuery({ + args: { + sinceTs: v.number(), + recentSinceTs: v.number(), + limit: v.number(), + singletonOnly: v.optional(v.boolean()), + }, + handler: async (ctx, { sinceTs, recentSinceTs, limit, singletonOnly }) => { + const candidacies = await Promise.all([ + ctx.db + .query("eventCandidacy") + .withIndex("by_status_last_article_at", (q) => + q.eq("status", "published").gte("lastArticleAt", recentSinceTs), + ) + .order("desc") + .take(limit * 3), + ctx.db + .query("eventCandidacy") + .withIndex("by_status_last_article_at", (q) => + q.eq("status", "processing").gte("lastArticleAt", recentSinceTs), + ) + .order("desc") + .take(limit * 3), + ]); + + return [...candidacies[0], ...candidacies[1]] + .filter( + (row) => + row.updatedAt > sinceTs && + (!singletonOnly || row.articleCount <= 2), + ) + .sort( + (a, b) => + b.updatedAt - a.updatedAt || + b.lastArticleAt - a.lastArticleAt || + b._creationTime - a._creationTime, + ) + .slice(0, limit); + }, +}); + +export const getClusteringJobState = internalQuery({ + args: { + jobName: v.string(), + }, + handler: async (ctx, { jobName }) => { + return await ctx.db + .query("clusteringJobState") + .withIndex("by_job_name", (q) => q.eq("jobName", jobName)) + .unique(); + }, +}); + +export const upsertClusteringJobState = internalMutation({ + args: { + jobName: v.string(), + lastProcessedAt: v.optional(v.number()), + lastProcessedDayBucket: v.optional(v.string()), + lastRunAt: v.number(), + lastRunMetricsJson: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("clusteringJobState") + .withIndex("by_job_name", (q) => q.eq("jobName", args.jobName)) + .unique(); + + const payload = { + lastProcessedAt: args.lastProcessedAt, + lastProcessedDayBucket: args.lastProcessedDayBucket, + lastRunAt: args.lastRunAt, + lastRunMetricsJson: args.lastRunMetricsJson, + updatedAt: Date.now(), + }; + + if (existing) { + await ctx.db.patch(existing._id, payload); + return; + } + + await ctx.db.insert("clusteringJobState", { + jobName: args.jobName, + ...payload, + }); + }, +}); + export const getClusterCandidatesByEmbeddingMatches = internalQuery({ args: { embeddingMatches: v.array( @@ -2069,16 +2490,55 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({ ), }, handler: async (ctx, { embeddingMatches }) => { + const uniqueEmbeddingIds = Array.from( + new Set(embeddingMatches.map((match) => String(match.embeddingId))), + ); + const embeddingRows = await Promise.all( + uniqueEmbeddingIds.map((embeddingId) => ctx.db.get(embeddingId as Id<"eventEmbeddings">)), + ); + const embeddingsById = new Map>(); + for (const row of embeddingRows) { + if (row) embeddingsById.set(String(row._id), row); + } + + const uniqueEventIds = Array.from( + new Set( + embeddingRows + .filter((row): row is Doc<"eventEmbeddings"> => row !== null) + .map((row) => String(row.eventId)), + ), + ); + const eventRows = await Promise.all( + uniqueEventIds.map((eventId) => ctx.db.get(eventId as Id<"events">)), + ); + const eventsById = new Map>(); + for (const row of eventRows) { + if (row) eventsById.set(String(row._id), row); + } + + const candidacyRows = await Promise.all( + uniqueEventIds.map((eventId) => + ctx.db + .query("eventCandidacy") + .withIndex("by_event", (q) => q.eq("eventId", eventId as Id<"events">)) + .first(), + ), + ); + const candidaciesByEventId = new Map>(); + for (const row of candidacyRows) { + if (row) candidaciesByEventId.set(String(row.eventId), row); + } + const seenEventIds = new Set(); const candidates: ClusterCandidateVectorResult[] = []; let missingCandidacy = 0; let missingEvents = 0; for (const match of embeddingMatches) { - const embeddingRow = await ctx.db.get(match.embeddingId); + const embeddingRow = embeddingsById.get(String(match.embeddingId)); if (!embeddingRow) continue; - const event = await ctx.db.get(embeddingRow.eventId); + const event = eventsById.get(String(embeddingRow.eventId)); if (!event) { missingEvents++; continue; @@ -2088,10 +2548,7 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({ if (seenEventIds.has(eventKey)) continue; seenEventIds.add(eventKey); - const candidacy = await ctx.db - .query("eventCandidacy") - .withIndex("by_event", (q) => q.eq("eventId", event._id)) - .first(); + const candidacy = candidaciesByEventId.get(eventKey); if (!candidacy) { missingCandidacy++; continue; @@ -2117,6 +2574,45 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({ }, }); +export const getClusterCandidatesByEventIds = internalQuery({ + args: { + eventIds: v.array(v.id("events")), + }, + handler: async (ctx, { eventIds }) => { + const uniqueEventIds = Array.from( + new Set(eventIds.map((eventId) => String(eventId))), + ) as Array>; + + const rows = await Promise.all( + uniqueEventIds.map(async (eventId) => { + const [event, candidacy, embeddingRow] = await Promise.all([ + ctx.db.get(eventId), + ctx.db + .query("eventCandidacy") + .withIndex("by_event", (q) => q.eq("eventId", eventId)) + .first(), + ctx.db + .query("eventEmbeddings") + .withIndex("by_event", (q) => q.eq("eventId", eventId)) + .first(), + ]); + + if (!event || !candidacy || !embeddingRow) return null; + + return buildCandidateQueryResult({ + event, + embeddingRow, + candidacy, + }); + }), + ); + + return rows.filter( + (candidate): candidate is ClusterCandidateQueryResult => candidate !== null, + ); + }, +}); + export const createEventFromArticle = internalMutation({ args: { articleId: v.id("articles"), @@ -2169,6 +2665,11 @@ export const createEventFromArticle = internalMutation({ embedding: toEventEmbedding(eventEmbedding), version, status: initialStatus, + ...buildEventEmbeddingFilterFields({ + status: initialStatus, + lastArticleAt: publishedAt, + articleCount: 1, + }), }); const candidacyTokens = buildCandidacyFromArticle( @@ -2345,6 +2846,11 @@ export const attachArticleToEvent = internalMutation({ embedding: nextEmbedding, version, status: nextStatus, + ...buildEventEmbeddingFilterFields({ + status: nextStatus, + lastArticleAt: nextLastArticleAt, + articleCount: nextArticleCount, + }), }); } else { await ctx.db.insert("eventEmbeddings", { @@ -2352,6 +2858,11 @@ export const attachArticleToEvent = internalMutation({ embedding: nextEmbedding, version, status: nextStatus, + ...buildEventEmbeddingFilterFields({ + status: nextStatus, + lastArticleAt: nextLastArticleAt, + articleCount: nextArticleCount, + }), }); } @@ -3795,6 +4306,11 @@ export const mergeEvents = internalMutation({ embedding: mergedEmbedding, version, status: mergedStatus, + ...buildEventEmbeddingFilterFields({ + status: mergedStatus, + lastArticleAt: mergedLastArticleAt, + articleCount: mergedArticleCount, + }), }); } else { await ctx.db.insert("eventEmbeddings", { @@ -3802,6 +4318,11 @@ export const mergeEvents = internalMutation({ embedding: mergedEmbedding, version, status: mergedStatus, + ...buildEventEmbeddingFilterFields({ + status: mergedStatus, + lastArticleAt: mergedLastArticleAt, + articleCount: mergedArticleCount, + }), }); } @@ -3909,22 +4430,32 @@ export const mergeNearDuplicateEvents = internalAction({ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; } + const startedAt = Date.now(); + const metrics = createJobMetrics("mergeNearDuplicateEvents"); + try { - const candidateFetchStart = Date.now(); - const recentCandidatesRaw = await ctx.runQuery( - internal.clustering.getRecentClusterCandidates, - { - sinceTs: Date.now() - RECENT_EVENT_WINDOW_MS, - limit: MAX_CANDIDATE_EVENTS, - }, - ); - const candidateFetchMs = Date.now() - candidateFetchStart; + const budget = await getVectorSearchBudgetState(ctx); + metrics.budgetAllowed = budget.allowed; + if (!budget.allowed) { + console.log( + `[clustering] mergeNearDuplicateEvents skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`, + ); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; + } + + const jobState = await ctx.runQuery(internal.clustering.getClusteringJobState, { + jobName: "mergeNearDuplicateEvents", + }); + const sinceTs = jobState?.lastProcessedAt ?? 0; + const candidateFetchStart = Date.now(); const mergeConfig = await ctx.runQuery(internal.config.getBatch, { keys: [ "merge_min_similarity", "merge_min_title_jaccard", "merge_max_time_delta_hours", + "merge_vector_search_limit", ], }); @@ -3948,47 +4479,105 @@ export const mergeNearDuplicateEvents = internalAction({ 72, ), }; + const vectorSearchLimit = safeInteger( + mergeConfig.merge_vector_search_limit, + MERGE_VECTOR_SEARCH_LIMIT, + 1, + 60, + ); + const recentSinceTs = Date.now() - settings.maxTimeDeltaHours * 60 * 60 * 1000; + const changedCandidateRows = await ctx.runQuery( + internal.clustering.getChangedClusterCandidates, + { + sinceTs, + recentSinceTs, + limit: MAX_CANDIDATE_EVENTS, + singletonOnly: false, + }, + ); + markStageDuration(metrics, "candidateFetch", candidateFetchStart); + + if (changedCandidateRows.length === 0) { + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "mergeNearDuplicateEvents", + lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, + lastProcessedDayBucket: + jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; + } - const candidates: ClusterCandidate[] = ( - recentCandidatesRaw as ClusterCandidateQueryResult[] - ).map((candidate) => ({ - ...candidate, - embeddingId: candidate.embeddingId, - lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt, - sourceCount: candidate.sourceCount ?? candidate.sourceIds?.length ?? 0, - titleTokens: normalizeTitleTokens(candidate.title), - evidenceTokens: new Set(candidate.evidenceTokens ?? []), - factTokens: new Set(candidate.factTokens ?? []), - entityTokens: new Set(candidate.entityTokens ?? []), - topicSlugs: new Set(candidate.topicSlugs ?? []), - sourceIds: new Set(candidate.sourceIds ?? []), - })); + const seedCandidateResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEventIds, + { + eventIds: changedCandidateRows.map((row) => row.eventId), + }, + ); + const seeds = (seedCandidateResults as ClusterCandidateQueryResult[]).map( + (candidate) => hydrateClusterCandidate(candidate), + ); + metrics.mergeSeedEvents = seeds.length; const neighborSearchStart = Date.now(); - const candidatesByEmbeddingId = new Map(); - for (const candidate of candidates) { - if (!candidate.embeddingId) continue; - candidatesByEmbeddingId.set(String(candidate.embeddingId), candidate); + const dayBuckets = collectRecentDayBuckets(settings.maxTimeDeltaHours); + const mergeSearchBuckets = buildMergeSearchBuckets(dayBuckets); + const candidatesByEventId = new Map(); + for (const candidate of seeds) { + candidatesByEventId.set(String(candidate.eventId), candidate); } - const pairKeys = new Set(); const candidatePairs: Array<[ClusterCandidate, ClusterCandidate]> = []; - for (const candidate of candidates) { + for (const candidate of seeds) { if (!candidate.embeddingId) continue; + metrics.vectorSearches++; const neighbors = await ctx.vectorSearch( "eventEmbeddings", "by_embedding", { vector: toEventEmbedding(candidate.embedding), - limit: MERGE_VECTOR_SEARCH_LIMIT, + limit: vectorSearchLimit, filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), + mergeSearchBuckets.length <= 1 + ? q.eq( + "mergeSearchBucket", + mergeSearchBuckets[0] ?? + `published::${MERGE_RECENT_BUCKET}::${formatUtcDayBucket(Date.now())}`, + ) + : q.or( + ...mergeSearchBuckets.map((bucket) => + q.eq("mergeSearchBucket", bucket), + ), + ), }, ); + metrics.vectorMatchesReturned += neighbors.length; + if (neighbors.length === 0) continue; - for (const neighbor of neighbors) { - const match = candidatesByEmbeddingId.get(String(neighbor._id)); + const hydratedResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEmbeddingMatches, + { + embeddingMatches: neighbors.map((result) => ({ + embeddingId: result._id, + similarity: result._score, + })), + }, + ); + metrics.vectorMatchesHydrated += hydratedResults.length; + metrics.vectorMatchesDiscardedPostFetch += + neighbors.length - hydratedResults.length; + + for (const result of hydratedResults as ClusterCandidateVectorResult[]) { + const matchKey = String(result.eventId); + let match = candidatesByEventId.get(matchKey); + if (!match) { + match = hydrateClusterCandidate(result); + candidatesByEventId.set(matchKey, match); + } + match.similarity = result.similarity; if (!match || match.eventId === candidate.eventId) continue; const key = buildEventPairKey(candidate.eventId, match.eventId); if (pairKeys.has(key)) continue; @@ -3996,10 +4585,11 @@ export const mergeNearDuplicateEvents = internalAction({ candidatePairs.push([candidate, match]); } } - const neighborSearchMs = Date.now() - neighborSearchStart; + markStageDuration(metrics, "neighborSearch", neighborSearchStart); + metrics.candidateCacheSize = candidatesByEventId.size; console.log( - `[clustering] Merge candidates: ${candidates.length} events, ${candidatePairs.length} pairs (fetchMs=${candidateFetchMs}, neighborMs=${neighborSearchMs})`, + `[clustering] Merge candidates: ${seeds.length} seeds, ${candidatePairs.length} pairs`, ); const removedIds = new Set(); @@ -4133,12 +4723,29 @@ export const mergeNearDuplicateEvents = internalAction({ for (const topicSlug of remove.topicSlugs) { keep.topicSlugs.add(topicSlug); } + candidatesByEventId.set(String(keep.eventId), keep); } + metrics.vectorSearchesPerCandidateEvent = + seeds.length > 0 ? metrics.vectorSearches / seeds.length : 0; + console.log( `[clustering] Merge pass complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`, ); + const lastProcessedAt = changedCandidateRows.reduce( + (max, row) => Math.max(max, row.updatedAt), + sinceTs, + ); + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "mergeNearDuplicateEvents", + lastProcessedAt, + lastProcessedDayBucket: formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs, examinedPairs, skipped }; } finally { try { @@ -4186,11 +4793,30 @@ export const reclusterRecentSingletonEvents = internalAction({ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; } + const startedAt = Date.now(); + const metrics = createJobMetrics("reclusterRecentSingletonEvents"); + try { + const budget = await getVectorSearchBudgetState(ctx); + metrics.budgetAllowed = budget.allowed; + if (!budget.allowed) { + console.log( + `[clustering] reclusterRecentSingletonEvents skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`, + ); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; + } + + const jobState = await ctx.runQuery(internal.clustering.getClusteringJobState, { + jobName: "reclusterRecentSingletonEvents", + }); + const sinceTs = jobState?.lastProcessedAt ?? 0; + const reclusterConfig = await ctx.runQuery(internal.config.getBatch, { keys: [ "singleton_recluster_min_similarity", "singleton_recluster_window_hours", + "recluster_vector_search_limit", ], }); const settings: ReclusterSettings = { @@ -4207,59 +4833,108 @@ export const reclusterRecentSingletonEvents = internalAction({ 168, ), }; + const vectorSearchLimit = safeInteger( + reclusterConfig.recluster_vector_search_limit, + RECLUSTER_VECTOR_SEARCH_LIMIT, + 1, + 60, + ); const candidateFetchStart = Date.now(); - const recentCandidatesRaw = await ctx.runQuery( - internal.clustering.getRecentClusterCandidates, + const changedCandidateRows = await ctx.runQuery( + internal.clustering.getChangedClusterCandidates, { - sinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000, + sinceTs, + recentSinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000, limit: MAX_CANDIDATE_EVENTS, + singletonOnly: true, }, ); - const candidateFetchMs = Date.now() - candidateFetchStart; - const candidates: ClusterCandidate[] = ( - recentCandidatesRaw as ClusterCandidateQueryResult[] - ) - .filter((candidate) => candidate.articleCount <= 2) - .map((candidate) => ({ - ...candidate, - embeddingId: candidate.embeddingId, - lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt, - sourceCount: - candidate.sourceCount ?? candidate.sourceIds?.length ?? 0, - titleTokens: normalizeTitleTokens(candidate.title), - evidenceTokens: new Set(candidate.evidenceTokens ?? []), - factTokens: new Set(candidate.factTokens ?? []), - entityTokens: new Set(candidate.entityTokens ?? []), - topicSlugs: new Set(candidate.topicSlugs ?? []), - sourceIds: new Set(candidate.sourceIds ?? []), - })); + markStageDuration(metrics, "candidateFetch", candidateFetchStart); + + if (changedCandidateRows.length === 0) { + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "reclusterRecentSingletonEvents", + lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, + lastProcessedDayBucket: + jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs: 0, examinedPairs: 0, skipped: 0 }; + } + + const seedCandidateResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEventIds, + { + eventIds: changedCandidateRows.map((row) => row.eventId), + }, + ); + const candidates = (seedCandidateResults as ClusterCandidateQueryResult[]).map( + (candidate) => hydrateClusterCandidate(candidate), + ); + metrics.reclusterSeedEvents = candidates.length; const neighborSearchStart = Date.now(); - const candidatesByEmbeddingId = new Map(); + const candidatesByEventId = new Map(); for (const candidate of candidates) { if (!candidate.embeddingId) continue; - candidatesByEmbeddingId.set(String(candidate.embeddingId), candidate); + candidatesByEventId.set(String(candidate.eventId), candidate); } const pairKeys = new Set(); const candidatePairs: Array<[ClusterCandidate, ClusterCandidate]> = []; + const dayBuckets = collectRecentDayBuckets(settings.windowHours); + const singletonSearchBuckets = buildSingletonSearchBuckets(dayBuckets); for (const candidate of candidates) { if (!candidate.embeddingId) continue; + metrics.vectorSearches++; const neighbors = await ctx.vectorSearch( "eventEmbeddings", "by_embedding", { vector: toEventEmbedding(candidate.embedding), - limit: RECLUSTER_VECTOR_SEARCH_LIMIT, + limit: vectorSearchLimit, filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), + singletonSearchBuckets.length <= 1 + ? q.eq( + "singletonSearchBucket", + singletonSearchBuckets[0] ?? + `published::${SINGLETON_BUCKET}::${formatUtcDayBucket(Date.now())}`, + ) + : q.or( + ...singletonSearchBuckets.map((bucket) => + q.eq("singletonSearchBucket", bucket), + ), + ), }, ); + metrics.vectorMatchesReturned += neighbors.length; + if (neighbors.length === 0) continue; - for (const neighbor of neighbors) { - const match = candidatesByEmbeddingId.get(String(neighbor._id)); + const hydratedResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEmbeddingMatches, + { + embeddingMatches: neighbors.map((result) => ({ + embeddingId: result._id, + similarity: result._score, + })), + }, + ); + metrics.vectorMatchesHydrated += hydratedResults.length; + metrics.vectorMatchesDiscardedPostFetch += + neighbors.length - hydratedResults.length; + + for (const result of hydratedResults as ClusterCandidateVectorResult[]) { + const matchKey = String(result.eventId); + let match = candidatesByEventId.get(matchKey); + if (!match) { + match = hydrateClusterCandidate(result); + candidatesByEventId.set(matchKey, match); + } + match.similarity = result.similarity; if (!match || match.eventId === candidate.eventId) continue; const key = buildEventPairKey(candidate.eventId, match.eventId); if (pairKeys.has(key)) continue; @@ -4267,10 +4942,11 @@ export const reclusterRecentSingletonEvents = internalAction({ candidatePairs.push([candidate, match]); } } - const neighborSearchMs = Date.now() - neighborSearchStart; + markStageDuration(metrics, "neighborSearch", neighborSearchStart); + metrics.candidateCacheSize = candidatesByEventId.size; console.log( - `[clustering] Recluster candidates: ${candidates.length} events, ${candidatePairs.length} pairs (fetchMs=${candidateFetchMs}, neighborMs=${neighborSearchMs})`, + `[clustering] Recluster candidates: ${candidates.length} seeds, ${candidatePairs.length} pairs`, ); const removedIds = new Set(); @@ -4397,12 +5073,29 @@ export const reclusterRecentSingletonEvents = internalAction({ for (const topicSlug of remove.topicSlugs) { keep.topicSlugs.add(topicSlug); } + candidatesByEventId.set(String(keep.eventId), keep); } + metrics.vectorSearchesPerCandidateEvent = + candidates.length > 0 ? metrics.vectorSearches / candidates.length : 0; + console.log( `[clustering] Singleton recluster complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`, ); + const lastProcessedAt = changedCandidateRows.reduce( + (max, row) => Math.max(max, row.updatedAt), + sinceTs, + ); + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "reclusterRecentSingletonEvents", + lastProcessedAt, + lastProcessedDayBucket: formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs, examinedPairs, skipped }; } finally { try { @@ -4456,6 +5149,9 @@ export const clusterEnrichedArticles = internalAction({ }; } + const startedAt = Date.now(); + const metrics = createJobMetrics("clusterEnrichedArticles"); + try { const hasEnriched = await ctx.runQuery( internal.clustering.hasEnrichedArticlesForClustering, @@ -4505,6 +5201,7 @@ export const clusterEnrichedArticles = internalAction({ "topic_inference_min_score", "topic_inference_confidence_ratio", "topic_inference_max_topics", + "clustering_vector_search_limit", ], }); @@ -4586,27 +5283,70 @@ export const clusterEnrichedArticles = internalAction({ 5, ), }; + const vectorSearchLimit = safeInteger( + clusteringConfig.clustering_vector_search_limit, + VECTOR_SEARCH_LIMIT, + 1, + 80, + ); + + const budget = await getVectorSearchBudgetState(ctx); + metrics.budgetAllowed = budget.allowed; + metrics.batchArticles = articles.length; + const useFallbackMode = !budget.allowed && budget.fallbackModeEnabled; + metrics.usedFallbackMode = useFallbackMode; + + if (!budget.allowed && !budget.fallbackModeEnabled) { + console.log( + `[clustering] clusterEnrichedArticles skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`, + ); + await flushJobMetrics(ctx, metrics, startedAt); + return { + clusteredIntoExisting: 0, + createdEvents: 0, + skipped: 0, + }; + } const candidateSearchStart = Date.now(); - let vectorSearches = 0; - let vectorMatches = 0; const candidateCache = new Map(); + let batchStateVersion = 0; + const searchedArticleIds = new Set(); + const articleBatchVersionSeen = new Map(); + if (useFallbackMode) { + const fallbackCandidates = await ctx.runQuery( + internal.clustering.getRecentClusterCandidates, + { + sinceTs: Date.now() - RECENT_EVENT_WINDOW_MS, + limit: MAX_CANDIDATE_EVENTS, + }, + ); + for (const candidate of fallbackCandidates as ClusterCandidateQueryResult[]) { + candidateCache.set( + String(candidate.eventId), + hydrateClusterCandidate(candidate), + ); + } + metrics.candidateCacheSize = candidateCache.size; + } const loadCandidatesForEmbedding = async ( + articleId: Id<"articles">, embedding: number[], ): Promise => { - vectorSearches++; + searchedArticleIds.add(String(articleId)); + metrics.vectorSearches++; const vectorResults = await ctx.vectorSearch( "eventEmbeddings", "by_embedding", { vector: toEventEmbedding(embedding), - limit: VECTOR_SEARCH_LIMIT, + limit: vectorSearchLimit, filter: (q) => q.or(q.eq("status", "published"), q.eq("status", "processing")), }, ); - vectorMatches += vectorResults.length; + metrics.vectorMatchesReturned += vectorResults.length; if (vectorResults.length === 0) return []; @@ -4619,23 +5359,16 @@ export const clusterEnrichedArticles = internalAction({ })), }, ); + metrics.vectorMatchesHydrated += vectorCandidates.length; + metrics.vectorMatchesDiscardedPostFetch += + vectorResults.length - vectorCandidates.length; const matches: ClusterCandidate[] = []; for (const result of vectorCandidates as ClusterCandidateVectorResult[]) { const key = String(result.eventId); let candidate = candidateCache.get(key); if (!candidate) { - candidate = { - ...result, - lastArticleAt: result.lastArticleAt ?? result.firstPublishedAt, - sourceCount: result.sourceCount ?? result.sourceIds?.length ?? 0, - titleTokens: normalizeTitleTokens(result.title), - evidenceTokens: new Set(result.evidenceTokens ?? []), - factTokens: new Set(result.factTokens ?? []), - entityTokens: new Set(result.entityTokens ?? []), - topicSlugs: new Set(result.topicSlugs ?? []), - sourceIds: new Set(result.sourceIds ?? []), - }; + candidate = hydrateClusterCandidate(result); candidateCache.set(key, candidate); } candidate.similarity = result.similarity; @@ -4661,6 +5394,8 @@ export const clusterEnrichedArticles = internalAction({ publishedAt: number; id: string; }; + lastBatchStateVersionSeen: number; + needsFreshVectorSearch: boolean; }; const pendingArticles: PendingArticle[] = []; @@ -4698,33 +5433,31 @@ export const clusterEnrichedArticles = internalAction({ for (const topicSlug of topicSlugs) { candidate.topicSlugs.add(topicSlug); } + batchStateVersion++; }; - const tryAttach = async ( + const resolveArticle = ( + article: (typeof articles)[number], + topicSlugs: string[], + ) => ({ + articleId: article._id, + title: article.title, + rssSnippet: article.rssSnippet, + summary: article.summary, + atomicFacts: article.atomicFacts, + entities: article.entities, + topicSlugs, + extractionQuality: article.extractionQuality, + publishedAt: article.publishedAt, + embedding: article.embedding, + sourceId: article.sourceId, + }); + + const attachToMatch = async ( payload: AttachPayload, - ): Promise<"attached" | "unmatched" | "skipped"> => { + match: ClusterCandidate, + ): Promise<"attached" | "skipped"> => { const { article, paddedEmbedding, topicSlugs } = payload; - const candidates = await loadCandidatesForEmbedding(paddedEmbedding); - const match = findBestCandidate( - { - articleId: article._id, - title: article.title, - rssSnippet: article.rssSnippet, - summary: article.summary, - atomicFacts: article.atomicFacts, - entities: article.entities, - topicSlugs, - extractionQuality: article.extractionQuality, - publishedAt: article.publishedAt, - embedding: article.embedding, - sourceId: article.sourceId, - }, - candidates, - settings, - ); - - if (!match) return "unmatched"; - const result = await ctx.runMutation( internal.clustering.attachArticleToEvent, { @@ -4754,6 +5487,36 @@ export const clusterEnrichedArticles = internalAction({ return "attached"; }; + const tryBatchLocalAttach = async ( + payload: AttachPayload, + heuristicOnly: boolean, + ): Promise<"attached" | "unmatched" | "skipped"> => { + const { article, topicSlugs } = payload; + const candidatePool = Array.from(candidateCache.values()); + if (candidatePool.length === 0) return "unmatched"; + + const articleContext = resolveArticle(article, topicSlugs); + const match = heuristicOnly + ? findHeuristicCandidate(articleContext, candidatePool) + : findBatchLocalCandidate(articleContext, candidatePool, settings); + if (!match) return "unmatched"; + return await attachToMatch(payload, match); + }; + + const tryVectorAttach = async ( + payload: AttachPayload, + ): Promise<"attached" | "unmatched" | "skipped"> => { + const { article, paddedEmbedding, topicSlugs } = payload; + const candidates = await loadCandidatesForEmbedding(article._id, paddedEmbedding); + const match = findBestCandidate( + resolveArticle(article, topicSlugs), + candidates, + settings, + ); + if (!match) return "unmatched"; + return await attachToMatch(payload, match); + }; + for (const article of articles) { const paddedEmbedding = toEventEmbedding(article.embedding); const topicSlugs = inferTopicSlugs( @@ -4773,7 +5536,9 @@ export const clusterEnrichedArticles = internalAction({ paddedEmbedding, topicSlugs, }; - const outcome = await tryAttach(payload); + const outcome = useFallbackMode + ? await tryBatchLocalAttach(payload, true) + : await tryVectorAttach(payload); if (outcome === "unmatched") { const entityTokenCount = extractEntityTokens( article.title, @@ -4791,7 +5556,10 @@ export const clusterEnrichedArticles = internalAction({ publishedAt: article.publishedAt, id: String(article._id), }, + lastBatchStateVersionSeen: batchStateVersion, + needsFreshVectorSearch: false, }); + articleBatchVersionSeen.set(String(article._id), batchStateVersion); } } @@ -4805,7 +5573,25 @@ export const clusterEnrichedArticles = internalAction({ ); for (const pending of pendingArticles) { - const outcome = await tryAttach(pending); + let outcome = await tryBatchLocalAttach(pending, useFallbackMode); + if (outcome !== "unmatched") continue; + + const pendingKey = String(pending.article._id); + const lastSeenVersion = + articleBatchVersionSeen.get(pendingKey) ?? + pending.lastBatchStateVersionSeen; + const batchChanged = batchStateVersion > lastSeenVersion; + if (!useFallbackMode && batchChanged) { + pending.needsFreshVectorSearch = true; + } + articleBatchVersionSeen.set(pendingKey, batchStateVersion); + + if (!useFallbackMode && pending.needsFreshVectorSearch) { + outcome = await tryVectorAttach(pending); + pending.needsFreshVectorSearch = false; + pending.lastBatchStateVersionSeen = batchStateVersion; + } + if (outcome !== "unmatched") continue; const { article, paddedEmbedding, topicSlugs } = pending; @@ -4876,15 +5662,20 @@ export const clusterEnrichedArticles = internalAction({ creationTime: Date.now(), }; candidateCache.set(String(result.eventId), newCandidate); + batchStateVersion++; } - const candidateSearchMs = Date.now() - candidateSearchStart; + markStageDuration(metrics, "candidateSearch", candidateSearchStart); + metrics.candidateCacheSize = candidateCache.size; + metrics.vectorSearchesPerArticle = + articles.length > 0 ? metrics.vectorSearches / articles.length : 0; console.log( - `[clustering] Candidate search: ${vectorSearches} queries, ${vectorMatches} matches, ${candidateCache.size} unique (ms=${candidateSearchMs})`, + `[clustering] Candidate search: ${metrics.vectorSearches} queries, ${metrics.vectorMatchesReturned} matches, ${candidateCache.size} unique`, ); console.log( `[clustering] Done: ${clusteredIntoExisting} attached, ${createdEvents} new events, ${skipped} skipped (minSim=${settings.minSimilarity}, strongSim=${settings.strongSimilarity}, sameSourceMinSim=${settings.sameSourceMinSimilarity}, publishMin=${publishSettings.minArticles} articles/${publishSettings.minSources} sources, topicMinScore=${topicSettings.minScore})`, ); + await flushJobMetrics(ctx, metrics, startedAt); return { clusteredIntoExisting, diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts index d25d723..4dc7a54 100644 --- a/packages/backend/convex/config.ts +++ b/packages/backend/convex/config.ts @@ -737,6 +737,42 @@ export const seedDefaults = internalMutation({ description: "How many hours back the singleton recluster pass should inspect recent small events.", }, + { + key: "vector_search_daily_budget_qgb", + value: 0.25, + description: + "Daily Convex vector-search read budget in qGB. Clustering workers use this to hard-stop expensive semantic search when spend spikes.", + }, + { + key: "vector_search_budget_enabled", + value: true, + description: + "When true, clustering workers enforce the daily vector-search qGB budget before semantic matching work.", + }, + { + key: "vector_search_fallback_mode_enabled", + value: true, + description: + "When true, clusterEnrichedArticles falls back to heuristic-only batch-local matching after the vector-search budget is exhausted.", + }, + { + key: "clustering_vector_search_limit", + value: 40, + description: + "Top-K limit used for article-to-event vector search during clusterEnrichedArticles.", + }, + { + key: "merge_vector_search_limit", + value: 24, + description: + "Top-K limit used for event-to-event vector search during duplicate-merge passes.", + }, + { + key: "recluster_vector_search_limit", + value: 24, + description: + "Top-K limit used for event-to-event vector search during singleton recluster passes.", + }, ]; const forcedDefaultKeys = new Set([ @@ -753,6 +789,12 @@ export const seedDefaults = internalMutation({ "merge_min_title_jaccard", "merge_max_time_delta_hours", "singleton_recluster_min_similarity", + "vector_search_daily_budget_qgb", + "vector_search_budget_enabled", + "vector_search_fallback_mode_enabled", + "clustering_vector_search_limit", + "merge_vector_search_limit", + "recluster_vector_search_limit", ]); let created = 0; diff --git a/packages/backend/convex/ingestion.ts b/packages/backend/convex/ingestion.ts index f3efbb0..b012a47 100644 --- a/packages/backend/convex/ingestion.ts +++ b/packages/backend/convex/ingestion.ts @@ -14,6 +14,7 @@ */ import { v } from "convex/values"; +import { paginationOptsValidator, type PaginationResult } from "convex/server"; import { internalAction, internalMutation, @@ -39,7 +40,7 @@ const USER_AGENT = /** Run-level lease for ingestAllFeeds; prevents overlapping cron/manual runs. */ const INGEST_ALL_FEEDS_LOCK_KEY = "ingestAllFeeds"; const INGEST_ALL_FEEDS_LOCK_TTL_MS = 20 * 60 * 1000; -const EVENT_EMBEDDING_DIMENSIONS = 1536; +const EVENT_EMBEDDING_DIMENSIONS = 512; // --------------------------------------------------------------------------- // Types @@ -481,6 +482,33 @@ function toEventEmbedding(articleEmbedding: number[]): number[] { return padded; } +function formatUtcDayBucket(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10); +} + +function buildEventEmbeddingFilterFields(args: { + status?: "processing" | "published"; + lastArticleAt: number; + articleCount: number; + referenceTime?: number; +}) { + const referenceTime = args.referenceTime ?? Date.now(); + const recentWindowBucket = + referenceTime - args.lastArticleAt <= 48 * 60 * 60 * 1000 + ? "recent_2d" + : "stale"; + const singletonBucket = args.articleCount <= 2 ? "singleton" : "multi"; + const updatedDayBucket = formatUtcDayBucket(args.lastArticleAt); + const status = args.status ?? "processing"; + return { + recentWindowBucket, + singletonBucket, + updatedDayBucket, + mergeSearchBucket: `${status}::${recentWindowBucket}::${updatedDayBucket}`, + singletonSearchBucket: `${status}::${singletonBucket}::${updatedDayBucket}`, + }; +} + /** Extract domain from a URL (e.g. "nytimes.com" from "https://www.nytimes.com/...") */ function extractDomain(url: string): string { try { @@ -788,6 +816,11 @@ async function recomputeEventEmbeddingForEvent( embedding: averagedEmbedding, version: latestVersion, status: event.status, + ...buildEventEmbeddingFilterFields({ + status: event.status, + lastArticleAt: event.lastArticleAt ?? event.firstPublishedAt, + articleCount: event.articleCount ?? articles.length, + }), }); } else { await ctx.db.insert("eventEmbeddings", { @@ -795,10 +828,71 @@ async function recomputeEventEmbeddingForEvent( embedding: averagedEmbedding, version: latestVersion, status: event.status, + ...buildEventEmbeddingFilterFields({ + status: event.status, + lastArticleAt: event.lastArticleAt ?? event.firstPublishedAt, + articleCount: event.articleCount ?? articles.length, + }), }); } } +export const recomputeEventEmbeddingForEventInternal = internalMutation({ + args: { + eventId: v.id("events"), + }, + handler: async (ctx, { eventId }) => { + await recomputeEventEmbeddingForEvent(ctx, eventId); + return { recomputed: true as const }; + }, +}); + +export const getEventsForEmbeddingBackfill = internalQuery({ + args: { + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, { paginationOpts }) => { + return (await ctx.db + .query("events") + .order("desc") + .paginate(paginationOpts)) as PaginationResult<{ _id: Id<"events"> }>; + }, +}); + +export const backfillEventEmbeddingDimensions = internalAction({ + args: { + cursor: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, { cursor, limit }) => { + const pageSize = Math.min(Math.max(Math.floor(limit ?? 100), 1), 200); + const page: PaginationResult<{ _id: Id<"events"> }> = await ctx.runQuery( + internal.ingestion.getEventsForEmbeddingBackfill, + { + paginationOpts: { + cursor: cursor ?? null, + numItems: pageSize, + }, + }, + ); + + for (const event of page.page) { + await ctx.runMutation( + internal.ingestion.recomputeEventEmbeddingForEventInternal, + { + eventId: event._id, + }, + ); + } + + return { + processed: page.page.length, + continueCursor: page.continueCursor ?? null, + isDone: page.isDone, + }; + }, +}); + export const cleanupDuplicateArticlesInEvents = internalMutation({ args: { limit: v.optional(v.number()), diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 43329b0..02ed60b 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -113,12 +113,24 @@ export default defineSchema({ status: v.optional( v.union(v.literal("processing"), v.literal("published")), ), + recentWindowBucket: v.optional(v.string()), + singletonBucket: v.optional(v.string()), + updatedDayBucket: v.optional(v.string()), + mergeSearchBucket: v.optional(v.string()), + singletonSearchBucket: v.optional(v.string()), }) .index("by_event", ["eventId"]) .vectorIndex("by_embedding", { vectorField: "embedding", - dimensions: 1536, - filterFields: ["status"], + dimensions: 512, + filterFields: [ + "status", + "recentWindowBucket", + "singletonBucket", + "updatedDayBucket", + "mergeSearchBucket", + "singletonSearchBucket", + ], }), // ========================================================================= @@ -672,6 +684,47 @@ export default defineSchema({ shard: v.optional(v.number()), }).index("by_expiresAt", ["expiresAt"]), + // ========================================================================= + // 11b. VECTOR SEARCH BUDGET DAILY SHARDS + // ========================================================================= + vectorSearchDaily: defineTable({ + date: v.string(), // "YYYY-MM-DD" + shard: v.number(), // 0-23 (UTC hour) + qgbRead: v.number(), + vectorSearches: v.number(), + runCount: v.number(), + updatedAt: v.number(), + }) + .index("by_date", ["date"]) + .index("by_date_shard", ["date", "shard"]), + + vectorSearchDailyTotal: defineTable({ + date: v.string(), + qgbRead: v.number(), + vectorSearches: v.number(), + runCount: v.number(), + updatedAt: v.number(), + }).index("by_date", ["date"]), + + vectorSearchRuns: defineTable({ + jobName: v.string(), + runId: v.string(), + date: v.string(), + qgbRead: v.number(), + vectorSearches: v.number(), + vectorMatchesReturned: v.number(), + vectorMatchesHydrated: v.number(), + vectorMatchesDiscardedPostFetch: v.number(), + usedFallbackMode: v.boolean(), + budgetAllowed: v.boolean(), + elapsedMs: v.number(), + metricsJson: v.string(), + createdAt: v.number(), + }) + .index("by_run_id", ["runId"]) + .index("by_date", ["date"]) + .index("by_job_createdAt", ["jobName", "createdAt"]), + // ========================================================================= // 12. PIPELINE LOCKS (Short-lived leases for scheduled jobs) // ========================================================================= @@ -682,4 +735,13 @@ export default defineSchema({ updatedAt: v.number(), expiresAt: v.number(), }).index("by_key", ["key"]), + + clusteringJobState: defineTable({ + jobName: v.string(), + lastProcessedAt: v.optional(v.number()), + lastProcessedDayBucket: v.optional(v.string()), + lastRunAt: v.optional(v.number()), + lastRunMetricsJson: v.optional(v.string()), + updatedAt: v.number(), + }).index("by_job_name", ["jobName"]), }); diff --git a/packages/backend/convex/seeds.ts b/packages/backend/convex/seeds.ts index 519cbe6..8927aa5 100644 --- a/packages/backend/convex/seeds.ts +++ b/packages/backend/convex/seeds.ts @@ -144,19 +144,29 @@ export const seedDB = internalMutation({ // ========================================================================= // 3b. EVENT EMBEDDINGS (placeholder zeros — real embeddings come from AI) // ========================================================================= - const dummyEmbedding = new Array(1536).fill(0); + const dummyEmbedding = new Array(512).fill(0); await ctx.db.insert("eventEmbeddings", { eventId: eventFedRates, embedding: dummyEmbedding, version: 1, status: "published", + recentWindowBucket: "recent_2d", + singletonBucket: "multi", + updatedDayBucket: new Date().toISOString().slice(0, 10), + mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`, + singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`, }); await ctx.db.insert("eventEmbeddings", { eventId: eventAIRegulations, embedding: dummyEmbedding, version: 1, status: "published", + recentWindowBucket: "recent_2d", + singletonBucket: "multi", + updatedDayBucket: new Date().toISOString().slice(0, 10), + mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`, + singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`, }); console.log("✅ Created 2 eventEmbeddings"); diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts new file mode 100644 index 0000000..58eb8e6 --- /dev/null +++ b/packages/backend/convex/vectorSearchBudget.ts @@ -0,0 +1,334 @@ +import { v } from "convex/values"; +import { + internalMutation, + internalQuery, + query, +} from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { requireAdminUser } from "./lib/betaAccess"; + +const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 0.25; +const ESTIMATED_VECTOR_ROW_BYTES = 5 * 1024; + +function roundQgb(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function formatUtcDate(timestamp: number): string { + return new Date(timestamp).toISOString().split("T")[0]!; +} + +function getUtcShard(timestamp: number): number { + return new Date(timestamp).getUTCHours(); +} + +async function getConfigBoolean( + ctx: QueryCtx | MutationCtx, + key: string, + fallback: boolean, +): Promise { + const row = await ctx.db + .query("config") + .withIndex("by_key", (q) => q.eq("key", key)) + .unique(); + if (!row) return fallback; + + try { + return JSON.parse(row.value) === true; + } catch { + return fallback; + } +} + +async function getConfigNumber( + ctx: QueryCtx | MutationCtx, + key: string, + fallback: number, +): Promise { + const row = await ctx.db + .query("config") + .withIndex("by_key", (q) => q.eq("key", key)) + .unique(); + if (!row) return fallback; + + try { + const parsed = Number(JSON.parse(row.value)); + return Number.isFinite(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +async function getDailyTotals( + ctx: QueryCtx | MutationCtx, + date: string, +): Promise<{ qgbRead: number; vectorSearches: number; runCount: number }> { + const total = await ctx.db + .query("vectorSearchDailyTotal") + .withIndex("by_date", (q) => q.eq("date", date)) + .unique(); + if (total) { + return { + qgbRead: roundQgb(total.qgbRead), + vectorSearches: total.vectorSearches, + runCount: total.runCount, + }; + } + + const rows = await ctx.db + .query("vectorSearchDaily") + .withIndex("by_date", (q) => q.eq("date", date)) + .collect(); + return { + qgbRead: roundQgb(rows.reduce((sum, row) => sum + row.qgbRead, 0)), + vectorSearches: rows.reduce((sum, row) => sum + row.vectorSearches, 0), + runCount: rows.reduce((sum, row) => sum + row.runCount, 0), + }; +} + +async function ensureDailyTotal(ctx: MutationCtx, date: string) { + const existing = await ctx.db + .query("vectorSearchDailyTotal") + .withIndex("by_date", (q) => q.eq("date", date)) + .unique(); + if (existing) return existing; + + const totals = await getDailyTotals(ctx, date); + const totalId = await ctx.db.insert("vectorSearchDailyTotal", { + date, + qgbRead: totals.qgbRead, + vectorSearches: totals.vectorSearches, + runCount: totals.runCount, + updatedAt: Date.now(), + }); + return await ctx.db.get(totalId); +} + +async function adjustDailyUsage( + ctx: MutationCtx, + args: { + date: string; + shard: number; + deltaQgbRead: number; + deltaVectorSearches: number; + deltaRunCount: number; + }, +): Promise { + const existing = await ctx.db + .query("vectorSearchDaily") + .withIndex("by_date_shard", (q) => + q.eq("date", args.date).eq("shard", args.shard), + ) + .unique(); + + const nextQgbRead = roundQgb( + Math.max(0, (existing?.qgbRead ?? 0) + args.deltaQgbRead), + ); + const nextVectorSearches = Math.max( + 0, + (existing?.vectorSearches ?? 0) + args.deltaVectorSearches, + ); + const nextRunCount = Math.max(0, (existing?.runCount ?? 0) + args.deltaRunCount); + const appliedQgb = roundQgb(nextQgbRead - (existing?.qgbRead ?? 0)); + const appliedSearches = nextVectorSearches - (existing?.vectorSearches ?? 0); + const appliedRuns = nextRunCount - (existing?.runCount ?? 0); + const total = await ensureDailyTotal(ctx, args.date); + + if (existing) { + await ctx.db.patch(existing._id, { + qgbRead: nextQgbRead, + vectorSearches: nextVectorSearches, + runCount: nextRunCount, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("vectorSearchDaily", { + date: args.date, + shard: args.shard, + qgbRead: nextQgbRead, + vectorSearches: nextVectorSearches, + runCount: nextRunCount, + updatedAt: Date.now(), + }); + } + + if (total) { + await ctx.db.patch(total._id, { + qgbRead: roundQgb((total.qgbRead ?? 0) + appliedQgb), + vectorSearches: Math.max(0, (total.vectorSearches ?? 0) + appliedSearches), + runCount: Math.max(0, (total.runCount ?? 0) + appliedRuns), + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("vectorSearchDailyTotal", { + date: args.date, + qgbRead: roundQgb(appliedQgb), + vectorSearches: Math.max(0, appliedSearches), + runCount: Math.max(0, appliedRuns), + updatedAt: Date.now(), + }); + } +} + +export function estimateVectorSearchQgbRead(args: { + vectorSearches: number; + averageMatchesReturned: number; + estimatedVectorRowBytes?: number; +}): number { + const estimatedVectorRowBytes = + args.estimatedVectorRowBytes ?? ESTIMATED_VECTOR_ROW_BYTES; + const bytesRead = + Math.max(0, args.vectorSearches) * + Math.max(0, args.averageMatchesReturned) * + estimatedVectorRowBytes; + return roundQgb(bytesRead / 1_000_000_000); +} + +export const checkBudget = internalQuery({ + args: {}, + handler: async (ctx) => { + const enabled = await getConfigBoolean( + ctx, + "vector_search_budget_enabled", + true, + ); + const dailyLimitQgb = await getConfigNumber( + ctx, + "vector_search_daily_budget_qgb", + DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB, + ); + const today = formatUtcDate(Date.now()); + const totals = await getDailyTotals(ctx, today); + const allowed = !enabled || totals.qgbRead < dailyLimitQgb; + + return { + enabled, + allowed, + usedQgb: totals.qgbRead, + remainingQgb: roundQgb(Math.max(0, dailyLimitQgb - totals.qgbRead)), + dailyLimitQgb, + vectorSearches: totals.vectorSearches, + runCount: totals.runCount, + fallbackModeEnabled: await getConfigBoolean( + ctx, + "vector_search_fallback_mode_enabled", + true, + ), + }; + }, +}); + +export const recordUsage = internalMutation({ + args: { + jobName: v.string(), + runId: v.string(), + qgbRead: v.number(), + vectorSearches: v.number(), + vectorMatchesReturned: v.number(), + vectorMatchesHydrated: v.number(), + vectorMatchesDiscardedPostFetch: v.number(), + usedFallbackMode: v.boolean(), + budgetAllowed: v.boolean(), + elapsedMs: v.number(), + metricsJson: v.string(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const date = formatUtcDate(now); + const shard = getUtcShard(now); + + await adjustDailyUsage(ctx, { + date, + shard, + deltaQgbRead: roundQgb(Math.max(0, args.qgbRead)), + deltaVectorSearches: Math.max(0, Math.floor(args.vectorSearches)), + deltaRunCount: 1, + }); + + await ctx.db.insert("vectorSearchRuns", { + jobName: args.jobName, + runId: args.runId, + date, + qgbRead: roundQgb(Math.max(0, args.qgbRead)), + vectorSearches: Math.max(0, Math.floor(args.vectorSearches)), + vectorMatchesReturned: Math.max( + 0, + Math.floor(args.vectorMatchesReturned), + ), + vectorMatchesHydrated: Math.max( + 0, + Math.floor(args.vectorMatchesHydrated), + ), + vectorMatchesDiscardedPostFetch: Math.max( + 0, + Math.floor(args.vectorMatchesDiscardedPostFetch), + ), + usedFallbackMode: args.usedFallbackMode, + budgetAllowed: args.budgetAllowed, + elapsedMs: Math.max(0, Math.floor(args.elapsedMs)), + metricsJson: args.metricsJson, + createdAt: now, + }); + + return { recorded: true as const }; + }, +}); + +export const getRecentRunSummaryForAdmin = query({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, { limit }) => { + await requireAdminUser(ctx); + + const pageSize = Math.max(1, Math.min(limit ?? 50, 200)); + const rows = await ctx.db + .query("vectorSearchRuns") + .withIndex("by_date") + .order("desc") + .take(pageSize); + + const totals = rows.reduce( + (acc, row) => { + acc.qgbRead += row.qgbRead; + acc.vectorSearches += row.vectorSearches; + acc.vectorMatchesReturned += row.vectorMatchesReturned; + acc.vectorMatchesHydrated += row.vectorMatchesHydrated; + acc.vectorMatchesDiscardedPostFetch += + row.vectorMatchesDiscardedPostFetch; + acc.runCount++; + if (row.usedFallbackMode) acc.fallbackRuns++; + if (!row.budgetAllowed) acc.budgetBlockedRuns++; + return acc; + }, + { + qgbRead: 0, + vectorSearches: 0, + vectorMatchesReturned: 0, + vectorMatchesHydrated: 0, + vectorMatchesDiscardedPostFetch: 0, + runCount: 0, + fallbackRuns: 0, + budgetBlockedRuns: 0, + }, + ); + + return { + totals: { + ...totals, + qgbRead: roundQgb(totals.qgbRead), + }, + runs: rows.map((row) => ({ + ...row, + metrics: + (() => { + try { + return JSON.parse(row.metricsJson); + } catch { + return null; + } + })(), + })), + }; + }, +}); From 389fe3e274147df79967bf3524697c95cec11a41 Mon Sep 17 00:00:00 2001 From: flavius Date: Sun, 10 May 2026 17:21:16 +0300 Subject: [PATCH 16/19] redesign profile page & fix coderabbit issues --- apps/web/src/components/LanguagePicker.tsx | 65 +++- .../feed/event-claim-comparison.tsx | 94 ++++-- apps/web/src/components/header.tsx | 20 +- .../src/components/layout/MobileTabBar.tsx | 21 +- .../components/profile/AnonymousProfile.tsx | 157 ++++----- .../profile/AuthenticatedProfile.tsx | 316 ++++++++++-------- .../web/src/components/share-event-button.tsx | 4 +- apps/web/src/components/sign-in-form.tsx | 39 ++- apps/web/src/components/sign-up-form.tsx | 2 +- .../src/components/ui/page-loading-state.tsx | 4 +- apps/web/src/components/user-menu.tsx | 18 +- apps/web/src/hooks/use-scroll-visibility.ts | 89 +++++ apps/web/src/lib/i18n/LocaleContext.tsx | 4 +- apps/web/src/lib/i18n/getLocaleFromMatches.ts | 17 + apps/web/src/lib/i18n/resolveLocale.ts | 25 +- apps/web/src/lib/i18n/strings.ts | 44 ++- apps/web/src/routes/__root.tsx | 49 ++- apps/web/src/routes/activitate.tsx | 114 ++++--- apps/web/src/routes/dashboard.tsx | 22 +- apps/web/src/routes/event.$slug.tsx | 57 ++-- apps/web/src/routes/feed.tsx | 307 ++++++++++------- apps/web/src/routes/profil.tsx | 9 +- apps/web/src/routes/reset-password.tsx | 36 +- apps/web/src/routes/salvate.tsx | 101 +++--- apps/web/src/routes/source.$sourceId.tsx | 25 +- apps/web/src/routes/unsubscribe.tsx | 19 +- 26 files changed, 1027 insertions(+), 631 deletions(-) create mode 100644 apps/web/src/hooks/use-scroll-visibility.ts create mode 100644 apps/web/src/lib/i18n/getLocaleFromMatches.ts diff --git a/apps/web/src/components/LanguagePicker.tsx b/apps/web/src/components/LanguagePicker.tsx index 109e1ca..4c8c7c7 100644 --- a/apps/web/src/components/LanguagePicker.tsx +++ b/apps/web/src/components/LanguagePicker.tsx @@ -6,6 +6,7 @@ import { api } from "@news-app/backend/convex/_generated/api"; import { useLocale, useT } from "@/lib/i18n/LocaleContext"; import type { Locale } from "@/lib/i18n/strings"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; function validateLocale(input: unknown): Locale { if (input === "ro" || input === "en") { @@ -27,7 +28,33 @@ const setLocaleCookie = createServerFn({ method: "POST" }) return { locale: data }; }); -export function LanguagePicker() { +const LANGUAGE_OPTIONS: Array<{ + locale: Locale; + flag: string; + shortLabel: string; + translationKey: "settings.language.ro" | "settings.language.en"; +}> = [ + { + locale: "ro", + flag: "🇷🇴", + shortLabel: "RO", + translationKey: "settings.language.ro", + }, + { + locale: "en", + flag: "🇺🇸", + shortLabel: "EN", + translationKey: "settings.language.en", + }, +]; + +export function LanguagePicker({ + compact = false, + className, +}: { + compact?: boolean; + className?: string; +}) { const router = useRouter(); const t = useT(); const currentLocale = useLocale(); @@ -54,24 +81,30 @@ export function LanguagePicker() { return (
- - + {LANGUAGE_OPTIONS.map(({ locale, flag, shortLabel, translationKey }) => ( + + ))}
); } diff --git a/apps/web/src/components/feed/event-claim-comparison.tsx b/apps/web/src/components/feed/event-claim-comparison.tsx index 767b0da..10b62be 100644 --- a/apps/web/src/components/feed/event-claim-comparison.tsx +++ b/apps/web/src/components/feed/event-claim-comparison.tsx @@ -49,6 +49,57 @@ const STATUS_ICONS: Record = { exclusive_center: MinusCircleIcon, }; +function getStatusLabel(t: ReturnType, status: ClaimStatus) { + switch (status) { + case "agreement": + return t("claim.agreement"); + case "divergence": + return t("claim.divergence"); + case "framing": + return t("claim.framing"); + case "exclusive_left": + return t("claim.leftExclusive"); + case "exclusive_right": + return t("claim.rightExclusive"); + case "exclusive_center": + return t("claim.centerExclusive"); + } +} + +function getStatusHeading(t: ReturnType, status: ClaimStatus) { + switch (status) { + case "agreement": + return t("claim.agreements"); + case "divergence": + return t("claim.divergences"); + case "framing": + return t("claim.framings"); + case "exclusive_left": + return t("claim.leftExclusives"); + case "exclusive_right": + return t("claim.rightExclusives"); + case "exclusive_center": + return t("claim.centerExclusives"); + } +} + +function getStatusBody(t: ReturnType, status: ClaimStatus) { + switch (status) { + case "agreement": + return t("claim.agreementBody"); + case "divergence": + return t("claim.divergenceBody"); + case "framing": + return t("claim.framingBody"); + case "exclusive_left": + return t("claim.leftExclusiveBody"); + case "exclusive_right": + return t("claim.rightExclusiveBody"); + case "exclusive_center": + return t("claim.centerExclusiveBody"); + } +} + function formatLean(value: string) { return value .split("-") @@ -176,17 +227,7 @@ function ClaimCard({
- {status === "agreement" - ? t("claim.agreement") - : status === "divergence" - ? t("claim.divergence") - : status === "framing" - ? t("claim.framing") - : status === "exclusive_left" - ? t("claim.leftExclusive") - : status === "exclusive_right" - ? t("claim.rightExclusive") - : t("claim.centerExclusive")} + {getStatusLabel(t, status)} {claim.importance}/5 {t("claim.importance")} @@ -311,7 +352,12 @@ export default function EventClaimComparison({ -
+
{t("claim.loading")} @@ -455,30 +501,10 @@ export default function EventClaimComparison({

- {status === "agreement" - ? t("claim.agreements") - : status === "divergence" - ? t("claim.divergences") - : status === "framing" - ? t("claim.framings") - : status === "exclusive_left" - ? t("claim.leftExclusives") - : status === "exclusive_right" - ? t("claim.rightExclusives") - : t("claim.centerExclusives")} + {getStatusHeading(t, status)}

- {status === "agreement" - ? t("claim.agreementBody") - : status === "divergence" - ? t("claim.divergenceBody") - : status === "framing" - ? t("claim.framingBody") - : status === "exclusive_left" - ? t("claim.leftExclusiveBody") - : status === "exclusive_right" - ? t("claim.rightExclusiveBody") - : t("claim.centerExclusiveBody")} + {getStatusBody(t, status)}

diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index b776e65..fd8aed6 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -2,7 +2,10 @@ import { useState } from "react"; import { Link, useRouterState } from "@tanstack/react-router"; import { Menu, Newspaper, Bookmark, LayoutDashboard, User } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { LanguagePicker } from "@/components/LanguagePicker"; +import { useScrollVisibility } from "@/hooks/use-scroll-visibility"; import { useT } from "@/lib/i18n/LocaleContext"; +import { cn } from "@/lib/utils"; import { Sheet, SheetContent, @@ -19,14 +22,20 @@ const links = [ export default function Header() { const [open, setOpen] = useState(false); + const isVisible = useScrollVisibility(); const t = useT(); const routerState = useRouterState(); const currentPath = routerState.location.pathname; return ( -
+
-
+
{/* Logo */}
@@ -62,6 +71,10 @@ export default function Header() { })} +
+ +
+ {/* Mobile burger */} @@ -110,6 +123,9 @@ export default function Header() { ); })} +
+ +
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 892f0b5..d619861 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -1,6 +1,7 @@ import { Bookmark, BarChart3, Newspaper, User } from "lucide-react"; import { Link, useRouterState } from "@tanstack/react-router"; import type { ComponentType, MouseEvent } from "react"; +import { useScrollVisibility } from "@/hooks/use-scroll-visibility"; import { useT } from "@/lib/i18n/LocaleContext"; import { cn } from "@/lib/utils"; @@ -33,8 +34,8 @@ const tabDefinitions: readonly TabDefinition[] = [ { to: "/profil", key: "tabs.profile", icon: User }, ]; -function matchesPath(pathname: string, to: string) { - return pathname === to || pathname.startsWith(`${to}/`); +function matchesPath(pathname: string, to: string, allowPrefix = false) { + return pathname === to || (allowPrefix && pathname.startsWith(`${to}/`)); } export function MobileTabBar() { @@ -42,6 +43,7 @@ export function MobileTabBar() { const pathname = useRouterState({ select: (state) => state.location.pathname, }); + const isVisible = useScrollVisibility(); const handleTabClick = (isActive: boolean) => (event: MouseEvent) => { @@ -59,10 +61,14 @@ export function MobileTabBar() { return ( ); diff --git a/apps/web/src/components/profile/AnonymousProfile.tsx b/apps/web/src/components/profile/AnonymousProfile.tsx index 9891b42..dc62ec3 100644 --- a/apps/web/src/components/profile/AnonymousProfile.tsx +++ b/apps/web/src/components/profile/AnonymousProfile.tsx @@ -1,105 +1,80 @@ -import { BarChart3, Bookmark, Scale, Sparkles } from "lucide-react"; import { Link } from "@tanstack/react-router"; import { useT } from "@/lib/i18n/LocaleContext"; import { Button } from "@/components/ui/button"; - -const features = [ - { - titleKey: "profile.feature.bias", - descriptionKey: "profile.feature.biasBody", - icon: Scale, - }, - { - titleKey: "profile.feature.compare", - descriptionKey: "profile.feature.compareBody", - icon: BarChart3, - }, - { - titleKey: "profile.feature.facts", - descriptionKey: "profile.feature.factsBody", - icon: Sparkles, - }, -] as const; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export function AnonymousProfile() { const t = useT(); return ( -
-
-
-
-
-

- {t("tabs.profile")} -

-

- {t("profile.hero")} -

-

- {t("profile.heroBody")} -

-
- -
- - -
-
+
+
+
+
+ + +
+

+ {t("tabs.profile")} +

+

+ {t("profile.account")} +

+

+ {t("profile.signInBody")} +

+
+
+
-
- {features.map(({ titleKey, descriptionKey, icon: Icon }) => ( -
-
- + + + {t("auth.unlockTitle")} + + +
+
+

+ {t("profile.changePassword")} +

+

+ {t("profile.passwordBody")} +

+
+
+

+ {t("profile.security")} +

+

+ {t("profile.securityBody")} +

+
-

{t(titleKey)}

-

- {t(descriptionKey)} -

-
- ))} -
+ + +
-
-
-
-

{t("profile.learnMore")}

-

- {t("profile.learnMoreBody")} -

-
-
- - - {t("footer.howItWorks")} - - - {t("footer.sources")} - - - {t("footer.about")} - -
-
-
+
+ + + {t("auth.account")} + + +
+ + +
+
+
+
diff --git a/apps/web/src/components/profile/AuthenticatedProfile.tsx b/apps/web/src/components/profile/AuthenticatedProfile.tsx index 8bc8caa..e78ce8b 100644 --- a/apps/web/src/components/profile/AuthenticatedProfile.tsx +++ b/apps/web/src/components/profile/AuthenticatedProfile.tsx @@ -1,28 +1,48 @@ -import { Bell, LogOut, MoonStar, ShieldCheck, User2 } from "lucide-react"; +import { useState } from "react"; +import { CheckCircle2, Globe2, KeyRound, LogOut, ShieldCheck } from "lucide-react"; import { Link } from "@tanstack/react-router"; import { authClient } from "@/lib/auth-client"; import { LanguagePicker } from "@/components/LanguagePicker"; import { useT } from "@/lib/i18n/LocaleContext"; +import { SITE } from "@/lib/seo"; import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { toast } from "sonner"; type AuthenticatedProfileUser = { email: string; + emailVerified?: boolean; image?: string | null; name?: string | null; profile?: { avatar?: string; name?: string; + preferredLanguage?: "ro" | "en"; }; }; -function getInitials(name: string) { - return name - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part.charAt(0).toUpperCase()) - .join(""); +function getAvatarFallback(name: string, email: string) { + const firstNameLetter = name.trim().charAt(0).toUpperCase(); + + if (firstNameLetter) { + return firstNameLetter; + } + + return email.trim().charAt(0).toUpperCase() || "?"; +} + +function getPasswordResetRedirectURL() { + if (typeof window === "undefined") { + return `${SITE.url}/reset-password`; + } + + return `${window.location.origin}/reset-password`; } export function AuthenticatedProfile({ @@ -33,7 +53,9 @@ export function AuthenticatedProfile({ const t = useT(); const displayName = user.profile?.name || user.name || user.email; const avatarSrc = user.profile?.avatar || user.image || undefined; - const initials = getInitials(displayName); + const avatarFallback = getAvatarFallback(displayName, user.email); + const [isSendingReset, setIsSendingReset] = useState(false); + const [resetStatusMessage, setResetStatusMessage] = useState(""); const handleSignOut = async () => { try { @@ -50,149 +72,175 @@ export function AuthenticatedProfile({ } }; + const handleRequestPasswordReset = async () => { + setResetStatusMessage(""); + setIsSendingReset(true); + + try { + const response = await fetch("/api/auth/request-password-reset", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + email: user.email, + redirectTo: getPasswordResetRedirectURL(), + }), + }); + const payload = (await response.json().catch(() => null)) as + | { message?: string } + | null; + + if (!response.ok) { + throw new Error(payload?.message || t("auth.resetLinkFailed")); + } + + const message = payload?.message || t("auth.resetLinkSent"); + setResetStatusMessage(message); + toast.success(message); + } catch (error) { + const message = + error instanceof Error ? error.message : t("auth.resetLinkFailed"); + setResetStatusMessage(message); + toast.error(message); + } finally { + setIsSendingReset(false); + } + }; + return ( -
-
-
-
-
- {avatarSrc ? ( - {displayName} - ) : ( -
- {initials || "B"} +
+
+
+
+ + +
+ {avatarSrc ? ( + {displayName} + ) : ( +
+ {avatarFallback} +
+ )} + +
+

+ {t("profile.account")} +

+

+ {displayName} +

+
+
+
+ {t("profile.emailLabel")} +
+
+ {user.email} +
+
+
+
+ {t("profile.security")} +
+
+ {user.emailVerified + ? t("profile.verified") + : t("auth.checkEmailVerify")} +
+
+
+ {user.emailVerified ? ( +
+ + {t("profile.verified")} +
+ ) : null} +
- )} - -
-

- {t("profile.account")} -

-

- {displayName} -

-

- {user.email} -

-
-
-
- -
-
- -
-

{t("profile.settings")}

-

- {t("profile.settingsBody")} -

-
-
- -
-
-
-
-
- {t("settings.language")} + + + + + + {t("profile.settings")} + + +
+
+
+ + {t("settings.language")}
-

+

{t("profile.settingsBody")}

-
+ + +
-
-
-
- - {t("profile.theme")} -
-

- {t("profile.themeBody")} -

+
+ + + {t("profile.security")} + {t("profile.securityBody")} + + +
+ + -
-
-
- - {t("profile.notifications")} -
-

- {t("profile.notificationsBody")} -

-
-
-
- -
-
- -
-

{t("profile.accountSection")}

-

- {t("profile.accountBody")} -

-
-
- -
-
-

{t("profile.emailLabel")}

-

{user.email}

-
- -
- - -
- -
-
- - {t("profile.security")} -
-

- {t("profile.securityBody")} -

-
-
-
- - + + {resetStatusMessage ? ( +
+

+ {resetStatusMessage} +

+
+ ) : null} + + +
diff --git a/apps/web/src/components/share-event-button.tsx b/apps/web/src/components/share-event-button.tsx index a83c199..a1852a0 100644 --- a/apps/web/src/components/share-event-button.tsx +++ b/apps/web/src/components/share-event-button.tsx @@ -28,7 +28,7 @@ export default function ShareEventButton({ interactionContext, slug, title, - summary: _summary, + summary, size = "default", className, }: ShareEventButtonProps) { @@ -62,7 +62,7 @@ export default function ShareEventButton({ try { await navigator.share({ title, - text: title, + text: summary ?? title, url: shareUrl, }); logShare(); diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index 3eece17..f224963 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { toast } from "sonner"; import z from "zod"; import { useT } from "@/lib/i18n/LocaleContext"; +import { SITE } from "@/lib/seo"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -15,12 +16,38 @@ import { AuthDivider, GoogleSignInButton } from "./auth-social"; function getPasswordResetRedirectURL() { if (typeof window === "undefined") { - return `${redirectFallbackOrigin}/reset-password`; + return `${SITE.url}/reset-password`; } return `${window.location.origin}/reset-password`; } -const redirectFallbackOrigin = "http://localhost:3001"; +function getLocalizedSignInError( + t: ReturnType, + error: { + error?: { + code?: string; + message?: string; + statusText?: string; + }; + }, +) { + const code = error.error?.code?.trim().toLowerCase(); + const message = error.error?.message?.trim().toLowerCase(); + + if ( + code === "invalid_credentials" || + message === "invalid credentials" || + message === "invalid email or password" + ) { + return t("auth.invalidCredentials"); + } + + return ( + error.error?.message ?? + error.error?.statusText ?? + t("auth.signInError") + ); +} export default function SignInForm({ onSwitchToSignUp, @@ -68,11 +95,7 @@ export default function SignInForm({ toast.success(t("auth.signInSuccess")); }, onError: (error) => { - toast.error( - error.error?.message ?? - error.error?.statusText ?? - t("auth.signInError"), - ); + toast.error(getLocalizedSignInError(t, error)); }, } ); @@ -245,7 +268,7 @@ export default function SignInForm({ {state.isSubmitting ? ( <> - {t("auth.signIn")}... + {t("auth.signingIn")} ) : ( resolvedSubmitLabel diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index c7eddc2..b3fcc59 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -197,7 +197,7 @@ export default function SignUpForm({ {state.isSubmitting ? ( <> - {t("auth.createAccount")}... + {t("auth.creatingAccount")} ) : ( resolvedSubmitLabel diff --git a/apps/web/src/components/ui/page-loading-state.tsx b/apps/web/src/components/ui/page-loading-state.tsx index c66f8c4..4712746 100644 --- a/apps/web/src/components/ui/page-loading-state.tsx +++ b/apps/web/src/components/ui/page-loading-state.tsx @@ -4,8 +4,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useT } from "@/lib/i18n/LocaleContext"; export function PageLoadingState({ - title = "Loading your page", - description = "Pulling in the latest view and getting things ready.", + title, + description, cardCount = 2, }: { title?: string; diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 1fd0978..0873e32 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -12,6 +12,7 @@ import { Button } from "./ui/button"; import { useQuery } from "convex/react"; import { api } from "@news-app/backend/convex/_generated/api"; import { ChevronDown, Flame, LogOut, User } from "lucide-react"; +import { toast } from "sonner"; export default function UserMenu() { const t = useT(); @@ -19,13 +20,18 @@ export default function UserMenu() { const streak = user?.stats.currentStreak ?? 0; const handleSignOut = async () => { - await authClient.signOut({ - fetchOptions: { - onSuccess: () => { - location.reload(); + try { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + location.reload(); + }, }, - }, - }); + }); + } catch (error) { + console.error("Failed to sign out from user menu:", error); + toast.error(t("auth.signOutError")); + } }; const userName = user?.profile?.name || user?.email || t("user.account"); diff --git a/apps/web/src/hooks/use-scroll-visibility.ts b/apps/web/src/hooks/use-scroll-visibility.ts new file mode 100644 index 0000000..8df2f41 --- /dev/null +++ b/apps/web/src/hooks/use-scroll-visibility.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from "react"; + +type ScrollListener = (scrollY: number) => void; + +const listeners = new Set(); +let frameId = 0; + +function emitScrollPosition() { + frameId = 0; + const scrollY = window.scrollY || window.pageYOffset || 0; + + listeners.forEach((listener) => { + listener(scrollY); + }); +} + +function scheduleEmit() { + if (frameId !== 0) { + return; + } + + frameId = window.requestAnimationFrame(emitScrollPosition); +} + +function subscribeToScroll(listener: ScrollListener) { + listeners.add(listener); + + if (listeners.size === 1) { + window.addEventListener("scroll", scheduleEmit, { passive: true }); + window.addEventListener("resize", scheduleEmit, { passive: true }); + } + + listener(window.scrollY || window.pageYOffset || 0); + + return () => { + listeners.delete(listener); + + if (listeners.size === 0) { + window.removeEventListener("scroll", scheduleEmit); + window.removeEventListener("resize", scheduleEmit); + + if (frameId !== 0) { + window.cancelAnimationFrame(frameId); + frameId = 0; + } + } + }; +} + +export function useScrollVisibility({ + deltaThreshold = 12, + topOffset = 24, +}: { + deltaThreshold?: number; + topOffset?: number; +} = {}) { + const [isVisible, setIsVisible] = useState(true); + const isVisibleRef = useRef(true); + const lastScrollYRef = useRef(0); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + return subscribeToScroll((currentScrollY) => { + const previousScrollY = lastScrollYRef.current; + const delta = currentScrollY - previousScrollY; + + if (Math.abs(delta) < deltaThreshold) { + return; + } + + const nextVisible = + currentScrollY <= topOffset ? true : delta < 0; + + lastScrollYRef.current = currentScrollY; + + if (isVisibleRef.current === nextVisible) { + return; + } + + isVisibleRef.current = nextVisible; + setIsVisible(nextVisible); + }); + }, [deltaThreshold, topOffset]); + + return isVisible; +} diff --git a/apps/web/src/lib/i18n/LocaleContext.tsx b/apps/web/src/lib/i18n/LocaleContext.tsx index bae14ca..c88722c 100644 --- a/apps/web/src/lib/i18n/LocaleContext.tsx +++ b/apps/web/src/lib/i18n/LocaleContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, type ReactNode } from "react"; -import { STRINGS, type Locale, type StringKey } from "./strings"; +import { getString, type Locale, type StringKey } from "./strings"; const LocaleContext = createContext("en"); @@ -23,6 +23,6 @@ export function useT() { const locale = useLocale(); return (key: StringKey, fallback?: string): string => { - return STRINGS[locale][key] ?? STRINGS.en[key] ?? fallback ?? key; + return getString(locale, key, fallback); }; } diff --git a/apps/web/src/lib/i18n/getLocaleFromMatches.ts b/apps/web/src/lib/i18n/getLocaleFromMatches.ts new file mode 100644 index 0000000..c91373b --- /dev/null +++ b/apps/web/src/lib/i18n/getLocaleFromMatches.ts @@ -0,0 +1,17 @@ +import type { Locale } from "./strings"; + +const SUPPORTED_LOCALES = ["ro", "en"] as const satisfies readonly Locale[]; + +export function getLocaleFromMatches( + matches: ReadonlyArray<{ context?: unknown }>, +): Locale { + const rootContext = matches[0]?.context; + if (!rootContext || typeof rootContext !== "object") { + return "en"; + } + + const locale = + "locale" in rootContext ? (rootContext.locale as string | undefined) : null; + + return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "en"; +} diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts index c401dea..bb7a049 100644 --- a/apps/web/src/lib/i18n/resolveLocale.ts +++ b/apps/web/src/lib/i18n/resolveLocale.ts @@ -28,13 +28,26 @@ function isSupported(value: string | null | undefined): value is Locale { function parseAcceptLanguage(header: string | null | undefined): Locale | null { if (!header) return null; - const codes = header + const weightedCodes = header .split(",") - .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]) - .filter(Boolean); - - for (const code of codes) { - if (code && SUPPORTED.includes(code as Locale)) { + .map((part) => { + const [languagePart, ...params] = part.split(";"); + const code = languagePart?.trim().toLowerCase().split("-")[0]; + const qParam = params + .map((param) => param.trim()) + .find((param) => param.startsWith("q=")); + const weight = Number.parseFloat(qParam?.slice(2) ?? "1"); + + return { + code, + weight: Number.isFinite(weight) ? weight : 1, + }; + }) + .filter((entry): entry is { code: string; weight: number } => Boolean(entry.code)) + .sort((a, b) => b.weight - a.weight); + + for (const { code } of weightedCodes) { + if (SUPPORTED.includes(code as Locale)) { return code as Locale; } } diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts index e3c305f..7da66f7 100644 --- a/apps/web/src/lib/i18n/strings.ts +++ b/apps/web/src/lib/i18n/strings.ts @@ -23,6 +23,7 @@ const ro = { "auth.passwordCreatePlaceholder": "Creează o parolă", "auth.signInSuccess": "Te-ai autentificat", "auth.signInError": "Autentificarea a eșuat. Încearcă din nou.", + "auth.invalidCredentials": "E-mailul sau parola sunt incorecte.", "auth.invalidEmail": "Adresă de e-mail invalidă", "auth.passwordMin": "Parola trebuie să aibă cel puțin 8 caractere", "auth.nameMin": "Numele trebuie să aibă cel puțin 2 caractere", @@ -62,6 +63,8 @@ const ro = { "auth.signUpSubtitle": "Contul gratuit deblochează salvările, feed-ul personalizat și notificările viitoare.", "auth.createAccount": "Creează cont", + "auth.signingIn": "Te autentificăm...", + "auth.creatingAccount": "Creăm contul...", "auth.orContinueWith": "Sau continuă cu", "auth.googleError": "Autentificarea cu Google a eșuat. Încearcă din nou.", "auth.promptTitle": @@ -178,6 +181,9 @@ const ro = { "profile.preparing.title": "Se pregătește profilul", "profile.preparing.body": "Încărcăm preferințele și detaliile contului tău.", "profile.account": "Contul tău", + "profile.verified": "E-mail verificat", + "profile.signInBody": + "Autentifică-te pentru a gestiona limba, securitatea și preferințele contului.", "profile.settings": "Setări", "profile.settingsBody": "Opțiuni rapide pentru preferințele tale.", "profile.theme": "Temă", @@ -190,7 +196,10 @@ const ro = { "profile.accountBody": "Informațiile tale de autentificare și opțiunile sensibile ale contului.", "profile.changePassword": "Schimbă parola", + "profile.passwordBody": + "Primești prin e-mail un link sigur pentru resetarea parolei.", "profile.deleteAccount": "Solicită ștergerea contului", + "profile.requestDeletion": "Solicită ștergerea contului", "profile.emailLabel": "E-mail", "profile.security": "Siguranța contului", "profile.securityBody": @@ -299,7 +308,9 @@ const ro = { "event.updated": "Actualizat {time}", "event.sources": "{count} surse", "event.articles.one": "1 articol", + "event.articles.few": "{count} articole", "event.articles.many": "{count} articole", + "event.articles.other": "{count} articole", "event.follow": "Urmărește evenimentul", "event.biasDistribution": "Distribuția biasului surselor: {left} stânga, {center} centru, {right} dreapta", @@ -326,7 +337,9 @@ const ro = { "event.centerTab": "Centru", "event.right": "Dreapta", "event.sourceCount.one": "1 sursă", + "event.sourceCount.few": "{count} surse", "event.sourceCount.many": "{count} surse", + "event.sourceCount.other": "{count} surse", "claim.agreement": "Acord", "claim.agreements": "Acorduri", "claim.agreementBody": "Afirmații confirmate de mai multe surse.", @@ -465,7 +478,7 @@ const ro = { "Acest link către sursă este invalid sau nu mai este disponibil.", "source.backToFeed": "Înapoi la feed", "source.profile": "Profilul sursei", - "source.reliability": "Fiabilitate {count}/10", + "source.reliability": "Fiabilitate {score}/10", "source.recentArticles": "Articole recente", "source.events": "Evenimente", "source.aiBiasAvg": "Media biasului estimat de AI", @@ -476,8 +489,11 @@ const ro = { "source.credibilityLabel": "Credibilitate", "source.rollingSample": "Eșantion AI rolling", "source.rollingArticles": "{count} articole", - "source.mean": "Media {count}", - "source.stddev": "Deviația standard {count}", + "source.mean": "Media {value}", + "source.stddev": "Deviația standard {value}", + "read.duration.seconds": "{count}s", + "read.duration.minutes": "{count} min", + "scroll.depth": "{count}% profunzime", "source.recentReporting": "Relatări recente", "source.aiBias": "Bias AI {count}", "source.outlier": "Anomalie", @@ -509,6 +525,7 @@ const en: { [K in keyof BaseStrings]: string } = { "auth.passwordCreatePlaceholder": "Create a password", "auth.signInSuccess": "You’re signed in", "auth.signInError": "Sign-in failed. Please try again.", + "auth.invalidCredentials": "Incorrect email or password.", "auth.invalidEmail": "Invalid email address", "auth.passwordMin": "Password must be at least 8 characters", "auth.nameMin": "Name must be at least 2 characters", @@ -548,6 +565,8 @@ const en: { [K in keyof BaseStrings]: string } = { "auth.signUpSubtitle": "A free account unlocks saved items, a personalized feed, and future notifications.", "auth.createAccount": "Create account", + "auth.signingIn": "Signing in...", + "auth.creatingAccount": "Creating account...", "auth.orContinueWith": "Or continue with", "auth.googleError": "Google sign-in failed. Please try again.", "auth.promptTitle": @@ -661,6 +680,9 @@ const en: { [K in keyof BaseStrings]: string } = { "profile.preparing.body": "We are loading your preferences and account details.", "profile.account": "Your account", + "profile.verified": "Verified email", + "profile.signInBody": + "Sign in to manage language, security, and your account preferences.", "profile.settings": "Settings", "profile.settingsBody": "Quick options for your preferences.", "profile.theme": "Theme", @@ -673,7 +695,10 @@ const en: { [K in keyof BaseStrings]: string } = { "profile.accountBody": "Your authentication details and the sensitive options for your account.", "profile.changePassword": "Change password", + "profile.passwordBody": + "You’ll receive a secure password-reset link by email.", "profile.deleteAccount": "Request account deletion", + "profile.requestDeletion": "Request deletion", "profile.emailLabel": "Email", "profile.security": "Account security", "profile.securityBody": @@ -782,7 +807,9 @@ const en: { [K in keyof BaseStrings]: string } = { "event.updated": "Updated {time}", "event.sources": "{count} sources", "event.articles.one": "1 article", + "event.articles.few": "{count} articles", "event.articles.many": "{count} articles", + "event.articles.other": "{count} articles", "event.follow": "Follow the event", "event.biasDistribution": "Source bias distribution: {left} left, {center} center, {right} right", @@ -809,7 +836,9 @@ const en: { [K in keyof BaseStrings]: string } = { "event.centerTab": "Center", "event.right": "Right", "event.sourceCount.one": "1 source", + "event.sourceCount.few": "{count} sources", "event.sourceCount.many": "{count} sources", + "event.sourceCount.other": "{count} sources", "claim.agreement": "Agreement", "claim.agreements": "Agreements", "claim.agreementBody": "Claims confirmed by multiple sources.", @@ -946,7 +975,7 @@ const en: { [K in keyof BaseStrings]: string } = { "This source link is invalid or no longer available.", "source.backToFeed": "Back to feed", "source.profile": "Source profile", - "source.reliability": "Reliability {count}/10", + "source.reliability": "Reliability {score}/10", "source.recentArticles": "Recent articles", "source.events": "Events", "source.aiBiasAvg": "Average AI bias", @@ -957,8 +986,11 @@ const en: { [K in keyof BaseStrings]: string } = { "source.credibilityLabel": "Credibility", "source.rollingSample": "Rolling AI bias sample", "source.rollingArticles": "{count} articles", - "source.mean": "Mean {count}", - "source.stddev": "Standard deviation {count}", + "source.mean": "Mean {value}", + "source.stddev": "Standard deviation {value}", + "read.duration.seconds": "{count}s", + "read.duration.minutes": "{count} min", + "scroll.depth": "{count}% depth", "source.recentReporting": "Recent reporting", "source.aiBias": "AI bias {count}", "source.outlier": "Outlier", diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a10d911..14c02cd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { SITE } from "@/lib/seo"; import { Footer } from "@/components/layout/Footer"; import { MobileTabBar } from "@/components/layout/MobileTabBar"; import { LocaleProvider } from "@/lib/i18n/LocaleContext"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { getServerLocale } from "@/lib/i18n/getServerLocale"; import { getString, type Locale } from "@/lib/i18n/strings"; import { api } from "@news-app/backend/convex/_generated/api"; @@ -49,10 +50,7 @@ function getExistingAuthContext( ? rootContext.isAuthenticated : undefined; const locale = - "locale" in rootContext && - (rootContext.locale === "ro" || rootContext.locale === "en") - ? rootContext.locale - : undefined; + "locale" in rootContext ? getLocaleFromMatches([{ context: rootContext }]) : undefined; return { token, isAuthenticated, locale }; } @@ -71,7 +69,7 @@ export interface RouterAppContext { export const Route = createRootRouteWithContext()({ head: ({ matches }) => { - const locale = getExistingAuthContext(matches).locale ?? "en"; + const locale = getLocaleFromMatches(matches); const title = getString(locale, "seo.siteTitle"); const description = getString(locale, "seo.siteDescription"); @@ -116,9 +114,43 @@ export const Route = createRootRouteWithContext()({ if (typeof document !== "undefined") { const existing = getExistingAuthContext(ctx.matches); + const searchLocale = new URLSearchParams(window.location.search).get("lang"); + const cookieLocale = + document.cookie + .split("; ") + .find((entry) => entry.startsWith("bv_locale=")) + ?.split("=")[1] ?? null; + const decodedCookieLocale = cookieLocale + ? decodeURIComponent(cookieLocale) + : null; + + if ( + existing.locale && + (!searchLocale || searchLocale === existing.locale) && + (!decodedCookieLocale || decodedCookieLocale === existing.locale) + ) { + return existing; + } + + let userPreference: string | null = null; + if (existing.isAuthenticated) { + try { + const currentUser = await ctx.context.convexClient.query( + api.user.getCurrentUser, + {}, + ); + userPreference = currentUser?.profile?.preferredLanguage ?? null; + } catch (error) { + console.warn( + "Failed to resolve client-side user locale preference:", + error, + ); + } + } + const locale = await getServerLocale({ data: { - userPreference: null, + userPreference, }, }); @@ -139,7 +171,8 @@ export const Route = createRootRouteWithContext()({ {}, ); userPreference = currentUser?.profile?.preferredLanguage ?? null; - } catch { + } catch (error) { + console.warn("Failed to resolve current user locale preference:", error); userPreference = null; } } @@ -168,7 +201,7 @@ function RootDocument() { -
+
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx index f671043..6e7890d 100644 --- a/apps/web/src/routes/activitate.tsx +++ b/apps/web/src/routes/activitate.tsx @@ -5,14 +5,13 @@ import StreakActivityCalendar from "@/components/streak-activity-calendar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useLocale, useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; import { api } from "@news-app/backend/convex/_generated/api"; import { createFileRoute, Link } from "@tanstack/react-router"; import { - Authenticated, - AuthLoading, - Unauthenticated, + useConvexAuth, useMutation as useConvexMutationHook, useQuery, } from "convex/react"; @@ -27,16 +26,27 @@ import { Sparkles, } from "lucide-react"; -function formatReadDuration(seconds?: number) { +function formatReadDuration( + t: ReturnType, + seconds?: number, +) { if (!seconds || seconds <= 0) return null; - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) { + return t("read.duration.seconds").replace("{count}", String(seconds)); + } const minutes = Math.round(seconds / 60); - return `${minutes} min`; + return t("read.duration.minutes").replace("{count}", String(minutes)); } -function formatScrollDepth(percentage?: number) { +function formatScrollDepth( + t: ReturnType, + percentage?: number, +) { if (percentage === undefined) return null; - return `${Math.round(percentage * 100)}% depth`; + return t("scroll.depth").replace( + "{count}", + String(Math.round(percentage * 100)), + ); } function getBiasSnapshotLabel( @@ -62,13 +72,7 @@ function getNextStreakMilestone(streak: number) { export const Route = createFileRoute("/activitate")({ head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [ @@ -82,34 +86,44 @@ export const Route = createFileRoute("/activitate")({ function RouteComponent() { const t = useT(); + const { isAuthenticated, isLoading } = useConvexAuth(); + const currentUser = useQuery( + api.user.getCurrentUser, + isAuthenticated ? {} : "skip", + ); + + if (isLoading || (isAuthenticated && currentUser === undefined)) { + return ( + + ); + } + + if (!currentUser) { + return ( + + ); + } return ( - <> - - - - - - - - - - + ); } -function AuthorizedDashboard() { +function AuthorizedDashboard({ + currentUser, +}: { + currentUser: NonNullable>>; +}) { const locale = useLocale(); const t = useT(); - const currentUser = useQuery(api.user.getCurrentUser); const dashboardOverview = useQuery(api.interactions.getDashboardOverview); const isAdmin = useQuery(api.user.isCurrentUserAdmin); const topicDiagnostics = useQuery( @@ -129,17 +143,9 @@ function AuthorizedDashboard() { setMinScoreInput(String(currentSettings.minScore)); setConfidenceRatioInput(String(currentSettings.confidenceRatio)); setMaxTopicsInput(String(currentSettings.maxTopics)); - }, [ - currentSettings?.minScore, - currentSettings?.confidenceRatio, - currentSettings?.maxTopics, - ]); - - if ( - currentUser === undefined || - dashboardOverview === undefined || - isAdmin === undefined - ) { + }, [currentSettings]); + + if (dashboardOverview === undefined || isAdmin === undefined) { return ( @@ -441,9 +447,11 @@ function AuthorizedDashboard() {
{recentHistory.slice(0, 4).map((entry) => { const durationLabel = formatReadDuration( + t, entry.metadata.timeSpentSeconds, ); const scrollLabel = formatScrollDepth( + t, entry.metadata.scrollDepthPercentage, ); const detailBits = [durationLabel, scrollLabel].filter( @@ -710,7 +718,11 @@ function AuthorizedDashboard() { {t("activity.admin.reset")} {configMessage && ( -

+

{configMessage}

)} @@ -740,13 +752,13 @@ function AuthorizedDashboard() { t.displayName, + (topic) => topic.displayName, )} /> t.displayName, + (topic) => topic.displayName, )} />
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index f46d327..5cfb4dd 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,10 +1,12 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; +import { api } from "@news-app/backend/convex/_generated/api"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useConvexAuth } from "convex/react"; +import { useConvexAuth, useQuery } from "convex/react"; import { useEffect, useRef } from "react"; import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect"; import { toast } from "sonner"; @@ -19,13 +21,7 @@ const searchSchema = z.object({ export const Route = createFileRoute("/dashboard")({ validateSearch: searchSchema, head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [ @@ -42,6 +38,10 @@ function DashboardAuthPage() { const navigate = useNavigate({ from: "/dashboard" }); const search = Route.useSearch(); const { isAuthenticated, isLoading } = useConvexAuth(); + const currentUser = useQuery( + api.user.getCurrentUser, + isAuthenticated ? {} : "skip", + ); const redirectTo: AuthRedirectPath = search.redirect && isAuthRedirectPath(search.redirect) ? search.redirect @@ -51,10 +51,10 @@ function DashboardAuthPage() { const showSignIn = search.mode !== "signup" || isVerified; useEffect(() => { - if (isAuthenticated) { + if (currentUser) { void navigate({ to: "/activitate", replace: true }); } - }, [isAuthenticated, navigate]); + }, [currentUser, navigate]); const replaceDashboardSearch = ( mode: "signin" | "signup", @@ -81,7 +81,7 @@ function DashboardAuthPage() { void replaceDashboardSearch("signin"); }, [isVerified, search.redirect, t, navigate]); - if (isLoading || isAuthenticated) { + if (isLoading || (isAuthenticated && currentUser === undefined)) { return ( + candidate in STRINGS[locale] || candidate in STRINGS.en, + ) ?? `${baseKey}.many`; + + return getString(locale, resolvedKey as StringKey).replace( + "{count}", + String(count), + ); +} + export const Route = createFileRoute("/event/$slug")({ validateSearch: searchSchema, loader: async ({ context, params }) => { @@ -49,13 +74,7 @@ export const Route = createFileRoute("/event/$slug")({ } }, head: ({ loaderData, params, matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); const title = loaderData?.event?.title ? `${loaderData.event.title} — ${SITE.name}` : getString(locale, "event.metaTitle"); @@ -342,21 +361,19 @@ function EventDetailPage() {
- {articles.length === 1 - ? t("event.articles.one") - : t("event.articles.many").replace( - "{count}", - String(articles.length), - )} + {getPluralizedCountLabel( + locale, + "event.articles", + articles.length, + )} • - {sourceCount === 1 - ? t("event.sourceCount.one") - : t("event.sourceCount.many").replace( - "{count}", - String(sourceCount), - )} + {getPluralizedCountLabel( + locale, + "event.sourceCount", + sourceCount, + )}
diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index afe1689..2d6716a 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -38,6 +38,8 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useIsMobile } from "@/components/ui/use-mobile"; +import { useScrollVisibility } from "@/hooks/use-scroll-visibility"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; import { cn } from "@/lib/utils"; @@ -45,13 +47,7 @@ import { SITE } from "@/lib/seo"; export const Route = createFileRoute("/feed")({ head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); const title = getString(locale, "feed.meta.title"); const description = getString(locale, "feed.meta.description"); @@ -252,6 +248,7 @@ function FeedComponent() { function FeedContent() { const t = useT(); + const isMobile = useIsMobile(); const { isAuthenticated } = useConvexAuth(); const topics = useQuery(api.topics.getTopics); const currentUser = useQuery( @@ -277,11 +274,16 @@ function FeedContent() { const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending"); const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); + const isSearching = debouncedSearch.length >= 2; const [isSearchFocused, setIsSearchFocused] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const searchInputRef = useRef(null); + const controlsRef = useRef(null); const loadMoreTriggerRef = useRef(null); const isLoadingMoreRef = useRef(false); + const areControlsVisible = useScrollVisibility(); + const [controlsHeight, setControlsHeight] = useState(0); + const hiddenControlsOffset = controlsHeight + (isMobile ? 12 : 80); useEffect(() => { try { @@ -332,6 +334,45 @@ function FeedContent() { return () => window.removeEventListener("keydown", onKeyDown); }, []); + useEffect(() => { + const element = controlsRef.current; + if (!element) { + return; + } + + const updateHeight = () => { + const nextHeight = Math.round(element.getBoundingClientRect().height); + setControlsHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }; + + updateHeight(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateHeight); + return () => { + window.removeEventListener("resize", updateHeight); + }; + } + + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [ + isSearchFocused, + recentSearches.length, + searchInput.length, + selectedTopic, + feedSort, + isSearching, + ]); + const { results: events, status, @@ -343,7 +384,6 @@ function FeedContent() { : { topicId: selectedTopic, sort: feedSort }, { initialNumItems: pageSize }, ); - const isSearching = debouncedSearch.length >= 2; const searchResults = useQuery( api.events.searchPublishedEvents, isSearching @@ -356,6 +396,7 @@ function FeedContent() { ); const canLoadMore = !isSearching && status === "CanLoadMore"; const isLoadingMore = !isSearching && status === "LoadingMore"; + const loadMoreRef = useRef(loadMore); const preferredTopicIds = useMemo(() => { if (!topics || !currentUser?.privateContext?.interests?.length) { @@ -388,6 +429,10 @@ function FeedContent() { : "skip", ); + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + useEffect(() => { if (status !== "LoadingMore") { isLoadingMoreRef.current = false; @@ -412,7 +457,7 @@ function FeedContent() { } isLoadingMoreRef.current = true; - loadMore(pageSize); + loadMoreRef.current(pageSize); }, { rootMargin: "1200px 0px", @@ -422,7 +467,7 @@ function FeedContent() { observer.observe(target); return () => observer.disconnect(); - }, [canLoadMore, loadMore, pageSize]); + }, [canLoadMore, pageSize]); useEffect(() => { if ( @@ -477,123 +522,143 @@ function FeedContent() {
-
-
-
-
-
- setSearchInput(event.target.value)} - onFocus={() => setIsSearchFocused(true)} - onBlur={() => { - window.setTimeout(() => setIsSearchFocused(false), 100); - }} - placeholder={t("feed.search.placeholder")} - className="h-10 rounded-full border-border/80 bg-background/75 pr-11 text-base sm:h-11 sm:pr-12" - aria-label={t("feed.search.label")} - /> - {searchInput.length > 0 && ( - - )} -
-
-
-
-
- +
+
{!isAuthenticated && ( { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [{ title: getString(locale, "profile.metaTitle") }], diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index a44c3e0..d175298 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -1,8 +1,10 @@ +import { useMemo } from "react"; import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; @@ -18,13 +20,7 @@ const searchSchema = z.object({ export const Route = createFileRoute("/reset-password")({ validateSearch: searchSchema, head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [{ title: getString(locale, "reset.metaTitle") }], @@ -38,15 +34,19 @@ function ResetPasswordRoute() { const t = useT(); const search = Route.useSearch(); const token = search.token?.trim(); - const resetPasswordSchema = z - .object({ - password: z.string().min(8, t("reset.passwordMin")), - confirmPassword: z.string(), - }) - .refine((value) => value.password === value.confirmPassword, { - message: t("reset.passwordMismatch"), - path: ["confirmPassword"], - }); + const resetPasswordSchema = useMemo( + () => + z + .object({ + password: z.string().min(8, t("reset.passwordMin")), + confirmPassword: z.string(), + }) + .refine((value) => value.password === value.confirmPassword, { + message: t("reset.passwordMismatch"), + path: ["confirmPassword"], + }), + [t], + ); const errorMessage = !search.error ? null @@ -150,7 +150,7 @@ function ResetPasswordRoute() { className="h-11" /> {field.state.meta.errors.map((error, index) => ( -

+

{error?.message}

))} @@ -180,7 +180,7 @@ function ResetPasswordRoute() { className="h-11" /> {field.state.meta.errors.map((error, index) => ( -

+

{error?.message}

))} diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx index 2956c5b..a002af2 100644 --- a/apps/web/src/routes/salvate.tsx +++ b/apps/web/src/routes/salvate.tsx @@ -1,33 +1,23 @@ import { useMemo } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { api } from "@news-app/backend/convex/_generated/api"; -import { - Authenticated, - AuthLoading, - Unauthenticated, - useQuery, -} from "convex/react"; +import { useConvexAuth, useQuery } from "convex/react"; import { SignInPrompt } from "@/components/SignInPrompt"; import EventCard from "@/components/feed/event-card"; import { Button } from "@/components/ui/button"; import { PageLoadingState } from "@/components/ui/page-loading-state"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; function safePositiveInt(raw: unknown, fallback: number): number { const n = Number(raw); - return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : fallback; + return Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback; } export const Route = createFileRoute("/salvate")({ head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [ @@ -41,44 +31,53 @@ export const Route = createFileRoute("/salvate")({ function SalvatePage() { const t = useT(); + const { isAuthenticated, isLoading } = useConvexAuth(); + const currentUser = useQuery( + api.user.getCurrentUser, + isAuthenticated ? {} : "skip", + ); + + if (isLoading || (isAuthenticated && currentUser === undefined)) { + return ( + + ); + } + + if (!currentUser) { + return ( + + +
+ } + /> + ); + } return ( - <> - - - - - - - - -
- } - /> - - - - - + ); } @@ -142,6 +141,8 @@ function SalvateContent() {
{t("source.reliability").replace( - "{count}", + "{score}", String(source.reliabilityScore), )} @@ -322,12 +317,12 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { {typeof source.rollingBiasMean === "number" && (

{t("source.mean").replace( - "{count}", + "{value}", source.rollingBiasMean.toFixed(1), )} {typeof source.rollingBiasStddev === "number" ? ` · ${t("source.stddev").replace( - "{count}", + "{value}", source.rollingBiasStddev.toFixed(1), )}` : ""} diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx index 1fcca1c..87d3903 100644 --- a/apps/web/src/routes/unsubscribe.tsx +++ b/apps/web/src/routes/unsubscribe.tsx @@ -6,6 +6,7 @@ import { api } from "@news-app/backend/convex/_generated/api"; import { z } from "zod"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; import { @@ -23,13 +24,7 @@ const searchSchema = z.object({ export const Route = createFileRoute("/unsubscribe")({ validateSearch: searchSchema, head: ({ matches }) => { - const locale = - matches[0]?.context && - typeof matches[0].context === "object" && - "locale" in matches[0].context && - (matches[0].context.locale === "ro" || matches[0].context.locale === "en") - ? matches[0].context.locale - : "en"; + const locale = getLocaleFromMatches(matches); return { meta: [ @@ -49,13 +44,14 @@ function UnsubscribePage() { const unsubscribe = useMutation({ mutationFn: useConvexMutation(api.waitlist.unsubscribe), }); + const { reset } = unsubscribe; useEffect(() => { if (email !== lastEmail) { - unsubscribe.reset(); + reset(); setLastEmail(email); } - }, [email, lastEmail]); + }, [email, lastEmail, reset]); useEffect(() => { if ( @@ -135,10 +131,7 @@ function UnsubscribePage() { {t("unsubscribe.successTitle")}

- {t("unsubscribe.successBody").replace( - "{email}", - email ? email : "", - )} + {t("unsubscribe.successBody").replace("{email}", email)}

From 9347793bc69f8df01388d7ad112b2af081925c62 Mon Sep 17 00:00:00 2001 From: flavius Date: Mon, 11 May 2026 07:49:19 +0300 Subject: [PATCH 17/19] fix coderabbit issues --- .../src/components/layout/MobileTabBar.tsx | 2 +- apps/web/src/components/sign-in-form.tsx | 2 + apps/web/src/components/sign-up-form.tsx | 3 + .../src/components/ui/page-loading-state.tsx | 12 +- apps/web/src/lib/i18n/resolveLocale.ts | 5 +- apps/web/src/routes/__root.tsx | 11 +- apps/web/src/routes/activitate.tsx | 255 +++++++++------- apps/web/src/routes/dashboard.tsx | 2 +- apps/web/src/routes/feed.tsx | 6 +- packages/backend/convex/clustering.ts | 281 +++++++++++++----- packages/backend/convex/config.ts | 76 +++++ packages/backend/convex/migrations.ts | 57 ++++ packages/backend/convex/schema.ts | 26 +- packages/backend/convex/seeds.ts | 13 +- packages/backend/convex/vectorSearchBudget.ts | 205 ++++++++++++- 15 files changed, 751 insertions(+), 205 deletions(-) diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index d619861..152daab 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -65,7 +65,7 @@ export function MobileTabBar() { "fixed inset-x-0 bottom-0 z-50 px-4 pb-4 transition-transform duration-300 ease-out md:hidden", isVisible ? "translate-y-0" : "translate-y-[calc(100%+1rem)]", )} - style={{ paddingBottom: "env(safe-area-inset-bottom)" }} + style={{ paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))" }} >

diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index f224963..02a91ff 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -138,6 +138,7 @@ export default function SignInForm({ id={field.name} name={field.name} type="email" + aria-label={t("auth.email")} placeholder={t("auth.emailPlaceholder")} value={field.state.value} onBlur={field.handleBlur} @@ -242,6 +243,7 @@ export default function SignInForm({ id={field.name} name={field.name} type="password" + aria-label={t("auth.password")} placeholder={t("auth.passwordPlaceholder")} value={field.state.value} onBlur={field.handleBlur} diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index b3fcc59..982530c 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -120,6 +120,7 @@ export default function SignUpForm({ +
@@ -31,7 +34,12 @@ export function PageLoadingState({
- +

{resolvedTitle}

diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts index bb7a049..faf1ad5 100644 --- a/apps/web/src/lib/i18n/resolveLocale.ts +++ b/apps/web/src/lib/i18n/resolveLocale.ts @@ -43,7 +43,10 @@ function parseAcceptLanguage(header: string | null | undefined): Locale | null { weight: Number.isFinite(weight) ? weight : 1, }; }) - .filter((entry): entry is { code: string; weight: number } => Boolean(entry.code)) + .filter( + (entry): entry is { code: string; weight: number } => + Boolean(entry.code) && entry.weight > 0, + ) .sort((a, b) => b.weight - a.weight); for (const { code } of weightedCodes) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 14c02cd..c45739f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -120,9 +120,14 @@ export const Route = createRootRouteWithContext()({ .split("; ") .find((entry) => entry.startsWith("bv_locale=")) ?.split("=")[1] ?? null; - const decodedCookieLocale = cookieLocale - ? decodeURIComponent(cookieLocale) - : null; + let decodedCookieLocale: string | null = null; + if (cookieLocale) { + try { + decodedCookieLocale = decodeURIComponent(cookieLocale); + } catch { + decodedCookieLocale = null; + } + } if ( existing.locale && diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx index 6e7890d..304a0b1 100644 --- a/apps/web/src/routes/activitate.tsx +++ b/apps/web/src/routes/activitate.tsx @@ -26,6 +26,21 @@ import { Sparkles, } from "lucide-react"; +const TOPIC_INFERENCE_DEFAULTS = { + minScore: 4.5, + confidenceRatio: 0.55, + maxTopics: 3, +} as const; + +function getNumericConfigValue( + row: { value: unknown } | null | undefined, + fallback: number, +) { + return typeof row?.value === "number" && Number.isFinite(row.value) + ? row.value + : fallback; +} + function formatReadDuration( t: ReturnType, seconds?: number, @@ -130,20 +145,50 @@ function AuthorizedDashboard({ api.clustering.getRecentTopicInferenceDiagnosticsForAdmin, isAdmin ? { limit: 10 } : "skip", ); - const setConfig = useConvexMutationHook(api.config.set); + const minScoreConfig = useQuery( + api.config.get, + isAdmin ? { key: "topic_inference_min_score" } : "skip", + ); + const confidenceRatioConfig = useQuery( + api.config.get, + isAdmin ? { key: "topic_inference_confidence_ratio" } : "skip", + ); + const maxTopicsConfig = useQuery( + api.config.get, + isAdmin ? { key: "topic_inference_max_topics" } : "skip", + ); + const setTopicInferenceSettings = useConvexMutationHook( + api.config.setTopicInferenceSettings, + ); const [minScoreInput, setMinScoreInput] = useState(""); const [confidenceRatioInput, setConfidenceRatioInput] = useState(""); const [maxTopicsInput, setMaxTopicsInput] = useState(""); const [configMessage, setConfigMessage] = useState(""); const [isSavingConfig, setIsSavingConfig] = useState(false); - const currentSettings = topicDiagnostics?.[0]?.settings; + const currentSettings = { + minScore: getNumericConfigValue( + minScoreConfig, + TOPIC_INFERENCE_DEFAULTS.minScore, + ), + confidenceRatio: getNumericConfigValue( + confidenceRatioConfig, + TOPIC_INFERENCE_DEFAULTS.confidenceRatio, + ), + maxTopics: getNumericConfigValue( + maxTopicsConfig, + TOPIC_INFERENCE_DEFAULTS.maxTopics, + ), + }; useEffect(() => { - if (!currentSettings) return; setMinScoreInput(String(currentSettings.minScore)); setConfidenceRatioInput(String(currentSettings.confidenceRatio)); setMaxTopicsInput(String(currentSettings.maxTopics)); - }, [currentSettings]); + }, [ + currentSettings.minScore, + currentSettings.confidenceRatio, + currentSettings.maxTopics, + ]); if (dashboardOverview === undefined || isAdmin === undefined) { return ( @@ -156,13 +201,11 @@ function AuthorizedDashboard({ } const hasConfigChanges = - !!currentSettings && - (minScoreInput !== String(currentSettings.minScore) || + minScoreInput !== String(currentSettings.minScore) || confidenceRatioInput !== String(currentSettings.confidenceRatio) || - maxTopicsInput !== String(currentSettings.maxTopics)); + maxTopicsInput !== String(currentSettings.maxTopics); const handleResetConfig = () => { - if (!currentSettings) return; setMinScoreInput(String(currentSettings.minScore)); setConfidenceRatioInput(String(currentSettings.confidenceRatio)); setMaxTopicsInput(String(currentSettings.maxTopics)); @@ -171,7 +214,7 @@ function AuthorizedDashboard({ const handleSaveConfig = async (event: FormEvent) => { event.preventDefault(); - if (!currentSettings || isSavingConfig) return; + if (isSavingConfig) return; const minScore = Number(minScoreInput); const confidenceRatio = Number(confidenceRatioInput); @@ -198,26 +241,11 @@ function AuthorizedDashboard({ setConfigMessage(""); try { - await Promise.all([ - setConfig({ - key: "topic_inference_min_score", - value: JSON.stringify(minScore), - description: - "Minimum weighted lexical score required before a topic is attached to a clustered event.", - }), - setConfig({ - key: "topic_inference_confidence_ratio", - value: JSON.stringify(confidenceRatio), - description: - "Relative score threshold for keeping additional inferred topics alongside the top-scoring topic.", - }), - setConfig({ - key: "topic_inference_max_topics", - value: JSON.stringify(maxTopics), - description: - "Maximum number of inferred topics attached to an event during clustering.", - }), - ]); + await setTopicInferenceSettings({ + minScore, + confidenceRatio, + maxTopics, + }); setConfigMessage(t("activity.admin.saved")); } catch (error) { console.error("Failed to save topic inference settings:", error); @@ -619,7 +647,7 @@ function AuthorizedDashboard({
{/* Admin: Topic Diagnostics */} - {isAdmin && topicDiagnostics && topicDiagnostics.length > 0 && ( + {isAdmin && (

@@ -633,105 +661,104 @@ function AuthorizedDashboard({
- {currentSettings && ( -
-
-
- - setMinScoreInput(e.target.value)} - disabled={isSavingConfig} - /> -
-
- - - setConfidenceRatioInput(e.target.value) - } - disabled={isSavingConfig} - /> -
-
- - setMaxTopicsInput(e.target.value)} - disabled={isSavingConfig} - /> -
+ +
+
+ + setMinScoreInput(e.target.value)} + disabled={isSavingConfig} + />
-
- -
+
+ + setMaxTopicsInput(e.target.value)} + disabled={isSavingConfig} + />
- - )} +
+
+ + + {configMessage && ( +

+ {configMessage} +

+ )} +
+
- {topicDiagnostics.map((event) => ( + {(topicDiagnostics ?? []).map((event) => (
{ if (currentUser) { - void navigate({ to: "/activitate", replace: true }); + void navigate({ to: redirectTo || "/activitate", replace: true }); } }, [currentUser, navigate]); diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index 2d6716a..3e918b1 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -86,7 +86,10 @@ function TopicFilterContent({ return ( - + {t("feed.topic.empty")} @@ -182,6 +185,7 @@ function TopicFilter({ variant="ghost" size="icon" className="size-8 rounded-full" + aria-label={t("feed.close")} > {t("feed.close")} diff --git a/packages/backend/convex/clustering.ts b/packages/backend/convex/clustering.ts index 9e03b19..5560841 100644 --- a/packages/backend/convex/clustering.ts +++ b/packages/backend/convex/clustering.ts @@ -312,6 +312,57 @@ async function getVectorSearchBudgetState( return await ctx.runQuery(internal.vectorSearchBudget.checkBudget, {}); } +async function reserveVectorSearch( + ctx: any, + metrics: JobMetrics, + limit: number, +) { + const reservation = await ctx.runMutation( + internal.vectorSearchBudget.reserveUsage, + { + jobName: metrics.jobName, + runId: metrics.runId, + qgbRead: estimateVectorSearchQgbRead({ + vectorSearches: 1, + averageMatchesReturned: limit, + }), + vectorSearches: 1, + }, + ); + + metrics.budgetAllowed = reservation.allowed; + if (!reservation.allowed) { + metrics.usedFallbackMode = reservation.fallbackModeEnabled; + console.log( + `[clustering] ${metrics.jobName} vector search skipped: budget exhausted (${reservation.usedQgb}/${reservation.dailyLimitQgb} qGB)`, + ); + return null; + } + + return reservation.reservationId; +} + +async function consumeVectorSearchReservation( + ctx: any, + reservationId: any, + matchCount: number, +) { + await ctx.runMutation(internal.vectorSearchBudget.consumeReservation, { + reservationId, + qgbRead: estimateVectorSearchQgbRead({ + vectorSearches: 1, + averageMatchesReturned: matchCount, + }), + vectorSearches: 1, + }); +} + +async function releaseVectorSearchReservation(ctx: any, reservationId: any) { + await ctx.runMutation(internal.vectorSearchBudget.releaseReservation, { + reservationId, + }); +} + function appendArticleEmbeddingToEventMean( existingEventEmbedding: number[], currentArticleCount: number, @@ -2396,38 +2447,44 @@ export const getRecentClusterCandidates = internalQuery({ export const getChangedClusterCandidates = internalQuery({ args: { sinceTs: v.number(), + sinceCreationTime: v.optional(v.number()), recentSinceTs: v.number(), limit: v.number(), singletonOnly: v.optional(v.boolean()), }, - handler: async (ctx, { sinceTs, recentSinceTs, limit, singletonOnly }) => { + handler: async ( + ctx, + { sinceTs, sinceCreationTime, recentSinceTs, limit, singletonOnly }, + ) => { const candidacies = await Promise.all([ ctx.db .query("eventCandidacy") - .withIndex("by_status_last_article_at", (q) => - q.eq("status", "published").gte("lastArticleAt", recentSinceTs), + .withIndex("by_status_updated_at", (q) => + q.eq("status", "published").gte("updatedAt", sinceTs), ) .order("desc") - .take(limit * 3), + .take(limit), ctx.db .query("eventCandidacy") - .withIndex("by_status_last_article_at", (q) => - q.eq("status", "processing").gte("lastArticleAt", recentSinceTs), + .withIndex("by_status_updated_at", (q) => + q.eq("status", "processing").gte("updatedAt", sinceTs), ) .order("desc") - .take(limit * 3), + .take(limit), ]); return [...candidacies[0], ...candidacies[1]] .filter( (row) => - row.updatedAt > sinceTs && + row.lastArticleAt >= recentSinceTs && + (row.updatedAt > sinceTs || + (row.updatedAt === sinceTs && + row._creationTime > (sinceCreationTime ?? 0))) && (!singletonOnly || row.articleCount <= 2), ) .sort( (a, b) => b.updatedAt - a.updatedAt || - b.lastArticleAt - a.lastArticleAt || b._creationTime - a._creationTime, ) .slice(0, limit); @@ -2446,10 +2503,37 @@ export const getClusteringJobState = internalQuery({ }, }); +function advanceChangedCandidateCursor( + rows: Array<{ updatedAt: number; _creationTime: number }>, + sinceTs: number, + sinceCreationTime = 0, +) { + return rows.reduce( + (cursor, row) => { + if ( + row.updatedAt > cursor.lastProcessedAt || + (row.updatedAt === cursor.lastProcessedAt && + row._creationTime > cursor.lastProcessedCreationTime) + ) { + return { + lastProcessedAt: row.updatedAt, + lastProcessedCreationTime: row._creationTime, + }; + } + return cursor; + }, + { + lastProcessedAt: sinceTs, + lastProcessedCreationTime: sinceCreationTime, + }, + ); +} + export const upsertClusteringJobState = internalMutation({ args: { jobName: v.string(), lastProcessedAt: v.optional(v.number()), + lastProcessedCreationTime: v.optional(v.number()), lastProcessedDayBucket: v.optional(v.string()), lastRunAt: v.number(), lastRunMetricsJson: v.optional(v.string()), @@ -2462,6 +2546,7 @@ export const upsertClusteringJobState = internalMutation({ const payload = { lastProcessedAt: args.lastProcessedAt, + lastProcessedCreationTime: args.lastProcessedCreationTime, lastProcessedDayBucket: args.lastProcessedDayBucket, lastRunAt: args.lastRunAt, lastRunMetricsJson: args.lastRunMetricsJson, @@ -4448,6 +4533,7 @@ export const mergeNearDuplicateEvents = internalAction({ jobName: "mergeNearDuplicateEvents", }); const sinceTs = jobState?.lastProcessedAt ?? 0; + const sinceCreationTime = jobState?.lastProcessedCreationTime ?? 0; const candidateFetchStart = Date.now(); const mergeConfig = await ctx.runQuery(internal.config.getBatch, { @@ -4490,6 +4576,7 @@ export const mergeNearDuplicateEvents = internalAction({ internal.clustering.getChangedClusterCandidates, { sinceTs, + sinceCreationTime, recentSinceTs, limit: MAX_CANDIDATE_EVENTS, singletonOnly: false, @@ -4501,6 +4588,8 @@ export const mergeNearDuplicateEvents = internalAction({ await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "mergeNearDuplicateEvents", lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, + lastProcessedCreationTime: + jobState?.lastProcessedCreationTime ?? sinceCreationTime, lastProcessedDayBucket: jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), @@ -4533,27 +4622,41 @@ export const mergeNearDuplicateEvents = internalAction({ for (const candidate of seeds) { if (!candidate.embeddingId) continue; - metrics.vectorSearches++; - const neighbors = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", - { - vector: toEventEmbedding(candidate.embedding), - limit: vectorSearchLimit, - filter: (q) => - mergeSearchBuckets.length <= 1 - ? q.eq( - "mergeSearchBucket", - mergeSearchBuckets[0] ?? - `published::${MERGE_RECENT_BUCKET}::${formatUtcDayBucket(Date.now())}`, - ) - : q.or( - ...mergeSearchBuckets.map((bucket) => - q.eq("mergeSearchBucket", bucket), - ), - ), - }, + const reservationId = await reserveVectorSearch( + ctx, + metrics, + vectorSearchLimit, ); + if (!reservationId) continue; + + let neighbors: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; + try { + neighbors = await ctx.vectorSearch( + "eventEmbeddings", + "by_embedding", + { + vector: toEventEmbedding(candidate.embedding), + limit: vectorSearchLimit, + filter: (q) => + mergeSearchBuckets.length <= 1 + ? q.eq( + "mergeSearchBucket", + mergeSearchBuckets[0] ?? + `published::${MERGE_RECENT_BUCKET}::${formatUtcDayBucket(Date.now())}`, + ) + : q.or( + ...mergeSearchBuckets.map((bucket) => + q.eq("mergeSearchBucket", bucket), + ), + ), + }, + ); + } catch (error) { + await releaseVectorSearchReservation(ctx, reservationId); + throw error; + } + await consumeVectorSearchReservation(ctx, reservationId, neighbors.length); + metrics.vectorSearches++; metrics.vectorMatchesReturned += neighbors.length; if (neighbors.length === 0) continue; @@ -4733,13 +4836,16 @@ export const mergeNearDuplicateEvents = internalAction({ `[clustering] Merge pass complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`, ); - const lastProcessedAt = changedCandidateRows.reduce( - (max, row) => Math.max(max, row.updatedAt), + const advancedCursor = advanceChangedCandidateCursor( + changedCandidateRows, sinceTs, + sinceCreationTime, ); await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "mergeNearDuplicateEvents", - lastProcessedAt, + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: + advancedCursor.lastProcessedCreationTime, lastProcessedDayBucket: formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), lastRunMetricsJson: JSON.stringify(metrics), @@ -4811,6 +4917,7 @@ export const reclusterRecentSingletonEvents = internalAction({ jobName: "reclusterRecentSingletonEvents", }); const sinceTs = jobState?.lastProcessedAt ?? 0; + const sinceCreationTime = jobState?.lastProcessedCreationTime ?? 0; const reclusterConfig = await ctx.runQuery(internal.config.getBatch, { keys: [ @@ -4845,6 +4952,7 @@ export const reclusterRecentSingletonEvents = internalAction({ internal.clustering.getChangedClusterCandidates, { sinceTs, + sinceCreationTime, recentSinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000, limit: MAX_CANDIDATE_EVENTS, singletonOnly: true, @@ -4856,6 +4964,8 @@ export const reclusterRecentSingletonEvents = internalAction({ await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "reclusterRecentSingletonEvents", lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, + lastProcessedCreationTime: + jobState?.lastProcessedCreationTime ?? sinceCreationTime, lastProcessedDayBucket: jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), @@ -4890,27 +5000,41 @@ export const reclusterRecentSingletonEvents = internalAction({ for (const candidate of candidates) { if (!candidate.embeddingId) continue; - metrics.vectorSearches++; - const neighbors = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", - { - vector: toEventEmbedding(candidate.embedding), - limit: vectorSearchLimit, - filter: (q) => - singletonSearchBuckets.length <= 1 - ? q.eq( - "singletonSearchBucket", - singletonSearchBuckets[0] ?? - `published::${SINGLETON_BUCKET}::${formatUtcDayBucket(Date.now())}`, - ) - : q.or( - ...singletonSearchBuckets.map((bucket) => - q.eq("singletonSearchBucket", bucket), - ), - ), - }, + const reservationId = await reserveVectorSearch( + ctx, + metrics, + vectorSearchLimit, ); + if (!reservationId) continue; + + let neighbors: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; + try { + neighbors = await ctx.vectorSearch( + "eventEmbeddings", + "by_embedding", + { + vector: toEventEmbedding(candidate.embedding), + limit: vectorSearchLimit, + filter: (q) => + singletonSearchBuckets.length <= 1 + ? q.eq( + "singletonSearchBucket", + singletonSearchBuckets[0] ?? + `published::${SINGLETON_BUCKET}::${formatUtcDayBucket(Date.now())}`, + ) + : q.or( + ...singletonSearchBuckets.map((bucket) => + q.eq("singletonSearchBucket", bucket), + ), + ), + }, + ); + } catch (error) { + await releaseVectorSearchReservation(ctx, reservationId); + throw error; + } + await consumeVectorSearchReservation(ctx, reservationId, neighbors.length); + metrics.vectorSearches++; metrics.vectorMatchesReturned += neighbors.length; if (neighbors.length === 0) continue; @@ -5083,13 +5207,16 @@ export const reclusterRecentSingletonEvents = internalAction({ `[clustering] Singleton recluster complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`, ); - const lastProcessedAt = changedCandidateRows.reduce( - (max, row) => Math.max(max, row.updatedAt), + const advancedCursor = advanceChangedCandidateCursor( + changedCandidateRows, sinceTs, + sinceCreationTime, ); await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "reclusterRecentSingletonEvents", - lastProcessedAt, + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: + advancedCursor.lastProcessedCreationTime, lastProcessedDayBucket: formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), lastRunMetricsJson: JSON.stringify(metrics), @@ -5334,17 +5461,35 @@ export const clusterEnrichedArticles = internalAction({ embedding: number[], ): Promise => { searchedArticleIds.add(String(articleId)); - metrics.vectorSearches++; - const vectorResults = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", - { - vector: toEventEmbedding(embedding), - limit: vectorSearchLimit, - filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), - }, + const reservationId = await reserveVectorSearch( + ctx, + metrics, + vectorSearchLimit, ); + if (!reservationId) return []; + + let vectorResults: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; + try { + vectorResults = await ctx.vectorSearch( + "eventEmbeddings", + "by_embedding", + { + vector: toEventEmbedding(embedding), + limit: vectorSearchLimit, + filter: (q) => + q.or(q.eq("status", "published"), q.eq("status", "processing")), + }, + ); + } catch (error) { + await releaseVectorSearchReservation(ctx, reservationId); + throw error; + } + await consumeVectorSearchReservation( + ctx, + reservationId, + vectorResults.length, + ); + metrics.vectorSearches++; metrics.vectorMatchesReturned += vectorResults.length; @@ -5489,7 +5634,7 @@ export const clusterEnrichedArticles = internalAction({ const tryBatchLocalAttach = async ( payload: AttachPayload, - heuristicOnly: boolean, + heuristicOnly = false, ): Promise<"attached" | "unmatched" | "skipped"> => { const { article, topicSlugs } = payload; const candidatePool = Array.from(candidateCache.values()); @@ -5537,7 +5682,7 @@ export const clusterEnrichedArticles = internalAction({ topicSlugs, }; const outcome = useFallbackMode - ? await tryBatchLocalAttach(payload, true) + ? await tryBatchLocalAttach(payload) : await tryVectorAttach(payload); if (outcome === "unmatched") { const entityTokenCount = extractEntityTokens( @@ -5573,7 +5718,7 @@ export const clusterEnrichedArticles = internalAction({ ); for (const pending of pendingArticles) { - let outcome = await tryBatchLocalAttach(pending, useFallbackMode); + let outcome = await tryBatchLocalAttach(pending); if (outcome !== "unmatched") continue; const pendingKey = String(pending.article._id); diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts index 4dc7a54..0f23ca5 100644 --- a/packages/backend/convex/config.ts +++ b/packages/backend/convex/config.ts @@ -339,6 +339,82 @@ export const set = mutation({ }, }); +export const setTopicInferenceSettings = mutation({ + args: { + minScore: v.number(), + confidenceRatio: v.number(), + maxTopics: v.number(), + }, + handler: async (ctx, args) => { + await requireAdminUser(ctx); + + if (!Number.isFinite(args.minScore) || args.minScore < 1 || args.minScore > 20) { + throw new ConvexError("Invalid topic inference minimum score"); + } + if ( + !Number.isFinite(args.confidenceRatio) || + args.confidenceRatio < 0.1 || + args.confidenceRatio > 1 + ) { + throw new ConvexError("Invalid topic inference confidence ratio"); + } + if ( + !Number.isInteger(args.maxTopics) || + args.maxTopics < 1 || + args.maxTopics > 5 + ) { + throw new ConvexError("Invalid topic inference max topics"); + } + + const now = Date.now(); + const entries = [ + { + key: "topic_inference_min_score", + value: args.minScore, + description: + "Minimum weighted lexical score required before a topic is attached to a clustered event.", + }, + { + key: "topic_inference_confidence_ratio", + value: args.confidenceRatio, + description: + "Relative score threshold for keeping additional inferred topics alongside the top-scoring topic.", + }, + { + key: "topic_inference_max_topics", + value: args.maxTopics, + description: + "Maximum number of inferred topics attached to an event during clustering.", + }, + ]; + + for (const entry of entries) { + const existing = await ctx.db + .query("config") + .withIndex("by_key", (q) => q.eq("key", entry.key)) + .unique(); + const value = JSON.stringify(entry.value); + + if (existing) { + await ctx.db.patch(existing._id, { + value, + description: entry.description, + updatedAt: now, + }); + } else { + await ctx.db.insert("config", { + key: entry.key, + value, + description: entry.description, + updatedAt: now, + }); + } + } + + return { updated: true as const }; + }, +}); + /** Remove a config key (falls back to hardcoded default). */ export const remove = mutation({ args: { key: v.string() }, diff --git a/packages/backend/convex/migrations.ts b/packages/backend/convex/migrations.ts index e8cf869..df72c82 100644 --- a/packages/backend/convex/migrations.ts +++ b/packages/backend/convex/migrations.ts @@ -18,6 +18,7 @@ import { buildEventShareRenderSignature } from "./shareAssets"; const MAX_FACT_EXTRACTION_ATTEMPTS = 3; const MAX_BIAS_DETECTION_ATTEMPTS = 3; +const EVENT_EMBEDDING_DIMENSIONS = 512; function articleHasAtomicFacts(article: { atomicFacts?: string[]; @@ -520,3 +521,59 @@ export const queueEventShareAssetsBackfill = mutation({ }; }, }); + +export const deleteInvalidEventEmbeddingsFor512dVectorIndex = mutation({ + args: { + cursor: v.optional(v.string()), + pageSize: v.optional(v.number()), + autoContinue: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const safePageSize = Math.min( + Math.max(Math.floor(args.pageSize ?? 100), 1), + 250, + ); + const page = await ctx.db.query("eventEmbeddings").paginate({ + cursor: args.cursor ?? null, + numItems: safePageSize, + }); + + let deleted = 0; + let kept = 0; + + for (const row of page.page) { + if (row.embedding.length === EVENT_EMBEDDING_DIMENSIONS) { + kept++; + continue; + } + await ctx.db.delete(row._id); + deleted++; + } + + const shouldAutoContinue = args.autoContinue ?? true; + const nextCursor = page.continueCursor ?? undefined; + const scheduledContinuation = + shouldAutoContinue && !page.isDone && Boolean(nextCursor); + + if (scheduledContinuation && nextCursor) { + await ctx.scheduler.runAfter( + 0, + api.migrations.deleteInvalidEventEmbeddingsFor512dVectorIndex, + { + cursor: nextCursor, + pageSize: safePageSize, + autoContinue: true, + }, + ); + } + + return { + processed: page.page.length, + deleted, + kept, + isDone: page.isDone, + continueCursor: page.continueCursor, + scheduledContinuation, + }; + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 02ed60b..4102088 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -152,7 +152,8 @@ export default defineSchema({ updatedAt: v.number(), }) .index("by_event", ["eventId"]) - .index("by_status_last_article_at", ["status", "lastArticleAt"]), + .index("by_status_last_article_at", ["status", "lastArticleAt"]) + .index("by_status_updated_at", ["status", "updatedAt"]), // ========================================================================= // 3d. PUBLIC EVENT PREVIEWS (Denormalized feed cards for anonymous traffic) @@ -725,6 +726,28 @@ export default defineSchema({ .index("by_date", ["date"]) .index("by_job_createdAt", ["jobName", "createdAt"]), + vectorSearchReservations: defineTable({ + jobName: v.string(), + runId: v.string(), + date: v.string(), + shard: v.number(), + qgbReserved: v.number(), + vectorSearchesReserved: v.number(), + qgbConsumed: v.optional(v.number()), + vectorSearchesConsumed: v.optional(v.number()), + status: v.union( + v.literal("reserved"), + v.literal("consumed"), + v.literal("released"), + ), + createdAt: v.number(), + expiresAt: v.number(), + updatedAt: v.number(), + }) + .index("by_run_id", ["runId"]) + .index("by_expiresAt", ["expiresAt"]) + .index("by_status_expiresAt", ["status", "expiresAt"]), + // ========================================================================= // 12. PIPELINE LOCKS (Short-lived leases for scheduled jobs) // ========================================================================= @@ -739,6 +762,7 @@ export default defineSchema({ clusteringJobState: defineTable({ jobName: v.string(), lastProcessedAt: v.optional(v.number()), + lastProcessedCreationTime: v.optional(v.number()), lastProcessedDayBucket: v.optional(v.string()), lastRunAt: v.optional(v.number()), lastRunMetricsJson: v.optional(v.string()), diff --git a/packages/backend/convex/seeds.ts b/packages/backend/convex/seeds.ts index 8927aa5..eed30c9 100644 --- a/packages/backend/convex/seeds.ts +++ b/packages/backend/convex/seeds.ts @@ -145,6 +145,7 @@ export const seedDB = internalMutation({ // 3b. EVENT EMBEDDINGS (placeholder zeros — real embeddings come from AI) // ========================================================================= const dummyEmbedding = new Array(512).fill(0); + const day = new Date().toISOString().slice(0, 10); await ctx.db.insert("eventEmbeddings", { eventId: eventFedRates, @@ -153,9 +154,9 @@ export const seedDB = internalMutation({ status: "published", recentWindowBucket: "recent_2d", singletonBucket: "multi", - updatedDayBucket: new Date().toISOString().slice(0, 10), - mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`, - singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`, + updatedDayBucket: day, + mergeSearchBucket: `published::recent_2d::${day}`, + singletonSearchBucket: `published::multi::${day}`, }); await ctx.db.insert("eventEmbeddings", { eventId: eventAIRegulations, @@ -164,9 +165,9 @@ export const seedDB = internalMutation({ status: "published", recentWindowBucket: "recent_2d", singletonBucket: "multi", - updatedDayBucket: new Date().toISOString().slice(0, 10), - mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`, - singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`, + updatedDayBucket: day, + mergeSearchBucket: `published::recent_2d::${day}`, + singletonSearchBucket: `published::multi::${day}`, }); console.log("✅ Created 2 eventEmbeddings"); diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts index 58eb8e6..6337b9b 100644 --- a/packages/backend/convex/vectorSearchBudget.ts +++ b/packages/backend/convex/vectorSearchBudget.ts @@ -9,6 +9,7 @@ import { requireAdminUser } from "./lib/betaAccess"; const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 0.25; const ESTIMATED_VECTOR_ROW_BYTES = 5 * 1024; +const VECTOR_SEARCH_RESERVATION_TTL_MS = 15 * 60 * 1000; function roundQgb(value: number): number { return Math.round(value * 1_000_000) / 1_000_000; @@ -170,6 +171,32 @@ async function adjustDailyUsage( } } +async function releaseExpiredReservations( + ctx: MutationCtx, + now: number, +): Promise { + const expired = await ctx.db + .query("vectorSearchReservations") + .withIndex("by_status_expiresAt", (q) => + q.eq("status", "reserved").lte("expiresAt", now), + ) + .take(100); + + for (const reservation of expired) { + await adjustDailyUsage(ctx, { + date: reservation.date, + shard: reservation.shard, + deltaQgbRead: -reservation.qgbReserved, + deltaVectorSearches: -reservation.vectorSearchesReserved, + deltaRunCount: 0, + }); + await ctx.db.patch(reservation._id, { + status: "released", + updatedAt: now, + }); + } +} + export function estimateVectorSearchQgbRead(args: { vectorSearches: number; averageMatchesReturned: number; @@ -218,6 +245,149 @@ export const checkBudget = internalQuery({ }, }); +export const reserveUsage = internalMutation({ + args: { + jobName: v.string(), + runId: v.string(), + qgbRead: v.number(), + vectorSearches: v.number(), + }, + handler: async (ctx, args) => { + const enabled = await getConfigBoolean( + ctx, + "vector_search_budget_enabled", + true, + ); + const dailyLimitQgb = await getConfigNumber( + ctx, + "vector_search_daily_budget_qgb", + DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB, + ); + const now = Date.now(); + const date = formatUtcDate(now); + const shard = getUtcShard(now); + await releaseExpiredReservations(ctx, now); + const totals = await getDailyTotals(ctx, date); + const qgbRead = roundQgb(Math.max(0, args.qgbRead)); + const vectorSearches = Math.max(0, Math.floor(args.vectorSearches)); + const allowed = !enabled || totals.qgbRead + qgbRead <= dailyLimitQgb; + + if (!allowed) { + return { + allowed: false as const, + enabled, + usedQgb: totals.qgbRead, + remainingQgb: roundQgb(Math.max(0, dailyLimitQgb - totals.qgbRead)), + dailyLimitQgb, + fallbackModeEnabled: await getConfigBoolean( + ctx, + "vector_search_fallback_mode_enabled", + true, + ), + }; + } + + await adjustDailyUsage(ctx, { + date, + shard, + deltaQgbRead: qgbRead, + deltaVectorSearches: vectorSearches, + deltaRunCount: 0, + }); + + const reservationId = await ctx.db.insert("vectorSearchReservations", { + jobName: args.jobName, + runId: args.runId, + date, + shard, + qgbReserved: qgbRead, + vectorSearchesReserved: vectorSearches, + status: "reserved", + createdAt: now, + expiresAt: now + VECTOR_SEARCH_RESERVATION_TTL_MS, + updatedAt: now, + }); + + return { + allowed: true as const, + reservationId, + enabled, + usedQgb: roundQgb(totals.qgbRead + qgbRead), + remainingQgb: roundQgb( + Math.max(0, dailyLimitQgb - totals.qgbRead - qgbRead), + ), + dailyLimitQgb, + fallbackModeEnabled: await getConfigBoolean( + ctx, + "vector_search_fallback_mode_enabled", + true, + ), + }; + }, +}); + +export const consumeReservation = internalMutation({ + args: { + reservationId: v.id("vectorSearchReservations"), + qgbRead: v.number(), + vectorSearches: v.number(), + }, + handler: async (ctx, args) => { + const reservation = await ctx.db.get(args.reservationId); + if (!reservation || reservation.status !== "reserved") { + return { consumed: false as const }; + } + + const qgbRead = roundQgb(Math.max(0, args.qgbRead)); + const vectorSearches = Math.max(0, Math.floor(args.vectorSearches)); + + await adjustDailyUsage(ctx, { + date: reservation.date, + shard: reservation.shard, + deltaQgbRead: roundQgb(qgbRead - reservation.qgbReserved), + deltaVectorSearches: + vectorSearches - reservation.vectorSearchesReserved, + deltaRunCount: 0, + }); + + await ctx.db.patch(reservation._id, { + qgbConsumed: qgbRead, + vectorSearchesConsumed: vectorSearches, + status: "consumed", + updatedAt: Date.now(), + }); + + return { consumed: true as const }; + }, +}); + +export const releaseReservation = internalMutation({ + args: { + reservationId: v.id("vectorSearchReservations"), + }, + handler: async (ctx, args) => { + const reservation = await ctx.db.get(args.reservationId); + if (!reservation || reservation.status !== "reserved") { + return { released: false as const }; + } + + await adjustDailyUsage(ctx, { + date: reservation.date, + shard: reservation.shard, + deltaQgbRead: -reservation.qgbReserved, + deltaVectorSearches: -reservation.vectorSearchesReserved, + deltaRunCount: 0, + }); + + await ctx.db.patch(reservation._id, { + status: "released", + updatedAt: Date.now(), + }); + + return { released: true as const }; + }, +}); + export const recordUsage = internalMutation({ args: { jobName: v.string(), @@ -236,14 +406,35 @@ export const recordUsage = internalMutation({ const now = Date.now(); const date = formatUtcDate(now); const shard = getUtcShard(now); + const existingRun = await ctx.db + .query("vectorSearchRuns") + .withIndex("by_run_id", (q) => q.eq("runId", args.runId)) + .first(); + if (existingRun) { + return { recorded: true as const }; + } + const reservations = await ctx.db + .query("vectorSearchReservations") + .withIndex("by_run_id", (q) => q.eq("runId", args.runId)) + .collect(); - await adjustDailyUsage(ctx, { - date, - shard, - deltaQgbRead: roundQgb(Math.max(0, args.qgbRead)), - deltaVectorSearches: Math.max(0, Math.floor(args.vectorSearches)), - deltaRunCount: 1, - }); + if (reservations.length === 0) { + await adjustDailyUsage(ctx, { + date, + shard, + deltaQgbRead: roundQgb(Math.max(0, args.qgbRead)), + deltaVectorSearches: Math.max(0, Math.floor(args.vectorSearches)), + deltaRunCount: 1, + }); + } else { + await adjustDailyUsage(ctx, { + date, + shard, + deltaQgbRead: 0, + deltaVectorSearches: 0, + deltaRunCount: 1, + }); + } await ctx.db.insert("vectorSearchRuns", { jobName: args.jobName, From 78161f09d8f4de88e6ddc45ee83db315173e6197 Mon Sep 17 00:00:00 2001 From: flavius Date: Mon, 11 May 2026 08:18:16 +0300 Subject: [PATCH 18/19] fix coderabbit issues --- .../src/components/layout/MobileTabBar.tsx | 7 +- .../profile/AuthenticatedProfile.tsx | 6 +- apps/web/src/components/sign-in-form.tsx | 19 +- apps/web/src/components/sign-up-form.tsx | 52 ++++- .../src/components/ui/page-loading-state.tsx | 4 +- apps/web/src/lib/i18n/strings.ts | 6 + apps/web/src/lib/seo.ts | 4 + apps/web/src/routes/activitate.tsx | 59 ++++-- apps/web/src/routes/event.$slug.tsx | 6 +- apps/web/src/routes/feed.tsx | 13 +- apps/web/src/routes/robots[.]txt.ts | 4 +- apps/web/src/routes/sitemap[.]xml.ts | 4 +- apps/web/src/routes/source.$sourceId.tsx | 14 +- packages/backend/convex/clustering.ts | 25 ++- packages/backend/convex/config.ts | 37 +++- packages/backend/convex/crons.ts | 11 + packages/backend/convex/migrations.ts | 39 +++- packages/backend/convex/schema.ts | 1 + packages/backend/convex/vectorSearchBudget.ts | 189 ++++++++++++++---- 19 files changed, 373 insertions(+), 127 deletions(-) diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 152daab..dd7f49b 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -46,8 +46,8 @@ export function MobileTabBar() { const isVisible = useScrollVisibility(); const handleTabClick = - (isActive: boolean) => (event: MouseEvent) => { - if (!isActive) { + (isSameDestination: boolean) => (event: MouseEvent) => { + if (!isSameDestination) { return; } @@ -73,13 +73,14 @@ export function MobileTabBar() { const isActive = customIsActive ? customIsActive(pathname) : matchesPath(pathname, to); + const isSameDestination = pathname === to; const label = t(key); return ( , + error: { + error?: { + code?: string; + message?: string; + statusText?: string; + }; + }, +) { + const code = normalizeAuthError(error.error?.code); + const message = normalizeAuthError(error.error?.message); + const emailInUseSignals = new Set([ + "user_already_exists", + "email_already_exists", + "email_already_in_use", + ]); + + if (emailInUseSignals.has(code) || emailInUseSignals.has(message)) { + return t("auth.emailInUse"); + } + + return t("auth.unexpectedError"); +} + export default function SignUpForm({ onSwitchToSignIn, initialEmail = "", @@ -41,12 +71,22 @@ export default function SignUpForm({ showGoogle?: boolean; }) { const t = useT(); + const locale = useLocale(); const verificationCallbackURL = getVerificationCallbackURL(redirectTo); const [submittedEmail, setSubmittedEmail] = useState(""); const [isResendingVerification, setIsResendingVerification] = useState(false); const resolvedTitle = title ?? t("auth.signUpTitle"); const resolvedSubtitle = subtitle ?? t("auth.signUpSubtitle"); const resolvedSubmitLabel = submitLabel ?? t("auth.createAccount"); + const signUpSchema = useMemo( + () => + z.object({ + name: z.string().min(2, getString(locale, "auth.nameMin")), + email: z.email(getString(locale, "auth.invalidEmail")), + password: z.string().min(8, getString(locale, "auth.passwordMin")), + }), + [locale], + ); const form = useForm({ defaultValues: { @@ -77,17 +117,13 @@ export default function SignUpForm({ }, onError: (error) => { console.error(error); - toast.error(error.error.message || t("auth.unexpectedError")); + toast.error(getLocalizedSignUpError(t, error)); }, } ); }, validators: { - onSubmit: z.object({ - name: z.string().min(2, t("auth.nameMin")), - email: z.email(t("auth.invalidEmail")), - password: z.string().min(8, t("auth.passwordMin")), - }), + onSubmit: signUpSchema, }, }); diff --git a/apps/web/src/components/ui/page-loading-state.tsx b/apps/web/src/components/ui/page-loading-state.tsx index 19e8f85..6b5a489 100644 --- a/apps/web/src/components/ui/page-loading-state.tsx +++ b/apps/web/src/components/ui/page-loading-state.tsx @@ -13,8 +13,8 @@ export function PageLoadingState({ cardCount?: number; }) { const t = useT(); - const resolvedTitle = title ?? t("feed.loading"); - const resolvedDescription = description ?? t("activity.loading.body"); + const resolvedTitle = title ?? t("common.loading.title"); + const resolvedDescription = description ?? t("common.loading.body"); return (
({ + minScore: getNumericConfigValue( + minScoreConfig, + TOPIC_INFERENCE_DEFAULTS.minScore, + ), + confidenceRatio: getNumericConfigValue( + confidenceRatioConfig, + TOPIC_INFERENCE_DEFAULTS.confidenceRatio, + ), + maxTopics: getNumericConfigValue( + maxTopicsConfig, + TOPIC_INFERENCE_DEFAULTS.maxTopics, + ), + }), + [minScoreConfig, confidenceRatioConfig, maxTopicsConfig], + ); useEffect(() => { setMinScoreInput(String(currentSettings.minScore)); @@ -220,19 +227,29 @@ function AuthorizedDashboard({ const confidenceRatio = Number(confidenceRatioInput); const maxTopics = Number(maxTopicsInput); - if (!Number.isFinite(minScore) || minScore < 1 || minScore > 20) { + if (!topicInferenceBounds) return; + + if ( + !Number.isFinite(minScore) || + minScore < topicInferenceBounds.minScore.min || + minScore > topicInferenceBounds.minScore.max + ) { setConfigMessage(t("activity.admin.minScoreError")); return; } if ( !Number.isFinite(confidenceRatio) || - confidenceRatio < 0.1 || - confidenceRatio > 1 + confidenceRatio < topicInferenceBounds.confidenceRatio.min || + confidenceRatio > topicInferenceBounds.confidenceRatio.max ) { setConfigMessage(t("activity.admin.confidenceError")); return; } - if (!Number.isInteger(maxTopics) || maxTopics < 1 || maxTopics > 5) { + if ( + !Number.isInteger(maxTopics) || + maxTopics < topicInferenceBounds.maxTopics.min || + maxTopics > topicInferenceBounds.maxTopics.max + ) { setConfigMessage(t("activity.admin.maxTopicsError")); return; } @@ -730,7 +747,9 @@ function AuthorizedDashboard({
)} {isSearching && ( -

+

{t("feed.search.indexed").replace( "{query}", debouncedSearch, diff --git a/apps/web/src/routes/robots[.]txt.ts b/apps/web/src/routes/robots[.]txt.ts index 9731c1a..97d8f4f 100644 --- a/apps/web/src/routes/robots[.]txt.ts +++ b/apps/web/src/routes/robots[.]txt.ts @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SITE } from "@/lib/seo"; +import { absoluteSiteUrl } from "@/lib/seo"; const ROBOTS_TXT = `User-agent: * Allow: / @@ -8,7 +8,7 @@ Disallow: /dashboard Disallow: /api/ Disallow: /unsubscribe -Sitemap: ${SITE.url}/sitemap.xml +Sitemap: ${absoluteSiteUrl("/sitemap.xml")} `; function buildRobotsHeaders() { diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts index 03f3ddc..4fdd6af 100644 --- a/apps/web/src/routes/sitemap[.]xml.ts +++ b/apps/web/src/routes/sitemap[.]xml.ts @@ -1,7 +1,7 @@ import { ConvexHttpClient } from "convex/browser"; import { createFileRoute } from "@tanstack/react-router"; import { api } from "@news-app/backend/convex/_generated/api"; -import { SITE } from "@/lib/seo"; +import { absoluteSiteUrl } from "@/lib/seo"; const convexUrl = process.env.VITE_CONVEX_URL!; @@ -15,7 +15,7 @@ function escapeXml(value: string) { } function toSitemapUrl(pathname: string, lastModifiedAt?: number) { - const url = `${SITE.url}${pathname}`; + const url = absoluteSiteUrl(pathname); const lastmod = lastModifiedAt ? `${new Date(lastModifiedAt).toISOString()}` : ""; diff --git a/apps/web/src/routes/source.$sourceId.tsx b/apps/web/src/routes/source.$sourceId.tsx index 6f4c0de..5459807 100644 --- a/apps/web/src/routes/source.$sourceId.tsx +++ b/apps/web/src/routes/source.$sourceId.tsx @@ -15,7 +15,7 @@ import { formatAbsoluteTimestamp, formatRelativeTimestamp } from "@/lib/dates"; import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; import { useLocale, useT } from "@/lib/i18n/LocaleContext"; import { getString } from "@/lib/i18n/strings"; -import { SITE } from "@/lib/seo"; +import { SITE, absoluteSiteUrl } from "@/lib/seo"; export const Route = createFileRoute("/source/$sourceId")({ loader: async ({ context, params }) => { @@ -65,12 +65,20 @@ export const Route = createFileRoute("/source/$sourceId")({ { property: "og:site_name", content: SITE.name }, { property: "og:type", content: "website" }, { property: "og:description", content: description }, - { property: "og:url", content: `${SITE.url}/source/${params.sourceId}` }, + { + property: "og:url", + content: absoluteSiteUrl(`/source/${params.sourceId}`), + }, { name: "twitter:card", content: "summary_large_image" }, { name: "twitter:title", content: title }, { name: "twitter:description", content: description }, ], - links: [{ rel: "canonical", href: `${SITE.url}/source/${params.sourceId}` }], + links: [ + { + rel: "canonical", + href: absoluteSiteUrl(`/source/${params.sourceId}`), + }, + ], }; }, component: SourceProfilePage, diff --git a/packages/backend/convex/clustering.ts b/packages/backend/convex/clustering.ts index 5560841..f5e5908 100644 --- a/packages/backend/convex/clustering.ts +++ b/packages/backend/convex/clustering.ts @@ -25,7 +25,7 @@ import { } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; -import type { MutationCtx } from "./_generated/server"; +import type { ActionCtx, MutationCtx } from "./_generated/server"; import { getConfig } from "./config"; import { normalizeArticleSnippet, normalizeArticleTitle } from "./ingestion"; import { requireAdminUser } from "./lib/betaAccess"; @@ -274,7 +274,7 @@ function markStageDuration( } async function flushJobMetrics( - ctx: any, + ctx: ActionCtx, metrics: JobMetrics, startedAt: number, ): Promise { @@ -307,13 +307,13 @@ async function flushJobMetrics( } async function getVectorSearchBudgetState( - ctx: any, + ctx: ActionCtx, ) { return await ctx.runQuery(internal.vectorSearchBudget.checkBudget, {}); } async function reserveVectorSearch( - ctx: any, + ctx: ActionCtx, metrics: JobMetrics, limit: number, ) { @@ -343,8 +343,8 @@ async function reserveVectorSearch( } async function consumeVectorSearchReservation( - ctx: any, - reservationId: any, + ctx: ActionCtx, + reservationId: Id<"vectorSearchReservations">, matchCount: number, ) { await ctx.runMutation(internal.vectorSearchBudget.consumeReservation, { @@ -357,7 +357,10 @@ async function consumeVectorSearchReservation( }); } -async function releaseVectorSearchReservation(ctx: any, reservationId: any) { +async function releaseVectorSearchReservation( + ctx: ActionCtx, + reservationId: Id<"vectorSearchReservations">, +) { await ctx.runMutation(internal.vectorSearchBudget.releaseReservation, { reservationId, }); @@ -2462,14 +2465,14 @@ export const getChangedClusterCandidates = internalQuery({ .withIndex("by_status_updated_at", (q) => q.eq("status", "published").gte("updatedAt", sinceTs), ) - .order("desc") + .order("asc") .take(limit), ctx.db .query("eventCandidacy") .withIndex("by_status_updated_at", (q) => q.eq("status", "processing").gte("updatedAt", sinceTs), ) - .order("desc") + .order("asc") .take(limit), ]); @@ -2484,8 +2487,8 @@ export const getChangedClusterCandidates = internalQuery({ ) .sort( (a, b) => - b.updatedAt - a.updatedAt || - b._creationTime - a._creationTime, + a.updatedAt - b.updatedAt || + a._creationTime - b._creationTime, ) .slice(0, limit); }, diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts index 0f23ca5..78bda8a 100644 --- a/packages/backend/convex/config.ts +++ b/packages/backend/convex/config.ts @@ -8,6 +8,12 @@ import { import type { QueryCtx } from "./_generated/server"; import { requireAdminUser } from "./lib/betaAccess"; +export const TOPIC_INFERENCE_BOUNDS = { + minScore: { min: 1, max: 20 }, + confidenceRatio: { min: 0.1, max: 1 }, + maxTopics: { min: 1, max: 5 }, +} as const; + // --------------------------------------------------------------------------- // Server-side helper — use from any query or mutation handler // --------------------------------------------------------------------------- @@ -104,6 +110,11 @@ export const getPublicRuntimeConfig = query({ }, }); +export const getTopicInferenceBounds = query({ + args: {}, + handler: async () => TOPIC_INFERENCE_BOUNDS, +}); + /** List all config entries (for an admin panel). */ export const list = query({ args: {}, @@ -348,20 +359,24 @@ export const setTopicInferenceSettings = mutation({ handler: async (ctx, args) => { await requireAdminUser(ctx); - if (!Number.isFinite(args.minScore) || args.minScore < 1 || args.minScore > 20) { + if ( + !Number.isFinite(args.minScore) || + args.minScore < TOPIC_INFERENCE_BOUNDS.minScore.min || + args.minScore > TOPIC_INFERENCE_BOUNDS.minScore.max + ) { throw new ConvexError("Invalid topic inference minimum score"); } if ( !Number.isFinite(args.confidenceRatio) || - args.confidenceRatio < 0.1 || - args.confidenceRatio > 1 + args.confidenceRatio < TOPIC_INFERENCE_BOUNDS.confidenceRatio.min || + args.confidenceRatio > TOPIC_INFERENCE_BOUNDS.confidenceRatio.max ) { throw new ConvexError("Invalid topic inference confidence ratio"); } if ( !Number.isInteger(args.maxTopics) || - args.maxTopics < 1 || - args.maxTopics > 5 + args.maxTopics < TOPIC_INFERENCE_BOUNDS.maxTopics.min || + args.maxTopics > TOPIC_INFERENCE_BOUNDS.maxTopics.max ) { throw new ConvexError("Invalid topic inference max topics"); } @@ -831,6 +846,12 @@ export const seedDefaults = internalMutation({ description: "When true, clusterEnrichedArticles falls back to heuristic-only batch-local matching after the vector-search budget is exhausted.", }, + { + key: "vector_search_run_retention_days", + value: 30, + description: + "Number of days to retain detailed vector-search run rows before daily cleanup deletes them.", + }, { key: "clustering_vector_search_limit", value: 40, @@ -865,12 +886,6 @@ export const seedDefaults = internalMutation({ "merge_min_title_jaccard", "merge_max_time_delta_hours", "singleton_recluster_min_similarity", - "vector_search_daily_budget_qgb", - "vector_search_budget_enabled", - "vector_search_fallback_mode_enabled", - "clustering_vector_search_limit", - "merge_vector_search_limit", - "recluster_vector_search_limit", ]); let created = 0; diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index cfa71ce..bd47529 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -130,6 +130,17 @@ crons.interval( {}, ); +// --------------------------------------------------------------------------- +// Vector Search Run Retention — Daily +// --------------------------------------------------------------------------- +// Keeps detailed vector-search telemetry bounded; daily totals remain intact. +crons.daily( + "cleanup-vector-search-runs", + { hourUTC: 4, minuteUTC: 45 }, + internal.vectorSearchBudget.cleanupVectorSearchRuns, + {}, +); + // --------------------------------------------------------------------------- // Unverified Auth Account Cleanup — Daily // --------------------------------------------------------------------------- diff --git a/packages/backend/convex/migrations.ts b/packages/backend/convex/migrations.ts index df72c82..cf60aa5 100644 --- a/packages/backend/convex/migrations.ts +++ b/packages/backend/convex/migrations.ts @@ -19,6 +19,8 @@ import { buildEventShareRenderSignature } from "./shareAssets"; const MAX_FACT_EXTRACTION_ATTEMPTS = 3; const MAX_BIAS_DETECTION_ATTEMPTS = 3; const EVENT_EMBEDDING_DIMENSIONS = 512; +const MIGRATION_CONTINUATION_DELAY_MS = 500; +const DEFAULT_MIGRATION_REMAINING_PAGES = 20; function articleHasAtomicFacts(article: { atomicFacts?: string[]; @@ -457,6 +459,7 @@ export const queueEventShareAssetsBackfill = mutation({ cursor: v.optional(v.string()), pageSize: v.optional(v.number()), autoContinue: v.optional(v.boolean()), + remainingPages: v.optional(v.number()), }, handler: async (ctx, args) => { const safePageSize = Math.min( @@ -497,17 +500,25 @@ export const queueEventShareAssetsBackfill = mutation({ const shouldAutoContinue = args.autoContinue ?? true; const nextCursor = page.continueCursor ?? undefined; + const remainingPages = Math.max( + 0, + Math.floor(args.remainingPages ?? DEFAULT_MIGRATION_REMAINING_PAGES), + ); const scheduledContinuation = - shouldAutoContinue && !page.isDone && Boolean(nextCursor); + shouldAutoContinue && + remainingPages > 0 && + !page.isDone && + Boolean(nextCursor); if (scheduledContinuation && nextCursor) { await ctx.scheduler.runAfter( - 0, + MIGRATION_CONTINUATION_DELAY_MS, api.migrations.queueEventShareAssetsBackfill, { cursor: nextCursor, pageSize: safePageSize, autoContinue: true, + remainingPages: remainingPages - 1, }, ); } @@ -518,6 +529,7 @@ export const queueEventShareAssetsBackfill = mutation({ isDone: page.isDone, continueCursor: page.continueCursor, scheduledContinuation, + remainingPages, }; }, }); @@ -527,6 +539,7 @@ export const deleteInvalidEventEmbeddingsFor512dVectorIndex = mutation({ cursor: v.optional(v.string()), pageSize: v.optional(v.number()), autoContinue: v.optional(v.boolean()), + remainingPages: v.optional(v.number()), }, handler: async (ctx, args) => { const safePageSize = Math.min( @@ -540,29 +553,45 @@ export const deleteInvalidEventEmbeddingsFor512dVectorIndex = mutation({ let deleted = 0; let kept = 0; + let scheduledRecomputes = 0; for (const row of page.page) { if (row.embedding.length === EVENT_EMBEDDING_DIMENSIONS) { kept++; continue; } + const eventId = row.eventId; await ctx.db.delete(row._id); + await ctx.scheduler.runAfter( + MIGRATION_CONTINUATION_DELAY_MS, + internal.ingestion.recomputeEventEmbeddingForEventInternal, + { eventId }, + ); deleted++; + scheduledRecomputes++; } const shouldAutoContinue = args.autoContinue ?? true; const nextCursor = page.continueCursor ?? undefined; + const remainingPages = Math.max( + 0, + Math.floor(args.remainingPages ?? DEFAULT_MIGRATION_REMAINING_PAGES), + ); const scheduledContinuation = - shouldAutoContinue && !page.isDone && Boolean(nextCursor); + shouldAutoContinue && + remainingPages > 0 && + !page.isDone && + Boolean(nextCursor); if (scheduledContinuation && nextCursor) { await ctx.scheduler.runAfter( - 0, + MIGRATION_CONTINUATION_DELAY_MS, api.migrations.deleteInvalidEventEmbeddingsFor512dVectorIndex, { cursor: nextCursor, pageSize: safePageSize, autoContinue: true, + remainingPages: remainingPages - 1, }, ); } @@ -571,9 +600,11 @@ export const deleteInvalidEventEmbeddingsFor512dVectorIndex = mutation({ processed: page.page.length, deleted, kept, + scheduledRecomputes, isDone: page.isDone, continueCursor: page.continueCursor, scheduledContinuation, + remainingPages, }; }, }); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 4102088..3b3fabf 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -724,6 +724,7 @@ export default defineSchema({ }) .index("by_run_id", ["runId"]) .index("by_date", ["date"]) + .index("by_createdAt", ["createdAt"]) .index("by_job_createdAt", ["jobName", "createdAt"]), vectorSearchReservations: defineTable({ diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts index 6337b9b..d42d125 100644 --- a/packages/backend/convex/vectorSearchBudget.ts +++ b/packages/backend/convex/vectorSearchBudget.ts @@ -4,12 +4,16 @@ import { internalQuery, query, } from "./_generated/server"; +import { internal } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { requireAdminUser } from "./lib/betaAccess"; const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 0.25; +const DEFAULT_VECTOR_SEARCH_RUN_RETENTION_DAYS = 30; const ESTIMATED_VECTOR_ROW_BYTES = 5 * 1024; const VECTOR_SEARCH_RESERVATION_TTL_MS = 15 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const VECTOR_SEARCH_RUN_CLEANUP_CONTINUATION_DELAY_MS = 500; function roundQgb(value: number): number { return Math.round(value * 1_000_000) / 1_000_000; @@ -95,14 +99,23 @@ async function ensureDailyTotal(ctx: MutationCtx, date: string) { if (existing) return existing; const totals = await getDailyTotals(ctx, date); - const totalId = await ctx.db.insert("vectorSearchDailyTotal", { - date, - qgbRead: totals.qgbRead, - vectorSearches: totals.vectorSearches, - runCount: totals.runCount, - updatedAt: Date.now(), - }); - return await ctx.db.get(totalId); + try { + const totalId = await ctx.db.insert("vectorSearchDailyTotal", { + date, + qgbRead: totals.qgbRead, + vectorSearches: totals.vectorSearches, + runCount: totals.runCount, + updatedAt: Date.now(), + }); + return await ctx.db.get(totalId); + } catch (error) { + const createdByConcurrentMutation = await ctx.db + .query("vectorSearchDailyTotal") + .withIndex("by_date", (q) => q.eq("date", date)) + .unique(); + if (createdByConcurrentMutation) return createdByConcurrentMutation; + throw error; + } } async function adjustDailyUsage( @@ -134,6 +147,9 @@ async function adjustDailyUsage( const appliedSearches = nextVectorSearches - (existing?.vectorSearches ?? 0); const appliedRuns = nextRunCount - (existing?.runCount ?? 0); const total = await ensureDailyTotal(ctx, args.date); + let appliedQgbToTotal = appliedQgb; + let appliedSearchesToTotal = appliedSearches; + let appliedRunsToTotal = appliedRuns; if (existing) { await ctx.db.patch(existing._id, { @@ -143,29 +159,67 @@ async function adjustDailyUsage( updatedAt: Date.now(), }); } else { - await ctx.db.insert("vectorSearchDaily", { - date: args.date, - shard: args.shard, - qgbRead: nextQgbRead, - vectorSearches: nextVectorSearches, - runCount: nextRunCount, - updatedAt: Date.now(), - }); + try { + await ctx.db.insert("vectorSearchDaily", { + date: args.date, + shard: args.shard, + qgbRead: nextQgbRead, + vectorSearches: nextVectorSearches, + runCount: nextRunCount, + updatedAt: Date.now(), + }); + } catch (error) { + const createdByConcurrentMutation = await ctx.db + .query("vectorSearchDaily") + .withIndex("by_date_shard", (q) => + q.eq("date", args.date).eq("shard", args.shard), + ) + .unique(); + if (!createdByConcurrentMutation) throw error; + + const retryQgbRead = roundQgb( + Math.max(0, createdByConcurrentMutation.qgbRead + args.deltaQgbRead), + ); + const retryVectorSearches = Math.max( + 0, + createdByConcurrentMutation.vectorSearches + args.deltaVectorSearches, + ); + const retryRunCount = Math.max( + 0, + createdByConcurrentMutation.runCount + args.deltaRunCount, + ); + appliedQgbToTotal = roundQgb( + retryQgbRead - createdByConcurrentMutation.qgbRead, + ); + appliedSearchesToTotal = + retryVectorSearches - createdByConcurrentMutation.vectorSearches; + appliedRunsToTotal = retryRunCount - createdByConcurrentMutation.runCount; + + await ctx.db.patch(createdByConcurrentMutation._id, { + qgbRead: retryQgbRead, + vectorSearches: retryVectorSearches, + runCount: retryRunCount, + updatedAt: Date.now(), + }); + } } if (total) { await ctx.db.patch(total._id, { - qgbRead: roundQgb((total.qgbRead ?? 0) + appliedQgb), - vectorSearches: Math.max(0, (total.vectorSearches ?? 0) + appliedSearches), - runCount: Math.max(0, (total.runCount ?? 0) + appliedRuns), + qgbRead: roundQgb((total.qgbRead ?? 0) + appliedQgbToTotal), + vectorSearches: Math.max( + 0, + (total.vectorSearches ?? 0) + appliedSearchesToTotal, + ), + runCount: Math.max(0, (total.runCount ?? 0) + appliedRunsToTotal), updatedAt: Date.now(), }); } else { await ctx.db.insert("vectorSearchDailyTotal", { date: args.date, - qgbRead: roundQgb(appliedQgb), - vectorSearches: Math.max(0, appliedSearches), - runCount: Math.max(0, appliedRuns), + qgbRead: roundQgb(appliedQgbToTotal), + vectorSearches: Math.max(0, appliedSearchesToTotal), + runCount: Math.max(0, appliedRunsToTotal), updatedAt: Date.now(), }); } @@ -175,25 +229,29 @@ async function releaseExpiredReservations( ctx: MutationCtx, now: number, ): Promise { - const expired = await ctx.db - .query("vectorSearchReservations") - .withIndex("by_status_expiresAt", (q) => - q.eq("status", "reserved").lte("expiresAt", now), - ) - .take(100); + for (;;) { + const expired = await ctx.db + .query("vectorSearchReservations") + .withIndex("by_status_expiresAt", (q) => + q.eq("status", "reserved").lte("expiresAt", now), + ) + .take(100); - for (const reservation of expired) { - await adjustDailyUsage(ctx, { - date: reservation.date, - shard: reservation.shard, - deltaQgbRead: -reservation.qgbReserved, - deltaVectorSearches: -reservation.vectorSearchesReserved, - deltaRunCount: 0, - }); - await ctx.db.patch(reservation._id, { - status: "released", - updatedAt: now, - }); + if (expired.length === 0) return; + + for (const reservation of expired) { + await adjustDailyUsage(ctx, { + date: reservation.date, + shard: reservation.shard, + deltaQgbRead: -reservation.qgbReserved, + deltaVectorSearches: -reservation.vectorSearchesReserved, + deltaRunCount: 0, + }); + await ctx.db.patch(reservation._id, { + status: "released", + updatedAt: now, + }); + } } } @@ -472,7 +530,7 @@ export const getRecentRunSummaryForAdmin = query({ handler: async (ctx, { limit }) => { await requireAdminUser(ctx); - const pageSize = Math.max(1, Math.min(limit ?? 50, 200)); + const pageSize = Math.floor(Math.max(1, Math.min(limit ?? 50, 200))); const rows = await ctx.db .query("vectorSearchRuns") .withIndex("by_date") @@ -523,3 +581,52 @@ export const getRecentRunSummaryForAdmin = query({ }; }, }); + +export const cleanupVectorSearchRuns = internalMutation({ + args: { + limit: v.optional(v.number()), + retentionDays: v.optional(v.number()), + autoContinue: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const configuredRetentionDays = await getConfigNumber( + ctx, + "vector_search_run_retention_days", + DEFAULT_VECTOR_SEARCH_RUN_RETENTION_DAYS, + ); + const retentionDays = Math.max( + 1, + Math.floor(args.retentionDays ?? configuredRetentionDays), + ); + const pageSize = Math.floor(Math.max(1, Math.min(args.limit ?? 100, 500))); + const cutoff = Date.now() - retentionDays * DAY_MS; + const rows = await ctx.db + .query("vectorSearchRuns") + .withIndex("by_createdAt", (q) => q.lt("createdAt", cutoff)) + .take(pageSize); + + for (const row of rows) { + await ctx.db.delete(row._id); + } + const hasMore = rows.length === pageSize; + const scheduledContinuation = Boolean(args.autoContinue ?? true) && hasMore; + if (scheduledContinuation) { + await ctx.scheduler.runAfter( + VECTOR_SEARCH_RUN_CLEANUP_CONTINUATION_DELAY_MS, + internal.vectorSearchBudget.cleanupVectorSearchRuns, + { + limit: pageSize, + retentionDays, + autoContinue: true, + }, + ); + } + + return { + deleted: rows.length, + retentionDays, + hasMore, + scheduledContinuation, + }; + }, +}); From 808d94b01ff3924429fba0982632ac465df99c88 Mon Sep 17 00:00:00 2001 From: flavius Date: Mon, 11 May 2026 08:26:40 +0300 Subject: [PATCH 19/19] optimize vector search --- packages/backend/convex/clustering.ts | 132 +++++++++--------- packages/backend/convex/config.ts | 4 +- packages/backend/convex/vectorSearchBudget.ts | 15 +- 3 files changed, 75 insertions(+), 76 deletions(-) diff --git a/packages/backend/convex/clustering.ts b/packages/backend/convex/clustering.ts index f5e5908..3f551bc 100644 --- a/packages/backend/convex/clustering.ts +++ b/packages/backend/convex/clustering.ts @@ -66,7 +66,8 @@ const DEFAULT_CLUSTER_PUBLISH_MIN_SOURCES = 2; const DEFAULT_MERGE_MIN_SIMILARITY = 0.94; const DEFAULT_MERGE_MIN_TITLE_JACCARD = 0.45; const DEFAULT_MERGE_MAX_TIME_DELTA_HOURS = 48; -const MERGE_VECTOR_SEARCH_LIMIT = 24; +const MERGE_VECTOR_SEARCH_LIMIT = 12; +const MERGE_CHANGED_SEED_LIMIT = 10; const RECLUSTER_VECTOR_SEARCH_LIMIT = 24; const MERGE_RECENT_BUCKET = "recent_2d"; const MERGE_STALE_BUCKET = "stale"; @@ -281,10 +282,6 @@ async function flushJobMetrics( metrics.elapsedMs = Date.now() - startedAt; metrics.qgbRead = estimateVectorSearchQgbRead({ vectorSearches: metrics.vectorSearches, - averageMatchesReturned: - metrics.vectorSearches > 0 - ? metrics.vectorMatchesReturned / metrics.vectorSearches - : 0, }); console.log( @@ -315,7 +312,6 @@ async function getVectorSearchBudgetState( async function reserveVectorSearch( ctx: ActionCtx, metrics: JobMetrics, - limit: number, ) { const reservation = await ctx.runMutation( internal.vectorSearchBudget.reserveUsage, @@ -324,7 +320,6 @@ async function reserveVectorSearch( runId: metrics.runId, qgbRead: estimateVectorSearchQgbRead({ vectorSearches: 1, - averageMatchesReturned: limit, }), vectorSearches: 1, }, @@ -345,13 +340,11 @@ async function reserveVectorSearch( async function consumeVectorSearchReservation( ctx: ActionCtx, reservationId: Id<"vectorSearchReservations">, - matchCount: number, ) { await ctx.runMutation(internal.vectorSearchBudget.consumeReservation, { reservationId, qgbRead: estimateVectorSearchQgbRead({ vectorSearches: 1, - averageMatchesReturned: matchCount, }), vectorSearches: 1, }); @@ -2459,38 +2452,53 @@ export const getChangedClusterCandidates = internalQuery({ ctx, { sinceTs, sinceCreationTime, recentSinceTs, limit, singletonOnly }, ) => { + const cursorCreationTime = sinceCreationTime ?? 0; + const fetchStatusPage = async ( + status: Doc<"eventCandidacy">["status"], + ) => { + const [sameTimestampRows, newerRows] = await Promise.all([ + ctx.db + .query("eventCandidacy") + .withIndex("by_status_updated_at", (q) => + q + .eq("status", status) + .eq("updatedAt", sinceTs) + .gt("_creationTime", cursorCreationTime), + ) + .order("asc") + .take(limit), + ctx.db + .query("eventCandidacy") + .withIndex("by_status_updated_at", (q) => + q.eq("status", status).gt("updatedAt", sinceTs), + ) + .order("asc") + .take(limit), + ]); + return [...sameTimestampRows, ...newerRows]; + }; + const candidacies = await Promise.all([ - ctx.db - .query("eventCandidacy") - .withIndex("by_status_updated_at", (q) => - q.eq("status", "published").gte("updatedAt", sinceTs), - ) - .order("asc") - .take(limit), - ctx.db - .query("eventCandidacy") - .withIndex("by_status_updated_at", (q) => - q.eq("status", "processing").gte("updatedAt", sinceTs), - ) - .order("asc") - .take(limit), + fetchStatusPage("published"), + fetchStatusPage("processing"), ]); - return [...candidacies[0], ...candidacies[1]] - .filter( - (row) => - row.lastArticleAt >= recentSinceTs && - (row.updatedAt > sinceTs || - (row.updatedAt === sinceTs && - row._creationTime > (sinceCreationTime ?? 0))) && - (!singletonOnly || row.articleCount <= 2), - ) + const cursorRows = [...candidacies[0], ...candidacies[1]] .sort( (a, b) => a.updatedAt - b.updatedAt || a._creationTime - b._creationTime, ) .slice(0, limit); + const rows = cursorRows + .filter( + (row) => + row.lastArticleAt >= recentSinceTs && + (!singletonOnly || row.articleCount <= 2), + ) + .slice(0, limit); + + return { rows, cursorRows }; }, }); @@ -4575,24 +4583,29 @@ export const mergeNearDuplicateEvents = internalAction({ 60, ); const recentSinceTs = Date.now() - settings.maxTimeDeltaHours * 60 * 60 * 1000; - const changedCandidateRows = await ctx.runQuery( + const changedCandidatePage = await ctx.runQuery( internal.clustering.getChangedClusterCandidates, { sinceTs, sinceCreationTime, recentSinceTs, - limit: MAX_CANDIDATE_EVENTS, + limit: MERGE_CHANGED_SEED_LIMIT, singletonOnly: false, }, ); + const changedCandidateRows = changedCandidatePage.rows; markStageDuration(metrics, "candidateFetch", candidateFetchStart); if (changedCandidateRows.length === 0) { + const advancedCursor = advanceChangedCandidateCursor( + changedCandidatePage.cursorRows, + sinceTs, + sinceCreationTime, + ); await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "mergeNearDuplicateEvents", - lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, - lastProcessedCreationTime: - jobState?.lastProcessedCreationTime ?? sinceCreationTime, + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: advancedCursor.lastProcessedCreationTime, lastProcessedDayBucket: jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), @@ -4625,11 +4638,7 @@ export const mergeNearDuplicateEvents = internalAction({ for (const candidate of seeds) { if (!candidate.embeddingId) continue; - const reservationId = await reserveVectorSearch( - ctx, - metrics, - vectorSearchLimit, - ); + const reservationId = await reserveVectorSearch(ctx, metrics); if (!reservationId) continue; let neighbors: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; @@ -4658,7 +4667,7 @@ export const mergeNearDuplicateEvents = internalAction({ await releaseVectorSearchReservation(ctx, reservationId); throw error; } - await consumeVectorSearchReservation(ctx, reservationId, neighbors.length); + await consumeVectorSearchReservation(ctx, reservationId); metrics.vectorSearches++; metrics.vectorMatchesReturned += neighbors.length; if (neighbors.length === 0) continue; @@ -4840,7 +4849,7 @@ export const mergeNearDuplicateEvents = internalAction({ ); const advancedCursor = advanceChangedCandidateCursor( - changedCandidateRows, + changedCandidatePage.cursorRows, sinceTs, sinceCreationTime, ); @@ -4951,7 +4960,7 @@ export const reclusterRecentSingletonEvents = internalAction({ ); const candidateFetchStart = Date.now(); - const changedCandidateRows = await ctx.runQuery( + const changedCandidatePage = await ctx.runQuery( internal.clustering.getChangedClusterCandidates, { sinceTs, @@ -4961,14 +4970,19 @@ export const reclusterRecentSingletonEvents = internalAction({ singletonOnly: true, }, ); + const changedCandidateRows = changedCandidatePage.rows; markStageDuration(metrics, "candidateFetch", candidateFetchStart); if (changedCandidateRows.length === 0) { + const advancedCursor = advanceChangedCandidateCursor( + changedCandidatePage.cursorRows, + sinceTs, + sinceCreationTime, + ); await ctx.runMutation(internal.clustering.upsertClusteringJobState, { jobName: "reclusterRecentSingletonEvents", - lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs, - lastProcessedCreationTime: - jobState?.lastProcessedCreationTime ?? sinceCreationTime, + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: advancedCursor.lastProcessedCreationTime, lastProcessedDayBucket: jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()), lastRunAt: Date.now(), @@ -5003,11 +5017,7 @@ export const reclusterRecentSingletonEvents = internalAction({ for (const candidate of candidates) { if (!candidate.embeddingId) continue; - const reservationId = await reserveVectorSearch( - ctx, - metrics, - vectorSearchLimit, - ); + const reservationId = await reserveVectorSearch(ctx, metrics); if (!reservationId) continue; let neighbors: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; @@ -5036,7 +5046,7 @@ export const reclusterRecentSingletonEvents = internalAction({ await releaseVectorSearchReservation(ctx, reservationId); throw error; } - await consumeVectorSearchReservation(ctx, reservationId, neighbors.length); + await consumeVectorSearchReservation(ctx, reservationId); metrics.vectorSearches++; metrics.vectorMatchesReturned += neighbors.length; if (neighbors.length === 0) continue; @@ -5211,7 +5221,7 @@ export const reclusterRecentSingletonEvents = internalAction({ ); const advancedCursor = advanceChangedCandidateCursor( - changedCandidateRows, + changedCandidatePage.cursorRows, sinceTs, sinceCreationTime, ); @@ -5464,11 +5474,7 @@ export const clusterEnrichedArticles = internalAction({ embedding: number[], ): Promise => { searchedArticleIds.add(String(articleId)); - const reservationId = await reserveVectorSearch( - ctx, - metrics, - vectorSearchLimit, - ); + const reservationId = await reserveVectorSearch(ctx, metrics); if (!reservationId) return []; let vectorResults: Array<{ _id: Id<"eventEmbeddings">; _score: number }>; @@ -5487,11 +5493,7 @@ export const clusterEnrichedArticles = internalAction({ await releaseVectorSearchReservation(ctx, reservationId); throw error; } - await consumeVectorSearchReservation( - ctx, - reservationId, - vectorResults.length, - ); + await consumeVectorSearchReservation(ctx, reservationId); metrics.vectorSearches++; metrics.vectorMatchesReturned += vectorResults.length; diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts index 78bda8a..55cf305 100644 --- a/packages/backend/convex/config.ts +++ b/packages/backend/convex/config.ts @@ -830,7 +830,7 @@ export const seedDefaults = internalMutation({ }, { key: "vector_search_daily_budget_qgb", - value: 0.25, + value: 9, description: "Daily Convex vector-search read budget in qGB. Clustering workers use this to hard-stop expensive semantic search when spend spikes.", }, @@ -860,7 +860,7 @@ export const seedDefaults = internalMutation({ }, { key: "merge_vector_search_limit", - value: 24, + value: 12, description: "Top-K limit used for event-to-event vector search during duplicate-merge passes.", }, diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts index d42d125..2269d2e 100644 --- a/packages/backend/convex/vectorSearchBudget.ts +++ b/packages/backend/convex/vectorSearchBudget.ts @@ -8,9 +8,9 @@ import { internal } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { requireAdminUser } from "./lib/betaAccess"; -const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 0.25; +const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 9; const DEFAULT_VECTOR_SEARCH_RUN_RETENTION_DAYS = 30; -const ESTIMATED_VECTOR_ROW_BYTES = 5 * 1024; +const ESTIMATED_PER_SEARCH_BYTES = 50 * 1024 * 1024; const VECTOR_SEARCH_RESERVATION_TTL_MS = 15 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000; const VECTOR_SEARCH_RUN_CLEANUP_CONTINUATION_DELAY_MS = 500; @@ -257,15 +257,12 @@ async function releaseExpiredReservations( export function estimateVectorSearchQgbRead(args: { vectorSearches: number; - averageMatchesReturned: number; - estimatedVectorRowBytes?: number; + estimatedPerSearchBytes?: number; }): number { - const estimatedVectorRowBytes = - args.estimatedVectorRowBytes ?? ESTIMATED_VECTOR_ROW_BYTES; + const estimatedPerSearchBytes = + args.estimatedPerSearchBytes ?? ESTIMATED_PER_SEARCH_BYTES; const bytesRead = - Math.max(0, args.vectorSearches) * - Math.max(0, args.averageMatchesReturned) * - estimatedVectorRowBytes; + Math.max(0, args.vectorSearches) * estimatedPerSearchBytes; return roundQgb(bytesRead / 1_000_000_000); }