@@ -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 */}
-
-
- {links.map(({ to, label, icon: Icon }) => {
- const isActive = to === "/" ? currentPath === "/" : currentPath.startsWith(to);
+
+ {links.map(({ to, key, icon: Icon }) => {
+ const isActive =
+ currentPath === to || currentPath.startsWith(`${to}/`);
return (
setOpen(false)}
>
- {label}
+ {t(key)}
);
})}
+
+
+
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 (
+
+ );
+}
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 (
+
+
+
+ {tabDefinitions.map(({ to, key, icon: Icon, isActive: customIsActive }) => {
+ const isActive = customIsActive
+ ? customIsActive(pathname)
+ : matchesPath(pathname, to);
+ const isSameDestination = pathname === to;
+ const label = t(key);
+
+ return (
+
+
+
+ {label}
+
+
+ );
+ })}
+
+
+
+ );
+}
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 ? (
+

+ ) : (
+
+ {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 (
-
diff --git a/apps/web/src/hooks/use-scroll-visibility.ts b/apps/web/src/hooks/use-scroll-visibility.ts
new file mode 100644
index 0000000..8df2f41
--- /dev/null
+++ b/apps/web/src/hooks/use-scroll-visibility.ts
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from "react";
+
+type ScrollListener = (scrollY: number) => void;
+
+const listeners = new Set
();
+let frameId = 0;
+
+function emitScrollPosition() {
+ frameId = 0;
+ const scrollY = window.scrollY || window.pageYOffset || 0;
+
+ listeners.forEach((listener) => {
+ listener(scrollY);
+ });
+}
+
+function scheduleEmit() {
+ if (frameId !== 0) {
+ return;
+ }
+
+ frameId = window.requestAnimationFrame(emitScrollPosition);
+}
+
+function subscribeToScroll(listener: ScrollListener) {
+ listeners.add(listener);
+
+ if (listeners.size === 1) {
+ window.addEventListener("scroll", scheduleEmit, { passive: true });
+ window.addEventListener("resize", scheduleEmit, { passive: true });
+ }
+
+ listener(window.scrollY || window.pageYOffset || 0);
+
+ return () => {
+ listeners.delete(listener);
+
+ if (listeners.size === 0) {
+ window.removeEventListener("scroll", scheduleEmit);
+ window.removeEventListener("resize", scheduleEmit);
+
+ if (frameId !== 0) {
+ window.cancelAnimationFrame(frameId);
+ frameId = 0;
+ }
+ }
+ };
+}
+
+export function useScrollVisibility({
+ deltaThreshold = 12,
+ topOffset = 24,
+}: {
+ deltaThreshold?: number;
+ topOffset?: number;
+} = {}) {
+ const [isVisible, setIsVisible] = useState(true);
+ const isVisibleRef = useRef(true);
+ const lastScrollYRef = useRef(0);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ return subscribeToScroll((currentScrollY) => {
+ const previousScrollY = lastScrollYRef.current;
+ const delta = currentScrollY - previousScrollY;
+
+ if (Math.abs(delta) < deltaThreshold) {
+ return;
+ }
+
+ const nextVisible =
+ currentScrollY <= topOffset ? true : delta < 0;
+
+ lastScrollYRef.current = currentScrollY;
+
+ if (isVisibleRef.current === nextVisible) {
+ return;
+ }
+
+ isVisibleRef.current = nextVisible;
+ setIsVisible(nextVisible);
+ });
+ }, [deltaThreshold, topOffset]);
+
+ return isVisible;
+}
diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts
index 685fc7c..9f8a313 100644
--- a/apps/web/src/lib/auth-redirect.ts
+++ b/apps/web/src/lib/auth-redirect.ts
@@ -1,5 +1,7 @@
export type AuthRedirectPath =
| "/"
+ | "/activitate"
+ | "/salvate"
| "/bookmarks"
| "/dashboard"
| "/feed"
@@ -15,6 +17,8 @@ export function isAuthRedirectPath(value: string): value is AuthRedirectPath {
return (
value === "/" ||
+ value === "/activitate" ||
+ value === "/salvate" ||
value === "/bookmarks" ||
value === "/dashboard" ||
value === "/feed" ||
diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts
index 84b2f07..b3d3209 100644
--- a/apps/web/src/lib/dates.ts
+++ b/apps/web/src/lib/dates.ts
@@ -1,16 +1,29 @@
-const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, {
- numeric: "auto",
-});
+import type { Locale } from "@/lib/i18n/strings";
function isFiniteTimestamp(timestamp: number): boolean {
return Number.isFinite(timestamp);
}
-export function formatRelativeTimestamp(timestamp: number): string {
+function getIntlLocale(locale?: Locale): string | undefined {
+ if (locale === "ro") return "ro-RO";
+ if (locale === "en") return "en-US";
+ return undefined;
+}
+
+export function formatRelativeTimestamp(
+ timestamp: number,
+ locale?: Locale,
+): string {
if (!isFiniteTimestamp(timestamp)) {
- return "recently";
+ return locale === "ro" ? "recent" : "recently";
}
+ const relativeTimeFormatter = new Intl.RelativeTimeFormat(
+ getIntlLocale(locale),
+ {
+ numeric: "auto",
+ },
+ );
const now = Date.now();
const diffMs = timestamp - now;
const diffMinutes = Math.round(diffMs / (60 * 1000));
@@ -43,12 +56,15 @@ export function formatRelativeTimestamp(timestamp: number): string {
return relativeTimeFormatter.format(diffYears, "year");
}
-export function formatAbsoluteTimestamp(timestamp: number): string {
+export function formatAbsoluteTimestamp(
+ timestamp: number,
+ locale?: Locale,
+): string {
if (!isFiniteTimestamp(timestamp)) {
- return "Unknown time";
+ return locale === "ro" ? "Oră necunoscută" : "Unknown time";
}
- return new Intl.DateTimeFormat(undefined, {
+ return new Intl.DateTimeFormat(getIntlLocale(locale), {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(timestamp));
diff --git a/apps/web/src/lib/i18n/LocaleContext.tsx b/apps/web/src/lib/i18n/LocaleContext.tsx
new file mode 100644
index 0000000..c88722c
--- /dev/null
+++ b/apps/web/src/lib/i18n/LocaleContext.tsx
@@ -0,0 +1,28 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { getString, type Locale, type StringKey } from "./strings";
+
+const LocaleContext = createContext("en");
+
+export function LocaleProvider({
+ locale,
+ children,
+}: {
+ locale: Locale;
+ children: ReactNode;
+}) {
+ return (
+ {children}
+ );
+}
+
+export function useLocale(): Locale {
+ return useContext(LocaleContext);
+}
+
+export function useT() {
+ const locale = useLocale();
+
+ return (key: StringKey, fallback?: string): string => {
+ return getString(locale, key, fallback);
+ };
+}
diff --git a/apps/web/src/lib/i18n/getLocaleFromMatches.ts b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
new file mode 100644
index 0000000..c91373b
--- /dev/null
+++ b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
@@ -0,0 +1,17 @@
+import type { Locale } from "./strings";
+
+const SUPPORTED_LOCALES = ["ro", "en"] as const satisfies readonly Locale[];
+
+export function getLocaleFromMatches(
+ matches: ReadonlyArray<{ context?: unknown }>,
+): Locale {
+ const rootContext = matches[0]?.context;
+ if (!rootContext || typeof rootContext !== "object") {
+ return "en";
+ }
+
+ const locale =
+ "locale" in rootContext ? (rootContext.locale as string | undefined) : null;
+
+ return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "en";
+}
diff --git a/apps/web/src/lib/i18n/getServerLocale.ts b/apps/web/src/lib/i18n/getServerLocale.ts
new file mode 100644
index 0000000..1eeb29e
--- /dev/null
+++ b/apps/web/src/lib/i18n/getServerLocale.ts
@@ -0,0 +1,44 @@
+import { createServerFn } from "@tanstack/react-start";
+import {
+ getCookie,
+ getRequestHeader,
+ getRequestUrl,
+} from "@tanstack/react-start/server";
+import { resolveLocale } from "./resolveLocale";
+import type { Locale } from "./strings";
+
+type GetServerLocaleInput = {
+ userPreference?: string | null;
+};
+
+function validateInput(input: unknown): GetServerLocaleInput {
+ if (!input || typeof input !== "object") {
+ return {};
+ }
+
+ const record = input as Record;
+ return {
+ userPreference:
+ typeof record.userPreference === "string" ? record.userPreference : null,
+ };
+}
+
+export const getServerLocale = createServerFn({ method: "GET" })
+ .inputValidator(validateInput)
+ .handler(({ data }): Locale => {
+ const requestUrl = getRequestUrl({
+ xForwardedHost: true,
+ xForwardedProto: true,
+ });
+
+ return resolveLocale({
+ searchParam: requestUrl.searchParams.get("lang"),
+ cookieValue: getCookie("bv_locale") ?? null,
+ userPreference: data.userPreference ?? null,
+ countryCode:
+ getRequestHeader("x-vercel-ip-country") ??
+ getRequestHeader("cf-ipcountry") ??
+ null,
+ acceptLanguage: getRequestHeader("accept-language") ?? null,
+ });
+ });
diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts
new file mode 100644
index 0000000..faf1ad5
--- /dev/null
+++ b/apps/web/src/lib/i18n/resolveLocale.ts
@@ -0,0 +1,59 @@
+import type { Locale } from "./strings";
+
+const SUPPORTED: readonly Locale[] = ["ro", "en"] as const;
+const DEFAULT_LOCALE: Locale = "en";
+
+export interface LocaleResolutionInput {
+ searchParam?: string | null;
+ cookieValue?: string | null;
+ userPreference?: string | null;
+ countryCode?: string | null;
+ acceptLanguage?: string | null;
+}
+
+export function resolveLocale(input: LocaleResolutionInput): Locale {
+ if (isSupported(input.searchParam)) return input.searchParam;
+ if (isSupported(input.cookieValue)) return input.cookieValue;
+ if (isSupported(input.userPreference)) return input.userPreference;
+ if (input.countryCode?.toUpperCase() === "RO") return "ro";
+ const fromAccept = parseAcceptLanguage(input.acceptLanguage);
+ if (fromAccept) return fromAccept;
+ return DEFAULT_LOCALE;
+}
+
+function isSupported(value: string | null | undefined): value is Locale {
+ return value === "ro" || value === "en";
+}
+
+function parseAcceptLanguage(header: string | null | undefined): Locale | null {
+ if (!header) return null;
+
+ const weightedCodes = header
+ .split(",")
+ .map((part) => {
+ const [languagePart, ...params] = part.split(";");
+ const code = languagePart?.trim().toLowerCase().split("-")[0];
+ const qParam = params
+ .map((param) => param.trim())
+ .find((param) => param.startsWith("q="));
+ const weight = Number.parseFloat(qParam?.slice(2) ?? "1");
+
+ return {
+ code,
+ weight: Number.isFinite(weight) ? weight : 1,
+ };
+ })
+ .filter(
+ (entry): entry is { code: string; weight: number } =>
+ Boolean(entry.code) && entry.weight > 0,
+ )
+ .sort((a, b) => b.weight - a.weight);
+
+ for (const { code } of weightedCodes) {
+ if (SUPPORTED.includes(code as Locale)) {
+ return code as Locale;
+ }
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts
new file mode 100644
index 0000000..1f12762
--- /dev/null
+++ b/apps/web/src/lib/i18n/strings.ts
@@ -0,0 +1,1019 @@
+export type Locale = "ro" | "en";
+
+const ro = {
+ "seo.siteTitle": "Biviant — fiecare poveste de știri, analizată fapt cu fapt",
+ "seo.siteDescription":
+ "Biviant nu îți arată doar perspective diferite, ci îți arată exact ce susține fiecare parte, unde sunt de acord și unde apar distorsiuni.",
+ "router.notFound": "Pagina nu a fost găsită",
+ "tabs.feed": "Feed",
+ "tabs.saved": "Salvate",
+ "tabs.activity": "Activitate",
+ "tabs.profile": "Profil",
+ "nav.mobile": "Navigație mobilă",
+ "common.loading.title": "Se încarcă",
+ "common.loading.body": "Pregătim informațiile.",
+ "auth.signUp": "Creează cont gratuit",
+ "auth.signIn": "Autentificare",
+ "auth.signOut": "Deconectare",
+ "auth.signOutError": "Nu am putut finaliza deconectarea. Încearcă din nou.",
+ "auth.email": "E-mail",
+ "auth.password": "Parolă",
+ "auth.name": "Nume",
+ "auth.emailPlaceholder": "tu@exemplu.com",
+ "auth.namePlaceholder": "Numele tău",
+ "auth.passwordPlaceholder": "Introdu parola",
+ "auth.passwordCreatePlaceholder": "Creează o parolă",
+ "auth.signInSuccess": "Te-ai autentificat",
+ "auth.signInError": "Autentificarea a eșuat. Încearcă din nou.",
+ "auth.invalidCredentials": "E-mailul sau parola sunt incorecte.",
+ "auth.invalidEmail": "Adresă de e-mail invalidă",
+ "auth.passwordMin": "Parola trebuie să aibă cel puțin 8 caractere",
+ "auth.nameMin": "Numele trebuie să aibă cel puțin 2 caractere",
+ "auth.forgotPassword": "Ai uitat parola?",
+ "auth.sending": "Se trimite...",
+ "auth.needAccount": "Nu ai cont?",
+ "auth.alreadyHaveAccount": "Ai deja un cont?",
+ "auth.resetEmailFirst":
+ "Introdu mai întâi adresa de e-mail, ca să știm unde să trimitem linkul de resetare.",
+ "auth.resetLinkFailed":
+ "Nu am putut trimite linkul de resetare. Încearcă din nou.",
+ "auth.resetLinkSent":
+ "Dacă acel e-mail există, am trimis un link de resetare a parolei.",
+ "auth.resetSendingStatus": "Trimitem emailul pentru resetarea parolei...",
+ "auth.checkEmailVerify": "Verifică e-mailul pentru a-ți confirma contul.",
+ "auth.checkEmailVerifyDev":
+ "Verifică e-mailul pentru a-ți confirma contul. Dacă nu apare nimic, folosește linkul de verificare afișat în logurile serverului.",
+ "auth.unexpectedError": "A apărut o eroare neașteptată. Încearcă din nou.",
+ "auth.emailInUse": "Există deja un cont pentru această adresă de e-mail.",
+ "auth.verifyMissingEmail":
+ "Nu ai primit e-mailul? Verifică spamul, apoi retrimite linkul de verificare la {email}.",
+ "auth.verifyResendFailed":
+ "Nu am putut retrimite e-mailul de verificare. Încearcă din nou.",
+ "auth.verifyResent":
+ "E-mailul de verificare a fost trimis. Verifică inboxul pentru noul link.",
+ "auth.verifyResending": "Trimitem e-mailul de verificare...",
+ "auth.verifyResend": "Retrimite e-mailul de verificare",
+ "auth.continueGoogle": "Continuă cu Google",
+ "auth.account": "Cont Biviant",
+ "auth.welcomeBack": "Bine ai revenit",
+ "auth.createFreeAccount": "Creează un cont gratuit",
+ "auth.accountIntro":
+ "Poți citi feedul fără cont. Autentificarea îți oferă salvări sincronizate, preferințe personale și acces rapid la activitatea ta.",
+ "auth.signInTitle": "Autentifică-te în cont",
+ "auth.signInSubtitle":
+ "Accesează salvările, preferințele și experiența ta personalizată.",
+ "auth.signUpTitle": "Creează-ți contul",
+ "auth.signUpSubtitle":
+ "Contul gratuit deblochează salvările, feed-ul personalizat și notificările viitoare.",
+ "auth.createAccount": "Creează cont",
+ "auth.signingIn": "Te autentificăm...",
+ "auth.creatingAccount": "Creăm contul...",
+ "auth.orContinueWith": "Sau continuă cu",
+ "auth.googleError": "Autentificarea cu Google a eșuat. Încearcă din nou.",
+ "auth.promptTitle":
+ "Creează un cont gratuit ca să salvezi și să personalizezi știrile",
+ "auth.promptBody":
+ "Poți citi fără cont. Autentificarea adaugă salvări, recomandări personale și funcții viitoare.",
+ "share.copied": "Linkul evenimentului a fost copiat",
+ "share.failed": "Nu am putut distribui acest eveniment. Încearcă din nou.",
+ "share.label": "Distribuie acest eveniment",
+ "auth.unlockTitle": "Ce deblochează un cont",
+ "auth.unlockBody":
+ "Conținutul rămâne deschis. Un cont adaugă doar funcții personale și persistente.",
+ "auth.unlockOne": "Salvezi articole și evenimente pentru mai târziu.",
+ "auth.unlockTwo": "Îți personalizezi experiența pe baza intereselor tale.",
+ "auth.unlockThree":
+ "Primești acces la notificări și funcții noi pe măsură ce apar.",
+ "auth.verificationTitle": "Verificarea contează",
+ "auth.verificationBody":
+ "Conturile create cu email și parolă necesită verificarea adresei înainte de prima conectare. Pentru autentificarea cu Google, verificarea furnizorului rămâne suficientă.",
+ "auth.verifiedToast": "Dacă ți-ai verificat contul, te poți conecta acum.",
+ "saved.empty.title": "Salvează articole pentru mai târziu",
+ "saved.empty.body":
+ "Fă-ți o listă personală cu tot ce vrei să citești sau să recitești.",
+ "saved.loading.title": "Se încarcă salvările",
+ "saved.loading.body":
+ "Pregătim articolele salvate și contextul lor.",
+ "saved.checking.title": "Verificăm salvările",
+ "saved.checking.body":
+ "Confirmăm sesiunea înainte să încărcăm articolele tale salvate.",
+ "saved.section": "Salvate",
+ "saved.heading": "Articolele tale salvate",
+ "saved.none": "Nu ai salvări încă",
+ "saved.noneBody":
+ "Când găsești o poveste importantă, folosește iconița de salvare ca să o păstrezi aici.",
+ "saved.browseFeed": "Explorează feed-ul",
+ "saved.summary.empty": "Articolele și evenimentele salvate vor apărea aici.",
+ "saved.summary.one": "Ai 1 articol salvat pentru mai târziu.",
+ "saved.summary.many": "Ai {count} articole salvate pentru mai târziu.",
+ "saved.metaTitle": "Salvate — Biviant",
+ "activity.empty.title": "Urmărește-ți obiceiurile de citire",
+ "activity.empty.body":
+ "Vezi balanța de bias, streak-urile de citire și statisticile tale într-un singur loc.",
+ "activity.loading.title": "Se încarcă activitatea",
+ "activity.loading.body":
+ "Pregătim salvările, streak-urile și statisticile tale.",
+ "activity.checking.title": "Verificăm sesiunea",
+ "activity.checking.body":
+ "Pregătim activitatea ta și verificăm starea contului.",
+ "activity.savedLabel": "Salvate",
+ "activity.savedSub": "Salvate pentru mai târziu",
+ "activity.savedAll": "Toate",
+ "activity.savedEmpty": "Salvează evenimente din feed ca să apară aici",
+ "activity.welcomeBack": "Bine ai revenit",
+ "activity.dayStreak": "zile consecutive",
+ "activity.daysToGoal": "{count} zile până la {milestone}",
+ "activity.articlesRead": "articole citite",
+ "activity.eventsExplored": "evenimente explorate",
+ "activity.bookmarked": "salvate",
+ "activity.section": "Activitate",
+ "activity.last12Weeks": "Ultimele 12 săptămâni de lectură",
+ "activity.current": "Curent",
+ "activity.best": "Record",
+ "activity.active": "Active",
+ "activity.biasBalance": "Balanță de bias",
+ "activity.biasMix": "Mixul perspectivelor din lectura ta",
+ "activity.recentReading": "Lectură recentă",
+ "activity.recentReadingBody": "Evenimente la care ai petrecut timp",
+ "activity.readingHistoryEmpty": "Istoricul tău de lectură va apărea aici",
+ "activity.sourcesOne": "1 sursă",
+ "activity.sourcesMany": "{count} surse",
+ "activity.feedCard": "Explorează feed-ul",
+ "activity.feedCardBody": "Vezi subiectele de astăzi",
+ "activity.savedCard": "Salvate",
+ "activity.savedOne": "1 articol salvat",
+ "activity.savedMany": "{count} articole salvate",
+ "activity.biasSnapshot.balanced": "Săptămâna aceasta ai avut un mix echilibrat",
+ "activity.biasSnapshot.left": "Săptămâna aceasta ai înclinat spre stânga",
+ "activity.biasSnapshot.leftStrong":
+ "Săptămâna aceasta ai înclinat puternic spre stânga",
+ "activity.biasSnapshot.right": "Săptămâna aceasta ai înclinat spre dreapta",
+ "activity.biasSnapshot.rightStrong":
+ "Săptămâna aceasta ai înclinat puternic spre dreapta",
+ "activity.biasReads": "{count} lecturi",
+ "activity.metaTitle": "Activitate — Biviant",
+ "profile.metaTitle": "Profil — Biviant",
+ "auth.metaTitle": "Cont — Biviant",
+ "activity.userFallback": "Utilizator",
+ "activity.adminTitle": "Diagnostic pentru inferența topicurilor",
+ "activity.adminBody": "Revizuiește alocările recente de topic pentru evenimente",
+ "activity.admin.minScore": "Scor minim",
+ "activity.admin.confidence": "Coeficient de încredere",
+ "activity.admin.maxTopics": "Număr maxim de topicuri",
+ "activity.admin.save": "Salvează setările",
+ "activity.admin.saving": "Se salvează...",
+ "activity.admin.reset": "Resetează",
+ "activity.admin.saved": "Setările pentru inferența topicurilor au fost salvate.",
+ "activity.admin.saveError":
+ "Nu am putut salva setările. Încearcă din nou.",
+ "activity.admin.minScoreError":
+ "Scorul minim trebuie să fie un număr între 1 și 20.",
+ "activity.admin.confidenceError":
+ "Raportul de încredere trebuie să fie între 0,1 și 1.",
+ "activity.admin.maxTopicsError":
+ "Numărul maxim de topicuri trebuie să fie un întreg între 1 și 5.",
+ "activity.admin.articles.one": "1 articol",
+ "activity.admin.articles.many": "{count} articole",
+ "activity.admin.attached": "Atașate",
+ "activity.admin.inferred": "Deduse",
+ "activity.admin.input": "Date de inferență",
+ "activity.admin.facts": "Fapte",
+ "activity.admin.candidates": "Candidați principali",
+ "activity.admin.signals": "{count} semnale",
+ "activity.none": "Niciunul",
+ "profile.preparing.title": "Se pregătește profilul",
+ "profile.preparing.body": "Încărcăm preferințele și detaliile contului tău.",
+ "profile.account": "Contul tău",
+ "profile.verified": "E-mail verificat",
+ "profile.signInBody":
+ "Autentifică-te pentru a gestiona limba, securitatea și preferințele contului.",
+ "profile.settings": "Setări",
+ "profile.settingsBody": "Opțiuni rapide pentru preferințele tale.",
+ "profile.theme": "Temă",
+ "profile.themeBody": "Selectorul de temă va fi disponibil în curând.",
+ "profile.notifications": "Notificări",
+ "profile.notificationsBody":
+ "Preferințele pentru alerte și rezumate vor apărea aici.",
+ "profile.comingSoon": "În curând",
+ "profile.accountSection": "Cont",
+ "profile.accountBody":
+ "Informațiile tale de autentificare și opțiunile sensibile ale contului.",
+ "profile.changePassword": "Schimbă parola",
+ "profile.passwordBody":
+ "Primești prin e-mail un link sigur pentru resetarea parolei.",
+ "profile.deleteAccount": "Solicită ștergerea contului",
+ "profile.requestDeletion": "Solicită ștergerea contului",
+ "profile.emailLabel": "E-mail",
+ "profile.security": "Siguranța contului",
+ "profile.securityBody":
+ "Dacă vrei modificări mai sensibile, ne poți scrie din pagina de contact și te ajutăm manual.",
+ "profile.hero":
+ "Înțelege știrile din mai multe perspective, fără zgomot inutil.",
+ "profile.heroBody":
+ "Biviant te ajută să urmărești biasul, comparațiile între surse și divergențele factuale, iar contul gratuit îți păstrează salvările și preferințele sincronizate.",
+ "profile.learnMore": "Află mai multe",
+ "profile.learnMoreBody":
+ "Explorează cum funcționează produsul și de unde provin perspectivele din feed.",
+ "profile.feature.bias": "Bias per articol",
+ "profile.feature.biasBody":
+ "Vezi rapid cum se poziționează sursele și ce nuanțe adaugă fiecare.",
+ "profile.feature.compare": "Comparație de perspective",
+ "profile.feature.compareBody":
+ "Pune față în față relatări diferite pentru același eveniment, fără să schimbi aplicația.",
+ "profile.feature.facts": "Divergențe factuale",
+ "profile.feature.factsBody":
+ "Observă unde apar discrepanțe reale între afirmațiile făcute de surse.",
+ "settings.title": "Setări",
+ "settings.language": "Limbă",
+ "settings.language.ro": "Română",
+ "settings.language.en": "Engleză",
+ "user.menu": "Meniul utilizatorului",
+ "user.account": "Cont",
+ "user.streakOne": "1 zi consecutivă",
+ "user.streakMany": "{count} zile consecutive",
+ "user.profileSoon": "Profilul complet apare în curând",
+ "header.primaryNav": "Navigație principală",
+ "header.mobileNav": "Navigație mobilă",
+ "header.openMenu": "Deschide meniul",
+ "footer.aboutSection": "Despre",
+ "footer.resourcesSection": "Resurse",
+ "footer.legalSection": "Legal",
+ "footer.about": "Despre noi",
+ "footer.contact": "Contact",
+ "footer.partners": "Parteneri",
+ "footer.privacy": "Confidențialitate",
+ "footer.terms": "Termeni",
+ "footer.howItWorks": "Cum funcționează",
+ "footer.sources": "Sursele noastre",
+ "placeholder.inProgress": "Pagina este în lucru.",
+ "page.about.title": "Despre noi",
+ "page.about.body":
+ "Pagina este în lucru. Pregătim o prezentare scurtă despre Biviant și misiunea noastră.",
+ "page.contact.title": "Contact",
+ "page.contact.body":
+ "Pagina este în lucru. Vom adăuga în curând detaliile de contact și canalele prin care ne poți scrie.",
+ "page.partners.title": "Parteneri",
+ "page.partners.body":
+ "Pagina este în lucru. Aici vor apărea partenerii, colaborările și inițiativele susținute de Biviant.",
+ "page.how.title": "Cum funcționează",
+ "page.how.body":
+ "Pagina este în lucru. Pregătim o explicație simplă despre feed, analiza biasului și comparația dintre surse.",
+ "page.privacy.title": "Politica de confidențialitate",
+ "page.privacy.body":
+ "Pagina este în lucru. Vom publica aici informațiile despre datele colectate, utilizare și drepturile tale.",
+ "page.terms.title": "Termeni",
+ "page.terms.body":
+ "Pagina este în lucru. Urmează să adăugăm termenii și condițiile de utilizare pentru Biviant.",
+ "page.sources.title": "Sursele noastre",
+ "page.sources.body":
+ "Pagina este în lucru. În curând vei vedea cum selectăm sursele și cum construim perspectivele din feed.",
+ "feed.meta.title": "Feed — Biviant",
+ "feed.meta.description":
+ "Explorează subiectele importante ale zilei din perspective multiple. Filtrează după topic și urmărește același eveniment în mai multe surse.",
+ "feed.topic.search": "Caută topicuri...",
+ "feed.topic.empty": "Nu am găsit topicuri.",
+ "feed.topic.all": "Toate topicurile",
+ "feed.topic.single": "Topic",
+ "feed.topic.filter": "Filtrează după topic",
+ "feed.topic.drawerTitle": "Filtrează după topic",
+ "feed.topic.drawerBody": "Selectează un topic pentru a filtra poveștile",
+ "feed.close": "Închide",
+ "feed.search.placeholder": "Caută evenimente după titlu...",
+ "feed.search.label": "Caută evenimente",
+ "feed.search.clear": "Șterge căutarea",
+ "feed.sort.recent": "Recente",
+ "feed.sort.trending": "În tendințe",
+ "feed.search.threshold": "Introdu cel puțin 2 caractere pentru a căuta.",
+ "feed.search.indexed": "Afișăm rezultatele indexate pentru „{query}”.",
+ "feed.authTitle": "Conturile gratuite deblochează salvările și un feed personalizat",
+ "feed.authBody":
+ "Poți citi totul anonim. Autentifică-te doar când vrei salvări sincronizate, clasare adaptată intereselor tale și alerte viitoare.",
+ "feed.searching": "Căutăm evenimente...",
+ "feed.loading": "Se încarcă…",
+ "feed.leadStory": "Subiect principal",
+ "feed.trendingStory": "Subiect în tendințe",
+ "feed.topSearch": "Cel mai bun rezultat",
+ "feed.searchTag": "Căutare",
+ "feed.moreSearch": "Mai multe rezultate",
+ "feed.moreEvents": "Mai multe evenimente",
+ "feed.bestMatches": "Cele mai bune potriviri",
+ "feed.noMatch": "Niciun eveniment nu se potrivește pentru „{query}”.",
+ "feed.tryFewer": "Încearcă mai puține cuvinte-cheie.",
+ "feed.preferredTopics": "Din topicurile tale preferate",
+ "feed.latestFive": "Ultimele 5",
+ "feed.none": "Nu am găsit evenimente.",
+ "feed.loadMore": "Încarcă mai mult",
+ "feed.filter.clear": "Elimină filtrul",
+ "event.general": "General",
+ "event.summaryPending": "Rezumatul se pregătește...",
+ "event.coveragePreview":
+ "Acoperire agregată din mai multe surse. Deschide evenimentul pentru a compara articolele.",
+ "event.updated": "Actualizat {time}",
+ "event.sources": "{count} surse",
+ "event.articles.one": "1 articol",
+ "event.articles.few": "{count} articole",
+ "event.articles.many": "{count} articole",
+ "event.articles.other": "{count} articole",
+ "event.follow": "Urmărește evenimentul",
+ "event.biasDistribution":
+ "Distribuția biasului surselor: {left} stânga, {center} centru, {right} dreapta",
+ "event.bias.left": "{count} stânga",
+ "event.bias.center": "{count} centru",
+ "event.bias.right": "{count} dreapta",
+ "event.metaTitle": "Eveniment — Biviant",
+ "event.metaDescription":
+ "Citește această poveste din mai multe perspective pe Biviant.",
+ "event.loading": "Se încarcă...",
+ "event.notFound": "Evenimentul nu a fost găsit",
+ "event.notFoundBody": "Evenimentul pe care îl cauți nu există.",
+ "event.backToFeed": "Înapoi la feed",
+ "event.cardLabel": "Eveniment",
+ "event.overview": "Privire de ansamblu",
+ "event.perspectives": "Perspective",
+ "event.claimBreakdown": "Analiza afirmațiilor",
+ "event.multiplePerspectives": "Perspective multiple",
+ "event.summary": "Rezumat",
+ "event.meaning": "Ce înseamnă asta",
+ "event.compareOriginal":
+ "Acoperire grupată din mai multe surse. Compară mai jos articolele originale.",
+ "event.left": "Stânga",
+ "event.centerTab": "Centru",
+ "event.right": "Dreapta",
+ "event.sourceCount.one": "1 sursă",
+ "event.sourceCount.few": "{count} surse",
+ "event.sourceCount.many": "{count} surse",
+ "event.sourceCount.other": "{count} surse",
+ "claim.agreement": "Acord",
+ "claim.agreements": "Acorduri",
+ "claim.agreementBody": "Afirmații confirmate de mai multe surse.",
+ "claim.divergence": "Divergență",
+ "claim.divergences": "Divergențe",
+ "claim.divergenceBody":
+ "Afirmații unde sursele raportează detalii semnificativ diferite.",
+ "claim.framing": "Încadrare",
+ "claim.framings": "Diferențe de încadrare",
+ "claim.framingBody":
+ "Fapte comune descrise cu limbaj semnificativ diferit.",
+ "claim.leftExclusive": "Exclusiv din stânga",
+ "claim.leftExclusives": "Exclusive din stânga",
+ "claim.leftExclusiveBody":
+ "Afirmații substanțiale găsite doar în surse de stânga sau centru-stânga.",
+ "claim.rightExclusive": "Exclusiv din dreapta",
+ "claim.rightExclusives": "Exclusive din dreapta",
+ "claim.rightExclusiveBody":
+ "Afirmații substanțiale găsite doar în surse de dreapta sau centru-dreapta.",
+ "claim.centerExclusive": "Exclusiv din centru",
+ "claim.centerExclusives": "Exclusive din centru",
+ "claim.centerExclusiveBody":
+ "Afirmații substanțiale găsite doar în surse de centru.",
+ "claim.unknownSource": "Sursă necunoscută",
+ "claim.readSourceArticle": "Citește articolul sursă",
+ "claim.importance": "importanță",
+ "claim.source.one": "1 sursă",
+ "claim.source.many": "{count} surse",
+ "claim.showLess": "Arată mai puțin",
+ "claim.showMore.one": "Arată încă 1 sursă",
+ "claim.showMore.many": "Arată încă {count} surse",
+ "claim.title": "Analiza afirmațiilor",
+ "claim.subtitle": "Compară cum relatează surse diferite aceeași poveste",
+ "claim.loading": "Se încarcă analiza afirmațiilor...",
+ "claim.unavailable": "Analiza afirmațiilor nu este disponibilă încă",
+ "claim.unavailableBody":
+ "Această vizualizare apare după ce suficiente articole au fapte atomice și procesorul de afirmații la nivel de eveniment analizează acordurile, divergențele și exclusivele.",
+ "claim.clearFilter": "Șterge filtrul",
+ "coverage.left": "Stânga / centru-stânga",
+ "coverage.right": "Dreapta / centru-dreapta",
+ "coverage.center": "Centru",
+ "coverage.unknown": "Necunoscut",
+ "coverage.title": "Acoperirea surselor",
+ "coverage.factual": "Factualitate",
+ "coverage.credibility": "Credibilitate",
+ "bookmark.added": "Salvat",
+ "bookmark.removed": "Salvarea a fost eliminată",
+ "bookmark.error": "Ceva nu a mers bine. Încearcă din nou.",
+ "bookmark.signInTitle": "Conectează-te pentru a salva evenimente",
+ "bookmark.signInAction": "Conectare",
+ "bookmark.remove": "Elimină salvarea",
+ "bookmark.add": "Salvează acest eveniment",
+ "bias.label.balanced": "Echilibrat",
+ "bias.label.left": "Înclină spre stânga",
+ "bias.label.leftStrong": "Puternic spre stânga",
+ "bias.label.right": "Înclină spre dreapta",
+ "bias.label.rightStrong": "Puternic spre dreapta",
+ "bias.copy.balanced": "Citești un mix sănătos de perspective.",
+ "bias.copy.left": "Lectura recentă înclină spre stânga.",
+ "bias.copy.leftStrong": "Lectura recentă este puternic orientată spre stânga.",
+ "bias.copy.right": "Lectura recentă înclină spre dreapta.",
+ "bias.copy.rightStrong":
+ "Lectura recentă este puternic orientată spre dreapta.",
+ "bias.balance": "Distribuția biasului",
+ "bias.leftHeavy": "Mai mult stânga",
+ "bias.center": "Echilibrat",
+ "bias.rightHeavy": "Mai mult dreapta",
+ "calendar.weekday.mon": "Lun",
+ "calendar.weekday.tue": "Mar",
+ "calendar.weekday.wed": "Mie",
+ "calendar.weekday.thu": "Joi",
+ "calendar.weekday.fri": "Vin",
+ "calendar.weekday.sat": "Sâm",
+ "calendar.weekday.sun": "Dum",
+ "calendar.eventsRead.one": "{count} eveniment citit",
+ "calendar.eventsRead.many": "{count} evenimente citite",
+ "calendar.less": "Mai puțin",
+ "calendar.more": "Mai mult",
+ "reset.metaTitle": "Resetează parola — Biviant",
+ "reset.passwordMin": "Parola trebuie să aibă cel puțin 8 caractere",
+ "reset.passwordMismatch": "Parolele nu se potrivesc",
+ "reset.invalidToken":
+ "Acest link de resetare a parolei este invalid sau a expirat. Cere unul nou din formularul de conectare.",
+ "reset.verifyLink":
+ "Nu am putut verifica acest link de resetare a parolei. Cere unul nou și încearcă din nou.",
+ "reset.invalidToast":
+ "Acest link de resetare a parolei este invalid sau a expirat. Cere unul nou și încearcă din nou.",
+ "reset.success":
+ "Parola a fost resetată. Te poți conecta acum.",
+ "reset.error":
+ "Nu am putut reseta parola. Cere un link nou și încearcă din nou.",
+ "reset.title": "Resetează parola",
+ "reset.body": "Alege o parolă nouă pentru contul tău Biviant.",
+ "reset.missingToken":
+ "Acest link de resetare nu conține un token. Cere unul nou din formularul de conectare.",
+ "reset.backToSignIn": "Înapoi la conectare",
+ "reset.newPassword": "Parolă nouă",
+ "reset.newPasswordAria": "Parolă nouă",
+ "reset.newPasswordPlaceholder": "Creează o parolă nouă",
+ "reset.confirmPassword": "Confirmă parola nouă",
+ "reset.confirmPasswordAria": "Confirmă parola nouă",
+ "reset.confirmPasswordPlaceholder": "Repetă noua parolă",
+ "reset.updating": "Actualizăm parola...",
+ "reset.submit": "Resetează parola",
+ "unsubscribe.meta.title": "Dezabonare — Biviant",
+ "unsubscribe.invalidTitle": "Link invalid",
+ "unsubscribe.invalidBody":
+ "Acest link de dezabonare nu conține adresa de e-mail. Folosește linkul primit în e-mail.",
+ "unsubscribe.goHome": "Mergi la început",
+ "unsubscribe.loading": "Te dezabonăm...",
+ "unsubscribe.errorTitle": "Ceva nu a mers bine",
+ "unsubscribe.errorBody":
+ "Nu am putut procesa cererea. Încearcă din nou sau răspunde la emailul nostru și te vom elimina manual.",
+ "unsubscribe.retry": "Încearcă din nou",
+ "unsubscribe.successTitle": "Te-ai dezabonat",
+ "unsubscribe.successBody":
+ "{email} a fost eliminat din toate e-mailurile Biviant. Nu te vom mai contacta.",
+ "unsubscribe.changedMind": "Te-ai răzgândit?",
+ "unsubscribe.rejoin": "Înscrie-te din nou pe lista de așteptare",
+ "articles.originalReporting": "Relatări originale",
+ "articles.source": "Sursă",
+ "articles.viewSource": "Vezi profilul sursei {name}",
+ "articles.readOriginal": "Citește originalul",
+ "articles.readOriginalAria":
+ "Citește articolul original (se deschide într-o filă nouă)",
+ "source.metaTitle": "Profil sursă — Biviant",
+ "source.metaDescriptionLoaded":
+ "Analizează biasul, fiabilitatea, credibilitatea și acoperirea recentă a lui {name} pe Biviant.",
+ "source.metaDescription":
+ "Analizează biasul, fiabilitatea, credibilitatea și articolele recente ale unei surse pe Biviant.",
+ "source.notRated": "Neevaluat",
+ "source.loading": "Se încarcă profilul sursei...",
+ "source.notFound": "Sursa nu a fost găsită",
+ "source.notFoundBody": "Această sursă nu este disponibilă.",
+ "source.invalidBody":
+ "Acest link către sursă este invalid sau nu mai este disponibil.",
+ "source.backToFeed": "Înapoi la feed",
+ "source.profile": "Profilul sursei",
+ "source.reliability": "Fiabilitate {score}/10",
+ "source.recentArticles": "Articole recente",
+ "source.events": "Evenimente",
+ "source.aiBiasAvg": "Media biasului estimat de AI",
+ "source.outliers": "Anomalii",
+ "source.credibilityTitle": "Credibilitate",
+ "source.mbfcCategory": "Categoria MBFC",
+ "source.factualRating": "Scor factual",
+ "source.credibilityLabel": "Credibilitate",
+ "source.rollingSample": "Eșantion AI rolling",
+ "source.rollingArticles": "{count} articole",
+ "source.mean": "Media {value}",
+ "source.stddev": "Deviația standard {value}",
+ "read.duration.seconds": "{count}s",
+ "read.duration.minutes": "{count} min",
+ "scroll.depth": "{count}% profunzime",
+ "source.recentReporting": "Relatări recente",
+ "source.aiBias": "Bias AI {count}",
+ "source.outlier": "Anomalie",
+ "source.notClustered": "Negrupat",
+} as const;
+
+type BaseStrings = typeof ro;
+
+const en: { [K in keyof BaseStrings]: string } = {
+ "tabs.feed": "Feed",
+ "seo.siteTitle": "Biviant — every news story broken down fact by fact",
+ "seo.siteDescription":
+ "Biviant doesn't just show you different perspectives. It shows you exactly what each side claims, where they agree, and where they spin.",
+ "router.notFound": "Page not found",
+ "tabs.saved": "Saved",
+ "tabs.activity": "Activity",
+ "tabs.profile": "Profile",
+ "nav.mobile": "Mobile navigation",
+ "common.loading.title": "Loading",
+ "common.loading.body": "Preparing the latest information.",
+ "auth.signUp": "Create free account",
+ "auth.signIn": "Sign in",
+ "auth.signOut": "Sign out",
+ "auth.signOutError": "We couldn't sign you out. Please try again.",
+ "auth.email": "Email",
+ "auth.password": "Password",
+ "auth.name": "Name",
+ "auth.emailPlaceholder": "you@example.com",
+ "auth.namePlaceholder": "Your name",
+ "auth.passwordPlaceholder": "Enter your password",
+ "auth.passwordCreatePlaceholder": "Create a password",
+ "auth.signInSuccess": "You’re signed in",
+ "auth.signInError": "Sign-in failed. Please try again.",
+ "auth.invalidCredentials": "Incorrect email or password.",
+ "auth.invalidEmail": "Invalid email address",
+ "auth.passwordMin": "Password must be at least 8 characters",
+ "auth.nameMin": "Name must be at least 2 characters",
+ "auth.forgotPassword": "Forgot password?",
+ "auth.sending": "Sending...",
+ "auth.needAccount": "Need an account?",
+ "auth.alreadyHaveAccount": "Already have an account?",
+ "auth.resetEmailFirst":
+ "Enter your email address first so we know where to send the reset link.",
+ "auth.resetLinkFailed":
+ "We couldn't send a reset link. Please try again.",
+ "auth.resetLinkSent":
+ "If that email exists, we sent a password reset link.",
+ "auth.resetSendingStatus": "Sending password reset email...",
+ "auth.checkEmailVerify": "Check your email to verify your account.",
+ "auth.checkEmailVerifyDev":
+ "Check your email to verify your account. If nothing arrives, use the verification link printed in the server logs.",
+ "auth.unexpectedError": "An unexpected error occurred. Please try again.",
+ "auth.emailInUse": "An account already exists for this email address.",
+ "auth.verifyMissingEmail":
+ "Didn't receive the email? Check spam, then resend the verification link to {email}.",
+ "auth.verifyResendFailed":
+ "We couldn't resend the verification email. Please try again.",
+ "auth.verifyResent":
+ "Verification email sent. Check your inbox for the new link.",
+ "auth.verifyResending": "Sending verification email...",
+ "auth.verifyResend": "Resend verification email",
+ "auth.continueGoogle": "Continue with Google",
+ "auth.account": "Biviant account",
+ "auth.welcomeBack": "Welcome back",
+ "auth.createFreeAccount": "Create a free account",
+ "auth.accountIntro":
+ "You can read the feed without an account. Signing in gives you synced saves, personal preferences, and quick access to your activity.",
+ "auth.signInTitle": "Sign in to your account",
+ "auth.signInSubtitle":
+ "Access your saved items, preferences, and personalized experience.",
+ "auth.signUpTitle": "Create your account",
+ "auth.signUpSubtitle":
+ "A free account unlocks saved items, a personalized feed, and future notifications.",
+ "auth.createAccount": "Create account",
+ "auth.signingIn": "Signing in...",
+ "auth.creatingAccount": "Creating account...",
+ "auth.orContinueWith": "Or continue with",
+ "auth.googleError": "Google sign-in failed. Please try again.",
+ "auth.promptTitle":
+ "Create a free account to save and personalize your news",
+ "auth.promptBody":
+ "You can read without an account. Signing in adds saved items, recommendations, and upcoming features.",
+ "share.copied": "Event link copied",
+ "share.failed": "Could not share this event. Please try again.",
+ "share.label": "Share this event",
+ "auth.unlockTitle": "What an account unlocks",
+ "auth.unlockBody":
+ "The content stays open. An account only adds personal, persistent features.",
+ "auth.unlockOne": "Save articles and events for later.",
+ "auth.unlockTwo": "Personalize the experience around your interests.",
+ "auth.unlockThree": "Get access to notifications and new features as they roll out.",
+ "auth.verificationTitle": "Verification matters",
+ "auth.verificationBody":
+ "Accounts created with email and password require address verification before the first sign-in. For Google sign-in, the provider verification remains sufficient.",
+ "auth.verifiedToast": "If you verified your account, you can sign in now.",
+ "saved.empty.title": "Save articles to read later",
+ "saved.empty.body":
+ "Build a personal list of everything you want to read or revisit.",
+ "saved.loading.title": "Loading saved items",
+ "saved.loading.body":
+ "We are preparing your saved articles and their context.",
+ "saved.checking.title": "Checking saved items",
+ "saved.checking.body":
+ "We are confirming your session before loading your saved articles.",
+ "saved.section": "Saved",
+ "saved.heading": "Your saved articles",
+ "saved.none": "No saved items yet",
+ "saved.noneBody":
+ "When you find an important story, use the save icon to keep it here.",
+ "saved.browseFeed": "Browse the feed",
+ "saved.summary.empty": "Saved articles and events will appear here.",
+ "saved.summary.one": "You have 1 saved article for later.",
+ "saved.summary.many": "You have {count} saved articles for later.",
+ "saved.metaTitle": "Saved — Biviant",
+ "activity.empty.title": "Track your reading habits",
+ "activity.empty.body":
+ "See your bias balance, reading streaks, and stats in one place.",
+ "activity.loading.title": "Loading activity",
+ "activity.loading.body":
+ "We are preparing your saved items, streaks, and stats.",
+ "activity.checking.title": "Checking session",
+ "activity.checking.body":
+ "We are preparing your activity and checking your account state.",
+ "activity.savedLabel": "Saved",
+ "activity.savedSub": "Kept for later",
+ "activity.savedAll": "All",
+ "activity.savedEmpty": "Save events from the feed to see them here",
+ "activity.welcomeBack": "Welcome back",
+ "activity.dayStreak": "day streak",
+ "activity.daysToGoal": "{count} days to {milestone}",
+ "activity.articlesRead": "articles read",
+ "activity.eventsExplored": "events explored",
+ "activity.bookmarked": "bookmarked",
+ "activity.section": "Activity",
+ "activity.last12Weeks": "Last 12 weeks of reading",
+ "activity.current": "Current",
+ "activity.best": "Best",
+ "activity.active": "Active",
+ "activity.biasBalance": "Bias balance",
+ "activity.biasMix": "Your reading perspective mix",
+ "activity.recentReading": "Recent reading",
+ "activity.recentReadingBody": "Events you spent time with",
+ "activity.readingHistoryEmpty": "Your reading history will appear here",
+ "activity.sourcesOne": "1 source",
+ "activity.sourcesMany": "{count} sources",
+ "activity.feedCard": "Browse the feed",
+ "activity.feedCardBody": "See today’s stories",
+ "activity.savedCard": "Saved",
+ "activity.savedOne": "1 saved article",
+ "activity.savedMany": "{count} saved articles",
+ "activity.biasSnapshot.balanced": "Balanced mix this week",
+ "activity.biasSnapshot.left": "Leaning left this week",
+ "activity.biasSnapshot.leftStrong": "Strongly left-leaning this week",
+ "activity.biasSnapshot.right": "Leaning right this week",
+ "activity.biasSnapshot.rightStrong": "Strongly right-leaning this week",
+ "activity.biasReads": "{count} reads",
+ "activity.metaTitle": "Activity — Biviant",
+ "profile.metaTitle": "Profile — Biviant",
+ "auth.metaTitle": "Account — Biviant",
+ "activity.userFallback": "User",
+ "activity.adminTitle": "Topic inference diagnostics",
+ "activity.adminBody": "Review recent event topic assignments",
+ "activity.admin.minScore": "Min score",
+ "activity.admin.confidence": "Confidence ratio",
+ "activity.admin.maxTopics": "Max topics",
+ "activity.admin.save": "Save settings",
+ "activity.admin.saving": "Saving...",
+ "activity.admin.reset": "Reset",
+ "activity.admin.saved": "Topic inference settings saved.",
+ "activity.admin.saveError": "Could not save settings. Please try again.",
+ "activity.admin.minScoreError":
+ "Min score must be a number between 1 and 20.",
+ "activity.admin.confidenceError":
+ "Confidence ratio must be between 0.1 and 1.",
+ "activity.admin.maxTopicsError":
+ "Max topics must be a whole number between 1 and 5.",
+ "activity.admin.articles.one": "1 article",
+ "activity.admin.articles.many": "{count} articles",
+ "activity.admin.attached": "Attached",
+ "activity.admin.inferred": "Inferred",
+ "activity.admin.input": "Inference input",
+ "activity.admin.facts": "Facts",
+ "activity.admin.candidates": "Top candidates",
+ "activity.admin.signals": "{count} signals",
+ "activity.none": "None",
+ "profile.preparing.title": "Preparing profile",
+ "profile.preparing.body":
+ "We are loading your preferences and account details.",
+ "profile.account": "Your account",
+ "profile.verified": "Verified email",
+ "profile.signInBody":
+ "Sign in to manage language, security, and your account preferences.",
+ "profile.settings": "Settings",
+ "profile.settingsBody": "Quick options for your preferences.",
+ "profile.theme": "Theme",
+ "profile.themeBody": "The theme selector will be available soon.",
+ "profile.notifications": "Notifications",
+ "profile.notificationsBody":
+ "Preferences for alerts and digests will appear here.",
+ "profile.comingSoon": "Coming soon",
+ "profile.accountSection": "Account",
+ "profile.accountBody":
+ "Your authentication details and the sensitive options for your account.",
+ "profile.changePassword": "Change password",
+ "profile.passwordBody":
+ "You’ll receive a secure password-reset link by email.",
+ "profile.deleteAccount": "Request account deletion",
+ "profile.requestDeletion": "Request deletion",
+ "profile.emailLabel": "Email",
+ "profile.security": "Account security",
+ "profile.securityBody":
+ "If you need more sensitive changes, contact us from the contact page and we will help manually.",
+ "profile.hero":
+ "Understand the news from multiple perspectives, without the noise.",
+ "profile.heroBody":
+ "Biviant helps you follow bias, compare sources, and spot factual divergence, while a free account keeps your saved items and preferences in sync.",
+ "profile.learnMore": "Learn more",
+ "profile.learnMoreBody":
+ "Explore how the product works and where the perspectives in the feed come from.",
+ "profile.feature.bias": "Article bias",
+ "profile.feature.biasBody":
+ "Quickly see how sources lean and what nuance each one adds.",
+ "profile.feature.compare": "Perspective comparison",
+ "profile.feature.compareBody":
+ "Put different coverage of the same event side by side without leaving the app.",
+ "profile.feature.facts": "Factual divergence",
+ "profile.feature.factsBody":
+ "Notice where real discrepancies appear between claims from different sources.",
+ "settings.title": "Settings",
+ "settings.language": "Language",
+ "settings.language.ro": "Romanian",
+ "settings.language.en": "English",
+ "user.menu": "User menu",
+ "user.account": "Account",
+ "user.streakOne": "1 day streak",
+ "user.streakMany": "{count} day streak",
+ "user.profileSoon": "Full profile coming soon",
+ "header.primaryNav": "Primary navigation",
+ "header.mobileNav": "Mobile navigation",
+ "header.openMenu": "Open menu",
+ "footer.aboutSection": "About",
+ "footer.resourcesSection": "Resources",
+ "footer.legalSection": "Legal",
+ "footer.about": "About us",
+ "footer.contact": "Contact",
+ "footer.partners": "Partners",
+ "footer.privacy": "Privacy",
+ "footer.terms": "Terms",
+ "footer.howItWorks": "How it works",
+ "footer.sources": "Our sources",
+ "placeholder.inProgress": "Page under construction.",
+ "page.about.title": "About us",
+ "page.about.body":
+ "Page under construction. We are preparing a short introduction to Biviant and our mission.",
+ "page.contact.title": "Contact",
+ "page.contact.body":
+ "Page under construction. We will add contact details and ways to reach us soon.",
+ "page.partners.title": "Partners",
+ "page.partners.body":
+ "Page under construction. This is where Biviant partners, collaborations, and supported initiatives will appear.",
+ "page.how.title": "How it works",
+ "page.how.body":
+ "Page under construction. We are preparing a simple explanation of the feed, bias analysis, and source comparison.",
+ "page.privacy.title": "Privacy policy",
+ "page.privacy.body":
+ "Page under construction. We will publish information here about collected data, usage, and your rights.",
+ "page.terms.title": "Terms",
+ "page.terms.body":
+ "Page under construction. We will add Biviant’s terms and conditions here soon.",
+ "page.sources.title": "Our sources",
+ "page.sources.body":
+ "Page under construction. Soon you will see how we select sources and build perspectives in the feed.",
+ "feed.meta.title": "Feed — Biviant",
+ "feed.meta.description":
+ "Browse the day’s important stories from multiple perspectives. Filter by topic and follow the same event across sources.",
+ "feed.topic.search": "Search topics...",
+ "feed.topic.empty": "No topics found.",
+ "feed.topic.all": "All topics",
+ "feed.topic.single": "Topic",
+ "feed.topic.filter": "Filter by topic",
+ "feed.topic.drawerTitle": "Filter by topic",
+ "feed.topic.drawerBody": "Select a topic to filter stories",
+ "feed.close": "Close",
+ "feed.search.placeholder": "Search events by title...",
+ "feed.search.label": "Search events",
+ "feed.search.clear": "Clear search",
+ "feed.sort.recent": "Recent",
+ "feed.sort.trending": "Trending",
+ "feed.search.threshold": "Type at least 2 characters to search.",
+ "feed.search.indexed": "Showing indexed results for “{query}”.",
+ "feed.authTitle": "Free accounts unlock bookmarks and a personalized feed",
+ "feed.authBody":
+ "You can read everything anonymously. Sign in only when you want synced bookmarks, ranking tuned to your interests, and future alerts.",
+ "feed.searching": "Searching events...",
+ "feed.loading": "Loading...",
+ "feed.leadStory": "Lead story",
+ "feed.trendingStory": "Trending story",
+ "feed.topSearch": "Top search match",
+ "feed.searchTag": "Search",
+ "feed.moreSearch": "More search results",
+ "feed.moreEvents": "More events",
+ "feed.bestMatches": "Best matches",
+ "feed.noMatch": "No events matched “{query}”.",
+ "feed.tryFewer": "Try fewer keywords.",
+ "feed.preferredTopics": "From your preferred topics",
+ "feed.latestFive": "Latest 5",
+ "feed.none": "No events found.",
+ "feed.loadMore": "Load more",
+ "feed.filter.clear": "Clear filter",
+ "event.general": "General",
+ "event.summaryPending": "Summary is on the way...",
+ "event.coveragePreview":
+ "Coverage grouped from multiple sources. Open the event to compare articles.",
+ "event.updated": "Updated {time}",
+ "event.sources": "{count} sources",
+ "event.articles.one": "1 article",
+ "event.articles.few": "{count} articles",
+ "event.articles.many": "{count} articles",
+ "event.articles.other": "{count} articles",
+ "event.follow": "Follow the event",
+ "event.biasDistribution":
+ "Source bias distribution: {left} left, {center} center, {right} right",
+ "event.bias.left": "{count} left",
+ "event.bias.center": "{count} center",
+ "event.bias.right": "{count} right",
+ "event.metaTitle": "Event — Biviant",
+ "event.metaDescription":
+ "Read this story from multiple perspectives on Biviant.",
+ "event.loading": "Loading...",
+ "event.notFound": "Event not found",
+ "event.notFoundBody": "The event you're looking for doesn't exist.",
+ "event.backToFeed": "Back to feed",
+ "event.cardLabel": "Event",
+ "event.overview": "Event overview",
+ "event.perspectives": "Perspectives",
+ "event.claimBreakdown": "Claim breakdown",
+ "event.multiplePerspectives": "Multiple perspectives",
+ "event.summary": "Summary",
+ "event.meaning": "What this means",
+ "event.compareOriginal":
+ "Coverage grouped from multiple sources. Compare the original reporting below.",
+ "event.left": "Left",
+ "event.centerTab": "Center",
+ "event.right": "Right",
+ "event.sourceCount.one": "1 source",
+ "event.sourceCount.few": "{count} sources",
+ "event.sourceCount.many": "{count} sources",
+ "event.sourceCount.other": "{count} sources",
+ "claim.agreement": "Agreement",
+ "claim.agreements": "Agreements",
+ "claim.agreementBody": "Claims confirmed by multiple sources.",
+ "claim.divergence": "Divergence",
+ "claim.divergences": "Divergences",
+ "claim.divergenceBody":
+ "Claims where sources report materially different details.",
+ "claim.framing": "Framing",
+ "claim.framings": "Framing differences",
+ "claim.framingBody":
+ "Shared facts described with meaningfully different language.",
+ "claim.leftExclusive": "Left-only claims",
+ "claim.leftExclusives": "Left-side exclusives",
+ "claim.leftExclusiveBody":
+ "Substantive claims only found in left or left-center coverage.",
+ "claim.rightExclusive": "Right-only claims",
+ "claim.rightExclusives": "Right-side exclusives",
+ "claim.rightExclusiveBody":
+ "Substantive claims only found in right or right-center coverage.",
+ "claim.centerExclusive": "Center-only claims",
+ "claim.centerExclusives": "Center exclusives",
+ "claim.centerExclusiveBody":
+ "Substantive claims only found in center coverage.",
+ "claim.unknownSource": "Unknown source",
+ "claim.readSourceArticle": "Read source article",
+ "claim.importance": "importance",
+ "claim.source.one": "1 source",
+ "claim.source.many": "{count} sources",
+ "claim.showLess": "Show less",
+ "claim.showMore.one": "Show 1 more source",
+ "claim.showMore.many": "Show {count} more sources",
+ "claim.title": "Claim breakdown",
+ "claim.subtitle": "Compare how different sources report this story",
+ "claim.loading": "Loading claim analysis...",
+ "claim.unavailable": "Claim analysis is not available yet",
+ "claim.unavailableBody":
+ "This view appears once enough articles have atomic facts and the event-level claim worker has analyzed agreements, divergences, and exclusives.",
+ "claim.clearFilter": "Clear filter",
+ "coverage.left": "Left / left-center",
+ "coverage.right": "Right / right-center",
+ "coverage.center": "Center",
+ "coverage.unknown": "Unknown",
+ "coverage.title": "Source coverage",
+ "coverage.factual": "Factual",
+ "coverage.credibility": "Credibility",
+ "bookmark.added": "Bookmarked",
+ "bookmark.removed": "Bookmark removed",
+ "bookmark.error": "Something went wrong. Please try again.",
+ "bookmark.signInTitle": "Sign in to bookmark events",
+ "bookmark.signInAction": "Sign in",
+ "bookmark.remove": "Remove bookmark",
+ "bookmark.add": "Bookmark this event",
+ "bias.label.balanced": "Balanced",
+ "bias.label.left": "Leaning left",
+ "bias.label.leftStrong": "Strongly left",
+ "bias.label.right": "Leaning right",
+ "bias.label.rightStrong": "Strongly right",
+ "bias.copy.balanced": "You’re reading a healthy mix of perspectives.",
+ "bias.copy.left": "Your recent reading leans left.",
+ "bias.copy.leftStrong": "Your recent reading leans strongly left.",
+ "bias.copy.right": "Your recent reading leans right.",
+ "bias.copy.rightStrong": "Your recent reading leans strongly right.",
+ "bias.balance": "Bias balance",
+ "bias.leftHeavy": "Left-heavy",
+ "bias.center": "Balanced",
+ "bias.rightHeavy": "Right-heavy",
+ "calendar.weekday.mon": "Mon",
+ "calendar.weekday.tue": "Tue",
+ "calendar.weekday.wed": "Wed",
+ "calendar.weekday.thu": "Thu",
+ "calendar.weekday.fri": "Fri",
+ "calendar.weekday.sat": "Sat",
+ "calendar.weekday.sun": "Sun",
+ "calendar.eventsRead.one": "{count} event read",
+ "calendar.eventsRead.many": "{count} events read",
+ "calendar.less": "Less",
+ "calendar.more": "More",
+ "reset.metaTitle": "Reset password — Biviant",
+ "reset.passwordMin": "Password must be at least 8 characters",
+ "reset.passwordMismatch": "Passwords do not match",
+ "reset.invalidToken":
+ "This password reset link is invalid or has expired. Request a new one from the sign-in form.",
+ "reset.verifyLink":
+ "We couldn't verify this password reset link. Request a new one and try again.",
+ "reset.invalidToast":
+ "This password reset link is invalid or has expired. Request a new one and try again.",
+ "reset.success": "Your password has been reset. You can sign in now.",
+ "reset.error":
+ "We couldn't reset your password. Request a new link and try again.",
+ "reset.title": "Reset your password",
+ "reset.body": "Choose a new password for your Biviant account.",
+ "reset.missingToken":
+ "This password reset link is missing a token. Request a new one from the sign-in form.",
+ "reset.backToSignIn": "Back to sign in",
+ "reset.newPassword": "New password",
+ "reset.newPasswordAria": "New password",
+ "reset.newPasswordPlaceholder": "Create a new password",
+ "reset.confirmPassword": "Confirm new password",
+ "reset.confirmPasswordAria": "Confirm new password",
+ "reset.confirmPasswordPlaceholder": "Repeat your new password",
+ "reset.updating": "Updating password...",
+ "reset.submit": "Reset password",
+ "unsubscribe.meta.title": "Unsubscribe — Biviant",
+ "unsubscribe.invalidTitle": "Invalid link",
+ "unsubscribe.invalidBody":
+ "This unsubscribe link is missing the email address. Please use the link from your email.",
+ "unsubscribe.goHome": "Go to home",
+ "unsubscribe.loading": "Unsubscribing...",
+ "unsubscribe.errorTitle": "Something went wrong",
+ "unsubscribe.errorBody":
+ "We couldn't process your request. Please try again or reply to our email and we'll remove you manually.",
+ "unsubscribe.retry": "Try again",
+ "unsubscribe.successTitle": "You're unsubscribed",
+ "unsubscribe.successBody":
+ "{email} has been removed from all Biviant emails. You won't hear from us again.",
+ "unsubscribe.changedMind": "Changed your mind?",
+ "unsubscribe.rejoin": "Re-join the waitlist",
+ "articles.originalReporting": "Original reporting",
+ "articles.source": "Source",
+ "articles.viewSource": "View {name} source profile",
+ "articles.readOriginal": "Read original",
+ "articles.readOriginalAria":
+ "Read original article (opens in a new tab)",
+ "source.metaTitle": "Source profile — Biviant",
+ "source.metaDescriptionLoaded":
+ "Review {name}'s bias, reliability, credibility, and recent coverage on Biviant.",
+ "source.metaDescription":
+ "Review source bias, reliability, credibility, and recent articles on Biviant.",
+ "source.notRated": "Not rated",
+ "source.loading": "Loading source profile...",
+ "source.notFound": "Source not found",
+ "source.notFoundBody": "This source is not available.",
+ "source.invalidBody":
+ "This source link is invalid or no longer available.",
+ "source.backToFeed": "Back to feed",
+ "source.profile": "Source profile",
+ "source.reliability": "Reliability {score}/10",
+ "source.recentArticles": "Recent articles",
+ "source.events": "Events",
+ "source.aiBiasAvg": "Average AI bias",
+ "source.outliers": "Outliers",
+ "source.credibilityTitle": "Credibility",
+ "source.mbfcCategory": "MBFC category",
+ "source.factualRating": "Factual rating",
+ "source.credibilityLabel": "Credibility",
+ "source.rollingSample": "Rolling AI bias sample",
+ "source.rollingArticles": "{count} articles",
+ "source.mean": "Mean {value}",
+ "source.stddev": "Standard deviation {value}",
+ "read.duration.seconds": "{count}s",
+ "read.duration.minutes": "{count} min",
+ "scroll.depth": "{count}% depth",
+ "source.recentReporting": "Recent reporting",
+ "source.aiBias": "AI bias {count}",
+ "source.outlier": "Outlier",
+ "source.notClustered": "Not clustered",
+} satisfies { [K in keyof BaseStrings]: string };
+
+export const STRINGS = {
+ ro,
+ en,
+} as const satisfies Record>;
+
+export type StringKey = keyof typeof STRINGS.ro;
+
+export function getString(
+ locale: Locale,
+ key: StringKey,
+ fallback?: string,
+): string {
+ return STRINGS[locale][key] ?? STRINGS.en[key] ?? fallback ?? key;
+}
diff --git a/apps/web/src/lib/seo.ts b/apps/web/src/lib/seo.ts
index 7c32183..620cd71 100644
--- a/apps/web/src/lib/seo.ts
+++ b/apps/web/src/lib/seo.ts
@@ -1,3 +1,5 @@
+import { getString, type Locale } from "@/lib/i18n/strings";
+
/**
* SEO constants used across all routes.
*
@@ -7,8 +9,18 @@
export const SITE = {
name: "Biviant",
url: "https://biviant.com",
- title: "Biviant — Every news story, broken down fact by fact",
- description:
- "Biviant doesn't just show you different perspectives — it shows you exactly what each side claims, where they agree, and where they spin.",
+ title: getString("en", "seo.siteTitle"),
+ description: getString("en", "seo.siteDescription"),
ogImage: "https://biviant.com/og-image.png",
} as const;
+
+export function absoluteSiteUrl(pathname: string): string {
+ return new URL(pathname, SITE.url).toString();
+}
+
+export function getSiteSeo(locale: Locale) {
+ return {
+ title: getString(locale, "seo.siteTitle"),
+ description: getString(locale, "seo.siteDescription"),
+ };
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 842d2dd..41de44a 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -10,12 +10,22 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
+import { Route as TermeniRouteImport } from './routes/termeni'
+import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre'
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
+import { Route as SalvateRouteImport } from './routes/salvate'
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
+import { Route as ProfilRouteImport } from './routes/profil'
+import { Route as PoliticaConfidentialitateRouteImport } from './routes/politica-confidentialitate'
+import { Route as ParteneriRouteImport } from './routes/parteneri'
import { Route as FeedRouteImport } from './routes/feed'
+import { Route as DespreRouteImport } from './routes/despre'
import { Route as DashboardRouteImport } from './routes/dashboard'
+import { Route as CumFunctioneazaRouteImport } from './routes/cum-functioneaza'
+import { Route as ContactRouteImport } from './routes/contact'
import { Route as BookmarksRouteImport } from './routes/bookmarks'
+import { Route as ActivitateRouteImport } from './routes/activitate'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SourceSourceIdRouteImport } from './routes/source.$sourceId'
import { Route as EventSlugRouteImport } from './routes/event.$slug'
@@ -26,11 +36,26 @@ const UnsubscribeRoute = UnsubscribeRouteImport.update({
path: '/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
+const TermeniRoute = TermeniRouteImport.update({
+ id: '/termeni',
+ path: '/termeni',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SurseleNoastreRoute = SurseleNoastreRouteImport.update({
+ id: '/sursele-noastre',
+ path: '/sursele-noastre',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
id: '/sitemap.xml',
path: '/sitemap.xml',
getParentRoute: () => rootRouteImport,
} as any)
+const SalvateRoute = SalvateRouteImport.update({
+ id: '/salvate',
+ path: '/salvate',
+ getParentRoute: () => rootRouteImport,
+} as any)
const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
id: '/robots.txt',
path: '/robots.txt',
@@ -41,21 +66,57 @@ const ResetPasswordRoute = ResetPasswordRouteImport.update({
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
+const ProfilRoute = ProfilRouteImport.update({
+ id: '/profil',
+ path: '/profil',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const PoliticaConfidentialitateRoute =
+ PoliticaConfidentialitateRouteImport.update({
+ id: '/politica-confidentialitate',
+ path: '/politica-confidentialitate',
+ getParentRoute: () => rootRouteImport,
+ } as any)
+const ParteneriRoute = ParteneriRouteImport.update({
+ id: '/parteneri',
+ path: '/parteneri',
+ getParentRoute: () => rootRouteImport,
+} as any)
const FeedRoute = FeedRouteImport.update({
id: '/feed',
path: '/feed',
getParentRoute: () => rootRouteImport,
} as any)
+const DespreRoute = DespreRouteImport.update({
+ id: '/despre',
+ path: '/despre',
+ getParentRoute: () => rootRouteImport,
+} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
+const CumFunctioneazaRoute = CumFunctioneazaRouteImport.update({
+ id: '/cum-functioneaza',
+ path: '/cum-functioneaza',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ContactRoute = ContactRouteImport.update({
+ id: '/contact',
+ path: '/contact',
+ getParentRoute: () => rootRouteImport,
+} as any)
const BookmarksRoute = BookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
getParentRoute: () => rootRouteImport,
} as any)
+const ActivitateRoute = ActivitateRouteImport.update({
+ id: '/activitate',
+ path: '/activitate',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -79,12 +140,22 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/activitate': typeof ActivitateRoute
'/bookmarks': typeof BookmarksRoute
+ '/contact': typeof ContactRoute
+ '/cum-functioneaza': typeof CumFunctioneazaRoute
'/dashboard': typeof DashboardRoute
+ '/despre': typeof DespreRoute
'/feed': typeof FeedRoute
+ '/parteneri': typeof ParteneriRoute
+ '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
+ '/profil': typeof ProfilRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
+ '/sursele-noastre': typeof SurseleNoastreRoute
+ '/termeni': typeof TermeniRoute
'/unsubscribe': typeof UnsubscribeRoute
'/event/$slug': typeof EventSlugRoute
'/source/$sourceId': typeof SourceSourceIdRoute
@@ -92,12 +163,22 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/activitate': typeof ActivitateRoute
'/bookmarks': typeof BookmarksRoute
+ '/contact': typeof ContactRoute
+ '/cum-functioneaza': typeof CumFunctioneazaRoute
'/dashboard': typeof DashboardRoute
+ '/despre': typeof DespreRoute
'/feed': typeof FeedRoute
+ '/parteneri': typeof ParteneriRoute
+ '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
+ '/profil': typeof ProfilRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
+ '/sursele-noastre': typeof SurseleNoastreRoute
+ '/termeni': typeof TermeniRoute
'/unsubscribe': typeof UnsubscribeRoute
'/event/$slug': typeof EventSlugRoute
'/source/$sourceId': typeof SourceSourceIdRoute
@@ -106,12 +187,22 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/activitate': typeof ActivitateRoute
'/bookmarks': typeof BookmarksRoute
+ '/contact': typeof ContactRoute
+ '/cum-functioneaza': typeof CumFunctioneazaRoute
'/dashboard': typeof DashboardRoute
+ '/despre': typeof DespreRoute
'/feed': typeof FeedRoute
+ '/parteneri': typeof ParteneriRoute
+ '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
+ '/profil': typeof ProfilRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
+ '/sursele-noastre': typeof SurseleNoastreRoute
+ '/termeni': typeof TermeniRoute
'/unsubscribe': typeof UnsubscribeRoute
'/event/$slug': typeof EventSlugRoute
'/source/$sourceId': typeof SourceSourceIdRoute
@@ -121,12 +212,22 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/activitate'
| '/bookmarks'
+ | '/contact'
+ | '/cum-functioneaza'
| '/dashboard'
+ | '/despre'
| '/feed'
+ | '/parteneri'
+ | '/politica-confidentialitate'
+ | '/profil'
| '/reset-password'
| '/robots.txt'
+ | '/salvate'
| '/sitemap.xml'
+ | '/sursele-noastre'
+ | '/termeni'
| '/unsubscribe'
| '/event/$slug'
| '/source/$sourceId'
@@ -134,12 +235,22 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/activitate'
| '/bookmarks'
+ | '/contact'
+ | '/cum-functioneaza'
| '/dashboard'
+ | '/despre'
| '/feed'
+ | '/parteneri'
+ | '/politica-confidentialitate'
+ | '/profil'
| '/reset-password'
| '/robots.txt'
+ | '/salvate'
| '/sitemap.xml'
+ | '/sursele-noastre'
+ | '/termeni'
| '/unsubscribe'
| '/event/$slug'
| '/source/$sourceId'
@@ -147,12 +258,22 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/activitate'
| '/bookmarks'
+ | '/contact'
+ | '/cum-functioneaza'
| '/dashboard'
+ | '/despre'
| '/feed'
+ | '/parteneri'
+ | '/politica-confidentialitate'
+ | '/profil'
| '/reset-password'
| '/robots.txt'
+ | '/salvate'
| '/sitemap.xml'
+ | '/sursele-noastre'
+ | '/termeni'
| '/unsubscribe'
| '/event/$slug'
| '/source/$sourceId'
@@ -161,12 +282,22 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ ActivitateRoute: typeof ActivitateRoute
BookmarksRoute: typeof BookmarksRoute
+ ContactRoute: typeof ContactRoute
+ CumFunctioneazaRoute: typeof CumFunctioneazaRoute
DashboardRoute: typeof DashboardRoute
+ DespreRoute: typeof DespreRoute
FeedRoute: typeof FeedRoute
+ ParteneriRoute: typeof ParteneriRoute
+ PoliticaConfidentialitateRoute: typeof PoliticaConfidentialitateRoute
+ ProfilRoute: typeof ProfilRoute
ResetPasswordRoute: typeof ResetPasswordRoute
RobotsDottxtRoute: typeof RobotsDottxtRoute
+ SalvateRoute: typeof SalvateRoute
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
+ SurseleNoastreRoute: typeof SurseleNoastreRoute
+ TermeniRoute: typeof TermeniRoute
UnsubscribeRoute: typeof UnsubscribeRoute
EventSlugRoute: typeof EventSlugRoute
SourceSourceIdRoute: typeof SourceSourceIdRoute
@@ -182,6 +313,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof UnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
+ '/termeni': {
+ id: '/termeni'
+ path: '/termeni'
+ fullPath: '/termeni'
+ preLoaderRoute: typeof TermeniRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/sursele-noastre': {
+ id: '/sursele-noastre'
+ path: '/sursele-noastre'
+ fullPath: '/sursele-noastre'
+ preLoaderRoute: typeof SurseleNoastreRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/sitemap.xml': {
id: '/sitemap.xml'
path: '/sitemap.xml'
@@ -189,6 +334,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SitemapDotxmlRouteImport
parentRoute: typeof rootRouteImport
}
+ '/salvate': {
+ id: '/salvate'
+ path: '/salvate'
+ fullPath: '/salvate'
+ preLoaderRoute: typeof SalvateRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/robots.txt': {
id: '/robots.txt'
path: '/robots.txt'
@@ -203,6 +355,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
+ '/profil': {
+ id: '/profil'
+ path: '/profil'
+ fullPath: '/profil'
+ preLoaderRoute: typeof ProfilRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/politica-confidentialitate': {
+ id: '/politica-confidentialitate'
+ path: '/politica-confidentialitate'
+ fullPath: '/politica-confidentialitate'
+ preLoaderRoute: typeof PoliticaConfidentialitateRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/parteneri': {
+ id: '/parteneri'
+ path: '/parteneri'
+ fullPath: '/parteneri'
+ preLoaderRoute: typeof ParteneriRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/feed': {
id: '/feed'
path: '/feed'
@@ -210,6 +383,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FeedRouteImport
parentRoute: typeof rootRouteImport
}
+ '/despre': {
+ id: '/despre'
+ path: '/despre'
+ fullPath: '/despre'
+ preLoaderRoute: typeof DespreRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
@@ -217,6 +397,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
+ '/cum-functioneaza': {
+ id: '/cum-functioneaza'
+ path: '/cum-functioneaza'
+ fullPath: '/cum-functioneaza'
+ preLoaderRoute: typeof CumFunctioneazaRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/contact': {
+ id: '/contact'
+ path: '/contact'
+ fullPath: '/contact'
+ preLoaderRoute: typeof ContactRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/bookmarks': {
id: '/bookmarks'
path: '/bookmarks'
@@ -224,6 +418,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BookmarksRouteImport
parentRoute: typeof rootRouteImport
}
+ '/activitate': {
+ id: '/activitate'
+ path: '/activitate'
+ fullPath: '/activitate'
+ preLoaderRoute: typeof ActivitateRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -257,12 +458,22 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ ActivitateRoute: ActivitateRoute,
BookmarksRoute: BookmarksRoute,
+ ContactRoute: ContactRoute,
+ CumFunctioneazaRoute: CumFunctioneazaRoute,
DashboardRoute: DashboardRoute,
+ DespreRoute: DespreRoute,
FeedRoute: FeedRoute,
+ ParteneriRoute: ParteneriRoute,
+ PoliticaConfidentialitateRoute: PoliticaConfidentialitateRoute,
+ ProfilRoute: ProfilRoute,
ResetPasswordRoute: ResetPasswordRoute,
RobotsDottxtRoute: RobotsDottxtRoute,
+ SalvateRoute: SalvateRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
+ SurseleNoastreRoute: SurseleNoastreRoute,
+ TermeniRoute: TermeniRoute,
UnsubscribeRoute: UnsubscribeRoute,
EventSlugRoute: EventSlugRoute,
SourceSourceIdRoute: SourceSourceIdRoute,
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 33c8bd3..fea879c 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -1,13 +1,22 @@
-import { createRouter as createTanStackRouter } from "@tanstack/react-router";
+import {
+ createRouter as createTanStackRouter,
+ type AnyRouter,
+} from "@tanstack/react-router";
import { QueryClient } from "@tanstack/react-query";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { ConvexQueryClient } from "@convex-dev/react-query";
import { ConvexProvider, ConvexReactClient } from "convex/react";
+import { useT } from "@/lib/i18n/LocaleContext";
import { routeTree } from "./routeTree.gen";
import Loader from "./components/loader";
import "./index.css";
-export function getRouter() {
+function NotFound() {
+ const t = useT();
+ return {t("router.notFound")}
;
+}
+
+export function getRouter(): AnyRouter {
const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!;
if (!CONVEX_URL) {
throw new Error(
@@ -35,7 +44,7 @@ export function getRouter() {
routeTree,
defaultPreload: "intent",
defaultPendingComponent: () => ,
- defaultNotFoundComponent: () => Not Found
,
+ defaultNotFoundComponent: NotFound,
context: { queryClient, convexClient: convex, convexQueryClient },
Wrap: ({ children }) => (
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 2d3cd98..c45739f 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,5 +1,12 @@
import { Toaster } from "@/components/ui/sonner";
import { SITE } from "@/lib/seo";
+import { Footer } from "@/components/layout/Footer";
+import { MobileTabBar } from "@/components/layout/MobileTabBar";
+import { LocaleProvider } from "@/lib/i18n/LocaleContext";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
+import { getServerLocale } from "@/lib/i18n/getServerLocale";
+import { getString, type Locale } from "@/lib/i18n/strings";
+import { api } from "@news-app/backend/convex/_generated/api";
import {
HeadContent,
@@ -27,10 +34,7 @@ const fetchAuth = createServerFn({ method: "GET" }).handler(async () => {
function getExistingAuthContext(
matches: Array<{ context: unknown }>,
-): {
- token?: string;
- isAuthenticated?: boolean;
-} {
+): RootContextState {
const rootContext = matches[0]?.context;
if (!rootContext || typeof rootContext !== "object") {
return {};
@@ -45,10 +49,18 @@ function getExistingAuthContext(
typeof rootContext.isAuthenticated === "boolean"
? rootContext.isAuthenticated
: undefined;
+ const locale =
+ "locale" in rootContext ? getLocaleFromMatches([{ context: rootContext }]) : undefined;
- return { token, isAuthenticated };
+ return { token, isAuthenticated, locale };
}
+type RootContextState = {
+ token?: string;
+ isAuthenticated?: boolean;
+ locale?: Locale;
+};
+
export interface RouterAppContext {
queryClient: QueryClient;
convexClient: ConvexReactClient;
@@ -56,83 +68,160 @@ export interface RouterAppContext {
}
export const Route = createRootRouteWithContext()({
- head: () => ({
- meta: [
- { charSet: "utf-8" },
- { name: "viewport", content: "width=device-width, initial-scale=1" },
- { title: SITE.title },
- { name: "description", content: SITE.description },
- // Open Graph defaults (child routes override title/description)
- { property: "og:site_name", content: SITE.name },
- { property: "og:type", content: "website" },
- { property: "og:title", content: SITE.title },
- { property: "og:description", content: SITE.description },
- { property: "og:image", content: SITE.ogImage },
- // Twitter / X
- { name: "twitter:card", content: "summary_large_image" },
- { name: "twitter:title", content: SITE.title },
- { name: "twitter:description", content: SITE.description },
- { name: "twitter:image", content: SITE.ogImage },
- // PWA / browser chrome
- { name: "theme-color", content: "#0f172a" },
- ],
- links: [
- { rel: "stylesheet", href: appCss },
- // Inter font — preload most critical weight (self-hosted)
- {
- rel: "preload",
- href: "/fonts/inter-latin-wght-normal.woff2",
- as: "font",
- type: "font/woff2",
- crossOrigin: "anonymous",
- },
- ],
- }),
+ head: ({ matches }) => {
+ const locale = getLocaleFromMatches(matches);
+ const title = getString(locale, "seo.siteTitle");
+ const description = getString(locale, "seo.siteDescription");
+
+ return {
+ meta: [
+ { charSet: "utf-8" },
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
+ { title },
+ { name: "description", content: description },
+ { property: "og:site_name", content: SITE.name },
+ { property: "og:type", content: "website" },
+ { property: "og:title", content: title },
+ { property: "og:description", content: description },
+ { property: "og:image", content: SITE.ogImage },
+ { name: "twitter:card", content: "summary_large_image" },
+ { name: "twitter:title", content: title },
+ { name: "twitter:description", content: description },
+ { name: "twitter:image", content: SITE.ogImage },
+ { name: "theme-color", content: "#0f172a" },
+ ],
+ links: [
+ { rel: "stylesheet", href: appCss },
+ {
+ rel: "preload",
+ href: "/fonts/inter-latin-wght-normal.woff2",
+ as: "font",
+ type: "font/woff2",
+ crossOrigin: "anonymous",
+ },
+ ],
+ };
+ },
component: RootDocument,
- beforeLoad: async (ctx) => {
- // Only fetch the auth token during SSR. On the client, auth state is
- // already maintained by ConvexBetterAuthProvider and intent preloading
- // would otherwise hit this server function on every hover.
- if (typeof document !== "undefined" || ctx.preload) {
+ beforeLoad: async (ctx): Promise => {
+ // During intent preloading we avoid extra round-trips and keep existing
+ // auth/locale context. During real client invalidations/navigation we
+ // still re-resolve locale so cookie changes apply immediately.
+ if (ctx.preload) {
return getExistingAuthContext(ctx.matches);
}
+ if (typeof document !== "undefined") {
+ const existing = getExistingAuthContext(ctx.matches);
+ const searchLocale = new URLSearchParams(window.location.search).get("lang");
+ const cookieLocale =
+ document.cookie
+ .split("; ")
+ .find((entry) => entry.startsWith("bv_locale="))
+ ?.split("=")[1] ?? null;
+ let decodedCookieLocale: string | null = null;
+ if (cookieLocale) {
+ try {
+ decodedCookieLocale = decodeURIComponent(cookieLocale);
+ } catch {
+ decodedCookieLocale = null;
+ }
+ }
+
+ if (
+ existing.locale &&
+ (!searchLocale || searchLocale === existing.locale) &&
+ (!decodedCookieLocale || decodedCookieLocale === existing.locale)
+ ) {
+ return existing;
+ }
+
+ let userPreference: string | null = null;
+ if (existing.isAuthenticated) {
+ try {
+ const currentUser = await ctx.context.convexClient.query(
+ api.user.getCurrentUser,
+ {},
+ );
+ userPreference = currentUser?.profile?.preferredLanguage ?? null;
+ } catch (error) {
+ console.warn(
+ "Failed to resolve client-side user locale preference:",
+ error,
+ );
+ }
+ }
+
+ const locale = await getServerLocale({
+ data: {
+ userPreference,
+ },
+ });
+
+ return {
+ ...existing,
+ locale,
+ };
+ }
+
const { token } = await fetchAuth();
+ let userPreference: string | null = null;
if (token) {
ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
+ try {
+ const currentUser =
+ await ctx.context.convexQueryClient.serverHttpClient?.query(
+ api.user.getCurrentUser,
+ {},
+ );
+ userPreference = currentUser?.profile?.preferredLanguage ?? null;
+ } catch (error) {
+ console.warn("Failed to resolve current user locale preference:", error);
+ userPreference = null;
+ }
}
+ const locale = await getServerLocale({ data: { userPreference } });
return {
token,
isAuthenticated: !!token,
+ locale,
};
},
});
function RootDocument() {
- const context = useRouteContext({ from: Route.id });
+ const context = useRouteContext({ strict: false }) as RouterAppContext &
+ RootContextState;
+ const locale = context.locale ?? "en";
return (
-
-
-
-
-
-
-
-
-
-
- {import.meta.env.DEV && (
-
- )}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {import.meta.env.DEV && (
+
+ )}
+
+
+
+
);
}
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx
new file mode 100644
index 0000000..9fac322
--- /dev/null
+++ b/apps/web/src/routes/activitate.tsx
@@ -0,0 +1,916 @@
+import { SignInPrompt } from "@/components/SignInPrompt";
+import UserMenu from "@/components/user-menu";
+import BiasBalanceMeter from "@/components/bias-balance-meter";
+import StreakActivityCalendar from "@/components/streak-activity-calendar";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { PageLoadingState } from "@/components/ui/page-loading-state";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
+import { useLocale, useT } from "@/lib/i18n/LocaleContext";
+import { getString } from "@/lib/i18n/strings";
+import { api } from "@news-app/backend/convex/_generated/api";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import {
+ useConvexAuth,
+ useMutation as useConvexMutationHook,
+ useQuery,
+} from "convex/react";
+import type { FormEvent } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { formatRelativeTimestamp } from "@/lib/dates";
+import {
+ Bookmark,
+ ChevronRight,
+ Flame,
+ Newspaper,
+ Sparkles,
+} from "lucide-react";
+
+const TOPIC_INFERENCE_DEFAULTS = {
+ minScore: 4.5,
+ confidenceRatio: 0.55,
+ maxTopics: 3,
+} as const;
+
+function getNumericConfigValue(
+ row: { value: unknown } | null | undefined,
+ fallback: number,
+) {
+ return typeof row?.value === "number" && Number.isFinite(row.value)
+ ? row.value
+ : fallback;
+}
+
+function formatReadDuration(
+ t: ReturnType,
+ seconds?: number,
+) {
+ if (!seconds || seconds <= 0) return null;
+ if (seconds < 60) {
+ return t("read.duration.seconds").replace("{count}", String(seconds));
+ }
+ const minutes = Math.round(seconds / 60);
+ return t("read.duration.minutes").replace("{count}", String(minutes));
+}
+
+function formatScrollDepth(
+ t: ReturnType,
+ percentage?: number,
+) {
+ if (percentage === undefined) return null;
+ return t("scroll.depth").replace(
+ "{count}",
+ String(Math.round(percentage * 100)),
+ );
+}
+
+function getBiasSnapshotLabel(
+ balance: number,
+ t: ReturnType,
+) {
+ const absolute = Math.abs(balance);
+ if (absolute < 15) return t("activity.biasSnapshot.balanced");
+ if (balance < 0) {
+ return absolute >= 60
+ ? t("activity.biasSnapshot.leftStrong")
+ : t("activity.biasSnapshot.left");
+ }
+ return absolute >= 60
+ ? t("activity.biasSnapshot.rightStrong")
+ : t("activity.biasSnapshot.right");
+}
+
+function getNextStreakMilestone(streak: number) {
+ const milestones = [7, 30, 100, 365];
+ return milestones.find((milestone) => milestone > streak) ?? null;
+}
+
+export const Route = createFileRoute("/activitate")({
+ head: ({ matches }) => {
+ const locale = getLocaleFromMatches(matches);
+
+ return {
+ meta: [
+ { title: getString(locale, "activity.metaTitle") },
+ { name: "robots", content: "noindex, nofollow" },
+ ],
+ };
+ },
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const t = useT();
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const currentUser = useQuery(
+ api.user.getCurrentUser,
+ isAuthenticated ? {} : "skip",
+ );
+
+ if (isLoading || (isAuthenticated && currentUser === undefined)) {
+ return (
+
+ );
+ }
+
+ if (!currentUser) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function AuthorizedDashboard({
+ currentUser,
+}: {
+ currentUser: NonNullable>>;
+}) {
+ const locale = useLocale();
+ const t = useT();
+ const dashboardOverview = useQuery(api.interactions.getDashboardOverview);
+ const isAdmin = useQuery(api.user.isCurrentUserAdmin);
+ const topicDiagnostics = useQuery(
+ api.clustering.getRecentTopicInferenceDiagnosticsForAdmin,
+ isAdmin ? { limit: 10 } : "skip",
+ );
+ const minScoreConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_min_score" } : "skip",
+ );
+ const confidenceRatioConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_confidence_ratio" } : "skip",
+ );
+ const maxTopicsConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_max_topics" } : "skip",
+ );
+ const topicInferenceBounds = useQuery(
+ api.config.getTopicInferenceBounds,
+ isAdmin ? {} : "skip",
+ );
+ const setTopicInferenceSettings = useConvexMutationHook(
+ api.config.setTopicInferenceSettings,
+ );
+ const [minScoreInput, setMinScoreInput] = useState("");
+ const [confidenceRatioInput, setConfidenceRatioInput] = useState("");
+ const [maxTopicsInput, setMaxTopicsInput] = useState("");
+ const [configMessage, setConfigMessage] = useState("");
+ const [isSavingConfig, setIsSavingConfig] = useState(false);
+ const currentSettings = useMemo(
+ () => ({
+ minScore: getNumericConfigValue(
+ minScoreConfig,
+ TOPIC_INFERENCE_DEFAULTS.minScore,
+ ),
+ confidenceRatio: getNumericConfigValue(
+ confidenceRatioConfig,
+ TOPIC_INFERENCE_DEFAULTS.confidenceRatio,
+ ),
+ maxTopics: getNumericConfigValue(
+ maxTopicsConfig,
+ TOPIC_INFERENCE_DEFAULTS.maxTopics,
+ ),
+ }),
+ [minScoreConfig, confidenceRatioConfig, maxTopicsConfig],
+ );
+
+ useEffect(() => {
+ setMinScoreInput(String(currentSettings.minScore));
+ setConfidenceRatioInput(String(currentSettings.confidenceRatio));
+ setMaxTopicsInput(String(currentSettings.maxTopics));
+ }, [
+ currentSettings.minScore,
+ currentSettings.confidenceRatio,
+ currentSettings.maxTopics,
+ ]);
+
+ if (dashboardOverview === undefined || isAdmin === undefined) {
+ return (
+
+ );
+ }
+
+ const hasConfigChanges =
+ minScoreInput !== String(currentSettings.minScore) ||
+ confidenceRatioInput !== String(currentSettings.confidenceRatio) ||
+ maxTopicsInput !== String(currentSettings.maxTopics);
+
+ const handleResetConfig = () => {
+ setMinScoreInput(String(currentSettings.minScore));
+ setConfidenceRatioInput(String(currentSettings.confidenceRatio));
+ setMaxTopicsInput(String(currentSettings.maxTopics));
+ setConfigMessage("");
+ };
+
+ const handleSaveConfig = async (event: FormEvent) => {
+ event.preventDefault();
+ if (isSavingConfig) return;
+
+ const minScore = Number(minScoreInput);
+ const confidenceRatio = Number(confidenceRatioInput);
+ const maxTopics = Number(maxTopicsInput);
+
+ if (!topicInferenceBounds) return;
+
+ if (
+ !Number.isFinite(minScore) ||
+ minScore < topicInferenceBounds.minScore.min ||
+ minScore > topicInferenceBounds.minScore.max
+ ) {
+ setConfigMessage(t("activity.admin.minScoreError"));
+ return;
+ }
+ if (
+ !Number.isFinite(confidenceRatio) ||
+ confidenceRatio < topicInferenceBounds.confidenceRatio.min ||
+ confidenceRatio > topicInferenceBounds.confidenceRatio.max
+ ) {
+ setConfigMessage(t("activity.admin.confidenceError"));
+ return;
+ }
+ if (
+ !Number.isInteger(maxTopics) ||
+ maxTopics < topicInferenceBounds.maxTopics.min ||
+ maxTopics > topicInferenceBounds.maxTopics.max
+ ) {
+ setConfigMessage(t("activity.admin.maxTopicsError"));
+ return;
+ }
+
+ setIsSavingConfig(true);
+ setConfigMessage("");
+
+ try {
+ await setTopicInferenceSettings({
+ minScore,
+ confidenceRatio,
+ maxTopics,
+ });
+ setConfigMessage(t("activity.admin.saved"));
+ } catch (error) {
+ console.error("Failed to save topic inference settings:", error);
+ setConfigMessage(t("activity.admin.saveError"));
+ } finally {
+ setIsSavingConfig(false);
+ }
+ };
+
+ const userName =
+ currentUser?.profile?.name || currentUser?.email || t("activity.userFallback");
+ 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 */}
+
+
+ {/* Bento Grid - Main Stats */}
+
+ {/* Streak - Prominent */}
+
+
+
+
+
+
+
+ {readingStreak}
+
+
+ {t("activity.dayStreak")}
+
+
+
+ {nextStreakMilestone && readingStreak > 0 && (
+
+ {t("activity.daysToGoal")
+ .replace(
+ "{count}",
+ String(nextStreakMilestone - readingStreak),
+ )
+ .replace("{milestone}", String(nextStreakMilestone))}
+
+ )}
+
+
+ {/* Articles Read */}
+
+
+
+
+
+
+
+ {articlesRead}
+
+
+ {t("activity.articlesRead")}
+
+
+
+
+
+ {/* Events Explored */}
+
+
+
+
+
+
+
+ {eventsExplored}
+
+
+ {t("activity.eventsExplored")}
+
+
+
+
+
+ {/* Bookmarks */}
+
+
+
+
+
+
+
+ {bookmarkCount}
+
+
+ {t("activity.bookmarked")}
+
+
+
+
+
+
+ {/* Activity + Bias Row */}
+
+ {/* Activity Calendar */}
+
+
+
+
{t("activity.section")}
+
+ {t("activity.last12Weeks")}
+
+
+
+
+
+ {readingStreak}
+
+
+ {t("activity.current")}
+
+
+
+
+ {longestStreak}
+
+
+ {t("activity.best")}
+
+
+
+
+ {activeDays}
+
+
+ {t("activity.active")}
+
+
+
+
+
+
+
+
+
+ {/* Bias Balance */}
+
+
{t("activity.biasBalance")}
+
+ {t("activity.biasMix")}
+
+
+
+
+ {weeklyBiasReads > 0 && (
+
+ {getBiasSnapshotLabel(weeklyBiasBalance, t)}{" "}
+ {t("activity.biasReads").replace(
+ "{count}",
+ String(weeklyBiasReads),
+ )}
+
+ )}
+
+
+
+ {/* Recent Activity */}
+
+ {/* Reading History */}
+
+
+
+
{t("activity.recentReading")}
+
+ {t("activity.recentReadingBody")}
+
+
+
+
+ {t("tabs.feed")}
+
+
+
+
+
+ {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")}
+
+
+
+
+ {t("activity.savedAll")}
+
+
+
+
+
+ {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")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {(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.
-
-
-
-
- Sign in to continue
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-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.
-
-
-
-
- Browse the feed
-
-
-
-
- ) : (
-
- {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
-
- Save bookmarks and keep them synced across devices.
-
- -
-
- 2
-
- Personalize ranking using your reading habits and
- interests.
-
- -
-
- 3
-
- Get notifications, digests, and future account-only tools.
-
-
-
-
-
-
- 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
+
+ {t("auth.unlockOne")}
+
+ -
+
+ 2
+
+ {t("auth.unlockTwo")}
+
+ -
+
+ 3
+
+ {t("auth.unlockThree")}
+
+
- {/* 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
-
-
-
-
- Feed
-
-
-
-
-
- {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
-
-
-
-
- All
-
-
-
-
-
- {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 && (
-
- )}
-
-
- {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")}
- Back to feed
+ {t("event.backToFeed")}
@@ -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 && (
- {
- setSearchInput("");
- setDebouncedSearch("");
- }}
- aria-label="Clear search"
- >
-
-
- )}
-
-
-
-
-
- {!isSearching && (
-
-
-
+
+
0 ? `${controlsHeight}px` : undefined }}
+ />
+
+
+
+
+
+
+
+
+ setSearchInput(event.target.value)}
+ onFocus={() => setIsSearchFocused(true)}
+ onBlur={() => {
+ window.setTimeout(() => setIsSearchFocused(false), 100);
+ }}
+ placeholder={t("feed.search.placeholder")}
+ className="h-10 rounded-full border-border/80 bg-background/75 pr-11 text-base sm:h-11 sm:pr-12"
+ aria-label={t("feed.search.label")}
+ />
+ {searchInput.length > 0 && (
+ {
+ setSearchInput("");
+ setDebouncedSearch("");
+ }}
+ aria-label={t("feed.search.clear")}
+ >
+
+
)}
- onClick={() => setFeedSort("recent")}
- aria-pressed={feedSort === "recent"}
- >
-
- Recent
-
-
+
+
+
+
+
+
+ {!isSearching && (
+
+ setFeedSort("recent")}
+ aria-pressed={feedSort === "recent"}
+ >
+
+ {t("feed.sort.recent")}
+
+ setFeedSort("trending")}
+ aria-pressed={feedSort === "trending"}
+ >
+
+ {t("feed.sort.trending")}
+
+
)}
- 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) => (
-
event.preventDefault()}
- onClick={() => {
- setSearchInput(recentSearch);
- setDebouncedSearch(recentSearch);
- searchInputRef.current?.focus();
- }}
+ {shouldShowThresholdHint && (
+
+ {t("feed.search.threshold")}
+
+ )}
+ {shouldShowRecentSearches && (
+
+ {recentSearches.map((recentSearch) => (
+ event.preventDefault()}
+ onClick={() => {
+ setSearchInput(recentSearch);
+ setDebouncedSearch(recentSearch);
+ searchInputRef.current?.focus();
+ }}
+ >
+ {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" && (
-
-
loadMore(pageSize)}
- variant="outline"
- className="rounded-full"
- >
- Load more
-
+ {!isSearching && (canLoadMore || isLoadingMore) && (
+
+
+ {isLoadingMore && (
+
+ {t("feed.loading")}
+
+ )}
)}
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 (
-
-
-
-
- Browse the feed
-
-
-
- {!isAuthenticated && (
-
-
- Create free account
-
-
- )}
-
- {!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 }) => (
-
- ))}
-
-
-
-
- {/* 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")}
- Back to sign in
+ {t("reset.backToSignIn")}
@@ -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")}
+
+
+
+ {t("saved.browseFeed")}
+
+
+
+ ) : (
+
+ {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")}
- Back to feed
+ {t("source.backToFeed")}
@@ -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"> }) {
)}
@@ -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")}
- Back to feed
+ {t("source.backToFeed")}
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")}
- Go to homepage
+ {t("unsubscribe.goHome")}
);
@@ -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")}
unsubscribe.mutate({ email })}
className="mt-6"
>
- Try again
+ {t("unsubscribe.retry")}
);
@@ -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")}
+
- Re-join the waitlist
+ {t("unsubscribe.rejoin")}
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,
+ };
+ },
+});