diff --git a/apps/web/src/components/LanguagePicker.tsx b/apps/web/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..4c8c7c7 --- /dev/null +++ b/apps/web/src/components/LanguagePicker.tsx @@ -0,0 +1,110 @@ +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"; +import { cn } from "@/lib/utils"; + +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 }; + }); + +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(); + 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 ( +
+ {LANGUAGE_OPTIONS.map(({ locale, flag, shortLabel, translationKey }) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/SignInPrompt.tsx b/apps/web/src/components/SignInPrompt.tsx new file mode 100644 index 0000000..2637cdf --- /dev/null +++ b/apps/web/src/components/SignInPrompt.tsx @@ -0,0 +1,49 @@ +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 = { + title: string; + description: string; + redirectTo: AuthRedirectPath; + illustration?: ReactNode; +}; + +export function SignInPrompt({ + title, + description, + redirectTo, + illustration, +}: SignInPromptProps) { + const t = useT(); + + 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 34110b4..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 = "Create a free account to save and personalize your news", - description = "Reading stays open to everyone. Accounts unlock bookmarks, personalized ranking, and notifications.", + 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..bfdd63a 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/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 1dcccb3..dee7a7d 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); @@ -43,15 +45,15 @@ export default function BookmarkButton({ const toggle = useMutation({ mutationFn: useConvexMutation(api.interactions.toggleBookmark), onSuccess: (data) => { - if (data.bookmarked) { - toast.success("Bookmarked"); + if (data?.bookmarked === true) { + 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; @@ -63,9 +65,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)}`; }, @@ -105,11 +107,11 @@ 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} - aria-label={bookmarked ? "Remove bookmark" : "Bookmark this event"} + aria-label={bookmarked ? t("bookmark.remove") : t("bookmark.add")} aria-pressed={bookmarked} > ; @@ -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")} { + const locale = useLocale(); + 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); - const lastUpdatedTitle = formatAbsoluteTimestamp(lastUpdatedAt); + const lastUpdatedLabel = formatRelativeTimestamp(lastUpdatedAt, locale); + const lastUpdatedTitle = formatAbsoluteTimestamp(lastUpdatedAt, locale); const interactionContext = buildInteractionContextFromSources( event.sources ?? [], ); @@ -161,7 +164,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 && (
@@ -234,8 +237,8 @@ const EventCard = ({

{summaryPreview} @@ -269,12 +272,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 +291,10 @@ const EventCard = ({
{(["left", "center", "right"] as BiasBucket[]).map( @@ -303,9 +314,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-claim-comparison.tsx b/apps/web/src/components/feed/event-claim-comparison.tsx index 667f9c4..10b62be 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,55 +40,66 @@ 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 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("-") @@ -116,6 +128,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 +162,7 @@ function ClaimVariantRow({ ) : ( - Unknown source + {t("claim.unknownSource")} )} @@ -173,7 +186,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 +207,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 +227,16 @@ function ClaimCard({
- {statusConfig.label} + {getStatusLabel(t, status)} - {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 +265,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 +348,19 @@ export default function EventClaimComparison({ - Claim Breakdown + {t("claim.title")} -
+
- Loading claim analysis... + {t("claim.loading")}
@@ -349,18 +373,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 +443,10 @@ export default function EventClaimComparison({
- Claim Breakdown + {t("claim.title")}

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

@@ -434,25 +456,25 @@ export default function EventClaimComparison({
toggleFilter("agreements")} /> toggleFilter("divergences")} /> toggleFilter("framing")} /> toggleFilter("exclusives")} @@ -465,7 +487,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 +497,14 @@ export default function EventClaimComparison({
{visibleStatuses.map((status) => { const statusClaims = claimsByStatus.get(status) ?? []; - const copy = STATUS_COPY[status]; return (

- {copy.heading} + {getStatusHeading(t, status)}

- {copy.description} + {getStatusBody(t, status)}

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/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 b044351..fd8aed6 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,7 +1,11 @@ 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 { 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, @@ -10,23 +14,30 @@ import { } from "@/components/ui/sheet"; const links = [ - { to: "/", label: "Home", icon: Home }, - { to: "/feed", label: "Feed", icon: Newspaper }, - { to: "/bookmarks", label: "Bookmarks", icon: Bookmark }, - { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { 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 isVisible = useScrollVisibility(); + const t = useT(); const routerState = useRouterState(); const currentPath = routerState.location.pathname; return ( -

+
-
+
{/* Logo */} - +
B
@@ -35,9 +46,12 @@ export default function Header() { {/* Desktop nav */} -
-
diff --git a/apps/web/src/components/layout/Footer.tsx b/apps/web/src/components/layout/Footer.tsx new file mode 100644 index 0000000..22e98e7 --- /dev/null +++ b/apps/web/src/components/layout/Footer.tsx @@ -0,0 +1,68 @@ +import { Link } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; + +const footerSections = [ + { + titleKey: "footer.aboutSection", + links: [ + { to: "/despre", labelKey: "footer.about" }, + { to: "/contact", labelKey: "footer.contact" }, + { to: "/parteneri", labelKey: "footer.partners" }, + ], + }, + { + titleKey: "footer.resourcesSection", + links: [ + { to: "/cum-functioneaza", labelKey: "footer.howItWorks" }, + { to: "/sursele-noastre", labelKey: "footer.sources" }, + ], + }, + { + titleKey: "footer.legalSection", + links: [ + { + to: "/politica-confidentialitate", + labelKey: "footer.privacy", + }, + { to: "/termeni", labelKey: "footer.terms" }, + ], + }, +] as const; + +export function Footer() { + const t = useT(); + + return ( +
+
+
+ {footerSections.map((section, index) => ( +
+

+ {t(section.titleKey)} +

+
+ {section.links.map((link) => ( + + {t(link.labelKey)} + + ))} +
+
+ ))} +
+ +
+ © {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..dd7f49b --- /dev/null +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -0,0 +1,109 @@ +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"; + +type TabItem = { + to: string; + icon: ComponentType<{ className?: string; strokeWidth?: number }>; + isActive?: (pathname: string) => boolean; +}; + +type TabKey = + | "tabs.feed" + | "tabs.saved" + | "tabs.activity" + | "tabs.profile"; + +type TabDefinition = TabItem & { key: TabKey }; + +const tabDefinitions: readonly TabDefinition[] = [ + { + to: "/feed", + key: "tabs.feed", + icon: Newspaper, + isActive: (pathname: string) => + pathname === "/feed" || + pathname.startsWith("/feed/") || + pathname.startsWith("/event/"), + }, + { to: "/salvate", key: "tabs.saved", icon: Bookmark }, + { to: "/activitate", key: "tabs.activity", icon: BarChart3 }, + { to: "/profil", key: "tabs.profile", icon: User }, +]; + +function matchesPath(pathname: string, to: string, allowPrefix = false) { + return pathname === to || (allowPrefix && pathname.startsWith(`${to}/`)); +} + +export function MobileTabBar() { + const t = useT(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const isVisible = useScrollVisibility(); + + const handleTabClick = + (isSameDestination: boolean) => (event: MouseEvent) => { + if (!isSameDestination) { + return; + } + + event.preventDefault(); + + if (typeof window !== "undefined") { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }; + + return ( + + ); +} diff --git a/apps/web/src/components/profile/AnonymousProfile.tsx b/apps/web/src/components/profile/AnonymousProfile.tsx new file mode 100644 index 0000000..dc62ec3 --- /dev/null +++ b/apps/web/src/components/profile/AnonymousProfile.tsx @@ -0,0 +1,82 @@ +import { Link } from "@tanstack/react-router"; +import { useT } from "@/lib/i18n/LocaleContext"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export function AnonymousProfile() { + const t = useT(); + + return ( +
+
+
+
+ + +
+

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

+

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

+

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

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

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

+

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

+
+
+

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

+

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

+
+
+
+
+
+ +
+ + + {t("auth.account")} + + +
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/profile/AuthenticatedProfile.tsx b/apps/web/src/components/profile/AuthenticatedProfile.tsx new file mode 100644 index 0000000..6d04bc6 --- /dev/null +++ b/apps/web/src/components/profile/AuthenticatedProfile.tsx @@ -0,0 +1,248 @@ +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 { absoluteSiteUrl } 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 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 absoluteSiteUrl("/reset-password"); + } + + return new URL("/reset-password", window.location.origin).toString(); +} + +export function AuthenticatedProfile({ + user, +}: { + user: AuthenticatedProfileUser; +}) { + const t = useT(); + const displayName = user.profile?.name || user.name || user.email; + const avatarSrc = user.profile?.avatar || user.image || undefined; + const avatarFallback = getAvatarFallback(displayName, user.email); + const [isSendingReset, setIsSendingReset] = useState(false); + const [resetStatusMessage, setResetStatusMessage] = useState(""); + + const handleSignOut = async () => { + try { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + location.reload(); + }, + }, + }); + } catch (error) { + console.error("Sign-out failed:", error); + toast.error(t("auth.signOutError")); + } + }; + + 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} + ) : ( +
+ {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.settings")} + + +
+
+
+ + {t("settings.language")} +
+

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

+
+ +
+
+
+
+ +
+ + + {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 390a874..a1852a0 100644 --- a/apps/web/src/components/share-event-button.tsx +++ b/apps/web/src/components/share-event-button.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import type { InteractionContextSnapshot } from "@/lib/interaction-tracking"; import { getClientDeviceType } from "@/lib/interaction-tracking"; import { SITE } from "@/lib/seo"; +import { useT } from "@/lib/i18n/LocaleContext"; import { cn } from "@/lib/utils"; type ShareEventButtonProps = { @@ -31,10 +32,10 @@ export default function ShareEventButton({ size = "default", className, }: ShareEventButtonProps) { + const t = useT(); const { isAuthenticated } = useConvexAuth(); const shareOrigin = SITE.url || window.location.origin; const shareUrl = `${shareOrigin}/event/${slug}`; - const shareText = summary?.trim() ? `${title} — ${summary.trim()}` : title; const iconSize = size === "sm" ? "size-4" : "size-5"; const buttonSize = size === "sm" ? "size-8" : "size-9"; const logInteraction = useMutation({ @@ -61,7 +62,7 @@ export default function ShareEventButton({ try { await navigator.share({ title, - text: shareText, + text: summary ?? title, url: shareUrl, }); logShare(); @@ -76,10 +77,10 @@ export default function ShareEventButton({ try { await navigator.clipboard.writeText(shareUrl); logShare(); - toast.success("Event link copied"); + toast.success(t("share.copied")); } catch (error) { console.error("Share failed:", error); - toast.error("Could not share this event. Please try again."); + toast.error(t("share.failed")); } }; @@ -94,7 +95,7 @@ export default function ShareEventButton({ className, )} onClick={handleClick} - aria-label="Share this event" + aria-label={t("share.label")} > diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index 8f6ba63..88741f8 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -5,6 +5,8 @@ import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { toast } from "sonner"; import z from "zod"; +import { useT } from "@/lib/i18n/LocaleContext"; +import { absoluteSiteUrl } from "@/lib/seo"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -14,21 +16,46 @@ import { AuthDivider, GoogleSignInButton } from "./auth-social"; function getPasswordResetRedirectURL() { if (typeof window === "undefined") { - return `${redirectFallbackOrigin}/reset-password`; + return absoluteSiteUrl("/reset-password"); } - return `${window.location.origin}/reset-password`; + return new URL("/reset-password", window.location.origin).toString(); } -const redirectFallbackOrigin = "http://localhost:3001"; +function normalizeErrorCode(code: string | undefined) { + return code?.trim().toLowerCase().replace(/[\s-]+/g, "_") ?? ""; +} + +function getLocalizedSignInError( + t: ReturnType, + error: { + error?: { + code?: string; + message?: string; + statusText?: string; + }; + }, +) { + const code = normalizeErrorCode(error.error?.code); + + if (["invalid_credentials", "invalid_password", "invalid_login"].includes(code)) { + return t("auth.invalidCredentials"); + } + + return ( + error.error?.message ?? + error.error?.statusText ?? + t("auth.signInError") + ); +} export default function SignInForm({ onSwitchToSignUp, initialEmail = "", emailLocked = false, - redirectTo = "/dashboard", - title = "Welcome Back", - subtitle = "Sign in to your Biviant account", - submitLabel = "Sign In", + redirectTo = "/activitate", + title, + subtitle, + submitLabel, showGoogle = true, }: { onSwitchToSignUp?: () => void; @@ -40,11 +67,15 @@ export default function SignInForm({ submitLabel?: string; showGoogle?: boolean; }) { + const t = useT(); const navigate = useNavigate({ from: "/", }); const [isSendingReset, setIsSendingReset] = useState(false); const [resetStatusMessage, setResetStatusMessage] = useState(""); + const resolvedTitle = title ?? t("auth.signInTitle"); + const resolvedSubtitle = subtitle ?? t("auth.signInSubtitle"); + const resolvedSubmitLabel = submitLabel ?? t("auth.signIn"); const form = useForm({ defaultValues: { @@ -60,22 +91,18 @@ export default function SignInForm({ { onSuccess: () => { navigate({ to: redirectTo as never }); - toast.success("Signed in"); + toast.success(t("auth.signInSuccess")); }, onError: (error) => { - toast.error( - error.error?.message ?? - error.error?.statusText ?? - "Sign-in failed. Please try again.", - ); + toast.error(getLocalizedSignInError(t, error)); }, } ); }, validators: { onSubmit: z.object({ - email: z.email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), + email: z.email(t("auth.invalidEmail")), + password: z.string().min(8, t("auth.passwordMin")), }), }, }); @@ -86,9 +113,9 @@ export default function SignInForm({
- {title} + {resolvedTitle}

- {subtitle} + {resolvedSubtitle}

@@ -105,12 +132,13 @@ export default function SignInForm({ {(field) => (
- + field.handleChange(e.target.value)} @@ -132,7 +160,7 @@ export default function SignInForm({ {(field) => (
- +
{isSendingReset - ? "Sending password reset..." + ? t("auth.resetSendingStatus") : resetStatusMessage}
@@ -214,7 +242,8 @@ export default function SignInForm({ id={field.name} name={field.name} type="password" - placeholder="Enter your password" + aria-label={t("auth.password")} + placeholder={t("auth.passwordPlaceholder")} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} @@ -240,10 +269,10 @@ export default function SignInForm({ {state.isSubmitting ? ( <> - Signing in... + {t("auth.signingIn")} ) : ( - submitLabel + resolvedSubmitLabel )} )} @@ -260,13 +289,13 @@ export default function SignInForm({ {onSwitchToSignUp && (

- Need an account?{" "} + {t("auth.needAccount")}{" "}

diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index 2b14a41..c1c6f80 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -1,9 +1,11 @@ import { authClient } from "@/lib/auth-client"; import type { AuthRedirectPath } from "@/lib/auth-redirect"; import { useForm } from "@tanstack/react-form"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import z from "zod"; +import { useLocale, useT } from "@/lib/i18n/LocaleContext"; +import { getString } from "@/lib/i18n/strings"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -20,14 +22,43 @@ function getVerificationCallbackURL(redirectTo: AuthRedirectPath) { return `/dashboard?${params.toString()}`; } +function normalizeAuthError(value: string | undefined) { + return value?.trim().toLowerCase().replace(/[\s-]+/g, "_") ?? ""; +} + +function getLocalizedSignUpError( + t: ReturnType, + 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 = "", emailLocked = false, - redirectTo = "/dashboard", - title = "Create Account", - subtitle = "Join Biviant and see the whole story", - submitLabel = "Create Account", + redirectTo = "/activitate", + title, + subtitle, + submitLabel, showGoogle = true, }: { onSwitchToSignIn?: () => void; @@ -39,9 +70,23 @@ export default function SignUpForm({ submitLabel?: string; 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: { @@ -65,24 +110,20 @@ export default function SignUpForm({ ["localhost", "127.0.0.1"].includes(window.location.hostname); toast.success( isLocalDev - ? "Check your email to verify your account. If nothing arrives, use the verification link printed in the server logs." - : "Check your email to verify your account.", + ? t("auth.checkEmailVerifyDev") + : t("auth.checkEmailVerify"), ); form.reset(); }, onError: (error) => { console.error(error); - toast.error(error.error.message || "An unexpected error occurred. Please try again."); + toast.error(getLocalizedSignUpError(t, error)); }, } ); }, validators: { - onSubmit: z.object({ - name: z.string().min(2, "Name must be at least 2 characters"), - email: z.email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - }), + onSubmit: signUpSchema, }, }); @@ -92,9 +133,9 @@ export default function SignUpForm({
- {title} + {resolvedTitle}

- {subtitle} + {resolvedSubtitle}

@@ -111,11 +152,12 @@ export default function SignUpForm({ {(field) => (
- + field.handleChange(e.target.value)} @@ -135,12 +177,13 @@ export default function SignUpForm({ {(field) => (
- + field.handleChange(e.target.value)} @@ -161,12 +204,13 @@ export default function SignUpForm({ {(field) => (
- + field.handleChange(e.target.value)} @@ -192,10 +236,10 @@ export default function SignUpForm({ {state.isSubmitting ? ( <> - Creating account... + {t("auth.creatingAccount")} ) : ( - submitLabel + resolvedSubmitLabel )} )} @@ -219,8 +263,7 @@ export default function SignUpForm({ aria-atomic="true" >

- Didn't receive the email? Check spam, then resend the - verification link to {submittedEmail}. + {t("auth.verifyMissingEmail").replace("{email}", submittedEmail)}

)} @@ -277,13 +318,13 @@ export default function SignUpForm({ {onSwitchToSignIn && (

- Already have an account?{" "} + {t("auth.alreadyHaveAccount")}{" "}

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/components/ui/page-loading-state.tsx b/apps/web/src/components/ui/page-loading-state.tsx index 1e812ae..6b5a489 100644 --- a/apps/web/src/components/ui/page-loading-state.tsx +++ b/apps/web/src/components/ui/page-loading-state.tsx @@ -1,18 +1,25 @@ import { Loader2 } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; 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; description?: string; cardCount?: number; }) { + const t = useT(); + const resolvedTitle = title ?? t("common.loading.title"); + const resolvedDescription = description ?? t("common.loading.body"); return ( -
+
@@ -27,10 +34,17 @@ export function PageLoadingState({
- -

{title}

+ +

+ {resolvedTitle} +

- {description} + {resolvedDescription}

diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index db09090..0873e32 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -7,32 +7,48 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { authClient } from "@/lib/auth-client"; +import { useT } from "@/lib/i18n/LocaleContext"; 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(); const user = useQuery(api.user.getCurrentUser); 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 || "Account"; + const userName = user?.profile?.name || user?.email || t("user.account"); const userInitial = userName.charAt(0).toUpperCase(); + const streakLabel = + streak === 1 + ? t("user.streakOne") + : t("user.streakMany").replace("{count}", String(streak)); return ( - +
+
+ {recentHistory.length === 0 ? ( +
+ {t("activity.readingHistoryEmpty")} +
+ ) : ( +
+ {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( + Boolean, + ); + + return ( + +
+ {entry.event.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {entry.event.title} +

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

{t("activity.savedLabel")}

+

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

+
+ +
+
+ {recentBookmarks.length === 0 ? ( +
+ {t("activity.savedEmpty")} +
+ ) : ( +
+ {recentBookmarks.slice(0, 4).map((entry) => ( + +
+ {entry.event.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {entry.event.title} +

+
+ + {formatRelativeTimestamp( + entry.bookmarkedAt, + locale, + )} + + · + + {(entry.event.sourceCount ?? 0) === 1 + ? t("activity.sourcesOne") + : t("activity.sourcesMany").replace( + "{count}", + String(entry.event.sourceCount ?? 0), + )} + +
+
+ + ))} +
+ )} +
+
+
+ + {/* Quick Actions */} +
+ +
+ +
+
+

{t("activity.feedCard")}

+

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

+
+ + + +
+ +
+
+

{t("activity.savedCard")}

+

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

+
+ +
+ + {/* Admin: Topic Diagnostics */} + {isAdmin && ( +
+
+

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

+

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

+
+ +
+ + + +
+ +
+
+
+ + 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 === 1 + ? t("activity.admin.articles.one") + : t("activity.admin.articles.many").replace( + "{count}", + String(event.articleCount), + )} +

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

+ {t("activity.admin.input")} +

+
+

{event.inferenceInput.title}

+ {event.inferenceInput.summary && ( +

{event.inferenceInput.summary}

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

+ {t("activity.admin.facts")} +

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

+ {t("activity.admin.candidates")} +

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

+ {candidate.displayName} +

+

+ {t("activity.admin.signals").replace( + "{count}", + String(candidate.signalCount), + )} +

+
+

+ {candidate.score} +

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

+ {label} +

+

{value}

+
+ ); +} + +function TopicChipList({ label, topics }: { label: string; topics: string[] }) { + const t = useT(); + return ( +
+ {label}: + {topics.length > 0 ? ( + topics.map((topic) => ( + + {topic} + + )) + ) : ( + {t("activity.none")} + )} +
+ ); +} diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 3f535e9..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/contact.tsx b/apps/web/src/routes/contact.tsx new file mode 100644 index 0000000..5ce00df --- /dev/null +++ b/apps/web/src/routes/contact.tsx @@ -0,0 +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 ( +
+

+ {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 new file mode 100644 index 0000000..ff5728f --- /dev/null +++ b/apps/web/src/routes/cum-functioneaza.tsx @@ -0,0 +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 ( +
+

+ {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 066f966..e38712a 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -1,33 +1,16 @@ 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 { 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, 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 { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useConvexAuth, useQuery } from "convex/react"; +import { useEffect, useRef } 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(), @@ -35,897 +18,161 @@ const searchSchema = z.object({ 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("/dashboard")({ validateSearch: searchSchema, - head: () => ({ - meta: [ - { title: "Dashboard — Biviant" }, - { name: "robots", content: "noindex, nofollow" }, - ], - }), - component: RouteComponent, + head: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + + return { + meta: [ + { title: getString(locale, "auth.metaTitle") }, + { name: "robots", content: "noindex, nofollow" }, + ], + }; + }, + component: DashboardAuthPage, }); -function RouteComponent() { - const search = Route.useSearch(); +function DashboardAuthPage() { + const t = useT(); 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 - : "/feed"; + : "/activitate"; const hasShownVerifiedToastRef = useRef(false); const isVerified = String(search.verified) === "1"; const showSignIn = search.mode !== "signup" || isVerified; useEffect(() => { - if (!isVerified || hasShownVerifiedToastRef.current) { - return; + if (currentUser) { + void navigate({ to: redirectTo || "/activitate", replace: true }); } - - hasShownVerifiedToastRef.current = true; - toast.message("If you recently verified, you can now log in."); - void navigate({ - search: (current) => ({ - ...current, - mode: "signin", - verified: undefined, - }), + }, [currentUser, navigate]); + + const replaceDashboardSearch = ( + mode: "signin" | "signup", + verified?: string | number, + ) => { + return navigate({ + to: "/dashboard", replace: true, + search: () => ({ + mode, + redirect: search.redirect, + ...(verified !== undefined ? { verified } : {}), + }), }); - }, [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 (!isVerified || hasShownVerifiedToastRef.current) { + return; + } + + hasShownVerifiedToastRef.current = true; + toast.message(t("auth.verifiedToast")); + void replaceDashboardSearch("signin"); + }, [isVerified, search.redirect, t, navigate]); - if ( - currentUser === undefined || - dashboardOverview === undefined || - isAdmin === undefined - ) { + if (isLoading || (isAuthenticated && currentUser === 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]} -

-
+
+
+
+
+

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

+

+ {showSignIn + ? t("auth.welcomeBack") + : t("auth.createFreeAccount")} +

+

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

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

- {readingStreak} -

-

day streak

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

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

+
+ {showSignIn ? ( + { + replaceDashboardSearch("signup", search.verified); + }} + /> + ) : ( + { + replaceDashboardSearch("signin", search.verified); + }} + /> )}
- - {/* 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 -

-
-
-
-
- -
+

{t("auth.unlockTitle")}

+

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

+
    +
  1. + + 1 + + {t("auth.unlockOne")} +
  2. +
  3. + + 2 + + {t("auth.unlockTwo")} +
  4. +
  5. + + 3 + + {t("auth.unlockThree")} +
  6. +
- {/* Bias Balance */}
-

Bias Balance

-

- Your reading perspective mix +

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

+

+ {t("auth.verificationBody")}

-
- -
- {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..cb75554 --- /dev/null +++ b/apps/web/src/routes/despre.tsx @@ -0,0 +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 ( +
+

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

+

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

+
+ ); +} diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx index 895ec76..dd9ad96 100644 --- a/apps/web/src/routes/event.$slug.tsx +++ b/apps/web/src/routes/event.$slug.tsx @@ -18,12 +18,39 @@ import { getScrollDepthPercentage, } from "@/lib/interaction-tracking"; import { formatAbsoluteTimestamp, formatRelativeTimestamp } from "@/lib/dates"; -import { SITE } from "@/lib/seo"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; +import { useLocale, useT } from "@/lib/i18n/LocaleContext"; +import { getString, STRINGS, type Locale, type StringKey } from "@/lib/i18n/strings"; +import { SITE, absoluteSiteUrl } from "@/lib/seo"; const searchSchema = z.object({ returnToFeed: z.string().optional(), }); +function getPluralizedCountLabel( + locale: Locale, + baseKey: "event.articles" | "event.sourceCount", + count: number, +) { + const pluralCategory = new Intl.PluralRules(locale).select(count); + const candidates = [ + `${baseKey}.${pluralCategory}`, + `${baseKey}.other`, + count === 1 ? `${baseKey}.one` : `${baseKey}.many`, + ] as const; + + const resolvedKey = + candidates.find( + (candidate) => + 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 }) => { @@ -46,14 +73,15 @@ export const Route = createFileRoute("/event/$slug")({ return null; } }, - head: ({ loaderData, params }) => { + head: ({ loaderData, params, matches }) => { + const locale = getLocaleFromMatches(matches); const title = loaderData?.event?.title ? `${loaderData.event.title} — ${SITE.name}` - : `Event — ${SITE.name}`; + : getString(locale, "event.metaTitle"); const description = loaderData?.event?.perspectiveSummaries?.center?.slice(0, 155) ?? loaderData?.event?.globalImpact?.slice(0, 155) ?? - "Read this story from multiple perspectives on Biviant."; + getString(locale, "event.metaDescription"); const imageUrl = loaderData?.event?.shareImageUrl ?? loaderData?.event?.imageUrl; @@ -65,7 +93,7 @@ export const Route = createFileRoute("/event/$slug")({ { property: "og:site_name", content: SITE.name }, { property: "og:description", content: description }, { property: "og:type", content: "article" }, - { property: "og:url", content: `${SITE.url}/event/${params.slug}` }, + { property: "og:url", content: absoluteSiteUrl(`/event/${params.slug}`) }, ...(loaderData?.event?.firstPublishedAt ? [ { @@ -114,13 +142,15 @@ export const Route = createFileRoute("/event/$slug")({ ] : []), ], - links: [{ rel: "canonical", href: `${SITE.url}/event/${params.slug}` }], + links: [{ rel: "canonical", href: absoluteSiteUrl(`/event/${params.slug}`) }], }; }, component: EventDetailPage, }); function EventDetailPage() { + const locale = useLocale(); + const t = useT(); const { slug } = Route.useParams(); const loaderData = Route.useLoaderData(); const search = Route.useSearch(); @@ -190,7 +220,7 @@ function EventDetailPage() { aria-atomic="true" className="text-sm text-muted-foreground" > - Loading... + {t("event.loading")}
); @@ -200,12 +230,12 @@ function EventDetailPage() { return (
-

Event not found

+

{t("event.notFound")}

- The event you're looking for doesn't exist. + {t("event.notFoundBody")}

@@ -237,7 +267,7 @@ function EventDetailPage() { onClick={handleBackToFeed} className="inline-flex items-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" > - ← Back to feed + ← {t("event.backToFeed")}
@@ -251,7 +281,7 @@ function EventDetailPage() { ) : (
- Event + {t("event.cardLabel")}
)} @@ -261,7 +291,7 @@ function EventDetailPage() {

- Event Overview + {t("event.overview")}

- Updated {formatRelativeTimestamp(lastUpdatedAt)} + {t("event.updated").replace( + "{time}", + formatRelativeTimestamp(lastUpdatedAt, locale), + )}
@@ -328,11 +361,20 @@ function EventDetailPage() {
- {articles.length}{" "} - {articles.length === 1 ? "article" : "articles"} + {getPluralizedCountLabel( + locale, + "event.articles", + articles.length, + )} • - {sourceCount} sources + + {getPluralizedCountLabel( + locale, + "event.sourceCount", + sourceCount, + )} +
@@ -345,13 +387,13 @@ function EventDetailPage() { className="h-full rounded-full border-0 py-0 text-sm font-medium after:hidden data-[state=active]:border-transparent data-[state=active]:bg-background data-[state=active]:shadow-sm dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-background/80" value="perspectives" > - Perspectives + {t("event.perspectives")} - Claim Breakdown + {t("event.claimBreakdown")} @@ -361,22 +403,28 @@ function EventDetailPage() { > {hasPerspectives ? ( - + - Multiple Perspectives + {t("event.multiplePerspectives")} - + )[tabCount] ?? "grid-cols-3"}`} > {event.perspectiveSummaries?.left && ( - Left + + {t("event.left")} + )} - Center + + {t("event.centerTab")} + {event.perspectiveSummaries?.right && ( - Right + + {t("event.right")} + )} @@ -391,7 +439,7 @@ function EventDetailPage() {

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

@@ -407,15 +455,15 @@ function EventDetailPage() {
) : ( - + - Summary + {t("event.summary")} - +

{event.perspectiveSummaries?.center ?? - "Coverage grouped from multiple sources. Compare the original reporting below."} + t("event.compareOriginal")}

@@ -423,12 +471,12 @@ function EventDetailPage() { {event.globalImpact && ( - + - What This Means + {t("event.meaning")} - +

{event.globalImpact}

diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index 34af7c7..1b61806 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -38,28 +38,38 @@ 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"; -import { SITE } from "@/lib/seo"; +import { SITE, absoluteSiteUrl } from "@/lib/seo"; export const Route = createFileRoute("/feed")({ - head: () => ({ - meta: [ - { title: `News 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.", - }, - { property: "og:title", content: `News Feed — ${SITE.name}` }, - { - property: "og:description", - content: - "Browse today's top stories from multiple political perspectives.", - }, - { property: "og:url", content: `${SITE.url}/feed` }, - ], - links: [{ rel: "canonical", href: `${SITE.url}/feed` }], - }), + head: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + const title = getString(locale, "feed.meta.title"); + const description = getString(locale, "feed.meta.description"); + + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:site_name", content: SITE.name }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { property: "og:image", content: SITE.ogImage }, + { name: "twitter:image", content: SITE.ogImage }, + { property: "og:url", content: absoluteSiteUrl("/feed") }, + { property: "og:type", content: "website" }, + { name: "twitter:card", content: "summary_large_image" }, + { property: "og:locale", content: locale === "ro" ? "ro_RO" : "en_US" }, + ], + links: [{ rel: "canonical", href: absoluteSiteUrl("/feed") }], + }; + }, component: FeedComponent, }); @@ -72,18 +82,23 @@ 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 +131,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); @@ -134,16 +153,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="Filter by topic" + aria-label={t("feed.topic.filter")} > - - - {selectedLabel} + + + {selectedLabel} - + ); @@ -156,9 +175,9 @@ function TopicFilter({
- Filter by Topic + {t("feed.topic.drawerTitle")} - Select a topic to filter stories + {t("feed.topic.drawerBody")}
@@ -166,9 +185,10 @@ function TopicFilter({ variant="ghost" size="icon" className="size-8 rounded-full" + aria-label={t("feed.close")} > - Close + {t("feed.close")}
@@ -189,7 +209,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 +237,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 +251,8 @@ function FeedComponent() { } function FeedContent() { + const t = useT(); + const isMobile = useIsMobile(); const { isAuthenticated } = useConvexAuth(); const topics = useQuery(api.topics.getTopics); const currentUser = useQuery( @@ -256,9 +278,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 { @@ -267,7 +296,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 { @@ -307,6 +338,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, @@ -318,7 +388,6 @@ function FeedContent() { : { topicId: selectedTopic, sort: feedSort }, { initialNumItems: pageSize }, ); - const isSearching = debouncedSearch.length >= 2; const searchResults = useQuery( api.events.searchPublishedEvents, isSearching @@ -329,23 +398,27 @@ function FeedContent() { } : "skip", ); + const canLoadMore = !isSearching && status === "CanLoadMore"; + const isLoadingMore = !isSearching && status === "LoadingMore"; + const loadMoreRef = useRef(loadMore); 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]); @@ -360,6 +433,46 @@ function FeedContent() { : "skip", ); + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + 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; + loadMoreRef.current(pageSize); + }, + { + rootMargin: "1200px 0px", + }, + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [canLoadMore, pageSize]); + useEffect(() => { if ( debouncedSearch.length < 2 || @@ -402,138 +515,166 @@ 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 (
-
-
-
-
-
-
-
- setSearchInput(event.target.value)} - onFocus={() => setIsSearchFocused(true)} - onBlur={() => { - window.setTimeout(() => setIsSearchFocused(false), 100); - }} - placeholder="Search events by title..." - className="h-11 rounded-full border-border/80 bg-background/75 pr-12" - aria-label="Search events" - /> - {searchInput.length > 0 && ( - - )} -
-
- -
-
- {!isSearching && ( -
-
- )} - onClick={() => setFeedSort("recent")} - aria-pressed={feedSort === "recent"} - > - - Recent - -
+
+
+
+ +
+ {!isSearching && ( +
+ + +
)} - onClick={() => setFeedSort("trending")} - aria-pressed={feedSort === "trending"} - > - - Trending - +
-

- {feedSort === "recent" - ? "Newest published events first." - : "Ranked by source diversity, article volume, and recency."} -

-
- )} - {shouldShowThresholdHint && ( -

- Type 2+ characters to search. -

- )} - {shouldShowRecentSearches && ( -
- {recentSearches.map((recentSearch) => ( - + ))} +
+ )} + {isSearching && ( +

- {recentSearch} - - ))} + {t("feed.search.indexed").replace( + "{query}", + debouncedSearch, + )} +

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

- Showing indexed search results for “{debouncedSearch}”. -

- )} -
+
+
- +
{!isAuthenticated && ( )} @@ -544,7 +685,7 @@ function FeedContent() { aria-live="polite" className="rounded-[1.2rem] border border-border/70 bg-card/70 px-5 py-8 text-sm text-muted-foreground" > - Searching events… + {t("feed.searching")}
)} {status === "LoadingFirstPage" && ( @@ -553,7 +694,7 @@ function FeedContent() { aria-live="polite" className="rounded-[1.2rem] border border-border/70 bg-card/70 px-5 py-8 text-sm text-muted-foreground" > - Loading… + {t("feed.loading")}
)} @@ -561,11 +702,10 @@ function FeedContent() {

- {feedSort === "recent" ? "Lead Story" : "Trending Story"} + {feedSort === "recent" + ? t("feed.leadStory") + : t("feed.trendingStory")}

-

- {feedSort === "recent" ? "Featured" : "Ranked"} -

- Top Search Match + {t("feed.topSearch")}

- Search + {t("feed.searchTag")}

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

- {isSearching ? "More Search Results" : "More Events"} + {isSearching ? t("feed.moreSearch") : t("feed.moreEvents")}

-

- {isSearching - ? "Best matches" - : feedSort === "recent" - ? "Latest coverage" - : "Ranked coverage"} -

+ {isSearching ? ( +

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

+ ) : null}
- {(isSearching ? remainingSearchEvents : remainingEvents)?.map((event) => ( - - ))} + {(isSearching ? remainingSearchEvents : remainingEvents)?.map( + (event) => ( + + ), + )}
) : null} @@ -631,17 +773,17 @@ function FeedContent() { {isSearching && searchResults?.length === 0 && (
-

No events matched “{debouncedSearch}”.

-

Try fewer keywords.

+

{t("feed.noMatch").replace("{query}", debouncedSearch)}

+

{t("feed.tryFewer")}

{fallbackEvents && fallbackEvents.length > 0 && (

- From your preferred topics + {t("feed.preferredTopics")}

- Latest 5 + {t("feed.latestFive")}

@@ -664,21 +806,27 @@ function FeedContent() { status !== "LoadingFirstPage" && (!events || events.length === 0) && (
- No events found. + {t("feed.none")}
)}
- {!isSearching && status === "CanLoadMore" && ( -
- + {!isSearching && (canLoadMore || isLoadingMore) && ( +
+ )}
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..9bab66a --- /dev/null +++ b/apps/web/src/routes/parteneri.tsx @@ -0,0 +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 ( +
+

+ {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 new file mode 100644 index 0000000..c5c9044 --- /dev/null +++ b/apps/web/src/routes/politica-confidentialitate.tsx @@ -0,0 +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 ( +
+

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

+

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

+
+ ); +} diff --git a/apps/web/src/routes/profil.tsx b/apps/web/src/routes/profil.tsx new file mode 100644 index 0000000..03a7739 --- /dev/null +++ b/apps/web/src/routes/profil.tsx @@ -0,0 +1,42 @@ +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 { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; +import { getString } from "@/lib/i18n/strings"; +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: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + + return { + meta: [{ title: getString(locale, "profile.metaTitle") }], + }; + }, + component: ProfilPage, +}); + +function ProfilPage() { + const { isAuthenticated, isLoading } = useConvexAuth(); + const user = useQuery(api.user.getCurrentUser); + const t = useT(); + + if (isLoading || (isAuthenticated && user === undefined)) { + return ( + + ); + } + + if (isAuthenticated && user) { + return ; + } + + return ; +} diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx index 44f0264..d175298 100644 --- a/apps/web/src/routes/reset-password.tsx +++ b/apps/web/src/routes/reset-password.tsx @@ -1,8 +1,12 @@ +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"; import { useForm } from "@tanstack/react-form"; import { toast } from "sonner"; @@ -13,39 +17,42 @@ const searchSchema = z.object({ error: z.string().optional(), }); -const resetPasswordSchema = z - .object({ - password: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string(), - }) - .refine((value) => value.password === value.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); - -function getResetErrorMessage(error?: string) { - if (!error) return null; - - if (error === "INVALID_TOKEN") { - return "This password reset link is invalid or has expired. Request a new one from the sign-in form."; - } - - return "We couldn't verify this password reset link. Request a new one and try again."; -} - export const Route = createFileRoute("/reset-password")({ validateSearch: searchSchema, - head: () => ({ - meta: [{ title: "Reset Password — Biviant" }], - }), + head: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + + return { + meta: [{ title: getString(locale, "reset.metaTitle") }], + }; + }, component: ResetPasswordRoute, }); function ResetPasswordRoute() { - const search = Route.useSearch(); const navigate = useNavigate({ from: "/reset-password" }); + const t = useT(); + const search = Route.useSearch(); const token = search.token?.trim(); - const errorMessage = getResetErrorMessage(search.error); + 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 + : search.error === "INVALID_TOKEN" + ? t("reset.invalidToken") + : t("reset.verifyLink"); const form = useForm({ defaultValues: { @@ -58,7 +65,7 @@ function ResetPasswordRoute() { onSubmit: async ({ value }) => { if (!token) { toast.error( - "This password reset link is invalid or has expired. Request a new one and try again.", + t("reset.invalidToast"), ); return; } @@ -70,16 +77,14 @@ function ResetPasswordRoute() { }, { onSuccess: () => { - toast.success("Your password has been reset. You can sign in now."); + toast.success(t("reset.success")); void navigate({ to: "/dashboard", search: { mode: "signin" }, }); }, onError: (error) => { - const message = - error.error.message || - "We couldn't reset your password. Request a new link and try again."; + const message = error.error.message || t("reset.error"); toast.error(message); }, }, @@ -93,10 +98,10 @@ function ResetPasswordRoute() { - Reset your password + {t("reset.title")}

- Choose a new password for your Biviant account. + {t("reset.body")}

@@ -109,11 +114,11 @@ function ResetPasswordRoute() { aria-live="polite" > {errorMessage || - "This password reset link is missing a token. Request a new one from the sign-in form."} + t("reset.missingToken")}

@@ -130,13 +135,13 @@ function ResetPasswordRoute() { {(field) => (
- + @@ -145,7 +150,7 @@ function ResetPasswordRoute() { className="h-11" /> {field.state.meta.errors.map((error, index) => ( -

+

{error?.message}

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

+

{error?.message}

))} @@ -190,8 +197,8 @@ function ResetPasswordRoute() { disabled={!state.canSubmit || state.isSubmitting} > {state.isSubmitting - ? "Updating password..." - : "Reset password"} + ? t("reset.updating") + : t("reset.submit")} )} @@ -202,7 +209,7 @@ function ResetPasswordRoute() { search={{ mode: "signin" }} className="text-primary hover:underline font-medium" > - Back to sign in + {t("reset.backToSignIn")}
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/salvate.tsx b/apps/web/src/routes/salvate.tsx new file mode 100644 index 0000000..a002af2 --- /dev/null +++ b/apps/web/src/routes/salvate.tsx @@ -0,0 +1,185 @@ +import { useMemo } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { api } from "@news-app/backend/convex/_generated/api"; +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(1, Math.floor(n)) : fallback; +} + +export const Route = createFileRoute("/salvate")({ + head: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + + return { + meta: [ + { title: getString(locale, "saved.metaTitle") }, + { name: "robots", content: "noindex, nofollow" }, + ], + }; + }, + component: SalvatePage, +}); + +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 ( + + ); +} + +function SalvateContent() { + const t = useT(); + 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 ( +
+
+
+
+
+
+

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

+
+

+ {t("saved.heading")} +

+

+ {bookmarks.length === 0 + ? t("saved.summary.empty") + : bookmarks.length === 1 + ? t("saved.summary.one") + : t("saved.summary.many").replace( + "{count}", + String(bookmarks.length), + )} +

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

{t("saved.none")}

+

+ {t("saved.noneBody")} +

+
+ +
+
+ ) : ( +
+ {bookmarks.map((event) => ( + + ))} +
+ )} +
+
+
+ ); +} 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 d752d8a..5459807 100644 --- a/apps/web/src/routes/source.$sourceId.tsx +++ b/apps/web/src/routes/source.$sourceId.tsx @@ -12,7 +12,10 @@ import BiasIndicator from "@/components/bias-indicator"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatAbsoluteTimestamp, formatRelativeTimestamp } from "@/lib/dates"; -import { SITE } from "@/lib/seo"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; +import { useLocale, useT } from "@/lib/i18n/LocaleContext"; +import { getString } from "@/lib/i18n/strings"; +import { SITE, absoluteSiteUrl } from "@/lib/seo"; export const Route = createFileRoute("/source/$sourceId")({ loader: async ({ context, params }) => { @@ -41,12 +44,18 @@ export const Route = createFileRoute("/source/$sourceId")({ return null; } }, - head: ({ loaderData, params }) => { - const sourceName = loaderData?.source.name ?? "Source Profile"; - const title = `${sourceName} — ${SITE.name}`; - const description = loaderData - ? `Review ${sourceName}'s bias, reliability, credibility metadata, and recent coverage on ${SITE.name}.` - : "Review source bias, reliability, credibility metadata, and recent articles on Biviant."; + head: ({ loaderData, params, matches }) => { + const locale = getLocaleFromMatches(matches); + const sourceName = loaderData?.source?.name; + const title = sourceName + ? `${sourceName} — ${SITE.name}` + : getString(locale, "source.metaTitle"); + const description = loaderData?.source + ? getString(locale, "source.metaDescriptionLoaded").replace( + "{name}", + sourceName ?? getString(locale, "source.metaTitle"), + ) + : getString(locale, "source.metaDescription"); return { meta: [ @@ -56,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, @@ -74,8 +91,8 @@ function formatBiasLabel(label: string) { .join("-"); } -function formatOptional(value: string | undefined) { - if (!value) return "Not rated"; +function formatOptional(value: string | undefined, fallback: string) { + if (!value) return fallback; return value .split("-") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) @@ -107,6 +124,8 @@ function SourceProfilePage() { } function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { + const locale = useLocale(); + const t = useT(); const loaderData = Route.useLoaderData(); const queryData = useQuery(api.sources.getSourceProfile, { sourceId, @@ -127,7 +146,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { aria-live="polite" className="rounded-xl border border-border/70 bg-card/70 px-5 py-8 text-sm text-muted-foreground" > - Loading source profile... + {t("source.loading")}
); @@ -137,12 +156,12 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { return (
-

Source not found

+

{t("source.notFound")}

- This source is not available. + {t("source.notFoundBody")}

@@ -161,7 +180,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { className="inline-flex w-fit items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" > - Back to feed + {t("source.backToFeed")}
@@ -184,7 +203,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {

- Source Profile + {t("source.profile")}

{source.name} @@ -200,7 +219,10 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { {formatBiasLabel(source.biasLabel)} - Reliability {source.reliabilityScore}/10 + {t("source.reliability").replace( + "{score}", + String(source.reliabilityScore), + )}

@@ -220,26 +242,32 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {

- Recent Articles + {t("source.recentArticles")}

{stats.totalArticles}

-

Events

+

{t("source.events")}

{stats.eventCount}

-

AI Bias Avg

+

+ {t("source.aiBiasAvg")} +

- {averageAiBias === null ? "N/A" : averageAiBias.toFixed(1)} + {averageAiBias === null + ? t("source.notRated") + : averageAiBias.toFixed(1)}

-

Outliers

+

+ {t("source.outliers")} +

{stats.biasOutlierCount + stats.sourceBiasOutlierCount}

@@ -253,40 +281,58 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { - Credibility + {t("source.credibilityTitle")}
-

MBFC category

+

+ {t("source.mbfcCategory")} +

- {formatOptional(source.mbfcCategory)} + {formatOptional(source.mbfcCategory, t("source.notRated"))}

-

Factual rating

+

+ {t("source.factualRating")} +

- {formatOptional(source.mbfcFactual)} + {formatOptional(source.mbfcFactual, t("source.notRated"))}

-

Credibility

+

+ {t("source.credibilityLabel")} +

- {formatOptional(source.mbfcCredibility)} + {formatOptional( + source.mbfcCredibility, + t("source.notRated"), + )}

- Rolling AI bias sample + {t("source.rollingSample")}

- {source.rollingBiasSampleSize ?? 0} articles + {t("source.rollingArticles").replace( + "{count}", + String(source.rollingBiasSampleSize ?? 0), + )}

{typeof source.rollingBiasMean === "number" && (

- Mean {source.rollingBiasMean.toFixed(1)} + {t("source.mean").replace( + "{value}", + source.rollingBiasMean.toFixed(1), + )} {typeof source.rollingBiasStddev === "number" - ? ` · Stddev ${source.rollingBiasStddev.toFixed(1)}` + ? ` · ${t("source.stddev").replace( + "{value}", + source.rollingBiasStddev.toFixed(1), + )}` : ""}

)} @@ -298,7 +344,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { - Recent Reporting + {t("source.recentReporting")} @@ -321,19 +367,29 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { )}
- - {formatRelativeTimestamp(article.publishedAt)} + + {formatRelativeTimestamp(article.publishedAt, locale)} {typeof article.aiBiasScore === "number" && ( <> · - AI bias {article.aiBiasScore.toFixed(1)} + + {t("source.aiBias").replace( + "{count}", + article.aiBiasScore.toFixed(1), + )} + )} {(article.biasOutlierFlag || article.sourceBiasOutlierFlag) && ( - Outlier + {t("source.outlier")} )}
@@ -356,7 +412,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { ) : ( - Not clustered + {t("source.notClustered")} )} }) { rel="noopener noreferrer" className="inline-flex items-center gap-1 rounded-full border border-border/80 bg-background px-3 py-1 text-xs font-medium text-primary hover:bg-muted" > - Read original + {t("articles.readOriginal")}
@@ -385,15 +441,16 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) { } function InvalidSourceId() { + const t = useT(); return (
-

Source not found

+

{t("source.notFound")}

- This source link is invalid or no longer available. + {t("source.invalidBody")}

diff --git a/apps/web/src/routes/sursele-noastre.tsx b/apps/web/src/routes/sursele-noastre.tsx new file mode 100644 index 0000000..11ba474 --- /dev/null +++ b/apps/web/src/routes/sursele-noastre.tsx @@ -0,0 +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 ( +
+

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

+

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

+
+ ); +} diff --git a/apps/web/src/routes/termeni.tsx b/apps/web/src/routes/termeni.tsx new file mode 100644 index 0000000..6dea516 --- /dev/null +++ b/apps/web/src/routes/termeni.tsx @@ -0,0 +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 ( +
+

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

+

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

+
+ ); +} diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx index 34e60c8..87d3903 100644 --- a/apps/web/src/routes/unsubscribe.tsx +++ b/apps/web/src/routes/unsubscribe.tsx @@ -6,6 +6,9 @@ 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 { AlertCircle, CheckCircle, @@ -20,29 +23,35 @@ const searchSchema = z.object({ export const Route = createFileRoute("/unsubscribe")({ validateSearch: searchSchema, - head: () => ({ - meta: [ - { title: "Unsubscribe — Biviant" }, - { name: "robots", content: "noindex, nofollow" }, - ], - }), + head: ({ matches }) => { + const locale = getLocaleFromMatches(matches); + + return { + meta: [ + { title: getString(locale, "unsubscribe.meta.title") }, + { name: "robots", content: "noindex, nofollow" }, + ], + }; + }, component: UnsubscribePage, }); function UnsubscribePage() { + const t = useT(); const { email } = Route.useSearch(); const [lastEmail, setLastEmail] = useState(undefined); 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 ( @@ -65,13 +74,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 +98,7 @@ function UnsubscribePage() { role="status" aria-live="polite" > - Unsubscribing... + {t("unsubscribe.loading")}

); @@ -98,16 +108,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 +127,20 @@ 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)}

-

Changed your mind?

+

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

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..3f551bc 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"; @@ -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; @@ -65,8 +66,38 @@ 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"; +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 +175,190 @@ 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: ActionCtx, + metrics: JobMetrics, + startedAt: number, +): Promise { + metrics.elapsedMs = Date.now() - startedAt; + metrics.qgbRead = estimateVectorSearchQgbRead({ + vectorSearches: metrics.vectorSearches, + }); + + 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: ActionCtx, +) { + return await ctx.runQuery(internal.vectorSearchBudget.checkBudget, {}); +} + +async function reserveVectorSearch( + ctx: ActionCtx, + metrics: JobMetrics, +) { + const reservation = await ctx.runMutation( + internal.vectorSearchBudget.reserveUsage, + { + jobName: metrics.jobName, + runId: metrics.runId, + qgbRead: estimateVectorSearchQgbRead({ + vectorSearches: 1, + }), + 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: ActionCtx, + reservationId: Id<"vectorSearchReservations">, +) { + await ctx.runMutation(internal.vectorSearchBudget.consumeReservation, { + reservationId, + qgbRead: estimateVectorSearchQgbRead({ + vectorSearches: 1, + }), + vectorSearches: 1, + }); +} + +async function releaseVectorSearchReservation( + ctx: ActionCtx, + reservationId: Id<"vectorSearchReservations">, +) { + await ctx.runMutation(internal.vectorSearchBudget.releaseReservation, { + reservationId, + }); +} + function appendArticleEmbeddingToEventMean( existingEventEmbedding: number[], currentArticleCount: number, @@ -532,6 +747,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 +1793,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 +2134,8 @@ export const backfillEventCandidacy = internalAction({ { eventId: event._id, status: event.status, + lastArticleAt, + articleCount, }, ); @@ -1839,6 +2155,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 +2313,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 +2440,142 @@ 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, 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([ + fetchStatusPage("published"), + fetchStatusPage("processing"), + ]); + + 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 }; + }, +}); + +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(); + }, +}); + +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()), + }, + 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, + lastProcessedCreationTime: args.lastProcessedCreationTime, + 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 +2586,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 +2644,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 +2670,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 +2761,11 @@ export const createEventFromArticle = internalMutation({ embedding: toEventEmbedding(eventEmbedding), version, status: initialStatus, + ...buildEventEmbeddingFilterFields({ + status: initialStatus, + lastArticleAt: publishedAt, + articleCount: 1, + }), }); const candidacyTokens = buildCandidacyFromArticle( @@ -2345,6 +2942,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 +2954,11 @@ export const attachArticleToEvent = internalMutation({ embedding: nextEmbedding, version, status: nextStatus, + ...buildEventEmbeddingFilterFields({ + status: nextStatus, + lastArticleAt: nextLastArticleAt, + articleCount: nextArticleCount, + }), }); } @@ -3795,6 +4402,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 +4414,11 @@ export const mergeEvents = internalMutation({ embedding: mergedEmbedding, version, status: mergedStatus, + ...buildEventEmbeddingFilterFields({ + status: mergedStatus, + lastArticleAt: mergedLastArticleAt, + articleCount: mergedArticleCount, + }), }); } @@ -3909,22 +4526,33 @@ 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 sinceCreationTime = jobState?.lastProcessedCreationTime ?? 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 +4576,123 @@ 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 changedCandidatePage = await ctx.runQuery( + internal.clustering.getChangedClusterCandidates, + { + sinceTs, + sinceCreationTime, + recentSinceTs, + 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: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: advancedCursor.lastProcessedCreationTime, + 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; - const neighbors = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", + const reservationId = await reserveVectorSearch(ctx, metrics); + 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); + metrics.vectorSearches++; + metrics.vectorMatchesReturned += neighbors.length; + if (neighbors.length === 0) continue; + + const hydratedResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEmbeddingMatches, { - vector: toEventEmbedding(candidate.embedding), - limit: MERGE_VECTOR_SEARCH_LIMIT, - filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), + embeddingMatches: neighbors.map((result) => ({ + embeddingId: result._id, + similarity: result._score, + })), }, ); - - for (const neighbor of neighbors) { - const match = candidatesByEmbeddingId.get(String(neighbor._id)); + 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 +4700,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 +4838,32 @@ 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 advancedCursor = advanceChangedCandidateCursor( + changedCandidatePage.cursorRows, + sinceTs, + sinceCreationTime, + ); + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "mergeNearDuplicateEvents", + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: + advancedCursor.lastProcessedCreationTime, + lastProcessedDayBucket: formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs, examinedPairs, skipped }; } finally { try { @@ -4186,11 +4911,31 @@ 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 sinceCreationTime = jobState?.lastProcessedCreationTime ?? 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 +4952,126 @@ 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 changedCandidatePage = await ctx.runQuery( + internal.clustering.getChangedClusterCandidates, { - sinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000, + sinceTs, + sinceCreationTime, + 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 ?? []), - })); + 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: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: advancedCursor.lastProcessedCreationTime, + 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; - const neighbors = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", + const reservationId = await reserveVectorSearch(ctx, metrics); + 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); + metrics.vectorSearches++; + metrics.vectorMatchesReturned += neighbors.length; + if (neighbors.length === 0) continue; + + const hydratedResults = await ctx.runQuery( + internal.clustering.getClusterCandidatesByEmbeddingMatches, { - vector: toEventEmbedding(candidate.embedding), - limit: RECLUSTER_VECTOR_SEARCH_LIMIT, - filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), + embeddingMatches: neighbors.map((result) => ({ + embeddingId: result._id, + similarity: result._score, + })), }, ); - - for (const neighbor of neighbors) { - const match = candidatesByEmbeddingId.get(String(neighbor._id)); + 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 +5079,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 +5210,32 @@ 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 advancedCursor = advanceChangedCandidateCursor( + changedCandidatePage.cursorRows, + sinceTs, + sinceCreationTime, + ); + await ctx.runMutation(internal.clustering.upsertClusteringJobState, { + jobName: "reclusterRecentSingletonEvents", + lastProcessedAt: advancedCursor.lastProcessedAt, + lastProcessedCreationTime: + advancedCursor.lastProcessedCreationTime, + lastProcessedDayBucket: formatUtcDayBucket(Date.now()), + lastRunAt: Date.now(), + lastRunMetricsJson: JSON.stringify(metrics), + }); + await flushJobMetrics(ctx, metrics, startedAt); + return { mergedPairs, examinedPairs, skipped }; } finally { try { @@ -4456,6 +5289,9 @@ export const clusterEnrichedArticles = internalAction({ }; } + const startedAt = Date.now(); + const metrics = createJobMetrics("clusterEnrichedArticles"); + try { const hasEnriched = await ctx.runQuery( internal.clustering.hasEnrichedArticlesForClustering, @@ -4505,6 +5341,7 @@ export const clusterEnrichedArticles = internalAction({ "topic_inference_min_score", "topic_inference_confidence_ratio", "topic_inference_max_topics", + "clustering_vector_search_limit", ], }); @@ -4586,27 +5423,80 @@ 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(); - const loadCandidatesForEmbedding = async ( - embedding: number[], - ): Promise => { - vectorSearches++; - const vectorResults = await ctx.vectorSearch( - "eventEmbeddings", - "by_embedding", + let batchStateVersion = 0; + const searchedArticleIds = new Set(); + const articleBatchVersionSeen = new Map(); + if (useFallbackMode) { + const fallbackCandidates = await ctx.runQuery( + internal.clustering.getRecentClusterCandidates, { - vector: toEventEmbedding(embedding), - limit: VECTOR_SEARCH_LIMIT, - filter: (q) => - q.or(q.eq("status", "published"), q.eq("status", "processing")), + 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 => { + searchedArticleIds.add(String(articleId)); + const reservationId = await reserveVectorSearch(ctx, metrics); + 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); + metrics.vectorSearches++; - vectorMatches += vectorResults.length; + metrics.vectorMatchesReturned += vectorResults.length; if (vectorResults.length === 0) return []; @@ -4619,23 +5509,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 +5544,8 @@ export const clusterEnrichedArticles = internalAction({ publishedAt: number; id: string; }; + lastBatchStateVersionSeen: number; + needsFreshVectorSearch: boolean; }; const pendingArticles: PendingArticle[] = []; @@ -4698,33 +5583,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 +5637,36 @@ export const clusterEnrichedArticles = internalAction({ return "attached"; }; + const tryBatchLocalAttach = async ( + payload: AttachPayload, + heuristicOnly = false, + ): 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 +5686,9 @@ export const clusterEnrichedArticles = internalAction({ paddedEmbedding, topicSlugs, }; - const outcome = await tryAttach(payload); + const outcome = useFallbackMode + ? await tryBatchLocalAttach(payload) + : await tryVectorAttach(payload); if (outcome === "unmatched") { const entityTokenCount = extractEntityTokens( article.title, @@ -4791,7 +5706,10 @@ export const clusterEnrichedArticles = internalAction({ publishedAt: article.publishedAt, id: String(article._id), }, + lastBatchStateVersionSeen: batchStateVersion, + needsFreshVectorSearch: false, }); + articleBatchVersionSeen.set(String(article._id), batchStateVersion); } } @@ -4805,7 +5723,25 @@ export const clusterEnrichedArticles = internalAction({ ); for (const pending of pendingArticles) { - const outcome = await tryAttach(pending); + let outcome = await tryBatchLocalAttach(pending); + 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 +5812,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..55cf305 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: {}, @@ -339,6 +350,86 @@ 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 < 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 < 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 < TOPIC_INFERENCE_BOUNDS.maxTopics.min || + args.maxTopics > TOPIC_INFERENCE_BOUNDS.maxTopics.max + ) { + 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() }, @@ -737,6 +828,48 @@ 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: 9, + 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: "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, + description: + "Top-K limit used for article-to-event vector search during clusterEnrichedArticles.", + }, + { + key: "merge_vector_search_limit", + value: 12, + 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([ 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/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/migrations.ts b/packages/backend/convex/migrations.ts index e8cf869..cf60aa5 100644 --- a/packages/backend/convex/migrations.ts +++ b/packages/backend/convex/migrations.ts @@ -18,6 +18,9 @@ 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[]; @@ -456,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( @@ -496,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, }, ); } @@ -517,6 +529,82 @@ export const queueEventShareAssetsBackfill = mutation({ isDone: page.isDone, continueCursor: page.continueCursor, scheduledContinuation, + remainingPages, + }; + }, +}); + +export const deleteInvalidEventEmbeddingsFor512dVectorIndex = mutation({ + args: { + 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( + 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; + 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 && + remainingPages > 0 && + !page.isDone && + Boolean(nextCursor); + + if (scheduledContinuation && nextCursor) { + await ctx.scheduler.runAfter( + MIGRATION_CONTINUATION_DELAY_MS, + api.migrations.deleteInvalidEventEmbeddingsFor512dVectorIndex, + { + cursor: nextCursor, + pageSize: safePageSize, + autoContinue: true, + remainingPages: remainingPages - 1, + }, + ); + } + + return { + 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 0aa14a6..3b3fabf 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", + ], }), // ========================================================================= @@ -140,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) @@ -449,6 +462,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"]) @@ -669,6 +685,70 @@ 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_createdAt", ["createdAt"]) + .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) // ========================================================================= @@ -679,4 +759,14 @@ export default defineSchema({ updatedAt: v.number(), expiresAt: v.number(), }).index("by_key", ["key"]), + + 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()), + updatedAt: v.number(), + }).index("by_job_name", ["jobName"]), }); diff --git a/packages/backend/convex/seeds.ts b/packages/backend/convex/seeds.ts index 519cbe6..eed30c9 100644 --- a/packages/backend/convex/seeds.ts +++ b/packages/backend/convex/seeds.ts @@ -144,19 +144,30 @@ 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); + const day = new Date().toISOString().slice(0, 10); await ctx.db.insert("eventEmbeddings", { eventId: eventFedRates, embedding: dummyEmbedding, version: 1, status: "published", + recentWindowBucket: "recent_2d", + singletonBucket: "multi", + updatedDayBucket: day, + mergeSearchBucket: `published::recent_2d::${day}`, + singletonSearchBucket: `published::multi::${day}`, }); await ctx.db.insert("eventEmbeddings", { eventId: eventAIRegulations, embedding: dummyEmbedding, version: 1, status: "published", + recentWindowBucket: "recent_2d", + singletonBucket: "multi", + updatedDayBucket: day, + mergeSearchBucket: `published::recent_2d::${day}`, + singletonSearchBucket: `published::multi::${day}`, }); console.log("✅ Created 2 eventEmbeddings"); 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. diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts new file mode 100644 index 0000000..2269d2e --- /dev/null +++ b/packages/backend/convex/vectorSearchBudget.ts @@ -0,0 +1,629 @@ +import { v } from "convex/values"; +import { + internalMutation, + 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 = 9; +const DEFAULT_VECTOR_SEARCH_RUN_RETENTION_DAYS = 30; +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; + +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); + 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( + 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); + let appliedQgbToTotal = appliedQgb; + let appliedSearchesToTotal = appliedSearches; + let appliedRunsToTotal = appliedRuns; + + if (existing) { + await ctx.db.patch(existing._id, { + qgbRead: nextQgbRead, + vectorSearches: nextVectorSearches, + runCount: nextRunCount, + updatedAt: Date.now(), + }); + } else { + 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) + 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(appliedQgbToTotal), + vectorSearches: Math.max(0, appliedSearchesToTotal), + runCount: Math.max(0, appliedRunsToTotal), + updatedAt: Date.now(), + }); + } +} + +async function releaseExpiredReservations( + ctx: MutationCtx, + now: number, +): Promise { + for (;;) { + const expired = await ctx.db + .query("vectorSearchReservations") + .withIndex("by_status_expiresAt", (q) => + q.eq("status", "reserved").lte("expiresAt", now), + ) + .take(100); + + 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, + }); + } + } +} + +export function estimateVectorSearchQgbRead(args: { + vectorSearches: number; + estimatedPerSearchBytes?: number; +}): number { + const estimatedPerSearchBytes = + args.estimatedPerSearchBytes ?? ESTIMATED_PER_SEARCH_BYTES; + const bytesRead = + Math.max(0, args.vectorSearches) * estimatedPerSearchBytes; + 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 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(), + 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); + 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(); + + 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, + 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.floor(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; + } + })(), + })), + }; + }, +}); + +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, + }; + }, +});