Date: Sun, 10 May 2026 12:48:02 +0300
Subject: [PATCH 14/19] text fixes, layout improvements
---
apps/web/src/components/feed/event-card.tsx | 4 +-
apps/web/src/components/layout/Footer.tsx | 9 +-
.../web/src/components/share-event-button.tsx | 5 +-
apps/web/src/lib/i18n/strings.ts | 158 +++++------
apps/web/src/routes/activitate.tsx | 10 +-
apps/web/src/routes/event.$slug.tsx | 24 +-
apps/web/src/routes/feed.tsx | 250 +++++++++++-------
7 files changed, 251 insertions(+), 209 deletions(-)
diff --git a/apps/web/src/components/feed/event-card.tsx b/apps/web/src/components/feed/event-card.tsx
index f2f040e..6aac1a5 100644
--- a/apps/web/src/components/feed/event-card.tsx
+++ b/apps/web/src/components/feed/event-card.tsx
@@ -237,8 +237,8 @@ const EventCard = ({
{summaryPreview}
diff --git a/apps/web/src/components/layout/Footer.tsx b/apps/web/src/components/layout/Footer.tsx
index 4e89204..22e98e7 100644
--- a/apps/web/src/components/layout/Footer.tsx
+++ b/apps/web/src/components/layout/Footer.tsx
@@ -35,9 +35,12 @@ export function Footer() {
return (
-
- {footerSections.map((section) => (
-
+
+ {footerSections.map((section, index) => (
+
{t(section.titleKey)}
diff --git a/apps/web/src/components/share-event-button.tsx b/apps/web/src/components/share-event-button.tsx
index ad559ab..a83c199 100644
--- a/apps/web/src/components/share-event-button.tsx
+++ b/apps/web/src/components/share-event-button.tsx
@@ -28,7 +28,7 @@ export default function ShareEventButton({
interactionContext,
slug,
title,
- summary,
+ summary: _summary,
size = "default",
className,
}: ShareEventButtonProps) {
@@ -36,7 +36,6 @@ export default function ShareEventButton({
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({
@@ -63,7 +62,7 @@ export default function ShareEventButton({
try {
await navigator.share({
title,
- text: shareText,
+ text: title,
url: shareUrl,
});
logShare();
diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts
index 5e640e4..e3c305f 100644
--- a/apps/web/src/lib/i18n/strings.ts
+++ b/apps/web/src/lib/i18n/strings.ts
@@ -1,7 +1,7 @@
export type Locale = "ro" | "en";
const ro = {
- "seo.siteTitle": "Biviant — Fiecare poveste de știri, analizată fapt cu fapt",
+ "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ă",
@@ -10,52 +10,52 @@ const ro = {
"tabs.activity": "Activitate",
"tabs.profile": "Profil",
"nav.mobile": "Navigație mobilă",
- "auth.signUp": "Înregistrare gratuită",
- "auth.signIn": "Conectare",
+ "auth.signUp": "Creează cont gratuit",
+ "auth.signIn": "Autentificare",
"auth.signOut": "Deconectare",
"auth.signOutError": "Nu am putut finaliza deconectarea. Încearcă din nou.",
- "auth.email": "Email",
+ "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 conectat",
- "auth.signInError": "Conectarea a eșuat. Încearcă din nou.",
- "auth.invalidEmail": "Adresă de email invalidă",
+ "auth.signInSuccess": "Te-ai autentificat",
+ "auth.signInError": "Autentificarea a eșuat. Încearcă din nou.",
+ "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": "Ai nevoie de un cont?",
- "auth.alreadyHaveAccount": "Ai deja cont?",
+ "auth.needAccount": "Nu ai cont?",
+ "auth.alreadyHaveAccount": "Ai deja un cont?",
"auth.resetEmailFirst":
- "Introdu mai întâi adresa de email ca să știm unde să trimitem linkul de resetare.",
+ "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 email există, am trimis un link de resetare a parolei.",
+ "Dacă acel e-mail există, am trimis un link de resetare a parolei.",
"auth.resetSendingStatus": "Trimitem emailul pentru resetarea parolei...",
- "auth.checkEmailVerify": "Verifică emailul pentru a-ți confirma contul.",
+ "auth.checkEmailVerify": "Verifică e-mailul pentru a-ți confirma contul.",
"auth.checkEmailVerifyDev":
- "Verifică emailul pentru a-ți confirma contul. Dacă nu apare nimic, folosește linkul de verificare afișat în logurile serverului.",
+ "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.verifyMissingEmail":
- "Nu ai primit emailul? Verifică spamul, apoi retrimite linkul de verificare la {email}.",
+ "Nu ai primit e-mailul? Verifică spamul, apoi retrimite linkul de verificare la {email}.",
"auth.verifyResendFailed":
- "Nu am putut retrimite emailul de verificare. Încearcă din nou.",
+ "Nu am putut retrimite e-mailul de verificare. Încearcă din nou.",
"auth.verifyResent":
- "Emailul de verificare a fost trimis. Verifică inboxul pentru noul link.",
- "auth.verifyResending": "Trimitem emailul de verificare...",
- "auth.verifyResend": "Retrimite emailul de verificare",
+ "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 feed-ul fără cont. Conectarea îți oferă salvări sincronizate, preferințe personale și acces rapid la activitatea ta.",
- "auth.signInTitle": "Conectează-te în cont",
+ "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",
@@ -71,9 +71,9 @@ const ro = {
"share.copied": "Linkul evenimentului a fost copiat",
"share.failed": "Nu am putut distribui acest eveniment. Încearcă din nou.",
"share.label": "Distribuie acest eveniment",
- "auth.unlockTitle": "Ce deblochează contul",
+ "auth.unlockTitle": "Ce deblochează un cont",
"auth.unlockBody":
- "Conținutul rămâne deschis. Contul adaugă doar funcții personale și persistente.",
+ "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":
@@ -87,7 +87,7 @@ const ro = {
"Fă-ți o listă personală cu tot ce vrei să citești sau să recitești.",
"saved.loading.title": "Se încarcă salvările",
"saved.loading.body":
- "Pregătim articolele salvate și contextul aferent.",
+ "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.",
@@ -111,7 +111,7 @@ const ro = {
"activity.checking.body":
"Pregătim activitatea ta și verificăm starea contului.",
"activity.savedLabel": "Salvate",
- "activity.savedSub": "Păstrate pentru mai târziu",
+ "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",
@@ -152,8 +152,8 @@ const ro = {
"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": "Raport de încredere",
- "activity.admin.maxTopics": "Topicuri maxime",
+ "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ă",
@@ -169,8 +169,8 @@ const ro = {
"activity.admin.articles.one": "1 articol",
"activity.admin.articles.many": "{count} articole",
"activity.admin.attached": "Atașate",
- "activity.admin.inferred": "Inferate",
- "activity.admin.input": "Date pentru inferență",
+ "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",
@@ -191,7 +191,7 @@ const ro = {
"Informațiile tale de autentificare și opțiunile sensibile ale contului.",
"profile.changePassword": "Schimbă parola",
"profile.deleteAccount": "Solicită ștergerea contului",
- "profile.emailLabel": "Email",
+ "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.",
@@ -233,28 +233,28 @@ const ro = {
"footer.terms": "Termeni",
"footer.howItWorks": "Cum funcționează",
"footer.sources": "Sursele noastre",
- "placeholder.inProgress": "Pagină în construcție.",
+ "placeholder.inProgress": "Pagina este în lucru.",
"page.about.title": "Despre noi",
"page.about.body":
- "Pagină în construcție. Pregătim o prezentare scurtă despre Biviant și misiunea noastră.",
+ "Pagina este în lucru. Pregătim o prezentare scurtă despre Biviant și misiunea noastră.",
"page.contact.title": "Contact",
"page.contact.body":
- "Pagină în construcție. Vom adăuga în curând detaliile de contact și canalele prin care ne poți scrie.",
+ "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":
- "Pagină în construcție. Aici vor apărea partenerii, colaborările și inițiativele susținute de Biviant.",
+ "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":
- "Pagină în construcție. Pregătim o explicație simplă despre feed, analiza de bias și comparația dintre surse.",
+ "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":
- "Pagină în construcție. Vom publica aici informațiile despre datele colectate, utilizare și drepturile tale.",
+ "Pagina este în lucru. Vom publica aici informațiile despre datele colectate, utilizare și drepturile tale.",
"page.terms.title": "Termeni",
"page.terms.body":
- "Pagină în construcție. Urmează să adăugăm termenii și condițiile de utilizare pentru Biviant.",
+ "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":
- "Pagină în construcție. În curând vei vedea cum selectăm sursele și cum construim perspectivele din feed.",
+ "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.",
@@ -270,28 +270,21 @@ const ro = {
"feed.search.label": "Caută evenimente",
"feed.search.clear": "Șterge căutarea",
"feed.sort.recent": "Recente",
- "feed.sort.trending": "Trending",
- "feed.sort.recentHint": "Evenimentele publicate cel mai recent apar primele.",
- "feed.sort.trendingHint":
- "Clasate după diversitatea surselor, volumul articolelor și recență.",
- "feed.search.threshold": "Scrie cel puțin 2 caractere pentru căutare.",
+ "feed.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. Conectează-te doar când vrei salvări sincronizate, clasare adaptată intereselor tale și alerte viitoare.",
- "feed.searching": "Căutăm evenimente…",
+ "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.featured": "Evidențiat",
- "feed.ranked": "Clasat",
"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.latestCoverage": "Cea mai nouă acoperire",
- "feed.rankedCoverage": "Acoperire clasificată",
"feed.noMatch": "Niciun eveniment nu se potrivește pentru „{query}”.",
"feed.tryFewer": "Încearcă mai puține cuvinte-cheie.",
"feed.preferredTopics": "Din topicurile tale preferate",
@@ -300,7 +293,7 @@ const ro = {
"feed.loadMore": "Încarcă mai mult",
"feed.filter.clear": "Elimină filtrul",
"event.general": "General",
- "event.summaryPending": "Rezumatul este în pregătire...",
+ "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}",
@@ -328,7 +321,7 @@ const ro = {
"event.summary": "Rezumat",
"event.meaning": "Ce înseamnă asta",
"event.compareOriginal":
- "Acoperire agregată din mai multe surse. Compară mai jos relatările originale.",
+ "Acoperire grupată din mai multe surse. Compară mai jos articolele originale.",
"event.left": "Stânga",
"event.centerTab": "Centru",
"event.right": "Dreapta",
@@ -345,16 +338,16 @@ const ro = {
"claim.framings": "Diferențe de încadrare",
"claim.framingBody":
"Fapte comune descrise cu limbaj semnificativ diferit.",
- "claim.leftExclusive": "Exclusiv stânga",
+ "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 dreapta",
+ "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 centru",
- "claim.centerExclusives": "Exclusive de centru",
+ "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ă",
@@ -388,16 +381,16 @@ const ro = {
"bookmark.add": "Salvează acest eveniment",
"bias.label.balanced": "Echilibrat",
"bias.label.left": "Înclină spre stânga",
- "bias.label.leftStrong": "Mai ales stânga",
+ "bias.label.leftStrong": "Puternic spre stânga",
"bias.label.right": "Înclină spre dreapta",
- "bias.label.rightStrong": "Mai ales 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": "Balanță de bias",
+ "bias.balance": "Distribuția biasului",
"bias.leftHeavy": "Mai mult stânga",
"bias.center": "Echilibrat",
"bias.rightHeavy": "Mai mult dreapta",
@@ -441,7 +434,7 @@ const ro = {
"unsubscribe.meta.title": "Dezabonare — Biviant",
"unsubscribe.invalidTitle": "Link invalid",
"unsubscribe.invalidBody":
- "Acest link de dezabonare nu conține adresa de email. Folosește linkul primit în email.",
+ "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",
@@ -450,7 +443,7 @@ const ro = {
"unsubscribe.retry": "Încearcă din nou",
"unsubscribe.successTitle": "Te-ai dezabonat",
"unsubscribe.successBody":
- "{email} a fost eliminat din toate emailurile Biviant. Nu te vom mai contacta.",
+ "{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",
@@ -461,7 +454,7 @@ const ro = {
"Citește articolul original (se deschide într-o filă nouă)",
"source.metaTitle": "Profil sursă — Biviant",
"source.metaDescriptionLoaded":
- "Analizează biasul, fiabilitatea, credibilitatea și acoperirea recentă pentru {name} pe Biviant.",
+ "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",
@@ -475,7 +468,7 @@ const ro = {
"source.reliability": "Fiabilitate {count}/10",
"source.recentArticles": "Articole recente",
"source.events": "Evenimente",
- "source.aiBiasAvg": "Media biasului AI",
+ "source.aiBiasAvg": "Media biasului estimat de AI",
"source.outliers": "Anomalii",
"source.credibilityTitle": "Credibilitate",
"source.mbfcCategory": "Categoria MBFC",
@@ -484,7 +477,7 @@ const ro = {
"source.rollingSample": "Eșantion AI rolling",
"source.rollingArticles": "{count} articole",
"source.mean": "Media {count}",
- "source.stddev": "Abaterea standard {count}",
+ "source.stddev": "Deviația standard {count}",
"source.recentReporting": "Relatări recente",
"source.aiBias": "Bias AI {count}",
"source.outlier": "Anomalie",
@@ -495,7 +488,7 @@ 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.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",
@@ -503,7 +496,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"tabs.activity": "Activity",
"tabs.profile": "Profile",
"nav.mobile": "Mobile navigation",
- "auth.signUp": "Sign up free",
+ "auth.signUp": "Create free account",
"auth.signIn": "Sign in",
"auth.signOut": "Sign out",
"auth.signOutError": "We couldn't sign you out. Please try again.",
@@ -514,7 +507,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"auth.namePlaceholder": "Your name",
"auth.passwordPlaceholder": "Enter your password",
"auth.passwordCreatePlaceholder": "Create a password",
- "auth.signInSuccess": "Signed in",
+ "auth.signInSuccess": "You’re signed in",
"auth.signInError": "Sign-in failed. Please try again.",
"auth.invalidEmail": "Invalid email address",
"auth.passwordMin": "Password must be at least 8 characters",
@@ -761,11 +754,8 @@ const en: { [K in keyof BaseStrings]: string } = {
"feed.search.clear": "Clear search",
"feed.sort.recent": "Recent",
"feed.sort.trending": "Trending",
- "feed.sort.recentHint": "Newest published events first.",
- "feed.sort.trendingHint":
- "Ranked by source diversity, article volume, and recency.",
- "feed.search.threshold": "Type 2+ characters to search.",
- "feed.search.indexed": "Showing indexed search results for “{query}”.",
+ "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.",
@@ -773,15 +763,11 @@ const en: { [K in keyof BaseStrings]: string } = {
"feed.loading": "Loading...",
"feed.leadStory": "Lead story",
"feed.trendingStory": "Trending story",
- "feed.featured": "Featured",
- "feed.ranked": "Ranked",
"feed.topSearch": "Top search match",
"feed.searchTag": "Search",
"feed.moreSearch": "More search results",
"feed.moreEvents": "More events",
"feed.bestMatches": "Best matches",
- "feed.latestCoverage": "Latest coverage",
- "feed.rankedCoverage": "Ranked coverage",
"feed.noMatch": "No events matched “{query}”.",
"feed.tryFewer": "Try fewer keywords.",
"feed.preferredTopics": "From your preferred topics",
@@ -790,7 +776,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"feed.loadMore": "Load more",
"feed.filter.clear": "Clear filter",
"event.general": "General",
- "event.summaryPending": "Summary pending...",
+ "event.summaryPending": "Summary is on the way...",
"event.coveragePreview":
"Coverage grouped from multiple sources. Open the event to compare articles.",
"event.updated": "Updated {time}",
@@ -835,15 +821,15 @@ const en: { [K in keyof BaseStrings]: string } = {
"claim.framings": "Framing differences",
"claim.framingBody":
"Shared facts described with meaningfully different language.",
- "claim.leftExclusive": "Left exclusive",
+ "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 exclusive",
+ "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 exclusive",
+ "claim.centerExclusive": "Center-only claims",
"claim.centerExclusives": "Center exclusives",
"claim.centerExclusiveBody":
"Substantive claims only found in center coverage.",
@@ -878,14 +864,14 @@ const en: { [K in keyof BaseStrings]: string } = {
"bookmark.add": "Bookmark this event",
"bias.label.balanced": "Balanced",
"bias.label.left": "Leaning left",
- "bias.label.leftStrong": "Mostly left",
+ "bias.label.leftStrong": "Strongly left",
"bias.label.right": "Leaning right",
- "bias.label.rightStrong": "Mostly right",
+ "bias.label.rightStrong": "Strongly right",
"bias.copy.balanced": "You’re reading a healthy mix of perspectives.",
- "bias.copy.left": "Recent reading tilts left-leaning.",
- "bias.copy.leftStrong": "Recent reading skews left-leaning.",
- "bias.copy.right": "Recent reading tilts right-leaning.",
- "bias.copy.rightStrong": "Recent reading skews right-leaning.",
+ "bias.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",
@@ -963,7 +949,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"source.reliability": "Reliability {count}/10",
"source.recentArticles": "Recent articles",
"source.events": "Events",
- "source.aiBiasAvg": "AI bias avg",
+ "source.aiBiasAvg": "Average AI bias",
"source.outliers": "Outliers",
"source.credibilityTitle": "Credibility",
"source.mbfcCategory": "MBFC category",
@@ -972,7 +958,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"source.rollingSample": "Rolling AI bias sample",
"source.rollingArticles": "{count} articles",
"source.mean": "Mean {count}",
- "source.stddev": "Stddev {count}",
+ "source.stddev": "Standard deviation {count}",
"source.recentReporting": "Recent reporting",
"source.aiBias": "AI bias {count}",
"source.outlier": "Outlier",
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx
index 87b8f5c..f671043 100644
--- a/apps/web/src/routes/activitate.tsx
+++ b/apps/web/src/routes/activitate.tsx
@@ -270,9 +270,9 @@ function AuthorizedDashboard() {
{/* Bento Grid - Main Stats */}
-
+
{/* Streak - Prominent */}
-
+
@@ -299,7 +299,7 @@ function AuthorizedDashboard() {
{/* Articles Read */}
-
+
@@ -316,7 +316,7 @@ function AuthorizedDashboard() {
{/* Events Explored */}
-
+
@@ -333,7 +333,7 @@ function AuthorizedDashboard() {
{/* Bookmarks */}
-
+
diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx
index 475d279..e81456c 100644
--- a/apps/web/src/routes/event.$slug.tsx
+++ b/apps/web/src/routes/event.$slug.tsx
@@ -386,22 +386,28 @@ function EventDetailPage() {
>
{hasPerspectives ? (
-
+
{t("event.multiplePerspectives")}
-
+
)[tabCount] ?? "grid-cols-3"}`}
>
{event.perspectiveSummaries?.left && (
- {t("event.left")}
+
+ {t("event.left")}
+
)}
- {t("event.centerTab")}
+
+ {t("event.centerTab")}
+
{event.perspectiveSummaries?.right && (
- {t("event.right")}
+
+ {t("event.right")}
+
)}
@@ -432,12 +438,12 @@ function EventDetailPage() {
) : (
-
+
{t("event.summary")}
-
+
{event.perspectiveSummaries?.center ??
t("event.compareOriginal")}
@@ -448,12 +454,12 @@ function EventDetailPage() {
{event.globalImpact && (
-
+
{t("event.meaning")}
-
+
{event.globalImpact}
diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx
index cc52635..afe1689 100644
--- a/apps/web/src/routes/feed.tsx
+++ b/apps/web/src/routes/feed.tsx
@@ -154,16 +154,16 @@ function TopicFilter({
variant="outline"
size="sm"
className={cn(
- "min-w-[140px] justify-between gap-2 rounded-full",
+ "w-full max-w-[13rem] min-w-0 justify-between gap-1.5 rounded-full px-3 sm:max-w-[15rem] md:min-w-[140px] md:max-w-[16rem] md:gap-2",
selectedTopic !== "all" && "border-primary/50 bg-primary/5",
)}
aria-label={t("feed.topic.filter")}
>
-
-
- {selectedLabel}
+
+
+ {selectedLabel}
-
+
);
@@ -280,6 +280,8 @@ function FeedContent() {
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [recentSearches, setRecentSearches] = useState([]);
const searchInputRef = useRef(null);
+ const loadMoreTriggerRef = useRef(null);
+ const isLoadingMoreRef = useRef(false);
useEffect(() => {
try {
@@ -288,7 +290,9 @@ function FeedContent() {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
setRecentSearches(
- parsed.filter((value): value is string => typeof value === "string").slice(0, 5),
+ parsed
+ .filter((value): value is string => typeof value === "string")
+ .slice(0, 5),
);
}
} catch {
@@ -350,23 +354,26 @@ function FeedContent() {
}
: "skip",
);
+ const canLoadMore = !isSearching && status === "CanLoadMore";
+ const isLoadingMore = !isSearching && status === "LoadingMore";
const preferredTopicIds = useMemo(() => {
if (!topics || !currentUser?.privateContext?.interests?.length) {
return [];
}
- const preferredNames = currentUser.privateContext.interests.map((interest) =>
- interest.trim().toLowerCase(),
+ const preferredNames = currentUser.privateContext.interests.map(
+ (interest) => interest.trim().toLowerCase(),
);
return topics
.filter((topic) => {
- const candidates = [
- topic.displayName,
- ...(topic.aliases ?? []),
- ].map((value) => value.trim().toLowerCase());
- return candidates.some((candidate) => preferredNames.includes(candidate));
+ const candidates = [topic.displayName, ...(topic.aliases ?? [])].map(
+ (value) => value.trim().toLowerCase(),
+ );
+ return candidates.some((candidate) =>
+ preferredNames.includes(candidate),
+ );
})
.map((topic) => topic._id);
}, [currentUser?.privateContext?.interests, topics]);
@@ -381,6 +388,42 @@ function FeedContent() {
: "skip",
);
+ useEffect(() => {
+ if (status !== "LoadingMore") {
+ isLoadingMoreRef.current = false;
+ }
+ }, [status]);
+
+ useEffect(() => {
+ if (!canLoadMore) {
+ return;
+ }
+
+ const target = loadMoreTriggerRef.current;
+ if (!target) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry?.isIntersecting || isLoadingMoreRef.current) {
+ return;
+ }
+
+ isLoadingMoreRef.current = true;
+ loadMore(pageSize);
+ },
+ {
+ rootMargin: "1200px 0px",
+ },
+ );
+
+ observer.observe(target);
+
+ return () => observer.disconnect();
+ }, [canLoadMore, loadMore, pageSize]);
+
useEffect(() => {
if (
debouncedSearch.length < 2 ||
@@ -423,18 +466,21 @@ function FeedContent() {
const remainingSearchEvents = featuredSearchEvent
? searchResults.slice(1)
: searchResults;
- const shouldShowThresholdHint = isSearchFocused && searchInput.trim().length < 2;
+ const shouldShowThresholdHint =
+ isSearchFocused && searchInput.trim().length < 2;
const shouldShowRecentSearches =
- isSearchFocused && searchInput.trim().length === 0 && recentSearches.length > 0;
+ isSearchFocused &&
+ searchInput.trim().length === 0 &&
+ recentSearches.length > 0;
return (
-
-
-
-
-
-
+
+
+
+
+
+
setIsSearchFocused(false), 100);
}}
placeholder={t("feed.search.placeholder")}
- className="h-11 rounded-full border-border/80 bg-background/75 pr-12"
+ className="h-10 rounded-full border-border/80 bg-background/75 pr-11 text-base sm:h-11 sm:pr-12"
aria-label={t("feed.search.label")}
/>
{searchInput.length > 0 && (
@@ -454,7 +500,7 @@ function FeedContent() {
type="button"
variant="ghost"
size="icon"
- className="absolute right-1 top-1 size-9 rounded-full"
+ className="absolute right-1 top-1 size-8 rounded-full sm:size-9"
onClick={() => {
setSearchInput("");
setDebouncedSearch("");
@@ -465,55 +511,52 @@ function FeedContent() {
)}
-
-
-
- {!isSearching && (
-
-
-
setFeedSort("recent")}
- aria-pressed={feedSort === "recent"}
- >
-
- {t("feed.sort.recent")}
-
-
setFeedSort("trending")}
- aria-pressed={feedSort === "trending"}
- >
-
- {t("feed.sort.trending")}
-
+
+
+
+
-
- {feedSort === "recent"
- ? t("feed.sort.recentHint")
- : t("feed.sort.trendingHint")}
-
+ {!isSearching && (
+
+ setFeedSort("recent")}
+ aria-pressed={feedSort === "recent"}
+ >
+
+ {t("feed.sort.recent")}
+
+ setFeedSort("trending")}
+ aria-pressed={feedSort === "trending"}
+ >
+
+ {t("feed.sort.trending")}
+
+
+ )}
- )}
+
{shouldShowThresholdHint && (
{t("feed.search.threshold")}
@@ -589,9 +632,6 @@ function FeedContent() {
? t("feed.leadStory")
: t("feed.trendingStory")}
-
- {feedSort === "recent" ? t("feed.featured") : t("feed.ranked")}
-
) : null}
- {((!isSearching && remainingEvents && remainingEvents.length > 0) ||
- (isSearching && remainingSearchEvents && remainingSearchEvents.length > 0)) ? (
+ {(!isSearching && remainingEvents && remainingEvents.length > 0) ||
+ (isSearching &&
+ remainingSearchEvents &&
+ remainingSearchEvents.length > 0) ? (
{isSearching ? t("feed.moreSearch") : t("feed.moreEvents")}
-
- {isSearching
- ? t("feed.bestMatches")
- : feedSort === "recent"
- ? t("feed.latestCoverage")
- : t("feed.rankedCoverage")}
-
+ {isSearching ? (
+
+ {t("feed.bestMatches")}
+
+ ) : null}
- {(isSearching ? remainingSearchEvents : remainingEvents)?.map((event) => (
-
- ))}
+ {(isSearching ? remainingSearchEvents : remainingEvents)?.map(
+ (event) => (
+
+ ),
+ )}
) : null}
@@ -695,16 +737,22 @@ function FeedContent() {
)}
- {!isSearching && status === "CanLoadMore" && (
-
-
loadMore(pageSize)}
- variant="outline"
- className="rounded-full"
- >
- {t("feed.loadMore")}
-
+ {!isSearching && (canLoadMore || isLoadingMore) && (
+
+
+ {isLoadingMore && (
+
+ {t("feed.loading")}
+
+ )}
)}
From 86e6392418a98b136bdb5ab7268f1a3cf8d6ac8c Mon Sep 17 00:00:00 2001
From: flavius
Date: Sun, 10 May 2026 13:40:07 +0300
Subject: [PATCH 15/19] optimize vector search across the whole codebase
---
packages/backend/convex/_generated/api.d.ts | 2 +
packages/backend/convex/clustering.ts | 1025 +++++++++++++++--
packages/backend/convex/config.ts | 42 +
packages/backend/convex/ingestion.ts | 96 +-
packages/backend/convex/schema.ts | 66 +-
packages/backend/convex/seeds.ts | 12 +-
packages/backend/convex/vectorSearchBudget.ts | 334 ++++++
7 files changed, 1456 insertions(+), 121 deletions(-)
create mode 100644 packages/backend/convex/vectorSearchBudget.ts
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index 705b374..f83e5e2 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -47,6 +47,7 @@ import type * as summarizationNode from "../summarizationNode.js";
import type * as topicCatalog from "../topicCatalog.js";
import type * as topics from "../topics.js";
import type * as user from "../user.js";
+import type * as vectorSearchBudget from "../vectorSearchBudget.js";
import type * as waitlist from "../waitlist.js";
import type {
@@ -95,6 +96,7 @@ declare const fullApi: ApiFromModules<{
topicCatalog: typeof topicCatalog;
topics: typeof topics;
user: typeof user;
+ vectorSearchBudget: typeof vectorSearchBudget;
waitlist: typeof waitlist;
}>;
diff --git a/packages/backend/convex/clustering.ts b/packages/backend/convex/clustering.ts
index 0dde046..9e03b19 100644
--- a/packages/backend/convex/clustering.ts
+++ b/packages/backend/convex/clustering.ts
@@ -35,6 +35,7 @@ import {
syncPublicEventPreview,
} from "./lib/publicEventPreviews";
import { buildEventShareRenderSignature } from "./shareAssets";
+import { estimateVectorSearchQgbRead } from "./vectorSearchBudget";
const CLUSTER_LOCK_KEY = "clusterEnrichedArticles";
const CLUSTER_LOCK_TTL_MS = 20 * 60 * 1000;
@@ -47,7 +48,7 @@ const MAX_CANDIDATE_EVENTS = 250;
const VECTOR_SEARCH_LIMIT = 40;
const EVENT_PRESENTATION_ARTICLE_LIMIT = 10;
const CANDIDACY_TOKEN_CAP = 200;
-const EVENT_EMBEDDING_DIMENSIONS = 1536;
+const EVENT_EMBEDDING_DIMENSIONS = 512;
const DEFAULT_MIN_CLUSTER_SIMILARITY = 0.74;
const DEFAULT_STRONG_CLUSTER_SIMILARITY = 0.84;
const DEFAULT_MIN_TITLE_TOKEN_OVERLAP = 2;
@@ -67,6 +68,35 @@ const DEFAULT_MERGE_MIN_TITLE_JACCARD = 0.45;
const DEFAULT_MERGE_MAX_TIME_DELTA_HOURS = 48;
const MERGE_VECTOR_SEARCH_LIMIT = 24;
const RECLUSTER_VECTOR_SEARCH_LIMIT = 24;
+const MERGE_RECENT_BUCKET = "recent_2d";
+const MERGE_STALE_BUCKET = "stale";
+const SINGLETON_BUCKET = "singleton";
+const MULTI_ARTICLE_BUCKET = "multi";
+
+type ClusteringJobName =
+ | "clusterEnrichedArticles"
+ | "mergeNearDuplicateEvents"
+ | "reclusterRecentSingletonEvents";
+
+type JobMetrics = {
+ jobName: ClusteringJobName;
+ runId: string;
+ budgetAllowed: boolean;
+ usedFallbackMode: boolean;
+ vectorSearches: number;
+ vectorMatchesReturned: number;
+ vectorMatchesHydrated: number;
+ vectorMatchesDiscardedPostFetch: number;
+ vectorSearchesPerArticle: number;
+ vectorSearchesPerCandidateEvent: number;
+ batchArticles: number;
+ candidateCacheSize: number;
+ mergeSeedEvents: number;
+ reclusterSeedEvents: number;
+ qgbRead: number;
+ elapsedMs: number;
+ stageMs: Record;
+};
const STOPWORDS = new Set([
"a",
@@ -144,6 +174,144 @@ function toEventEmbedding(articleEmbedding: number[]): number[] {
return padded;
}
+function formatUtcDayBucket(timestamp: number): string {
+ return new Date(timestamp).toISOString().slice(0, 10);
+}
+
+function buildRecentWindowBucket(
+ lastArticleAt: number,
+ referenceTime: number = Date.now(),
+): string {
+ return referenceTime - lastArticleAt <= RECENT_EVENT_WINDOW_MS
+ ? MERGE_RECENT_BUCKET
+ : MERGE_STALE_BUCKET;
+}
+
+function buildSingletonBucket(articleCount: number): string {
+ return articleCount <= 2 ? SINGLETON_BUCKET : MULTI_ARTICLE_BUCKET;
+}
+
+function buildEventEmbeddingFilterFields(args: {
+ status?: "processing" | "published";
+ lastArticleAt: number;
+ articleCount: number;
+ referenceTime?: number;
+}) {
+ const recentWindowBucket = buildRecentWindowBucket(
+ args.lastArticleAt,
+ args.referenceTime,
+ );
+ const singletonBucket = buildSingletonBucket(args.articleCount);
+ const updatedDayBucket = formatUtcDayBucket(args.lastArticleAt);
+ const status = args.status ?? "processing";
+
+ return {
+ recentWindowBucket,
+ singletonBucket,
+ updatedDayBucket,
+ mergeSearchBucket: `${status}::${recentWindowBucket}::${updatedDayBucket}`,
+ singletonSearchBucket: `${status}::${singletonBucket}::${updatedDayBucket}`,
+ };
+}
+
+function collectRecentDayBuckets(windowHours: number, now: number = Date.now()) {
+ const dayCount = Math.max(1, Math.ceil(windowHours / 24) + 1);
+ const buckets: string[] = [];
+ for (let offset = 0; offset < dayCount; offset++) {
+ buckets.push(formatUtcDayBucket(now - offset * 24 * 60 * 60 * 1000));
+ }
+ return buckets;
+}
+
+function buildMergeSearchBuckets(dayBuckets: string[]) {
+ return dayBuckets.flatMap((dayBucket) => [
+ `published::${MERGE_RECENT_BUCKET}::${dayBucket}`,
+ `processing::${MERGE_RECENT_BUCKET}::${dayBucket}`,
+ ]);
+}
+
+function buildSingletonSearchBuckets(dayBuckets: string[]) {
+ return dayBuckets.flatMap((dayBucket) => [
+ `published::${SINGLETON_BUCKET}::${dayBucket}`,
+ `processing::${SINGLETON_BUCKET}::${dayBucket}`,
+ ]);
+}
+
+function buildRunId(jobName: ClusteringJobName): string {
+ return `${jobName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function createJobMetrics(jobName: ClusteringJobName): JobMetrics {
+ return {
+ jobName,
+ runId: buildRunId(jobName),
+ budgetAllowed: true,
+ usedFallbackMode: false,
+ vectorSearches: 0,
+ vectorMatchesReturned: 0,
+ vectorMatchesHydrated: 0,
+ vectorMatchesDiscardedPostFetch: 0,
+ vectorSearchesPerArticle: 0,
+ vectorSearchesPerCandidateEvent: 0,
+ batchArticles: 0,
+ candidateCacheSize: 0,
+ mergeSeedEvents: 0,
+ reclusterSeedEvents: 0,
+ qgbRead: 0,
+ elapsedMs: 0,
+ stageMs: {},
+ };
+}
+
+function markStageDuration(
+ metrics: JobMetrics,
+ stage: string,
+ startedAt: number,
+): number {
+ const elapsed = Date.now() - startedAt;
+ metrics.stageMs[stage] = (metrics.stageMs[stage] ?? 0) + elapsed;
+ return elapsed;
+}
+
+async function flushJobMetrics(
+ ctx: any,
+ metrics: JobMetrics,
+ startedAt: number,
+): Promise {
+ metrics.elapsedMs = Date.now() - startedAt;
+ metrics.qgbRead = estimateVectorSearchQgbRead({
+ vectorSearches: metrics.vectorSearches,
+ averageMatchesReturned:
+ metrics.vectorSearches > 0
+ ? metrics.vectorMatchesReturned / metrics.vectorSearches
+ : 0,
+ });
+
+ console.log(
+ `[clustering] ${metrics.jobName} metrics ${JSON.stringify(metrics)}`,
+ );
+
+ await ctx.runMutation(internal.vectorSearchBudget.recordUsage, {
+ jobName: metrics.jobName,
+ runId: metrics.runId,
+ qgbRead: metrics.qgbRead,
+ vectorSearches: metrics.vectorSearches,
+ vectorMatchesReturned: metrics.vectorMatchesReturned,
+ vectorMatchesHydrated: metrics.vectorMatchesHydrated,
+ vectorMatchesDiscardedPostFetch: metrics.vectorMatchesDiscardedPostFetch,
+ usedFallbackMode: metrics.usedFallbackMode,
+ budgetAllowed: metrics.budgetAllowed,
+ elapsedMs: metrics.elapsedMs,
+ metricsJson: JSON.stringify(metrics),
+ });
+}
+
+async function getVectorSearchBudgetState(
+ ctx: any,
+) {
+ return await ctx.runQuery(internal.vectorSearchBudget.checkBudget, {});
+}
+
function appendArticleEmbeddingToEventMean(
existingEventEmbedding: number[],
currentArticleCount: number,
@@ -532,6 +700,23 @@ type ClusterCandidateVectorResult = ClusterCandidateQueryResult & {
similarity: number;
};
+function hydrateClusterCandidate(
+ candidate: ClusterCandidateQueryResult | ClusterCandidateVectorResult,
+): ClusterCandidate {
+ return {
+ ...candidate,
+ embeddingId: candidate.embeddingId,
+ lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt,
+ sourceCount: candidate.sourceCount ?? candidate.sourceIds?.length ?? 0,
+ titleTokens: normalizeTitleTokens(candidate.title),
+ evidenceTokens: new Set(candidate.evidenceTokens ?? []),
+ factTokens: new Set(candidate.factTokens ?? []),
+ entityTokens: new Set(candidate.entityTokens ?? []),
+ topicSlugs: new Set(candidate.topicSlugs ?? []),
+ sourceIds: new Set(candidate.sourceIds ?? []),
+ };
+}
+
type ClusterSettings = {
minSimilarity: number;
strongSimilarity: number;
@@ -1561,6 +1746,88 @@ function findBestCandidate(
return best?.candidate ?? null;
}
+function findBatchLocalCandidate(
+ article: {
+ articleId: Id<"articles">;
+ title: string;
+ rssSnippet: string;
+ summary: string;
+ atomicFacts: string[];
+ entities: string[];
+ topicSlugs: string[];
+ extractionQuality: "strong" | "weak";
+ publishedAt: number;
+ embedding: number[];
+ sourceId: Id<"sources">;
+ },
+ candidates: ClusterCandidate[],
+ settings: ClusterSettings,
+): ClusterCandidate | null {
+ const enrichedCandidates = candidates.map((candidate) => ({
+ ...candidate,
+ similarity: cosineSimilarity(
+ toEventEmbedding(article.embedding),
+ toEventEmbedding(candidate.embedding),
+ ),
+ }));
+
+ return findBestCandidate(article, enrichedCandidates, settings);
+}
+
+function findHeuristicCandidate(
+ article: {
+ title: string;
+ rssSnippet: string;
+ summary: string;
+ atomicFacts: string[];
+ entities: string[];
+ topicSlugs: string[];
+ publishedAt: number;
+ sourceId: Id<"sources">;
+ },
+ candidates: ClusterCandidate[],
+): ClusterCandidate | null {
+ const titleTokens = normalizeTitleTokens(normalizeTitleForClustering(article.title));
+ const evidenceTokens = mergeTokenSets(
+ normalizeTitleTokens(normalizeSnippetForClustering(article.rssSnippet)),
+ normalizeTitleTokens(normalizeSnippetForClustering(article.summary)),
+ );
+ const factTokens = normalizeTitleTokens(article.atomicFacts.join(" "));
+ const entityTokens = extractEntityTokens(
+ article.title,
+ article.rssSnippet,
+ article.summary,
+ article.entities.join(" "),
+ article.atomicFacts.join(" "),
+ );
+ const topicTokens = new Set(article.topicSlugs);
+
+ let best: { candidate: ClusterCandidate; score: number } | null = null;
+ for (const candidate of candidates) {
+ const timeDeltaMs = Math.abs(
+ article.publishedAt - candidate.firstPublishedAt,
+ );
+ if (timeDeltaMs > RECENT_EVENT_WINDOW_MS) continue;
+
+ const sameSourcePenalty = candidate.sourceIds.has(article.sourceId) ? -1.5 : 0;
+ const score =
+ countTokenOverlap(titleTokens, candidate.titleTokens) * 4 +
+ jaccardSimilarity(titleTokens, candidate.titleTokens) * 6 +
+ countTokenOverlap(evidenceTokens, candidate.evidenceTokens) * 1.8 +
+ countTokenOverlap(factTokens, candidate.factTokens) * 2 +
+ countTokenOverlap(entityTokens, candidate.entityTokens) * 2.2 +
+ countTokenOverlap(topicTokens, candidate.topicSlugs) * 2.5 +
+ sameSourcePenalty;
+
+ if (score < 4) continue;
+ if (!best || score > best.score) {
+ best = { candidate, score };
+ }
+ }
+
+ return best?.candidate ?? null;
+}
+
function shouldPublishCluster(
articleCount: number,
uniqueSourceCount: number,
@@ -1820,6 +2087,8 @@ export const backfillEventCandidacy = internalAction({
{
eventId: event._id,
status: event.status,
+ lastArticleAt,
+ articleCount,
},
);
@@ -1839,6 +2108,62 @@ export const backfillEventCandidacy = internalAction({
},
});
+export const backfillEventEmbeddingBuckets = internalAction({
+ args: {
+ cursor: v.optional(v.string()),
+ limit: v.optional(v.number()),
+ },
+ handler: async (
+ ctx,
+ { cursor, limit },
+ ): Promise<{
+ processed: number;
+ continueCursor: string | null;
+ isDone: boolean;
+ }> => {
+ const pageSize = Math.min(Math.max(Math.floor(limit ?? 100), 1), 200);
+ const page: PaginationResult> = await ctx.runQuery(
+ internal.clustering.getEventsForCandidacyBackfill,
+ {
+ paginationOpts: {
+ cursor: cursor ?? null,
+ numItems: pageSize,
+ },
+ },
+ );
+
+ for (const event of page.page) {
+ const fallbackArticles =
+ event.articleCount === undefined || event.lastArticleAt === undefined
+ ? await ctx.runQuery(internal.clustering.getArticlesForEventBackfill, {
+ eventId: event._id,
+ })
+ : null;
+ const articleCount = event.articleCount ?? fallbackArticles?.length ?? 0;
+ const lastArticleAt =
+ event.lastArticleAt ??
+ (fallbackArticles && fallbackArticles.length > 0
+ ? fallbackArticles.reduce(
+ (max, article) => Math.max(max, article.publishedAt),
+ event.firstPublishedAt,
+ )
+ : event.firstPublishedAt);
+ await ctx.runMutation(internal.clustering.syncEmbeddingStatusForBackfill, {
+ eventId: event._id,
+ status: event.status,
+ lastArticleAt,
+ articleCount,
+ });
+ }
+
+ return {
+ processed: page.page.length,
+ continueCursor: page.continueCursor ?? null,
+ isDone: page.isDone,
+ };
+ },
+});
+
export const getArticlesForEventBackfill = internalQuery({
args: { eventId: v.id("events") },
handler: async (ctx, { eventId }) => {
@@ -1941,14 +2266,23 @@ export const syncEmbeddingStatusForBackfill = internalMutation({
args: {
eventId: v.id("events"),
status: v.union(v.literal("processing"), v.literal("published")),
+ lastArticleAt: v.number(),
+ articleCount: v.number(),
},
- handler: async (ctx, { eventId, status }) => {
+ handler: async (ctx, { eventId, status, lastArticleAt, articleCount }) => {
const embeddingRow = await ctx.db
.query("eventEmbeddings")
.withIndex("by_event", (q) => q.eq("eventId", eventId))
.first();
- if (embeddingRow && embeddingRow.status !== status) {
- await ctx.db.patch(embeddingRow._id, { status });
+ if (embeddingRow) {
+ await ctx.db.patch(embeddingRow._id, {
+ status,
+ ...buildEventEmbeddingFilterFields({
+ status,
+ lastArticleAt,
+ articleCount,
+ }),
+ });
}
},
});
@@ -2059,6 +2393,93 @@ export const getRecentClusterCandidates = internalQuery({
},
});
+export const getChangedClusterCandidates = internalQuery({
+ args: {
+ sinceTs: v.number(),
+ recentSinceTs: v.number(),
+ limit: v.number(),
+ singletonOnly: v.optional(v.boolean()),
+ },
+ handler: async (ctx, { sinceTs, recentSinceTs, limit, singletonOnly }) => {
+ const candidacies = await Promise.all([
+ ctx.db
+ .query("eventCandidacy")
+ .withIndex("by_status_last_article_at", (q) =>
+ q.eq("status", "published").gte("lastArticleAt", recentSinceTs),
+ )
+ .order("desc")
+ .take(limit * 3),
+ ctx.db
+ .query("eventCandidacy")
+ .withIndex("by_status_last_article_at", (q) =>
+ q.eq("status", "processing").gte("lastArticleAt", recentSinceTs),
+ )
+ .order("desc")
+ .take(limit * 3),
+ ]);
+
+ return [...candidacies[0], ...candidacies[1]]
+ .filter(
+ (row) =>
+ row.updatedAt > sinceTs &&
+ (!singletonOnly || row.articleCount <= 2),
+ )
+ .sort(
+ (a, b) =>
+ b.updatedAt - a.updatedAt ||
+ b.lastArticleAt - a.lastArticleAt ||
+ b._creationTime - a._creationTime,
+ )
+ .slice(0, limit);
+ },
+});
+
+export const getClusteringJobState = internalQuery({
+ args: {
+ jobName: v.string(),
+ },
+ handler: async (ctx, { jobName }) => {
+ return await ctx.db
+ .query("clusteringJobState")
+ .withIndex("by_job_name", (q) => q.eq("jobName", jobName))
+ .unique();
+ },
+});
+
+export const upsertClusteringJobState = internalMutation({
+ args: {
+ jobName: v.string(),
+ lastProcessedAt: v.optional(v.number()),
+ lastProcessedDayBucket: v.optional(v.string()),
+ lastRunAt: v.number(),
+ lastRunMetricsJson: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("clusteringJobState")
+ .withIndex("by_job_name", (q) => q.eq("jobName", args.jobName))
+ .unique();
+
+ const payload = {
+ lastProcessedAt: args.lastProcessedAt,
+ lastProcessedDayBucket: args.lastProcessedDayBucket,
+ lastRunAt: args.lastRunAt,
+ lastRunMetricsJson: args.lastRunMetricsJson,
+ updatedAt: Date.now(),
+ };
+
+ if (existing) {
+ await ctx.db.patch(existing._id, payload);
+ return;
+ }
+
+ await ctx.db.insert("clusteringJobState", {
+ jobName: args.jobName,
+ ...payload,
+ });
+ },
+});
+
export const getClusterCandidatesByEmbeddingMatches = internalQuery({
args: {
embeddingMatches: v.array(
@@ -2069,16 +2490,55 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({
),
},
handler: async (ctx, { embeddingMatches }) => {
+ const uniqueEmbeddingIds = Array.from(
+ new Set(embeddingMatches.map((match) => String(match.embeddingId))),
+ );
+ const embeddingRows = await Promise.all(
+ uniqueEmbeddingIds.map((embeddingId) => ctx.db.get(embeddingId as Id<"eventEmbeddings">)),
+ );
+ const embeddingsById = new Map>();
+ for (const row of embeddingRows) {
+ if (row) embeddingsById.set(String(row._id), row);
+ }
+
+ const uniqueEventIds = Array.from(
+ new Set(
+ embeddingRows
+ .filter((row): row is Doc<"eventEmbeddings"> => row !== null)
+ .map((row) => String(row.eventId)),
+ ),
+ );
+ const eventRows = await Promise.all(
+ uniqueEventIds.map((eventId) => ctx.db.get(eventId as Id<"events">)),
+ );
+ const eventsById = new Map>();
+ for (const row of eventRows) {
+ if (row) eventsById.set(String(row._id), row);
+ }
+
+ const candidacyRows = await Promise.all(
+ uniqueEventIds.map((eventId) =>
+ ctx.db
+ .query("eventCandidacy")
+ .withIndex("by_event", (q) => q.eq("eventId", eventId as Id<"events">))
+ .first(),
+ ),
+ );
+ const candidaciesByEventId = new Map>();
+ for (const row of candidacyRows) {
+ if (row) candidaciesByEventId.set(String(row.eventId), row);
+ }
+
const seenEventIds = new Set();
const candidates: ClusterCandidateVectorResult[] = [];
let missingCandidacy = 0;
let missingEvents = 0;
for (const match of embeddingMatches) {
- const embeddingRow = await ctx.db.get(match.embeddingId);
+ const embeddingRow = embeddingsById.get(String(match.embeddingId));
if (!embeddingRow) continue;
- const event = await ctx.db.get(embeddingRow.eventId);
+ const event = eventsById.get(String(embeddingRow.eventId));
if (!event) {
missingEvents++;
continue;
@@ -2088,10 +2548,7 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({
if (seenEventIds.has(eventKey)) continue;
seenEventIds.add(eventKey);
- const candidacy = await ctx.db
- .query("eventCandidacy")
- .withIndex("by_event", (q) => q.eq("eventId", event._id))
- .first();
+ const candidacy = candidaciesByEventId.get(eventKey);
if (!candidacy) {
missingCandidacy++;
continue;
@@ -2117,6 +2574,45 @@ export const getClusterCandidatesByEmbeddingMatches = internalQuery({
},
});
+export const getClusterCandidatesByEventIds = internalQuery({
+ args: {
+ eventIds: v.array(v.id("events")),
+ },
+ handler: async (ctx, { eventIds }) => {
+ const uniqueEventIds = Array.from(
+ new Set(eventIds.map((eventId) => String(eventId))),
+ ) as Array>;
+
+ const rows = await Promise.all(
+ uniqueEventIds.map(async (eventId) => {
+ const [event, candidacy, embeddingRow] = await Promise.all([
+ ctx.db.get(eventId),
+ ctx.db
+ .query("eventCandidacy")
+ .withIndex("by_event", (q) => q.eq("eventId", eventId))
+ .first(),
+ ctx.db
+ .query("eventEmbeddings")
+ .withIndex("by_event", (q) => q.eq("eventId", eventId))
+ .first(),
+ ]);
+
+ if (!event || !candidacy || !embeddingRow) return null;
+
+ return buildCandidateQueryResult({
+ event,
+ embeddingRow,
+ candidacy,
+ });
+ }),
+ );
+
+ return rows.filter(
+ (candidate): candidate is ClusterCandidateQueryResult => candidate !== null,
+ );
+ },
+});
+
export const createEventFromArticle = internalMutation({
args: {
articleId: v.id("articles"),
@@ -2169,6 +2665,11 @@ export const createEventFromArticle = internalMutation({
embedding: toEventEmbedding(eventEmbedding),
version,
status: initialStatus,
+ ...buildEventEmbeddingFilterFields({
+ status: initialStatus,
+ lastArticleAt: publishedAt,
+ articleCount: 1,
+ }),
});
const candidacyTokens = buildCandidacyFromArticle(
@@ -2345,6 +2846,11 @@ export const attachArticleToEvent = internalMutation({
embedding: nextEmbedding,
version,
status: nextStatus,
+ ...buildEventEmbeddingFilterFields({
+ status: nextStatus,
+ lastArticleAt: nextLastArticleAt,
+ articleCount: nextArticleCount,
+ }),
});
} else {
await ctx.db.insert("eventEmbeddings", {
@@ -2352,6 +2858,11 @@ export const attachArticleToEvent = internalMutation({
embedding: nextEmbedding,
version,
status: nextStatus,
+ ...buildEventEmbeddingFilterFields({
+ status: nextStatus,
+ lastArticleAt: nextLastArticleAt,
+ articleCount: nextArticleCount,
+ }),
});
}
@@ -3795,6 +4306,11 @@ export const mergeEvents = internalMutation({
embedding: mergedEmbedding,
version,
status: mergedStatus,
+ ...buildEventEmbeddingFilterFields({
+ status: mergedStatus,
+ lastArticleAt: mergedLastArticleAt,
+ articleCount: mergedArticleCount,
+ }),
});
} else {
await ctx.db.insert("eventEmbeddings", {
@@ -3802,6 +4318,11 @@ export const mergeEvents = internalMutation({
embedding: mergedEmbedding,
version,
status: mergedStatus,
+ ...buildEventEmbeddingFilterFields({
+ status: mergedStatus,
+ lastArticleAt: mergedLastArticleAt,
+ articleCount: mergedArticleCount,
+ }),
});
}
@@ -3909,22 +4430,32 @@ export const mergeNearDuplicateEvents = internalAction({
return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
}
+ const startedAt = Date.now();
+ const metrics = createJobMetrics("mergeNearDuplicateEvents");
+
try {
- const candidateFetchStart = Date.now();
- const recentCandidatesRaw = await ctx.runQuery(
- internal.clustering.getRecentClusterCandidates,
- {
- sinceTs: Date.now() - RECENT_EVENT_WINDOW_MS,
- limit: MAX_CANDIDATE_EVENTS,
- },
- );
- const candidateFetchMs = Date.now() - candidateFetchStart;
+ const budget = await getVectorSearchBudgetState(ctx);
+ metrics.budgetAllowed = budget.allowed;
+ if (!budget.allowed) {
+ console.log(
+ `[clustering] mergeNearDuplicateEvents skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`,
+ );
+ await flushJobMetrics(ctx, metrics, startedAt);
+ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
+ }
+
+ const jobState = await ctx.runQuery(internal.clustering.getClusteringJobState, {
+ jobName: "mergeNearDuplicateEvents",
+ });
+ const sinceTs = jobState?.lastProcessedAt ?? 0;
+ const candidateFetchStart = Date.now();
const mergeConfig = await ctx.runQuery(internal.config.getBatch, {
keys: [
"merge_min_similarity",
"merge_min_title_jaccard",
"merge_max_time_delta_hours",
+ "merge_vector_search_limit",
],
});
@@ -3948,47 +4479,105 @@ export const mergeNearDuplicateEvents = internalAction({
72,
),
};
+ const vectorSearchLimit = safeInteger(
+ mergeConfig.merge_vector_search_limit,
+ MERGE_VECTOR_SEARCH_LIMIT,
+ 1,
+ 60,
+ );
+ const recentSinceTs = Date.now() - settings.maxTimeDeltaHours * 60 * 60 * 1000;
+ const changedCandidateRows = await ctx.runQuery(
+ internal.clustering.getChangedClusterCandidates,
+ {
+ sinceTs,
+ recentSinceTs,
+ limit: MAX_CANDIDATE_EVENTS,
+ singletonOnly: false,
+ },
+ );
+ markStageDuration(metrics, "candidateFetch", candidateFetchStart);
+
+ if (changedCandidateRows.length === 0) {
+ await ctx.runMutation(internal.clustering.upsertClusteringJobState, {
+ jobName: "mergeNearDuplicateEvents",
+ lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs,
+ lastProcessedDayBucket:
+ jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()),
+ lastRunAt: Date.now(),
+ lastRunMetricsJson: JSON.stringify(metrics),
+ });
+ await flushJobMetrics(ctx, metrics, startedAt);
+ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
+ }
- const candidates: ClusterCandidate[] = (
- recentCandidatesRaw as ClusterCandidateQueryResult[]
- ).map((candidate) => ({
- ...candidate,
- embeddingId: candidate.embeddingId,
- lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt,
- sourceCount: candidate.sourceCount ?? candidate.sourceIds?.length ?? 0,
- titleTokens: normalizeTitleTokens(candidate.title),
- evidenceTokens: new Set(candidate.evidenceTokens ?? []),
- factTokens: new Set(candidate.factTokens ?? []),
- entityTokens: new Set(candidate.entityTokens ?? []),
- topicSlugs: new Set(candidate.topicSlugs ?? []),
- sourceIds: new Set(candidate.sourceIds ?? []),
- }));
+ const seedCandidateResults = await ctx.runQuery(
+ internal.clustering.getClusterCandidatesByEventIds,
+ {
+ eventIds: changedCandidateRows.map((row) => row.eventId),
+ },
+ );
+ const seeds = (seedCandidateResults as ClusterCandidateQueryResult[]).map(
+ (candidate) => hydrateClusterCandidate(candidate),
+ );
+ metrics.mergeSeedEvents = seeds.length;
const neighborSearchStart = Date.now();
- const candidatesByEmbeddingId = new Map();
- for (const candidate of candidates) {
- if (!candidate.embeddingId) continue;
- candidatesByEmbeddingId.set(String(candidate.embeddingId), candidate);
+ const dayBuckets = collectRecentDayBuckets(settings.maxTimeDeltaHours);
+ const mergeSearchBuckets = buildMergeSearchBuckets(dayBuckets);
+ const candidatesByEventId = new Map();
+ for (const candidate of seeds) {
+ candidatesByEventId.set(String(candidate.eventId), candidate);
}
-
const pairKeys = new Set();
const candidatePairs: Array<[ClusterCandidate, ClusterCandidate]> = [];
- for (const candidate of candidates) {
+ for (const candidate of seeds) {
if (!candidate.embeddingId) continue;
+ metrics.vectorSearches++;
const neighbors = await ctx.vectorSearch(
"eventEmbeddings",
"by_embedding",
{
vector: toEventEmbedding(candidate.embedding),
- limit: MERGE_VECTOR_SEARCH_LIMIT,
+ limit: vectorSearchLimit,
filter: (q) =>
- q.or(q.eq("status", "published"), q.eq("status", "processing")),
+ mergeSearchBuckets.length <= 1
+ ? q.eq(
+ "mergeSearchBucket",
+ mergeSearchBuckets[0] ??
+ `published::${MERGE_RECENT_BUCKET}::${formatUtcDayBucket(Date.now())}`,
+ )
+ : q.or(
+ ...mergeSearchBuckets.map((bucket) =>
+ q.eq("mergeSearchBucket", bucket),
+ ),
+ ),
},
);
+ metrics.vectorMatchesReturned += neighbors.length;
+ if (neighbors.length === 0) continue;
- for (const neighbor of neighbors) {
- const match = candidatesByEmbeddingId.get(String(neighbor._id));
+ const hydratedResults = await ctx.runQuery(
+ internal.clustering.getClusterCandidatesByEmbeddingMatches,
+ {
+ embeddingMatches: neighbors.map((result) => ({
+ embeddingId: result._id,
+ similarity: result._score,
+ })),
+ },
+ );
+ metrics.vectorMatchesHydrated += hydratedResults.length;
+ metrics.vectorMatchesDiscardedPostFetch +=
+ neighbors.length - hydratedResults.length;
+
+ for (const result of hydratedResults as ClusterCandidateVectorResult[]) {
+ const matchKey = String(result.eventId);
+ let match = candidatesByEventId.get(matchKey);
+ if (!match) {
+ match = hydrateClusterCandidate(result);
+ candidatesByEventId.set(matchKey, match);
+ }
+ match.similarity = result.similarity;
if (!match || match.eventId === candidate.eventId) continue;
const key = buildEventPairKey(candidate.eventId, match.eventId);
if (pairKeys.has(key)) continue;
@@ -3996,10 +4585,11 @@ export const mergeNearDuplicateEvents = internalAction({
candidatePairs.push([candidate, match]);
}
}
- const neighborSearchMs = Date.now() - neighborSearchStart;
+ markStageDuration(metrics, "neighborSearch", neighborSearchStart);
+ metrics.candidateCacheSize = candidatesByEventId.size;
console.log(
- `[clustering] Merge candidates: ${candidates.length} events, ${candidatePairs.length} pairs (fetchMs=${candidateFetchMs}, neighborMs=${neighborSearchMs})`,
+ `[clustering] Merge candidates: ${seeds.length} seeds, ${candidatePairs.length} pairs`,
);
const removedIds = new Set();
@@ -4133,12 +4723,29 @@ export const mergeNearDuplicateEvents = internalAction({
for (const topicSlug of remove.topicSlugs) {
keep.topicSlugs.add(topicSlug);
}
+ candidatesByEventId.set(String(keep.eventId), keep);
}
+ metrics.vectorSearchesPerCandidateEvent =
+ seeds.length > 0 ? metrics.vectorSearches / seeds.length : 0;
+
console.log(
`[clustering] Merge pass complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`,
);
+ const lastProcessedAt = changedCandidateRows.reduce(
+ (max, row) => Math.max(max, row.updatedAt),
+ sinceTs,
+ );
+ await ctx.runMutation(internal.clustering.upsertClusteringJobState, {
+ jobName: "mergeNearDuplicateEvents",
+ lastProcessedAt,
+ lastProcessedDayBucket: formatUtcDayBucket(Date.now()),
+ lastRunAt: Date.now(),
+ lastRunMetricsJson: JSON.stringify(metrics),
+ });
+ await flushJobMetrics(ctx, metrics, startedAt);
+
return { mergedPairs, examinedPairs, skipped };
} finally {
try {
@@ -4186,11 +4793,30 @@ export const reclusterRecentSingletonEvents = internalAction({
return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
}
+ const startedAt = Date.now();
+ const metrics = createJobMetrics("reclusterRecentSingletonEvents");
+
try {
+ const budget = await getVectorSearchBudgetState(ctx);
+ metrics.budgetAllowed = budget.allowed;
+ if (!budget.allowed) {
+ console.log(
+ `[clustering] reclusterRecentSingletonEvents skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`,
+ );
+ await flushJobMetrics(ctx, metrics, startedAt);
+ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
+ }
+
+ const jobState = await ctx.runQuery(internal.clustering.getClusteringJobState, {
+ jobName: "reclusterRecentSingletonEvents",
+ });
+ const sinceTs = jobState?.lastProcessedAt ?? 0;
+
const reclusterConfig = await ctx.runQuery(internal.config.getBatch, {
keys: [
"singleton_recluster_min_similarity",
"singleton_recluster_window_hours",
+ "recluster_vector_search_limit",
],
});
const settings: ReclusterSettings = {
@@ -4207,59 +4833,108 @@ export const reclusterRecentSingletonEvents = internalAction({
168,
),
};
+ const vectorSearchLimit = safeInteger(
+ reclusterConfig.recluster_vector_search_limit,
+ RECLUSTER_VECTOR_SEARCH_LIMIT,
+ 1,
+ 60,
+ );
const candidateFetchStart = Date.now();
- const recentCandidatesRaw = await ctx.runQuery(
- internal.clustering.getRecentClusterCandidates,
+ const changedCandidateRows = await ctx.runQuery(
+ internal.clustering.getChangedClusterCandidates,
{
- sinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000,
+ sinceTs,
+ recentSinceTs: Date.now() - settings.windowHours * 60 * 60 * 1000,
limit: MAX_CANDIDATE_EVENTS,
+ singletonOnly: true,
},
);
- const candidateFetchMs = Date.now() - candidateFetchStart;
- const candidates: ClusterCandidate[] = (
- recentCandidatesRaw as ClusterCandidateQueryResult[]
- )
- .filter((candidate) => candidate.articleCount <= 2)
- .map((candidate) => ({
- ...candidate,
- embeddingId: candidate.embeddingId,
- lastArticleAt: candidate.lastArticleAt ?? candidate.firstPublishedAt,
- sourceCount:
- candidate.sourceCount ?? candidate.sourceIds?.length ?? 0,
- titleTokens: normalizeTitleTokens(candidate.title),
- evidenceTokens: new Set(candidate.evidenceTokens ?? []),
- factTokens: new Set(candidate.factTokens ?? []),
- entityTokens: new Set(candidate.entityTokens ?? []),
- topicSlugs: new Set(candidate.topicSlugs ?? []),
- sourceIds: new Set(candidate.sourceIds ?? []),
- }));
+ markStageDuration(metrics, "candidateFetch", candidateFetchStart);
+
+ if (changedCandidateRows.length === 0) {
+ await ctx.runMutation(internal.clustering.upsertClusteringJobState, {
+ jobName: "reclusterRecentSingletonEvents",
+ lastProcessedAt: jobState?.lastProcessedAt ?? sinceTs,
+ lastProcessedDayBucket:
+ jobState?.lastProcessedDayBucket ?? formatUtcDayBucket(Date.now()),
+ lastRunAt: Date.now(),
+ lastRunMetricsJson: JSON.stringify(metrics),
+ });
+ await flushJobMetrics(ctx, metrics, startedAt);
+ return { mergedPairs: 0, examinedPairs: 0, skipped: 0 };
+ }
+
+ const seedCandidateResults = await ctx.runQuery(
+ internal.clustering.getClusterCandidatesByEventIds,
+ {
+ eventIds: changedCandidateRows.map((row) => row.eventId),
+ },
+ );
+ const candidates = (seedCandidateResults as ClusterCandidateQueryResult[]).map(
+ (candidate) => hydrateClusterCandidate(candidate),
+ );
+ metrics.reclusterSeedEvents = candidates.length;
const neighborSearchStart = Date.now();
- const candidatesByEmbeddingId = new Map();
+ const candidatesByEventId = new Map();
for (const candidate of candidates) {
if (!candidate.embeddingId) continue;
- candidatesByEmbeddingId.set(String(candidate.embeddingId), candidate);
+ candidatesByEventId.set(String(candidate.eventId), candidate);
}
const pairKeys = new Set();
const candidatePairs: Array<[ClusterCandidate, ClusterCandidate]> = [];
+ const dayBuckets = collectRecentDayBuckets(settings.windowHours);
+ const singletonSearchBuckets = buildSingletonSearchBuckets(dayBuckets);
for (const candidate of candidates) {
if (!candidate.embeddingId) continue;
+ metrics.vectorSearches++;
const neighbors = await ctx.vectorSearch(
"eventEmbeddings",
"by_embedding",
{
vector: toEventEmbedding(candidate.embedding),
- limit: RECLUSTER_VECTOR_SEARCH_LIMIT,
+ limit: vectorSearchLimit,
filter: (q) =>
- q.or(q.eq("status", "published"), q.eq("status", "processing")),
+ singletonSearchBuckets.length <= 1
+ ? q.eq(
+ "singletonSearchBucket",
+ singletonSearchBuckets[0] ??
+ `published::${SINGLETON_BUCKET}::${formatUtcDayBucket(Date.now())}`,
+ )
+ : q.or(
+ ...singletonSearchBuckets.map((bucket) =>
+ q.eq("singletonSearchBucket", bucket),
+ ),
+ ),
},
);
+ metrics.vectorMatchesReturned += neighbors.length;
+ if (neighbors.length === 0) continue;
- for (const neighbor of neighbors) {
- const match = candidatesByEmbeddingId.get(String(neighbor._id));
+ const hydratedResults = await ctx.runQuery(
+ internal.clustering.getClusterCandidatesByEmbeddingMatches,
+ {
+ embeddingMatches: neighbors.map((result) => ({
+ embeddingId: result._id,
+ similarity: result._score,
+ })),
+ },
+ );
+ metrics.vectorMatchesHydrated += hydratedResults.length;
+ metrics.vectorMatchesDiscardedPostFetch +=
+ neighbors.length - hydratedResults.length;
+
+ for (const result of hydratedResults as ClusterCandidateVectorResult[]) {
+ const matchKey = String(result.eventId);
+ let match = candidatesByEventId.get(matchKey);
+ if (!match) {
+ match = hydrateClusterCandidate(result);
+ candidatesByEventId.set(matchKey, match);
+ }
+ match.similarity = result.similarity;
if (!match || match.eventId === candidate.eventId) continue;
const key = buildEventPairKey(candidate.eventId, match.eventId);
if (pairKeys.has(key)) continue;
@@ -4267,10 +4942,11 @@ export const reclusterRecentSingletonEvents = internalAction({
candidatePairs.push([candidate, match]);
}
}
- const neighborSearchMs = Date.now() - neighborSearchStart;
+ markStageDuration(metrics, "neighborSearch", neighborSearchStart);
+ metrics.candidateCacheSize = candidatesByEventId.size;
console.log(
- `[clustering] Recluster candidates: ${candidates.length} events, ${candidatePairs.length} pairs (fetchMs=${candidateFetchMs}, neighborMs=${neighborSearchMs})`,
+ `[clustering] Recluster candidates: ${candidates.length} seeds, ${candidatePairs.length} pairs`,
);
const removedIds = new Set();
@@ -4397,12 +5073,29 @@ export const reclusterRecentSingletonEvents = internalAction({
for (const topicSlug of remove.topicSlugs) {
keep.topicSlugs.add(topicSlug);
}
+ candidatesByEventId.set(String(keep.eventId), keep);
}
+ metrics.vectorSearchesPerCandidateEvent =
+ candidates.length > 0 ? metrics.vectorSearches / candidates.length : 0;
+
console.log(
`[clustering] Singleton recluster complete: ${mergedPairs} merged, ${examinedPairs} pairs examined, ${skipped} skipped`,
);
+ const lastProcessedAt = changedCandidateRows.reduce(
+ (max, row) => Math.max(max, row.updatedAt),
+ sinceTs,
+ );
+ await ctx.runMutation(internal.clustering.upsertClusteringJobState, {
+ jobName: "reclusterRecentSingletonEvents",
+ lastProcessedAt,
+ lastProcessedDayBucket: formatUtcDayBucket(Date.now()),
+ lastRunAt: Date.now(),
+ lastRunMetricsJson: JSON.stringify(metrics),
+ });
+ await flushJobMetrics(ctx, metrics, startedAt);
+
return { mergedPairs, examinedPairs, skipped };
} finally {
try {
@@ -4456,6 +5149,9 @@ export const clusterEnrichedArticles = internalAction({
};
}
+ const startedAt = Date.now();
+ const metrics = createJobMetrics("clusterEnrichedArticles");
+
try {
const hasEnriched = await ctx.runQuery(
internal.clustering.hasEnrichedArticlesForClustering,
@@ -4505,6 +5201,7 @@ export const clusterEnrichedArticles = internalAction({
"topic_inference_min_score",
"topic_inference_confidence_ratio",
"topic_inference_max_topics",
+ "clustering_vector_search_limit",
],
});
@@ -4586,27 +5283,70 @@ export const clusterEnrichedArticles = internalAction({
5,
),
};
+ const vectorSearchLimit = safeInteger(
+ clusteringConfig.clustering_vector_search_limit,
+ VECTOR_SEARCH_LIMIT,
+ 1,
+ 80,
+ );
+
+ const budget = await getVectorSearchBudgetState(ctx);
+ metrics.budgetAllowed = budget.allowed;
+ metrics.batchArticles = articles.length;
+ const useFallbackMode = !budget.allowed && budget.fallbackModeEnabled;
+ metrics.usedFallbackMode = useFallbackMode;
+
+ if (!budget.allowed && !budget.fallbackModeEnabled) {
+ console.log(
+ `[clustering] clusterEnrichedArticles skipped: vector-search budget exhausted (${budget.usedQgb}/${budget.dailyLimitQgb} qGB)`,
+ );
+ await flushJobMetrics(ctx, metrics, startedAt);
+ return {
+ clusteredIntoExisting: 0,
+ createdEvents: 0,
+ skipped: 0,
+ };
+ }
const candidateSearchStart = Date.now();
- let vectorSearches = 0;
- let vectorMatches = 0;
const candidateCache = new Map();
+ let batchStateVersion = 0;
+ const searchedArticleIds = new Set();
+ const articleBatchVersionSeen = new Map();
+ if (useFallbackMode) {
+ const fallbackCandidates = await ctx.runQuery(
+ internal.clustering.getRecentClusterCandidates,
+ {
+ sinceTs: Date.now() - RECENT_EVENT_WINDOW_MS,
+ limit: MAX_CANDIDATE_EVENTS,
+ },
+ );
+ for (const candidate of fallbackCandidates as ClusterCandidateQueryResult[]) {
+ candidateCache.set(
+ String(candidate.eventId),
+ hydrateClusterCandidate(candidate),
+ );
+ }
+ metrics.candidateCacheSize = candidateCache.size;
+ }
const loadCandidatesForEmbedding = async (
+ articleId: Id<"articles">,
embedding: number[],
): Promise => {
- vectorSearches++;
+ searchedArticleIds.add(String(articleId));
+ metrics.vectorSearches++;
const vectorResults = await ctx.vectorSearch(
"eventEmbeddings",
"by_embedding",
{
vector: toEventEmbedding(embedding),
- limit: VECTOR_SEARCH_LIMIT,
+ limit: vectorSearchLimit,
filter: (q) =>
q.or(q.eq("status", "published"), q.eq("status", "processing")),
},
);
- vectorMatches += vectorResults.length;
+ metrics.vectorMatchesReturned += vectorResults.length;
if (vectorResults.length === 0) return [];
@@ -4619,23 +5359,16 @@ export const clusterEnrichedArticles = internalAction({
})),
},
);
+ metrics.vectorMatchesHydrated += vectorCandidates.length;
+ metrics.vectorMatchesDiscardedPostFetch +=
+ vectorResults.length - vectorCandidates.length;
const matches: ClusterCandidate[] = [];
for (const result of vectorCandidates as ClusterCandidateVectorResult[]) {
const key = String(result.eventId);
let candidate = candidateCache.get(key);
if (!candidate) {
- candidate = {
- ...result,
- lastArticleAt: result.lastArticleAt ?? result.firstPublishedAt,
- sourceCount: result.sourceCount ?? result.sourceIds?.length ?? 0,
- titleTokens: normalizeTitleTokens(result.title),
- evidenceTokens: new Set(result.evidenceTokens ?? []),
- factTokens: new Set(result.factTokens ?? []),
- entityTokens: new Set(result.entityTokens ?? []),
- topicSlugs: new Set(result.topicSlugs ?? []),
- sourceIds: new Set(result.sourceIds ?? []),
- };
+ candidate = hydrateClusterCandidate(result);
candidateCache.set(key, candidate);
}
candidate.similarity = result.similarity;
@@ -4661,6 +5394,8 @@ export const clusterEnrichedArticles = internalAction({
publishedAt: number;
id: string;
};
+ lastBatchStateVersionSeen: number;
+ needsFreshVectorSearch: boolean;
};
const pendingArticles: PendingArticle[] = [];
@@ -4698,33 +5433,31 @@ export const clusterEnrichedArticles = internalAction({
for (const topicSlug of topicSlugs) {
candidate.topicSlugs.add(topicSlug);
}
+ batchStateVersion++;
};
- const tryAttach = async (
+ const resolveArticle = (
+ article: (typeof articles)[number],
+ topicSlugs: string[],
+ ) => ({
+ articleId: article._id,
+ title: article.title,
+ rssSnippet: article.rssSnippet,
+ summary: article.summary,
+ atomicFacts: article.atomicFacts,
+ entities: article.entities,
+ topicSlugs,
+ extractionQuality: article.extractionQuality,
+ publishedAt: article.publishedAt,
+ embedding: article.embedding,
+ sourceId: article.sourceId,
+ });
+
+ const attachToMatch = async (
payload: AttachPayload,
- ): Promise<"attached" | "unmatched" | "skipped"> => {
+ match: ClusterCandidate,
+ ): Promise<"attached" | "skipped"> => {
const { article, paddedEmbedding, topicSlugs } = payload;
- const candidates = await loadCandidatesForEmbedding(paddedEmbedding);
- const match = findBestCandidate(
- {
- articleId: article._id,
- title: article.title,
- rssSnippet: article.rssSnippet,
- summary: article.summary,
- atomicFacts: article.atomicFacts,
- entities: article.entities,
- topicSlugs,
- extractionQuality: article.extractionQuality,
- publishedAt: article.publishedAt,
- embedding: article.embedding,
- sourceId: article.sourceId,
- },
- candidates,
- settings,
- );
-
- if (!match) return "unmatched";
-
const result = await ctx.runMutation(
internal.clustering.attachArticleToEvent,
{
@@ -4754,6 +5487,36 @@ export const clusterEnrichedArticles = internalAction({
return "attached";
};
+ const tryBatchLocalAttach = async (
+ payload: AttachPayload,
+ heuristicOnly: boolean,
+ ): Promise<"attached" | "unmatched" | "skipped"> => {
+ const { article, topicSlugs } = payload;
+ const candidatePool = Array.from(candidateCache.values());
+ if (candidatePool.length === 0) return "unmatched";
+
+ const articleContext = resolveArticle(article, topicSlugs);
+ const match = heuristicOnly
+ ? findHeuristicCandidate(articleContext, candidatePool)
+ : findBatchLocalCandidate(articleContext, candidatePool, settings);
+ if (!match) return "unmatched";
+ return await attachToMatch(payload, match);
+ };
+
+ const tryVectorAttach = async (
+ payload: AttachPayload,
+ ): Promise<"attached" | "unmatched" | "skipped"> => {
+ const { article, paddedEmbedding, topicSlugs } = payload;
+ const candidates = await loadCandidatesForEmbedding(article._id, paddedEmbedding);
+ const match = findBestCandidate(
+ resolveArticle(article, topicSlugs),
+ candidates,
+ settings,
+ );
+ if (!match) return "unmatched";
+ return await attachToMatch(payload, match);
+ };
+
for (const article of articles) {
const paddedEmbedding = toEventEmbedding(article.embedding);
const topicSlugs = inferTopicSlugs(
@@ -4773,7 +5536,9 @@ export const clusterEnrichedArticles = internalAction({
paddedEmbedding,
topicSlugs,
};
- const outcome = await tryAttach(payload);
+ const outcome = useFallbackMode
+ ? await tryBatchLocalAttach(payload, true)
+ : await tryVectorAttach(payload);
if (outcome === "unmatched") {
const entityTokenCount = extractEntityTokens(
article.title,
@@ -4791,7 +5556,10 @@ export const clusterEnrichedArticles = internalAction({
publishedAt: article.publishedAt,
id: String(article._id),
},
+ lastBatchStateVersionSeen: batchStateVersion,
+ needsFreshVectorSearch: false,
});
+ articleBatchVersionSeen.set(String(article._id), batchStateVersion);
}
}
@@ -4805,7 +5573,25 @@ export const clusterEnrichedArticles = internalAction({
);
for (const pending of pendingArticles) {
- const outcome = await tryAttach(pending);
+ let outcome = await tryBatchLocalAttach(pending, useFallbackMode);
+ if (outcome !== "unmatched") continue;
+
+ const pendingKey = String(pending.article._id);
+ const lastSeenVersion =
+ articleBatchVersionSeen.get(pendingKey) ??
+ pending.lastBatchStateVersionSeen;
+ const batchChanged = batchStateVersion > lastSeenVersion;
+ if (!useFallbackMode && batchChanged) {
+ pending.needsFreshVectorSearch = true;
+ }
+ articleBatchVersionSeen.set(pendingKey, batchStateVersion);
+
+ if (!useFallbackMode && pending.needsFreshVectorSearch) {
+ outcome = await tryVectorAttach(pending);
+ pending.needsFreshVectorSearch = false;
+ pending.lastBatchStateVersionSeen = batchStateVersion;
+ }
+
if (outcome !== "unmatched") continue;
const { article, paddedEmbedding, topicSlugs } = pending;
@@ -4876,15 +5662,20 @@ export const clusterEnrichedArticles = internalAction({
creationTime: Date.now(),
};
candidateCache.set(String(result.eventId), newCandidate);
+ batchStateVersion++;
}
- const candidateSearchMs = Date.now() - candidateSearchStart;
+ markStageDuration(metrics, "candidateSearch", candidateSearchStart);
+ metrics.candidateCacheSize = candidateCache.size;
+ metrics.vectorSearchesPerArticle =
+ articles.length > 0 ? metrics.vectorSearches / articles.length : 0;
console.log(
- `[clustering] Candidate search: ${vectorSearches} queries, ${vectorMatches} matches, ${candidateCache.size} unique (ms=${candidateSearchMs})`,
+ `[clustering] Candidate search: ${metrics.vectorSearches} queries, ${metrics.vectorMatchesReturned} matches, ${candidateCache.size} unique`,
);
console.log(
`[clustering] Done: ${clusteredIntoExisting} attached, ${createdEvents} new events, ${skipped} skipped (minSim=${settings.minSimilarity}, strongSim=${settings.strongSimilarity}, sameSourceMinSim=${settings.sameSourceMinSimilarity}, publishMin=${publishSettings.minArticles} articles/${publishSettings.minSources} sources, topicMinScore=${topicSettings.minScore})`,
);
+ await flushJobMetrics(ctx, metrics, startedAt);
return {
clusteredIntoExisting,
diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts
index d25d723..4dc7a54 100644
--- a/packages/backend/convex/config.ts
+++ b/packages/backend/convex/config.ts
@@ -737,6 +737,42 @@ export const seedDefaults = internalMutation({
description:
"How many hours back the singleton recluster pass should inspect recent small events.",
},
+ {
+ key: "vector_search_daily_budget_qgb",
+ value: 0.25,
+ description:
+ "Daily Convex vector-search read budget in qGB. Clustering workers use this to hard-stop expensive semantic search when spend spikes.",
+ },
+ {
+ key: "vector_search_budget_enabled",
+ value: true,
+ description:
+ "When true, clustering workers enforce the daily vector-search qGB budget before semantic matching work.",
+ },
+ {
+ key: "vector_search_fallback_mode_enabled",
+ value: true,
+ description:
+ "When true, clusterEnrichedArticles falls back to heuristic-only batch-local matching after the vector-search budget is exhausted.",
+ },
+ {
+ key: "clustering_vector_search_limit",
+ value: 40,
+ description:
+ "Top-K limit used for article-to-event vector search during clusterEnrichedArticles.",
+ },
+ {
+ key: "merge_vector_search_limit",
+ value: 24,
+ description:
+ "Top-K limit used for event-to-event vector search during duplicate-merge passes.",
+ },
+ {
+ key: "recluster_vector_search_limit",
+ value: 24,
+ description:
+ "Top-K limit used for event-to-event vector search during singleton recluster passes.",
+ },
];
const forcedDefaultKeys = new Set([
@@ -753,6 +789,12 @@ export const seedDefaults = internalMutation({
"merge_min_title_jaccard",
"merge_max_time_delta_hours",
"singleton_recluster_min_similarity",
+ "vector_search_daily_budget_qgb",
+ "vector_search_budget_enabled",
+ "vector_search_fallback_mode_enabled",
+ "clustering_vector_search_limit",
+ "merge_vector_search_limit",
+ "recluster_vector_search_limit",
]);
let created = 0;
diff --git a/packages/backend/convex/ingestion.ts b/packages/backend/convex/ingestion.ts
index f3efbb0..b012a47 100644
--- a/packages/backend/convex/ingestion.ts
+++ b/packages/backend/convex/ingestion.ts
@@ -14,6 +14,7 @@
*/
import { v } from "convex/values";
+import { paginationOptsValidator, type PaginationResult } from "convex/server";
import {
internalAction,
internalMutation,
@@ -39,7 +40,7 @@ const USER_AGENT =
/** Run-level lease for ingestAllFeeds; prevents overlapping cron/manual runs. */
const INGEST_ALL_FEEDS_LOCK_KEY = "ingestAllFeeds";
const INGEST_ALL_FEEDS_LOCK_TTL_MS = 20 * 60 * 1000;
-const EVENT_EMBEDDING_DIMENSIONS = 1536;
+const EVENT_EMBEDDING_DIMENSIONS = 512;
// ---------------------------------------------------------------------------
// Types
@@ -481,6 +482,33 @@ function toEventEmbedding(articleEmbedding: number[]): number[] {
return padded;
}
+function formatUtcDayBucket(timestamp: number): string {
+ return new Date(timestamp).toISOString().slice(0, 10);
+}
+
+function buildEventEmbeddingFilterFields(args: {
+ status?: "processing" | "published";
+ lastArticleAt: number;
+ articleCount: number;
+ referenceTime?: number;
+}) {
+ const referenceTime = args.referenceTime ?? Date.now();
+ const recentWindowBucket =
+ referenceTime - args.lastArticleAt <= 48 * 60 * 60 * 1000
+ ? "recent_2d"
+ : "stale";
+ const singletonBucket = args.articleCount <= 2 ? "singleton" : "multi";
+ const updatedDayBucket = formatUtcDayBucket(args.lastArticleAt);
+ const status = args.status ?? "processing";
+ return {
+ recentWindowBucket,
+ singletonBucket,
+ updatedDayBucket,
+ mergeSearchBucket: `${status}::${recentWindowBucket}::${updatedDayBucket}`,
+ singletonSearchBucket: `${status}::${singletonBucket}::${updatedDayBucket}`,
+ };
+}
+
/** Extract domain from a URL (e.g. "nytimes.com" from "https://www.nytimes.com/...") */
function extractDomain(url: string): string {
try {
@@ -788,6 +816,11 @@ async function recomputeEventEmbeddingForEvent(
embedding: averagedEmbedding,
version: latestVersion,
status: event.status,
+ ...buildEventEmbeddingFilterFields({
+ status: event.status,
+ lastArticleAt: event.lastArticleAt ?? event.firstPublishedAt,
+ articleCount: event.articleCount ?? articles.length,
+ }),
});
} else {
await ctx.db.insert("eventEmbeddings", {
@@ -795,10 +828,71 @@ async function recomputeEventEmbeddingForEvent(
embedding: averagedEmbedding,
version: latestVersion,
status: event.status,
+ ...buildEventEmbeddingFilterFields({
+ status: event.status,
+ lastArticleAt: event.lastArticleAt ?? event.firstPublishedAt,
+ articleCount: event.articleCount ?? articles.length,
+ }),
});
}
}
+export const recomputeEventEmbeddingForEventInternal = internalMutation({
+ args: {
+ eventId: v.id("events"),
+ },
+ handler: async (ctx, { eventId }) => {
+ await recomputeEventEmbeddingForEvent(ctx, eventId);
+ return { recomputed: true as const };
+ },
+});
+
+export const getEventsForEmbeddingBackfill = internalQuery({
+ args: {
+ paginationOpts: paginationOptsValidator,
+ },
+ handler: async (ctx, { paginationOpts }) => {
+ return (await ctx.db
+ .query("events")
+ .order("desc")
+ .paginate(paginationOpts)) as PaginationResult<{ _id: Id<"events"> }>;
+ },
+});
+
+export const backfillEventEmbeddingDimensions = internalAction({
+ args: {
+ cursor: v.optional(v.string()),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, { cursor, limit }) => {
+ const pageSize = Math.min(Math.max(Math.floor(limit ?? 100), 1), 200);
+ const page: PaginationResult<{ _id: Id<"events"> }> = await ctx.runQuery(
+ internal.ingestion.getEventsForEmbeddingBackfill,
+ {
+ paginationOpts: {
+ cursor: cursor ?? null,
+ numItems: pageSize,
+ },
+ },
+ );
+
+ for (const event of page.page) {
+ await ctx.runMutation(
+ internal.ingestion.recomputeEventEmbeddingForEventInternal,
+ {
+ eventId: event._id,
+ },
+ );
+ }
+
+ return {
+ processed: page.page.length,
+ continueCursor: page.continueCursor ?? null,
+ isDone: page.isDone,
+ };
+ },
+});
+
export const cleanupDuplicateArticlesInEvents = internalMutation({
args: {
limit: v.optional(v.number()),
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 43329b0..02ed60b 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -113,12 +113,24 @@ export default defineSchema({
status: v.optional(
v.union(v.literal("processing"), v.literal("published")),
),
+ recentWindowBucket: v.optional(v.string()),
+ singletonBucket: v.optional(v.string()),
+ updatedDayBucket: v.optional(v.string()),
+ mergeSearchBucket: v.optional(v.string()),
+ singletonSearchBucket: v.optional(v.string()),
})
.index("by_event", ["eventId"])
.vectorIndex("by_embedding", {
vectorField: "embedding",
- dimensions: 1536,
- filterFields: ["status"],
+ dimensions: 512,
+ filterFields: [
+ "status",
+ "recentWindowBucket",
+ "singletonBucket",
+ "updatedDayBucket",
+ "mergeSearchBucket",
+ "singletonSearchBucket",
+ ],
}),
// =========================================================================
@@ -672,6 +684,47 @@ export default defineSchema({
shard: v.optional(v.number()),
}).index("by_expiresAt", ["expiresAt"]),
+ // =========================================================================
+ // 11b. VECTOR SEARCH BUDGET DAILY SHARDS
+ // =========================================================================
+ vectorSearchDaily: defineTable({
+ date: v.string(), // "YYYY-MM-DD"
+ shard: v.number(), // 0-23 (UTC hour)
+ qgbRead: v.number(),
+ vectorSearches: v.number(),
+ runCount: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_date", ["date"])
+ .index("by_date_shard", ["date", "shard"]),
+
+ vectorSearchDailyTotal: defineTable({
+ date: v.string(),
+ qgbRead: v.number(),
+ vectorSearches: v.number(),
+ runCount: v.number(),
+ updatedAt: v.number(),
+ }).index("by_date", ["date"]),
+
+ vectorSearchRuns: defineTable({
+ jobName: v.string(),
+ runId: v.string(),
+ date: v.string(),
+ qgbRead: v.number(),
+ vectorSearches: v.number(),
+ vectorMatchesReturned: v.number(),
+ vectorMatchesHydrated: v.number(),
+ vectorMatchesDiscardedPostFetch: v.number(),
+ usedFallbackMode: v.boolean(),
+ budgetAllowed: v.boolean(),
+ elapsedMs: v.number(),
+ metricsJson: v.string(),
+ createdAt: v.number(),
+ })
+ .index("by_run_id", ["runId"])
+ .index("by_date", ["date"])
+ .index("by_job_createdAt", ["jobName", "createdAt"]),
+
// =========================================================================
// 12. PIPELINE LOCKS (Short-lived leases for scheduled jobs)
// =========================================================================
@@ -682,4 +735,13 @@ export default defineSchema({
updatedAt: v.number(),
expiresAt: v.number(),
}).index("by_key", ["key"]),
+
+ clusteringJobState: defineTable({
+ jobName: v.string(),
+ lastProcessedAt: v.optional(v.number()),
+ lastProcessedDayBucket: v.optional(v.string()),
+ lastRunAt: v.optional(v.number()),
+ lastRunMetricsJson: v.optional(v.string()),
+ updatedAt: v.number(),
+ }).index("by_job_name", ["jobName"]),
});
diff --git a/packages/backend/convex/seeds.ts b/packages/backend/convex/seeds.ts
index 519cbe6..8927aa5 100644
--- a/packages/backend/convex/seeds.ts
+++ b/packages/backend/convex/seeds.ts
@@ -144,19 +144,29 @@ export const seedDB = internalMutation({
// =========================================================================
// 3b. EVENT EMBEDDINGS (placeholder zeros — real embeddings come from AI)
// =========================================================================
- const dummyEmbedding = new Array(1536).fill(0);
+ const dummyEmbedding = new Array(512).fill(0);
await ctx.db.insert("eventEmbeddings", {
eventId: eventFedRates,
embedding: dummyEmbedding,
version: 1,
status: "published",
+ recentWindowBucket: "recent_2d",
+ singletonBucket: "multi",
+ updatedDayBucket: new Date().toISOString().slice(0, 10),
+ mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`,
+ singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`,
});
await ctx.db.insert("eventEmbeddings", {
eventId: eventAIRegulations,
embedding: dummyEmbedding,
version: 1,
status: "published",
+ recentWindowBucket: "recent_2d",
+ singletonBucket: "multi",
+ updatedDayBucket: new Date().toISOString().slice(0, 10),
+ mergeSearchBucket: `published::recent_2d::${new Date().toISOString().slice(0, 10)}`,
+ singletonSearchBucket: `published::multi::${new Date().toISOString().slice(0, 10)}`,
});
console.log("✅ Created 2 eventEmbeddings");
diff --git a/packages/backend/convex/vectorSearchBudget.ts b/packages/backend/convex/vectorSearchBudget.ts
new file mode 100644
index 0000000..58eb8e6
--- /dev/null
+++ b/packages/backend/convex/vectorSearchBudget.ts
@@ -0,0 +1,334 @@
+import { v } from "convex/values";
+import {
+ internalMutation,
+ internalQuery,
+ query,
+} from "./_generated/server";
+import type { MutationCtx, QueryCtx } from "./_generated/server";
+import { requireAdminUser } from "./lib/betaAccess";
+
+const DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB = 0.25;
+const ESTIMATED_VECTOR_ROW_BYTES = 5 * 1024;
+
+function roundQgb(value: number): number {
+ return Math.round(value * 1_000_000) / 1_000_000;
+}
+
+function formatUtcDate(timestamp: number): string {
+ return new Date(timestamp).toISOString().split("T")[0]!;
+}
+
+function getUtcShard(timestamp: number): number {
+ return new Date(timestamp).getUTCHours();
+}
+
+async function getConfigBoolean(
+ ctx: QueryCtx | MutationCtx,
+ key: string,
+ fallback: boolean,
+): Promise {
+ const row = await ctx.db
+ .query("config")
+ .withIndex("by_key", (q) => q.eq("key", key))
+ .unique();
+ if (!row) return fallback;
+
+ try {
+ return JSON.parse(row.value) === true;
+ } catch {
+ return fallback;
+ }
+}
+
+async function getConfigNumber(
+ ctx: QueryCtx | MutationCtx,
+ key: string,
+ fallback: number,
+): Promise {
+ const row = await ctx.db
+ .query("config")
+ .withIndex("by_key", (q) => q.eq("key", key))
+ .unique();
+ if (!row) return fallback;
+
+ try {
+ const parsed = Number(JSON.parse(row.value));
+ return Number.isFinite(parsed) ? parsed : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+async function getDailyTotals(
+ ctx: QueryCtx | MutationCtx,
+ date: string,
+): Promise<{ qgbRead: number; vectorSearches: number; runCount: number }> {
+ const total = await ctx.db
+ .query("vectorSearchDailyTotal")
+ .withIndex("by_date", (q) => q.eq("date", date))
+ .unique();
+ if (total) {
+ return {
+ qgbRead: roundQgb(total.qgbRead),
+ vectorSearches: total.vectorSearches,
+ runCount: total.runCount,
+ };
+ }
+
+ const rows = await ctx.db
+ .query("vectorSearchDaily")
+ .withIndex("by_date", (q) => q.eq("date", date))
+ .collect();
+ return {
+ qgbRead: roundQgb(rows.reduce((sum, row) => sum + row.qgbRead, 0)),
+ vectorSearches: rows.reduce((sum, row) => sum + row.vectorSearches, 0),
+ runCount: rows.reduce((sum, row) => sum + row.runCount, 0),
+ };
+}
+
+async function ensureDailyTotal(ctx: MutationCtx, date: string) {
+ const existing = await ctx.db
+ .query("vectorSearchDailyTotal")
+ .withIndex("by_date", (q) => q.eq("date", date))
+ .unique();
+ if (existing) return existing;
+
+ const totals = await getDailyTotals(ctx, date);
+ const totalId = await ctx.db.insert("vectorSearchDailyTotal", {
+ date,
+ qgbRead: totals.qgbRead,
+ vectorSearches: totals.vectorSearches,
+ runCount: totals.runCount,
+ updatedAt: Date.now(),
+ });
+ return await ctx.db.get(totalId);
+}
+
+async function adjustDailyUsage(
+ ctx: MutationCtx,
+ args: {
+ date: string;
+ shard: number;
+ deltaQgbRead: number;
+ deltaVectorSearches: number;
+ deltaRunCount: number;
+ },
+): Promise {
+ const existing = await ctx.db
+ .query("vectorSearchDaily")
+ .withIndex("by_date_shard", (q) =>
+ q.eq("date", args.date).eq("shard", args.shard),
+ )
+ .unique();
+
+ const nextQgbRead = roundQgb(
+ Math.max(0, (existing?.qgbRead ?? 0) + args.deltaQgbRead),
+ );
+ const nextVectorSearches = Math.max(
+ 0,
+ (existing?.vectorSearches ?? 0) + args.deltaVectorSearches,
+ );
+ const nextRunCount = Math.max(0, (existing?.runCount ?? 0) + args.deltaRunCount);
+ const appliedQgb = roundQgb(nextQgbRead - (existing?.qgbRead ?? 0));
+ const appliedSearches = nextVectorSearches - (existing?.vectorSearches ?? 0);
+ const appliedRuns = nextRunCount - (existing?.runCount ?? 0);
+ const total = await ensureDailyTotal(ctx, args.date);
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ qgbRead: nextQgbRead,
+ vectorSearches: nextVectorSearches,
+ runCount: nextRunCount,
+ updatedAt: Date.now(),
+ });
+ } else {
+ await ctx.db.insert("vectorSearchDaily", {
+ date: args.date,
+ shard: args.shard,
+ qgbRead: nextQgbRead,
+ vectorSearches: nextVectorSearches,
+ runCount: nextRunCount,
+ updatedAt: Date.now(),
+ });
+ }
+
+ if (total) {
+ await ctx.db.patch(total._id, {
+ qgbRead: roundQgb((total.qgbRead ?? 0) + appliedQgb),
+ vectorSearches: Math.max(0, (total.vectorSearches ?? 0) + appliedSearches),
+ runCount: Math.max(0, (total.runCount ?? 0) + appliedRuns),
+ updatedAt: Date.now(),
+ });
+ } else {
+ await ctx.db.insert("vectorSearchDailyTotal", {
+ date: args.date,
+ qgbRead: roundQgb(appliedQgb),
+ vectorSearches: Math.max(0, appliedSearches),
+ runCount: Math.max(0, appliedRuns),
+ updatedAt: Date.now(),
+ });
+ }
+}
+
+export function estimateVectorSearchQgbRead(args: {
+ vectorSearches: number;
+ averageMatchesReturned: number;
+ estimatedVectorRowBytes?: number;
+}): number {
+ const estimatedVectorRowBytes =
+ args.estimatedVectorRowBytes ?? ESTIMATED_VECTOR_ROW_BYTES;
+ const bytesRead =
+ Math.max(0, args.vectorSearches) *
+ Math.max(0, args.averageMatchesReturned) *
+ estimatedVectorRowBytes;
+ return roundQgb(bytesRead / 1_000_000_000);
+}
+
+export const checkBudget = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ const enabled = await getConfigBoolean(
+ ctx,
+ "vector_search_budget_enabled",
+ true,
+ );
+ const dailyLimitQgb = await getConfigNumber(
+ ctx,
+ "vector_search_daily_budget_qgb",
+ DEFAULT_DAILY_VECTOR_SEARCH_BUDGET_QGB,
+ );
+ const today = formatUtcDate(Date.now());
+ const totals = await getDailyTotals(ctx, today);
+ const allowed = !enabled || totals.qgbRead < dailyLimitQgb;
+
+ return {
+ enabled,
+ allowed,
+ usedQgb: totals.qgbRead,
+ remainingQgb: roundQgb(Math.max(0, dailyLimitQgb - totals.qgbRead)),
+ dailyLimitQgb,
+ vectorSearches: totals.vectorSearches,
+ runCount: totals.runCount,
+ fallbackModeEnabled: await getConfigBoolean(
+ ctx,
+ "vector_search_fallback_mode_enabled",
+ true,
+ ),
+ };
+ },
+});
+
+export const recordUsage = internalMutation({
+ args: {
+ jobName: v.string(),
+ runId: v.string(),
+ qgbRead: v.number(),
+ vectorSearches: v.number(),
+ vectorMatchesReturned: v.number(),
+ vectorMatchesHydrated: v.number(),
+ vectorMatchesDiscardedPostFetch: v.number(),
+ usedFallbackMode: v.boolean(),
+ budgetAllowed: v.boolean(),
+ elapsedMs: v.number(),
+ metricsJson: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const date = formatUtcDate(now);
+ const shard = getUtcShard(now);
+
+ await adjustDailyUsage(ctx, {
+ date,
+ shard,
+ deltaQgbRead: roundQgb(Math.max(0, args.qgbRead)),
+ deltaVectorSearches: Math.max(0, Math.floor(args.vectorSearches)),
+ deltaRunCount: 1,
+ });
+
+ await ctx.db.insert("vectorSearchRuns", {
+ jobName: args.jobName,
+ runId: args.runId,
+ date,
+ qgbRead: roundQgb(Math.max(0, args.qgbRead)),
+ vectorSearches: Math.max(0, Math.floor(args.vectorSearches)),
+ vectorMatchesReturned: Math.max(
+ 0,
+ Math.floor(args.vectorMatchesReturned),
+ ),
+ vectorMatchesHydrated: Math.max(
+ 0,
+ Math.floor(args.vectorMatchesHydrated),
+ ),
+ vectorMatchesDiscardedPostFetch: Math.max(
+ 0,
+ Math.floor(args.vectorMatchesDiscardedPostFetch),
+ ),
+ usedFallbackMode: args.usedFallbackMode,
+ budgetAllowed: args.budgetAllowed,
+ elapsedMs: Math.max(0, Math.floor(args.elapsedMs)),
+ metricsJson: args.metricsJson,
+ createdAt: now,
+ });
+
+ return { recorded: true as const };
+ },
+});
+
+export const getRecentRunSummaryForAdmin = query({
+ args: {
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, { limit }) => {
+ await requireAdminUser(ctx);
+
+ const pageSize = Math.max(1, Math.min(limit ?? 50, 200));
+ const rows = await ctx.db
+ .query("vectorSearchRuns")
+ .withIndex("by_date")
+ .order("desc")
+ .take(pageSize);
+
+ const totals = rows.reduce(
+ (acc, row) => {
+ acc.qgbRead += row.qgbRead;
+ acc.vectorSearches += row.vectorSearches;
+ acc.vectorMatchesReturned += row.vectorMatchesReturned;
+ acc.vectorMatchesHydrated += row.vectorMatchesHydrated;
+ acc.vectorMatchesDiscardedPostFetch +=
+ row.vectorMatchesDiscardedPostFetch;
+ acc.runCount++;
+ if (row.usedFallbackMode) acc.fallbackRuns++;
+ if (!row.budgetAllowed) acc.budgetBlockedRuns++;
+ return acc;
+ },
+ {
+ qgbRead: 0,
+ vectorSearches: 0,
+ vectorMatchesReturned: 0,
+ vectorMatchesHydrated: 0,
+ vectorMatchesDiscardedPostFetch: 0,
+ runCount: 0,
+ fallbackRuns: 0,
+ budgetBlockedRuns: 0,
+ },
+ );
+
+ return {
+ totals: {
+ ...totals,
+ qgbRead: roundQgb(totals.qgbRead),
+ },
+ runs: rows.map((row) => ({
+ ...row,
+ metrics:
+ (() => {
+ try {
+ return JSON.parse(row.metricsJson);
+ } catch {
+ return null;
+ }
+ })(),
+ })),
+ };
+ },
+});
From 389fe3e274147df79967bf3524697c95cec11a41 Mon Sep 17 00:00:00 2001
From: flavius
Date: Sun, 10 May 2026 17:21:16 +0300
Subject: [PATCH 16/19] redesign profile page & fix coderabbit issues
---
apps/web/src/components/LanguagePicker.tsx | 65 +++-
.../feed/event-claim-comparison.tsx | 94 ++++--
apps/web/src/components/header.tsx | 20 +-
.../src/components/layout/MobileTabBar.tsx | 21 +-
.../components/profile/AnonymousProfile.tsx | 157 ++++-----
.../profile/AuthenticatedProfile.tsx | 316 ++++++++++--------
.../web/src/components/share-event-button.tsx | 4 +-
apps/web/src/components/sign-in-form.tsx | 39 ++-
apps/web/src/components/sign-up-form.tsx | 2 +-
.../src/components/ui/page-loading-state.tsx | 4 +-
apps/web/src/components/user-menu.tsx | 18 +-
apps/web/src/hooks/use-scroll-visibility.ts | 89 +++++
apps/web/src/lib/i18n/LocaleContext.tsx | 4 +-
apps/web/src/lib/i18n/getLocaleFromMatches.ts | 17 +
apps/web/src/lib/i18n/resolveLocale.ts | 25 +-
apps/web/src/lib/i18n/strings.ts | 44 ++-
apps/web/src/routes/__root.tsx | 49 ++-
apps/web/src/routes/activitate.tsx | 114 ++++---
apps/web/src/routes/dashboard.tsx | 22 +-
apps/web/src/routes/event.$slug.tsx | 57 ++--
apps/web/src/routes/feed.tsx | 307 ++++++++++-------
apps/web/src/routes/profil.tsx | 9 +-
apps/web/src/routes/reset-password.tsx | 36 +-
apps/web/src/routes/salvate.tsx | 101 +++---
apps/web/src/routes/source.$sourceId.tsx | 25 +-
apps/web/src/routes/unsubscribe.tsx | 19 +-
26 files changed, 1027 insertions(+), 631 deletions(-)
create mode 100644 apps/web/src/hooks/use-scroll-visibility.ts
create mode 100644 apps/web/src/lib/i18n/getLocaleFromMatches.ts
diff --git a/apps/web/src/components/LanguagePicker.tsx b/apps/web/src/components/LanguagePicker.tsx
index 109e1ca..4c8c7c7 100644
--- a/apps/web/src/components/LanguagePicker.tsx
+++ b/apps/web/src/components/LanguagePicker.tsx
@@ -6,6 +6,7 @@ import { api } from "@news-app/backend/convex/_generated/api";
import { useLocale, useT } from "@/lib/i18n/LocaleContext";
import type { Locale } from "@/lib/i18n/strings";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
function validateLocale(input: unknown): Locale {
if (input === "ro" || input === "en") {
@@ -27,7 +28,33 @@ const setLocaleCookie = createServerFn({ method: "POST" })
return { locale: data };
});
-export function LanguagePicker() {
+const LANGUAGE_OPTIONS: Array<{
+ locale: Locale;
+ flag: string;
+ shortLabel: string;
+ translationKey: "settings.language.ro" | "settings.language.en";
+}> = [
+ {
+ locale: "ro",
+ flag: "🇷🇴",
+ shortLabel: "RO",
+ translationKey: "settings.language.ro",
+ },
+ {
+ locale: "en",
+ flag: "🇺🇸",
+ shortLabel: "EN",
+ translationKey: "settings.language.en",
+ },
+];
+
+export function LanguagePicker({
+ compact = false,
+ className,
+}: {
+ compact?: boolean;
+ className?: string;
+}) {
const router = useRouter();
const t = useT();
const currentLocale = useLocale();
@@ -54,24 +81,30 @@ export function LanguagePicker() {
return (
- void handleChange("ro")}
- >
- {t("settings.language.ro")}
-
- void handleChange("en")}
- >
- {t("settings.language.en")}
-
+ {LANGUAGE_OPTIONS.map(({ locale, flag, shortLabel, translationKey }) => (
+ void handleChange(locale)}
+ aria-pressed={currentLocale === locale}
+ aria-label={t(translationKey)}
+ >
+
+ {flag}
+
+ {shortLabel}
+
+ ))}
);
}
diff --git a/apps/web/src/components/feed/event-claim-comparison.tsx b/apps/web/src/components/feed/event-claim-comparison.tsx
index 767b0da..10b62be 100644
--- a/apps/web/src/components/feed/event-claim-comparison.tsx
+++ b/apps/web/src/components/feed/event-claim-comparison.tsx
@@ -49,6 +49,57 @@ const STATUS_ICONS: Record = {
exclusive_center: MinusCircleIcon,
};
+function getStatusLabel(t: ReturnType, status: ClaimStatus) {
+ switch (status) {
+ case "agreement":
+ return t("claim.agreement");
+ case "divergence":
+ return t("claim.divergence");
+ case "framing":
+ return t("claim.framing");
+ case "exclusive_left":
+ return t("claim.leftExclusive");
+ case "exclusive_right":
+ return t("claim.rightExclusive");
+ case "exclusive_center":
+ return t("claim.centerExclusive");
+ }
+}
+
+function getStatusHeading(t: ReturnType, status: ClaimStatus) {
+ switch (status) {
+ case "agreement":
+ return t("claim.agreements");
+ case "divergence":
+ return t("claim.divergences");
+ case "framing":
+ return t("claim.framings");
+ case "exclusive_left":
+ return t("claim.leftExclusives");
+ case "exclusive_right":
+ return t("claim.rightExclusives");
+ case "exclusive_center":
+ return t("claim.centerExclusives");
+ }
+}
+
+function getStatusBody(t: ReturnType, status: ClaimStatus) {
+ switch (status) {
+ case "agreement":
+ return t("claim.agreementBody");
+ case "divergence":
+ return t("claim.divergenceBody");
+ case "framing":
+ return t("claim.framingBody");
+ case "exclusive_left":
+ return t("claim.leftExclusiveBody");
+ case "exclusive_right":
+ return t("claim.rightExclusiveBody");
+ case "exclusive_center":
+ return t("claim.centerExclusiveBody");
+ }
+}
+
function formatLean(value: string) {
return value
.split("-")
@@ -176,17 +227,7 @@ function ClaimCard({
- {status === "agreement"
- ? t("claim.agreement")
- : status === "divergence"
- ? t("claim.divergence")
- : status === "framing"
- ? t("claim.framing")
- : status === "exclusive_left"
- ? t("claim.leftExclusive")
- : status === "exclusive_right"
- ? t("claim.rightExclusive")
- : t("claim.centerExclusive")}
+ {getStatusLabel(t, status)}
{claim.importance}/5 {t("claim.importance")}
@@ -311,7 +352,12 @@ export default function EventClaimComparison({
-
+
{t("claim.loading")}
@@ -455,30 +501,10 @@ export default function EventClaimComparison({
- {status === "agreement"
- ? t("claim.agreements")
- : status === "divergence"
- ? t("claim.divergences")
- : status === "framing"
- ? t("claim.framings")
- : status === "exclusive_left"
- ? t("claim.leftExclusives")
- : status === "exclusive_right"
- ? t("claim.rightExclusives")
- : t("claim.centerExclusives")}
+ {getStatusHeading(t, status)}
- {status === "agreement"
- ? t("claim.agreementBody")
- : status === "divergence"
- ? t("claim.divergenceBody")
- : status === "framing"
- ? t("claim.framingBody")
- : status === "exclusive_left"
- ? t("claim.leftExclusiveBody")
- : status === "exclusive_right"
- ? t("claim.rightExclusiveBody")
- : t("claim.centerExclusiveBody")}
+ {getStatusBody(t, status)}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index b776e65..fd8aed6 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -2,7 +2,10 @@ import { useState } from "react";
import { Link, useRouterState } from "@tanstack/react-router";
import { Menu, Newspaper, Bookmark, LayoutDashboard, User } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { LanguagePicker } from "@/components/LanguagePicker";
+import { useScrollVisibility } from "@/hooks/use-scroll-visibility";
import { useT } from "@/lib/i18n/LocaleContext";
+import { cn } from "@/lib/utils";
import {
Sheet,
SheetContent,
@@ -19,14 +22,20 @@ const links = [
export default function Header() {
const [open, setOpen] = useState(false);
+ const isVisible = useScrollVisibility();
const t = useT();
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
return (
-
+
-
+
{/* Logo */}
@@ -62,6 +71,10 @@ export default function Header() {
})}
+
+
+
+
{/* Mobile burger */}
@@ -110,6 +123,9 @@ export default function Header() {
);
})}
+
+
+
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx
index 892f0b5..d619861 100644
--- a/apps/web/src/components/layout/MobileTabBar.tsx
+++ b/apps/web/src/components/layout/MobileTabBar.tsx
@@ -1,6 +1,7 @@
import { Bookmark, BarChart3, Newspaper, User } from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router";
import type { ComponentType, MouseEvent } from "react";
+import { useScrollVisibility } from "@/hooks/use-scroll-visibility";
import { useT } from "@/lib/i18n/LocaleContext";
import { cn } from "@/lib/utils";
@@ -33,8 +34,8 @@ const tabDefinitions: readonly TabDefinition[] = [
{ to: "/profil", key: "tabs.profile", icon: User },
];
-function matchesPath(pathname: string, to: string) {
- return pathname === to || pathname.startsWith(`${to}/`);
+function matchesPath(pathname: string, to: string, allowPrefix = false) {
+ return pathname === to || (allowPrefix && pathname.startsWith(`${to}/`));
}
export function MobileTabBar() {
@@ -42,6 +43,7 @@ export function MobileTabBar() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
+ const isVisible = useScrollVisibility();
const handleTabClick =
(isActive: boolean) => (event: MouseEvent
) => {
@@ -59,10 +61,14 @@ export function MobileTabBar() {
return (
-
+
+
{tabDefinitions.map(({ to, key, icon: Icon, isActive: customIsActive }) => {
const isActive = customIsActive
? customIsActive(pathname)
@@ -76,10 +82,10 @@ export function MobileTabBar() {
onClick={handleTabClick(isActive)}
aria-current={isActive ? "page" : undefined}
className={cn(
- "flex min-w-0 flex-col items-center gap-1 rounded-xl px-2 py-2 text-xs transition-colors",
+ "flex min-w-0 flex-col items-center gap-1 rounded-xl px-2 py-2 text-[11px] transition-all",
isActive
- ? "text-primary"
- : "text-muted-foreground hover:text-foreground",
+ ? "bg-primary/10 text-primary"
+ : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
);
})}
+
);
diff --git a/apps/web/src/components/profile/AnonymousProfile.tsx b/apps/web/src/components/profile/AnonymousProfile.tsx
index 9891b42..dc62ec3 100644
--- a/apps/web/src/components/profile/AnonymousProfile.tsx
+++ b/apps/web/src/components/profile/AnonymousProfile.tsx
@@ -1,105 +1,80 @@
-import { BarChart3, Bookmark, Scale, Sparkles } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useT } from "@/lib/i18n/LocaleContext";
import { Button } from "@/components/ui/button";
-
-const features = [
- {
- titleKey: "profile.feature.bias",
- descriptionKey: "profile.feature.biasBody",
- icon: Scale,
- },
- {
- titleKey: "profile.feature.compare",
- descriptionKey: "profile.feature.compareBody",
- icon: BarChart3,
- },
- {
- titleKey: "profile.feature.facts",
- descriptionKey: "profile.feature.factsBody",
- icon: Sparkles,
- },
-] as const;
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function AnonymousProfile() {
const t = useT();
return (
-
-
-
-
-
-
- {t("tabs.profile")}
-
-
- {t("profile.hero")}
-
-
- {t("profile.heroBody")}
-
-
-
-
-
-
- {t("auth.signUp")}
-
-
-
-
- {t("auth.signIn")}
-
-
-
-
+
+
+
+
+
+
+
+
+ {t("tabs.profile")}
+
+
+ {t("profile.account")}
+
+
+ {t("profile.signInBody")}
+
+
+
+
-
- {features.map(({ titleKey, descriptionKey, icon: Icon }) => (
-
-
-
+
+
+ {t("auth.unlockTitle")}
+
+
+
+
+
+ {t("profile.changePassword")}
+
+
+ {t("profile.passwordBody")}
+
+
+
+
+ {t("profile.security")}
+
+
+ {t("profile.securityBody")}
+
+
- {t(titleKey)}
-
- {t(descriptionKey)}
-
-
- ))}
-
+
+
+
-
-
-
-
{t("profile.learnMore")}
-
- {t("profile.learnMoreBody")}
-
-
-
-
-
- {t("footer.howItWorks")}
-
-
- {t("footer.sources")}
-
-
- {t("footer.about")}
-
-
-
-
+
+
+
+ {t("auth.account")}
+
+
+
+
+
+ {t("auth.signUp")}
+
+
+
+
+ {t("auth.signIn")}
+
+
+
+
+
+
diff --git a/apps/web/src/components/profile/AuthenticatedProfile.tsx b/apps/web/src/components/profile/AuthenticatedProfile.tsx
index 8bc8caa..e78ce8b 100644
--- a/apps/web/src/components/profile/AuthenticatedProfile.tsx
+++ b/apps/web/src/components/profile/AuthenticatedProfile.tsx
@@ -1,28 +1,48 @@
-import { Bell, LogOut, MoonStar, ShieldCheck, User2 } from "lucide-react";
+import { useState } from "react";
+import { CheckCircle2, Globe2, KeyRound, LogOut, ShieldCheck } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client";
import { LanguagePicker } from "@/components/LanguagePicker";
import { useT } from "@/lib/i18n/LocaleContext";
+import { SITE } from "@/lib/seo";
import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
import { toast } from "sonner";
type AuthenticatedProfileUser = {
email: string;
+ emailVerified?: boolean;
image?: string | null;
name?: string | null;
profile?: {
avatar?: string;
name?: string;
+ preferredLanguage?: "ro" | "en";
};
};
-function getInitials(name: string) {
- return name
- .split(" ")
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part.charAt(0).toUpperCase())
- .join("");
+function getAvatarFallback(name: string, email: string) {
+ const firstNameLetter = name.trim().charAt(0).toUpperCase();
+
+ if (firstNameLetter) {
+ return firstNameLetter;
+ }
+
+ return email.trim().charAt(0).toUpperCase() || "?";
+}
+
+function getPasswordResetRedirectURL() {
+ if (typeof window === "undefined") {
+ return `${SITE.url}/reset-password`;
+ }
+
+ return `${window.location.origin}/reset-password`;
}
export function AuthenticatedProfile({
@@ -33,7 +53,9 @@ export function AuthenticatedProfile({
const t = useT();
const displayName = user.profile?.name || user.name || user.email;
const avatarSrc = user.profile?.avatar || user.image || undefined;
- const initials = getInitials(displayName);
+ const avatarFallback = getAvatarFallback(displayName, user.email);
+ const [isSendingReset, setIsSendingReset] = useState(false);
+ const [resetStatusMessage, setResetStatusMessage] = useState("");
const handleSignOut = async () => {
try {
@@ -50,149 +72,175 @@ export function AuthenticatedProfile({
}
};
+ const handleRequestPasswordReset = async () => {
+ setResetStatusMessage("");
+ setIsSendingReset(true);
+
+ try {
+ const response = await fetch("/api/auth/request-password-reset", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ email: user.email,
+ redirectTo: getPasswordResetRedirectURL(),
+ }),
+ });
+ const payload = (await response.json().catch(() => null)) as
+ | { message?: string }
+ | null;
+
+ if (!response.ok) {
+ throw new Error(payload?.message || t("auth.resetLinkFailed"));
+ }
+
+ const message = payload?.message || t("auth.resetLinkSent");
+ setResetStatusMessage(message);
+ toast.success(message);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : t("auth.resetLinkFailed");
+ setResetStatusMessage(message);
+ toast.error(message);
+ } finally {
+ setIsSendingReset(false);
+ }
+ };
+
return (
-
-
-
-
-
- {avatarSrc ? (
-
- ) : (
-
- {initials || "B"}
+
+
+
+
+
+
+
+ {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.account")}
-
-
- {displayName}
-
-
- {user.email}
-
-
-
-
-
-
-
-
-
-
{t("profile.settings")}
-
- {t("profile.settingsBody")}
-
-
-
-
-
-
-
-
-
- {t("settings.language")}
+
+
+
+
+
+ {t("profile.settings")}
+
+
+
+
+
+
+ {t("settings.language")}
-
+
{t("profile.settingsBody")}
-
+
+
+
-
-
-
-
- {t("profile.theme")}
-
-
- {t("profile.themeBody")}
-
+
+
+
+ {t("profile.security")}
+ {t("profile.securityBody")}
+
+
+
void handleRequestPasswordReset()}
>
- {t("profile.comingSoon")}
+
+ {isSendingReset
+ ? t("auth.resetSendingStatus")
+ : t("profile.changePassword")}
+
+
+
+
+
+
+ {t("profile.requestDeletion")}
+
+
-
-
-
-
- {t("profile.notifications")}
-
-
- {t("profile.notificationsBody")}
-
- {t("profile.comingSoon")}
+ {t("auth.signOut")}
+
-
-
-
-
-
-
-
-
-
{t("profile.accountSection")}
-
- {t("profile.accountBody")}
-
-
-
-
-
-
-
{t("profile.emailLabel")}
-
{user.email}
-
-
-
-
- {t("profile.changePassword")}
-
-
- {t("profile.deleteAccount")}
-
-
-
-
-
-
- {t("profile.security")}
-
-
- {t("profile.securityBody")}
-
-
-
-
-
-
-
- {t("auth.signOut")}
-
+
+ {resetStatusMessage ? (
+
+
+ {resetStatusMessage}
+
+
+ ) : null}
+
+
+
diff --git a/apps/web/src/components/share-event-button.tsx b/apps/web/src/components/share-event-button.tsx
index a83c199..a1852a0 100644
--- a/apps/web/src/components/share-event-button.tsx
+++ b/apps/web/src/components/share-event-button.tsx
@@ -28,7 +28,7 @@ export default function ShareEventButton({
interactionContext,
slug,
title,
- summary: _summary,
+ summary,
size = "default",
className,
}: ShareEventButtonProps) {
@@ -62,7 +62,7 @@ export default function ShareEventButton({
try {
await navigator.share({
title,
- text: title,
+ text: summary ?? title,
url: shareUrl,
});
logShare();
diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx
index 3eece17..f224963 100644
--- a/apps/web/src/components/sign-in-form.tsx
+++ b/apps/web/src/components/sign-in-form.tsx
@@ -6,6 +6,7 @@ import { useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { useT } from "@/lib/i18n/LocaleContext";
+import { SITE } from "@/lib/seo";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -15,12 +16,38 @@ import { AuthDivider, GoogleSignInButton } from "./auth-social";
function getPasswordResetRedirectURL() {
if (typeof window === "undefined") {
- return `${redirectFallbackOrigin}/reset-password`;
+ return `${SITE.url}/reset-password`;
}
return `${window.location.origin}/reset-password`;
}
-const redirectFallbackOrigin = "http://localhost:3001";
+function getLocalizedSignInError(
+ t: ReturnType,
+ error: {
+ error?: {
+ code?: string;
+ message?: string;
+ statusText?: string;
+ };
+ },
+) {
+ const code = error.error?.code?.trim().toLowerCase();
+ const message = error.error?.message?.trim().toLowerCase();
+
+ if (
+ code === "invalid_credentials" ||
+ message === "invalid credentials" ||
+ message === "invalid email or password"
+ ) {
+ return t("auth.invalidCredentials");
+ }
+
+ return (
+ error.error?.message ??
+ error.error?.statusText ??
+ t("auth.signInError")
+ );
+}
export default function SignInForm({
onSwitchToSignUp,
@@ -68,11 +95,7 @@ export default function SignInForm({
toast.success(t("auth.signInSuccess"));
},
onError: (error) => {
- toast.error(
- error.error?.message ??
- error.error?.statusText ??
- t("auth.signInError"),
- );
+ toast.error(getLocalizedSignInError(t, error));
},
}
);
@@ -245,7 +268,7 @@ export default function SignInForm({
{state.isSubmitting ? (
<>
- {t("auth.signIn")}...
+ {t("auth.signingIn")}
>
) : (
resolvedSubmitLabel
diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx
index c7eddc2..b3fcc59 100644
--- a/apps/web/src/components/sign-up-form.tsx
+++ b/apps/web/src/components/sign-up-form.tsx
@@ -197,7 +197,7 @@ export default function SignUpForm({
{state.isSubmitting ? (
<>
- {t("auth.createAccount")}...
+ {t("auth.creatingAccount")}
>
) : (
resolvedSubmitLabel
diff --git a/apps/web/src/components/ui/page-loading-state.tsx b/apps/web/src/components/ui/page-loading-state.tsx
index c66f8c4..4712746 100644
--- a/apps/web/src/components/ui/page-loading-state.tsx
+++ b/apps/web/src/components/ui/page-loading-state.tsx
@@ -4,8 +4,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/lib/i18n/LocaleContext";
export function PageLoadingState({
- title = "Loading your page",
- description = "Pulling in the latest view and getting things ready.",
+ title,
+ description,
cardCount = 2,
}: {
title?: string;
diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx
index 1fd0978..0873e32 100644
--- a/apps/web/src/components/user-menu.tsx
+++ b/apps/web/src/components/user-menu.tsx
@@ -12,6 +12,7 @@ import { Button } from "./ui/button";
import { useQuery } from "convex/react";
import { api } from "@news-app/backend/convex/_generated/api";
import { ChevronDown, Flame, LogOut, User } from "lucide-react";
+import { toast } from "sonner";
export default function UserMenu() {
const t = useT();
@@ -19,13 +20,18 @@ export default function UserMenu() {
const streak = user?.stats.currentStreak ?? 0;
const handleSignOut = async () => {
- await authClient.signOut({
- fetchOptions: {
- onSuccess: () => {
- location.reload();
+ try {
+ await authClient.signOut({
+ fetchOptions: {
+ onSuccess: () => {
+ location.reload();
+ },
},
- },
- });
+ });
+ } catch (error) {
+ console.error("Failed to sign out from user menu:", error);
+ toast.error(t("auth.signOutError"));
+ }
};
const userName = user?.profile?.name || user?.email || t("user.account");
diff --git a/apps/web/src/hooks/use-scroll-visibility.ts b/apps/web/src/hooks/use-scroll-visibility.ts
new file mode 100644
index 0000000..8df2f41
--- /dev/null
+++ b/apps/web/src/hooks/use-scroll-visibility.ts
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from "react";
+
+type ScrollListener = (scrollY: number) => void;
+
+const listeners = new Set();
+let frameId = 0;
+
+function emitScrollPosition() {
+ frameId = 0;
+ const scrollY = window.scrollY || window.pageYOffset || 0;
+
+ listeners.forEach((listener) => {
+ listener(scrollY);
+ });
+}
+
+function scheduleEmit() {
+ if (frameId !== 0) {
+ return;
+ }
+
+ frameId = window.requestAnimationFrame(emitScrollPosition);
+}
+
+function subscribeToScroll(listener: ScrollListener) {
+ listeners.add(listener);
+
+ if (listeners.size === 1) {
+ window.addEventListener("scroll", scheduleEmit, { passive: true });
+ window.addEventListener("resize", scheduleEmit, { passive: true });
+ }
+
+ listener(window.scrollY || window.pageYOffset || 0);
+
+ return () => {
+ listeners.delete(listener);
+
+ if (listeners.size === 0) {
+ window.removeEventListener("scroll", scheduleEmit);
+ window.removeEventListener("resize", scheduleEmit);
+
+ if (frameId !== 0) {
+ window.cancelAnimationFrame(frameId);
+ frameId = 0;
+ }
+ }
+ };
+}
+
+export function useScrollVisibility({
+ deltaThreshold = 12,
+ topOffset = 24,
+}: {
+ deltaThreshold?: number;
+ topOffset?: number;
+} = {}) {
+ const [isVisible, setIsVisible] = useState(true);
+ const isVisibleRef = useRef(true);
+ const lastScrollYRef = useRef(0);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ return subscribeToScroll((currentScrollY) => {
+ const previousScrollY = lastScrollYRef.current;
+ const delta = currentScrollY - previousScrollY;
+
+ if (Math.abs(delta) < deltaThreshold) {
+ return;
+ }
+
+ const nextVisible =
+ currentScrollY <= topOffset ? true : delta < 0;
+
+ lastScrollYRef.current = currentScrollY;
+
+ if (isVisibleRef.current === nextVisible) {
+ return;
+ }
+
+ isVisibleRef.current = nextVisible;
+ setIsVisible(nextVisible);
+ });
+ }, [deltaThreshold, topOffset]);
+
+ return isVisible;
+}
diff --git a/apps/web/src/lib/i18n/LocaleContext.tsx b/apps/web/src/lib/i18n/LocaleContext.tsx
index bae14ca..c88722c 100644
--- a/apps/web/src/lib/i18n/LocaleContext.tsx
+++ b/apps/web/src/lib/i18n/LocaleContext.tsx
@@ -1,5 +1,5 @@
import { createContext, useContext, type ReactNode } from "react";
-import { STRINGS, type Locale, type StringKey } from "./strings";
+import { getString, type Locale, type StringKey } from "./strings";
const LocaleContext = createContext("en");
@@ -23,6 +23,6 @@ export function useT() {
const locale = useLocale();
return (key: StringKey, fallback?: string): string => {
- return STRINGS[locale][key] ?? STRINGS.en[key] ?? fallback ?? key;
+ return getString(locale, key, fallback);
};
}
diff --git a/apps/web/src/lib/i18n/getLocaleFromMatches.ts b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
new file mode 100644
index 0000000..c91373b
--- /dev/null
+++ b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
@@ -0,0 +1,17 @@
+import type { Locale } from "./strings";
+
+const SUPPORTED_LOCALES = ["ro", "en"] as const satisfies readonly Locale[];
+
+export function getLocaleFromMatches(
+ matches: ReadonlyArray<{ context?: unknown }>,
+): Locale {
+ const rootContext = matches[0]?.context;
+ if (!rootContext || typeof rootContext !== "object") {
+ return "en";
+ }
+
+ const locale =
+ "locale" in rootContext ? (rootContext.locale as string | undefined) : null;
+
+ return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "en";
+}
diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts
index c401dea..bb7a049 100644
--- a/apps/web/src/lib/i18n/resolveLocale.ts
+++ b/apps/web/src/lib/i18n/resolveLocale.ts
@@ -28,13 +28,26 @@ function isSupported(value: string | null | undefined): value is Locale {
function parseAcceptLanguage(header: string | null | undefined): Locale | null {
if (!header) return null;
- const codes = header
+ const weightedCodes = header
.split(",")
- .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0])
- .filter(Boolean);
-
- for (const code of codes) {
- if (code && SUPPORTED.includes(code as Locale)) {
+ .map((part) => {
+ const [languagePart, ...params] = part.split(";");
+ const code = languagePart?.trim().toLowerCase().split("-")[0];
+ const qParam = params
+ .map((param) => param.trim())
+ .find((param) => param.startsWith("q="));
+ const weight = Number.parseFloat(qParam?.slice(2) ?? "1");
+
+ return {
+ code,
+ weight: Number.isFinite(weight) ? weight : 1,
+ };
+ })
+ .filter((entry): entry is { code: string; weight: number } => Boolean(entry.code))
+ .sort((a, b) => b.weight - a.weight);
+
+ for (const { code } of weightedCodes) {
+ if (SUPPORTED.includes(code as Locale)) {
return code as Locale;
}
}
diff --git a/apps/web/src/lib/i18n/strings.ts b/apps/web/src/lib/i18n/strings.ts
index e3c305f..7da66f7 100644
--- a/apps/web/src/lib/i18n/strings.ts
+++ b/apps/web/src/lib/i18n/strings.ts
@@ -23,6 +23,7 @@ const ro = {
"auth.passwordCreatePlaceholder": "Creează o parolă",
"auth.signInSuccess": "Te-ai autentificat",
"auth.signInError": "Autentificarea a eșuat. Încearcă din nou.",
+ "auth.invalidCredentials": "E-mailul sau parola sunt incorecte.",
"auth.invalidEmail": "Adresă de e-mail invalidă",
"auth.passwordMin": "Parola trebuie să aibă cel puțin 8 caractere",
"auth.nameMin": "Numele trebuie să aibă cel puțin 2 caractere",
@@ -62,6 +63,8 @@ const ro = {
"auth.signUpSubtitle":
"Contul gratuit deblochează salvările, feed-ul personalizat și notificările viitoare.",
"auth.createAccount": "Creează cont",
+ "auth.signingIn": "Te autentificăm...",
+ "auth.creatingAccount": "Creăm contul...",
"auth.orContinueWith": "Sau continuă cu",
"auth.googleError": "Autentificarea cu Google a eșuat. Încearcă din nou.",
"auth.promptTitle":
@@ -178,6 +181,9 @@ const ro = {
"profile.preparing.title": "Se pregătește profilul",
"profile.preparing.body": "Încărcăm preferințele și detaliile contului tău.",
"profile.account": "Contul tău",
+ "profile.verified": "E-mail verificat",
+ "profile.signInBody":
+ "Autentifică-te pentru a gestiona limba, securitatea și preferințele contului.",
"profile.settings": "Setări",
"profile.settingsBody": "Opțiuni rapide pentru preferințele tale.",
"profile.theme": "Temă",
@@ -190,7 +196,10 @@ const ro = {
"profile.accountBody":
"Informațiile tale de autentificare și opțiunile sensibile ale contului.",
"profile.changePassword": "Schimbă parola",
+ "profile.passwordBody":
+ "Primești prin e-mail un link sigur pentru resetarea parolei.",
"profile.deleteAccount": "Solicită ștergerea contului",
+ "profile.requestDeletion": "Solicită ștergerea contului",
"profile.emailLabel": "E-mail",
"profile.security": "Siguranța contului",
"profile.securityBody":
@@ -299,7 +308,9 @@ const ro = {
"event.updated": "Actualizat {time}",
"event.sources": "{count} surse",
"event.articles.one": "1 articol",
+ "event.articles.few": "{count} articole",
"event.articles.many": "{count} articole",
+ "event.articles.other": "{count} articole",
"event.follow": "Urmărește evenimentul",
"event.biasDistribution":
"Distribuția biasului surselor: {left} stânga, {center} centru, {right} dreapta",
@@ -326,7 +337,9 @@ const ro = {
"event.centerTab": "Centru",
"event.right": "Dreapta",
"event.sourceCount.one": "1 sursă",
+ "event.sourceCount.few": "{count} surse",
"event.sourceCount.many": "{count} surse",
+ "event.sourceCount.other": "{count} surse",
"claim.agreement": "Acord",
"claim.agreements": "Acorduri",
"claim.agreementBody": "Afirmații confirmate de mai multe surse.",
@@ -465,7 +478,7 @@ const ro = {
"Acest link către sursă este invalid sau nu mai este disponibil.",
"source.backToFeed": "Înapoi la feed",
"source.profile": "Profilul sursei",
- "source.reliability": "Fiabilitate {count}/10",
+ "source.reliability": "Fiabilitate {score}/10",
"source.recentArticles": "Articole recente",
"source.events": "Evenimente",
"source.aiBiasAvg": "Media biasului estimat de AI",
@@ -476,8 +489,11 @@ const ro = {
"source.credibilityLabel": "Credibilitate",
"source.rollingSample": "Eșantion AI rolling",
"source.rollingArticles": "{count} articole",
- "source.mean": "Media {count}",
- "source.stddev": "Deviația standard {count}",
+ "source.mean": "Media {value}",
+ "source.stddev": "Deviația standard {value}",
+ "read.duration.seconds": "{count}s",
+ "read.duration.minutes": "{count} min",
+ "scroll.depth": "{count}% profunzime",
"source.recentReporting": "Relatări recente",
"source.aiBias": "Bias AI {count}",
"source.outlier": "Anomalie",
@@ -509,6 +525,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"auth.passwordCreatePlaceholder": "Create a password",
"auth.signInSuccess": "You’re signed in",
"auth.signInError": "Sign-in failed. Please try again.",
+ "auth.invalidCredentials": "Incorrect email or password.",
"auth.invalidEmail": "Invalid email address",
"auth.passwordMin": "Password must be at least 8 characters",
"auth.nameMin": "Name must be at least 2 characters",
@@ -548,6 +565,8 @@ const en: { [K in keyof BaseStrings]: string } = {
"auth.signUpSubtitle":
"A free account unlocks saved items, a personalized feed, and future notifications.",
"auth.createAccount": "Create account",
+ "auth.signingIn": "Signing in...",
+ "auth.creatingAccount": "Creating account...",
"auth.orContinueWith": "Or continue with",
"auth.googleError": "Google sign-in failed. Please try again.",
"auth.promptTitle":
@@ -661,6 +680,9 @@ const en: { [K in keyof BaseStrings]: string } = {
"profile.preparing.body":
"We are loading your preferences and account details.",
"profile.account": "Your account",
+ "profile.verified": "Verified email",
+ "profile.signInBody":
+ "Sign in to manage language, security, and your account preferences.",
"profile.settings": "Settings",
"profile.settingsBody": "Quick options for your preferences.",
"profile.theme": "Theme",
@@ -673,7 +695,10 @@ const en: { [K in keyof BaseStrings]: string } = {
"profile.accountBody":
"Your authentication details and the sensitive options for your account.",
"profile.changePassword": "Change password",
+ "profile.passwordBody":
+ "You’ll receive a secure password-reset link by email.",
"profile.deleteAccount": "Request account deletion",
+ "profile.requestDeletion": "Request deletion",
"profile.emailLabel": "Email",
"profile.security": "Account security",
"profile.securityBody":
@@ -782,7 +807,9 @@ const en: { [K in keyof BaseStrings]: string } = {
"event.updated": "Updated {time}",
"event.sources": "{count} sources",
"event.articles.one": "1 article",
+ "event.articles.few": "{count} articles",
"event.articles.many": "{count} articles",
+ "event.articles.other": "{count} articles",
"event.follow": "Follow the event",
"event.biasDistribution":
"Source bias distribution: {left} left, {center} center, {right} right",
@@ -809,7 +836,9 @@ const en: { [K in keyof BaseStrings]: string } = {
"event.centerTab": "Center",
"event.right": "Right",
"event.sourceCount.one": "1 source",
+ "event.sourceCount.few": "{count} sources",
"event.sourceCount.many": "{count} sources",
+ "event.sourceCount.other": "{count} sources",
"claim.agreement": "Agreement",
"claim.agreements": "Agreements",
"claim.agreementBody": "Claims confirmed by multiple sources.",
@@ -946,7 +975,7 @@ const en: { [K in keyof BaseStrings]: string } = {
"This source link is invalid or no longer available.",
"source.backToFeed": "Back to feed",
"source.profile": "Source profile",
- "source.reliability": "Reliability {count}/10",
+ "source.reliability": "Reliability {score}/10",
"source.recentArticles": "Recent articles",
"source.events": "Events",
"source.aiBiasAvg": "Average AI bias",
@@ -957,8 +986,11 @@ const en: { [K in keyof BaseStrings]: string } = {
"source.credibilityLabel": "Credibility",
"source.rollingSample": "Rolling AI bias sample",
"source.rollingArticles": "{count} articles",
- "source.mean": "Mean {count}",
- "source.stddev": "Standard deviation {count}",
+ "source.mean": "Mean {value}",
+ "source.stddev": "Standard deviation {value}",
+ "read.duration.seconds": "{count}s",
+ "read.duration.minutes": "{count} min",
+ "scroll.depth": "{count}% depth",
"source.recentReporting": "Recent reporting",
"source.aiBias": "AI bias {count}",
"source.outlier": "Outlier",
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index a10d911..14c02cd 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -3,6 +3,7 @@ import { SITE } from "@/lib/seo";
import { Footer } from "@/components/layout/Footer";
import { MobileTabBar } from "@/components/layout/MobileTabBar";
import { LocaleProvider } from "@/lib/i18n/LocaleContext";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { getServerLocale } from "@/lib/i18n/getServerLocale";
import { getString, type Locale } from "@/lib/i18n/strings";
import { api } from "@news-app/backend/convex/_generated/api";
@@ -49,10 +50,7 @@ function getExistingAuthContext(
? rootContext.isAuthenticated
: undefined;
const locale =
- "locale" in rootContext &&
- (rootContext.locale === "ro" || rootContext.locale === "en")
- ? rootContext.locale
- : undefined;
+ "locale" in rootContext ? getLocaleFromMatches([{ context: rootContext }]) : undefined;
return { token, isAuthenticated, locale };
}
@@ -71,7 +69,7 @@ export interface RouterAppContext {
export const Route = createRootRouteWithContext()({
head: ({ matches }) => {
- const locale = getExistingAuthContext(matches).locale ?? "en";
+ const locale = getLocaleFromMatches(matches);
const title = getString(locale, "seo.siteTitle");
const description = getString(locale, "seo.siteDescription");
@@ -116,9 +114,43 @@ export const Route = createRootRouteWithContext()({
if (typeof document !== "undefined") {
const existing = getExistingAuthContext(ctx.matches);
+ const searchLocale = new URLSearchParams(window.location.search).get("lang");
+ const cookieLocale =
+ document.cookie
+ .split("; ")
+ .find((entry) => entry.startsWith("bv_locale="))
+ ?.split("=")[1] ?? null;
+ const decodedCookieLocale = cookieLocale
+ ? decodeURIComponent(cookieLocale)
+ : null;
+
+ if (
+ existing.locale &&
+ (!searchLocale || searchLocale === existing.locale) &&
+ (!decodedCookieLocale || decodedCookieLocale === existing.locale)
+ ) {
+ return existing;
+ }
+
+ let userPreference: string | null = null;
+ if (existing.isAuthenticated) {
+ try {
+ const currentUser = await ctx.context.convexClient.query(
+ api.user.getCurrentUser,
+ {},
+ );
+ userPreference = currentUser?.profile?.preferredLanguage ?? null;
+ } catch (error) {
+ console.warn(
+ "Failed to resolve client-side user locale preference:",
+ error,
+ );
+ }
+ }
+
const locale = await getServerLocale({
data: {
- userPreference: null,
+ userPreference,
},
});
@@ -139,7 +171,8 @@ export const Route = createRootRouteWithContext()({
{},
);
userPreference = currentUser?.profile?.preferredLanguage ?? null;
- } catch {
+ } catch (error) {
+ console.warn("Failed to resolve current user locale preference:", error);
userPreference = null;
}
}
@@ -168,7 +201,7 @@ function RootDocument() {
-
+
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx
index f671043..6e7890d 100644
--- a/apps/web/src/routes/activitate.tsx
+++ b/apps/web/src/routes/activitate.tsx
@@ -5,14 +5,13 @@ import StreakActivityCalendar from "@/components/streak-activity-calendar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageLoadingState } from "@/components/ui/page-loading-state";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useLocale, useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
import { api } from "@news-app/backend/convex/_generated/api";
import { createFileRoute, Link } from "@tanstack/react-router";
import {
- Authenticated,
- AuthLoading,
- Unauthenticated,
+ useConvexAuth,
useMutation as useConvexMutationHook,
useQuery,
} from "convex/react";
@@ -27,16 +26,27 @@ import {
Sparkles,
} from "lucide-react";
-function formatReadDuration(seconds?: number) {
+function formatReadDuration(
+ t: ReturnType,
+ seconds?: number,
+) {
if (!seconds || seconds <= 0) return null;
- if (seconds < 60) return `${seconds}s`;
+ if (seconds < 60) {
+ return t("read.duration.seconds").replace("{count}", String(seconds));
+ }
const minutes = Math.round(seconds / 60);
- return `${minutes} min`;
+ return t("read.duration.minutes").replace("{count}", String(minutes));
}
-function formatScrollDepth(percentage?: number) {
+function formatScrollDepth(
+ t: ReturnType,
+ percentage?: number,
+) {
if (percentage === undefined) return null;
- return `${Math.round(percentage * 100)}% depth`;
+ return t("scroll.depth").replace(
+ "{count}",
+ String(Math.round(percentage * 100)),
+ );
}
function getBiasSnapshotLabel(
@@ -62,13 +72,7 @@ function getNextStreakMilestone(streak: number) {
export const Route = createFileRoute("/activitate")({
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [
@@ -82,34 +86,44 @@ export const Route = createFileRoute("/activitate")({
function RouteComponent() {
const t = useT();
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const currentUser = useQuery(
+ api.user.getCurrentUser,
+ isAuthenticated ? {} : "skip",
+ );
+
+ if (isLoading || (isAuthenticated && currentUser === undefined)) {
+ return (
+
+ );
+ }
+
+ if (!currentUser) {
+ return (
+
+ );
+ }
return (
- <>
-
-
-
-
-
-
-
-
-
- >
+
);
}
-function AuthorizedDashboard() {
+function AuthorizedDashboard({
+ currentUser,
+}: {
+ currentUser: NonNullable>>;
+}) {
const locale = useLocale();
const t = useT();
- const currentUser = useQuery(api.user.getCurrentUser);
const dashboardOverview = useQuery(api.interactions.getDashboardOverview);
const isAdmin = useQuery(api.user.isCurrentUserAdmin);
const topicDiagnostics = useQuery(
@@ -129,17 +143,9 @@ function AuthorizedDashboard() {
setMinScoreInput(String(currentSettings.minScore));
setConfidenceRatioInput(String(currentSettings.confidenceRatio));
setMaxTopicsInput(String(currentSettings.maxTopics));
- }, [
- currentSettings?.minScore,
- currentSettings?.confidenceRatio,
- currentSettings?.maxTopics,
- ]);
-
- if (
- currentUser === undefined ||
- dashboardOverview === undefined ||
- isAdmin === undefined
- ) {
+ }, [currentSettings]);
+
+ if (dashboardOverview === undefined || isAdmin === undefined) {
return (
- Feed
+ {t("tabs.feed")}
@@ -441,9 +447,11 @@ function AuthorizedDashboard() {
{recentHistory.slice(0, 4).map((entry) => {
const durationLabel = formatReadDuration(
+ t,
entry.metadata.timeSpentSeconds,
);
const scrollLabel = formatScrollDepth(
+ t,
entry.metadata.scrollDepthPercentage,
);
const detailBits = [durationLabel, scrollLabel].filter(
@@ -710,7 +718,11 @@ function AuthorizedDashboard() {
{t("activity.admin.reset")}
{configMessage && (
-
+
{configMessage}
)}
@@ -740,13 +752,13 @@ function AuthorizedDashboard() {
t.displayName,
+ (topic) => topic.displayName,
)}
/>
t.displayName,
+ (topic) => topic.displayName,
)}
/>
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx
index f46d327..5cfb4dd 100644
--- a/apps/web/src/routes/dashboard.tsx
+++ b/apps/web/src/routes/dashboard.tsx
@@ -1,10 +1,12 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { PageLoadingState } from "@/components/ui/page-loading-state";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
+import { api } from "@news-app/backend/convex/_generated/api";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useConvexAuth } from "convex/react";
+import { useConvexAuth, useQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { isAuthRedirectPath, type AuthRedirectPath } from "@/lib/auth-redirect";
import { toast } from "sonner";
@@ -19,13 +21,7 @@ const searchSchema = z.object({
export const Route = createFileRoute("/dashboard")({
validateSearch: searchSchema,
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [
@@ -42,6 +38,10 @@ function DashboardAuthPage() {
const navigate = useNavigate({ from: "/dashboard" });
const search = Route.useSearch();
const { isAuthenticated, isLoading } = useConvexAuth();
+ const currentUser = useQuery(
+ api.user.getCurrentUser,
+ isAuthenticated ? {} : "skip",
+ );
const redirectTo: AuthRedirectPath =
search.redirect && isAuthRedirectPath(search.redirect)
? search.redirect
@@ -51,10 +51,10 @@ function DashboardAuthPage() {
const showSignIn = search.mode !== "signup" || isVerified;
useEffect(() => {
- if (isAuthenticated) {
+ if (currentUser) {
void navigate({ to: "/activitate", replace: true });
}
- }, [isAuthenticated, navigate]);
+ }, [currentUser, navigate]);
const replaceDashboardSearch = (
mode: "signin" | "signup",
@@ -81,7 +81,7 @@ function DashboardAuthPage() {
void replaceDashboardSearch("signin");
}, [isVerified, search.redirect, t, navigate]);
- if (isLoading || isAuthenticated) {
+ if (isLoading || (isAuthenticated && currentUser === undefined)) {
return (
+ candidate in STRINGS[locale] || candidate in STRINGS.en,
+ ) ?? `${baseKey}.many`;
+
+ return getString(locale, resolvedKey as StringKey).replace(
+ "{count}",
+ String(count),
+ );
+}
+
export const Route = createFileRoute("/event/$slug")({
validateSearch: searchSchema,
loader: async ({ context, params }) => {
@@ -49,13 +74,7 @@ export const Route = createFileRoute("/event/$slug")({
}
},
head: ({ loaderData, params, matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
const title = loaderData?.event?.title
? `${loaderData.event.title} — ${SITE.name}`
: getString(locale, "event.metaTitle");
@@ -342,21 +361,19 @@ function EventDetailPage() {
- {articles.length === 1
- ? t("event.articles.one")
- : t("event.articles.many").replace(
- "{count}",
- String(articles.length),
- )}
+ {getPluralizedCountLabel(
+ locale,
+ "event.articles",
+ articles.length,
+ )}
•
- {sourceCount === 1
- ? t("event.sourceCount.one")
- : t("event.sourceCount.many").replace(
- "{count}",
- String(sourceCount),
- )}
+ {getPluralizedCountLabel(
+ locale,
+ "event.sourceCount",
+ sourceCount,
+ )}
diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx
index afe1689..2d6716a 100644
--- a/apps/web/src/routes/feed.tsx
+++ b/apps/web/src/routes/feed.tsx
@@ -38,6 +38,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useIsMobile } from "@/components/ui/use-mobile";
+import { useScrollVisibility } from "@/hooks/use-scroll-visibility";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
import { cn } from "@/lib/utils";
@@ -45,13 +47,7 @@ import { SITE } from "@/lib/seo";
export const Route = createFileRoute("/feed")({
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
const title = getString(locale, "feed.meta.title");
const description = getString(locale, "feed.meta.description");
@@ -252,6 +248,7 @@ function FeedComponent() {
function FeedContent() {
const t = useT();
+ const isMobile = useIsMobile();
const { isAuthenticated } = useConvexAuth();
const topics = useQuery(api.topics.getTopics);
const currentUser = useQuery(
@@ -277,11 +274,16 @@ function FeedContent() {
const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending");
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
+ const isSearching = debouncedSearch.length >= 2;
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [recentSearches, setRecentSearches] = useState
([]);
const searchInputRef = useRef(null);
+ const controlsRef = useRef(null);
const loadMoreTriggerRef = useRef(null);
const isLoadingMoreRef = useRef(false);
+ const areControlsVisible = useScrollVisibility();
+ const [controlsHeight, setControlsHeight] = useState(0);
+ const hiddenControlsOffset = controlsHeight + (isMobile ? 12 : 80);
useEffect(() => {
try {
@@ -332,6 +334,45 @@ function FeedContent() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
+ useEffect(() => {
+ const element = controlsRef.current;
+ if (!element) {
+ return;
+ }
+
+ const updateHeight = () => {
+ const nextHeight = Math.round(element.getBoundingClientRect().height);
+ setControlsHeight((currentHeight) =>
+ currentHeight === nextHeight ? currentHeight : nextHeight,
+ );
+ };
+
+ updateHeight();
+
+ if (typeof ResizeObserver === "undefined") {
+ window.addEventListener("resize", updateHeight);
+ return () => {
+ window.removeEventListener("resize", updateHeight);
+ };
+ }
+
+ const observer = new ResizeObserver(() => {
+ updateHeight();
+ });
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [
+ isSearchFocused,
+ recentSearches.length,
+ searchInput.length,
+ selectedTopic,
+ feedSort,
+ isSearching,
+ ]);
+
const {
results: events,
status,
@@ -343,7 +384,6 @@ function FeedContent() {
: { topicId: selectedTopic, sort: feedSort },
{ initialNumItems: pageSize },
);
- const isSearching = debouncedSearch.length >= 2;
const searchResults = useQuery(
api.events.searchPublishedEvents,
isSearching
@@ -356,6 +396,7 @@ function FeedContent() {
);
const canLoadMore = !isSearching && status === "CanLoadMore";
const isLoadingMore = !isSearching && status === "LoadingMore";
+ const loadMoreRef = useRef(loadMore);
const preferredTopicIds = useMemo(() => {
if (!topics || !currentUser?.privateContext?.interests?.length) {
@@ -388,6 +429,10 @@ function FeedContent() {
: "skip",
);
+ useEffect(() => {
+ loadMoreRef.current = loadMore;
+ }, [loadMore]);
+
useEffect(() => {
if (status !== "LoadingMore") {
isLoadingMoreRef.current = false;
@@ -412,7 +457,7 @@ function FeedContent() {
}
isLoadingMoreRef.current = true;
- loadMore(pageSize);
+ loadMoreRef.current(pageSize);
},
{
rootMargin: "1200px 0px",
@@ -422,7 +467,7 @@ function FeedContent() {
observer.observe(target);
return () => observer.disconnect();
- }, [canLoadMore, loadMore, pageSize]);
+ }, [canLoadMore, pageSize]);
useEffect(() => {
if (
@@ -477,123 +522,143 @@ function FeedContent() {
-
-
-
-
-
- setSearchInput(event.target.value)}
- onFocus={() => setIsSearchFocused(true)}
- onBlur={() => {
- window.setTimeout(() => setIsSearchFocused(false), 100);
- }}
- placeholder={t("feed.search.placeholder")}
- className="h-10 rounded-full border-border/80 bg-background/75 pr-11 text-base sm:h-11 sm:pr-12"
- aria-label={t("feed.search.label")}
- />
- {searchInput.length > 0 && (
- {
- setSearchInput("");
- setDebouncedSearch("");
- }}
- aria-label={t("feed.search.clear")}
- >
-
-
- )}
-
-
-
-
-
-
+
0 ? `${controlsHeight}px` : undefined }}
+ />
+
+
+
+
+
+
+
- {!isSearching && (
-
-
setFeedSort("recent")}
- aria-pressed={feedSort === "recent"}
- >
-
- {t("feed.sort.recent")}
-
-
setFeedSort("trending")}
- aria-pressed={feedSort === "trending"}
- >
-
- {t("feed.sort.trending")}
-
+
+
+
+
+
+ {!isSearching && (
+
+ setFeedSort("recent")}
+ aria-pressed={feedSort === "recent"}
+ >
+
+ {t("feed.sort.recent")}
+
+ setFeedSort("trending")}
+ aria-pressed={feedSort === "trending"}
+ >
+
+ {t("feed.sort.trending")}
+
+
+ )}
+
+ {shouldShowThresholdHint && (
+
+ {t("feed.search.threshold")}
+
+ )}
+ {shouldShowRecentSearches && (
+
+ {recentSearches.map((recentSearch) => (
+ event.preventDefault()}
+ onClick={() => {
+ setSearchInput(recentSearch);
+ setDebouncedSearch(recentSearch);
+ searchInputRef.current?.focus();
+ }}
+ >
+ {recentSearch}
+
+ ))}
+
+ )}
+ {isSearching && (
+
+ {t("feed.search.indexed").replace(
+ "{query}",
+ debouncedSearch,
+ )}
+
)}
- {shouldShowThresholdHint && (
-
- {t("feed.search.threshold")}
-
- )}
- {shouldShowRecentSearches && (
-
- {recentSearches.map((recentSearch) => (
- event.preventDefault()}
- onClick={() => {
- setSearchInput(recentSearch);
- setDebouncedSearch(recentSearch);
- searchInputRef.current?.focus();
- }}
- >
- {recentSearch}
-
- ))}
-
- )}
- {isSearching && (
-
- {t("feed.search.indexed").replace(
- "{query}",
- debouncedSearch,
- )}
-
- )}
-
+
-
+
{!isAuthenticated && (
{
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [{ title: getString(locale, "profile.metaTitle") }],
diff --git a/apps/web/src/routes/reset-password.tsx b/apps/web/src/routes/reset-password.tsx
index a44c3e0..d175298 100644
--- a/apps/web/src/routes/reset-password.tsx
+++ b/apps/web/src/routes/reset-password.tsx
@@ -1,8 +1,10 @@
+import { useMemo } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
@@ -18,13 +20,7 @@ const searchSchema = z.object({
export const Route = createFileRoute("/reset-password")({
validateSearch: searchSchema,
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [{ title: getString(locale, "reset.metaTitle") }],
@@ -38,15 +34,19 @@ function ResetPasswordRoute() {
const t = useT();
const search = Route.useSearch();
const token = search.token?.trim();
- const resetPasswordSchema = z
- .object({
- password: z.string().min(8, t("reset.passwordMin")),
- confirmPassword: z.string(),
- })
- .refine((value) => value.password === value.confirmPassword, {
- message: t("reset.passwordMismatch"),
- path: ["confirmPassword"],
- });
+ const resetPasswordSchema = useMemo(
+ () =>
+ z
+ .object({
+ password: z.string().min(8, t("reset.passwordMin")),
+ confirmPassword: z.string(),
+ })
+ .refine((value) => value.password === value.confirmPassword, {
+ message: t("reset.passwordMismatch"),
+ path: ["confirmPassword"],
+ }),
+ [t],
+ );
const errorMessage = !search.error
? null
@@ -150,7 +150,7 @@ function ResetPasswordRoute() {
className="h-11"
/>
{field.state.meta.errors.map((error, index) => (
-
+
{error?.message}
))}
@@ -180,7 +180,7 @@ function ResetPasswordRoute() {
className="h-11"
/>
{field.state.meta.errors.map((error, index) => (
-
+
{error?.message}
))}
diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx
index 2956c5b..a002af2 100644
--- a/apps/web/src/routes/salvate.tsx
+++ b/apps/web/src/routes/salvate.tsx
@@ -1,33 +1,23 @@
import { useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { api } from "@news-app/backend/convex/_generated/api";
-import {
- Authenticated,
- AuthLoading,
- Unauthenticated,
- useQuery,
-} from "convex/react";
+import { useConvexAuth, useQuery } from "convex/react";
import { SignInPrompt } from "@/components/SignInPrompt";
import EventCard from "@/components/feed/event-card";
import { Button } from "@/components/ui/button";
import { PageLoadingState } from "@/components/ui/page-loading-state";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
function safePositiveInt(raw: unknown, fallback: number): number {
const n = Number(raw);
- return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : fallback;
+ return Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback;
}
export const Route = createFileRoute("/salvate")({
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [
@@ -41,44 +31,53 @@ export const Route = createFileRoute("/salvate")({
function SalvatePage() {
const t = useT();
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const currentUser = useQuery(
+ api.user.getCurrentUser,
+ isAuthenticated ? {} : "skip",
+ );
+
+ if (isLoading || (isAuthenticated && currentUser === undefined)) {
+ return (
+
+ );
+ }
+
+ if (!currentUser) {
+ return (
+
+
+
+
+
+ }
+ />
+ );
+ }
return (
- <>
-
-
-
-
-
-
-
-
-
- }
- />
-
-
-
-
- >
+
);
}
@@ -142,6 +141,8 @@ function SalvateContent() {
{
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
- const sourceName = loaderData?.source.name ?? getString(locale, "source.metaTitle");
- const title = loaderData?.source.name
- ? `${loaderData.source.name} — ${SITE.name}`
+ const locale = getLocaleFromMatches(matches);
+ const sourceName = loaderData?.source?.name;
+ const title = sourceName
+ ? `${sourceName} — ${SITE.name}`
: getString(locale, "source.metaTitle");
- const description = loaderData
+ const description = loaderData?.source
? getString(locale, "source.metaDescriptionLoaded").replace(
"{name}",
- sourceName,
+ sourceName ?? getString(locale, "source.metaTitle"),
)
: getString(locale, "source.metaDescription");
@@ -217,7 +212,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {
{t("source.reliability").replace(
- "{count}",
+ "{score}",
String(source.reliabilityScore),
)}
@@ -322,12 +317,12 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {
{typeof source.rollingBiasMean === "number" && (
{t("source.mean").replace(
- "{count}",
+ "{value}",
source.rollingBiasMean.toFixed(1),
)}
{typeof source.rollingBiasStddev === "number"
? ` · ${t("source.stddev").replace(
- "{count}",
+ "{value}",
source.rollingBiasStddev.toFixed(1),
)}`
: ""}
diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx
index 1fcca1c..87d3903 100644
--- a/apps/web/src/routes/unsubscribe.tsx
+++ b/apps/web/src/routes/unsubscribe.tsx
@@ -6,6 +6,7 @@ import { api } from "@news-app/backend/convex/_generated/api";
import { z } from "zod";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
import { useT } from "@/lib/i18n/LocaleContext";
import { getString } from "@/lib/i18n/strings";
import {
@@ -23,13 +24,7 @@ const searchSchema = z.object({
export const Route = createFileRoute("/unsubscribe")({
validateSearch: searchSchema,
head: ({ matches }) => {
- const locale =
- matches[0]?.context &&
- typeof matches[0].context === "object" &&
- "locale" in matches[0].context &&
- (matches[0].context.locale === "ro" || matches[0].context.locale === "en")
- ? matches[0].context.locale
- : "en";
+ const locale = getLocaleFromMatches(matches);
return {
meta: [
@@ -49,13 +44,14 @@ function UnsubscribePage() {
const unsubscribe = useMutation({
mutationFn: useConvexMutation(api.waitlist.unsubscribe),
});
+ const { reset } = unsubscribe;
useEffect(() => {
if (email !== lastEmail) {
- unsubscribe.reset();
+ reset();
setLastEmail(email);
}
- }, [email, lastEmail]);
+ }, [email, lastEmail, reset]);
useEffect(() => {
if (
@@ -135,10 +131,7 @@ function UnsubscribePage() {
{t("unsubscribe.successTitle")}
- {t("unsubscribe.successBody").replace(
- "{email}",
- email ? email : "",
- )}
+ {t("unsubscribe.successBody").replace("{email}", email)}
From 9347793bc69f8df01388d7ad112b2af081925c62 Mon Sep 17 00:00:00 2001
From: flavius
Date: Mon, 11 May 2026 07:49:19 +0300
Subject: [PATCH 17/19] fix coderabbit issues
---
.../src/components/layout/MobileTabBar.tsx | 2 +-
apps/web/src/components/sign-in-form.tsx | 2 +
apps/web/src/components/sign-up-form.tsx | 3 +
.../src/components/ui/page-loading-state.tsx | 12 +-
apps/web/src/lib/i18n/resolveLocale.ts | 5 +-
apps/web/src/routes/__root.tsx | 11 +-
apps/web/src/routes/activitate.tsx | 255 +++++++++-------
apps/web/src/routes/dashboard.tsx | 2 +-
apps/web/src/routes/feed.tsx | 6 +-
packages/backend/convex/clustering.ts | 281 +++++++++++++-----
packages/backend/convex/config.ts | 76 +++++
packages/backend/convex/migrations.ts | 57 ++++
packages/backend/convex/schema.ts | 26 +-
packages/backend/convex/seeds.ts | 13 +-
packages/backend/convex/vectorSearchBudget.ts | 205 ++++++++++++-
15 files changed, 751 insertions(+), 205 deletions(-)
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx
index d619861..152daab 100644
--- a/apps/web/src/components/layout/MobileTabBar.tsx
+++ b/apps/web/src/components/layout/MobileTabBar.tsx
@@ -65,7 +65,7 @@ export function MobileTabBar() {
"fixed inset-x-0 bottom-0 z-50 px-4 pb-4 transition-transform duration-300 ease-out md:hidden",
isVisible ? "translate-y-0" : "translate-y-[calc(100%+1rem)]",
)}
- style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
+ style={{ paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))" }}
>
diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx
index f224963..02a91ff 100644
--- a/apps/web/src/components/sign-in-form.tsx
+++ b/apps/web/src/components/sign-in-form.tsx
@@ -138,6 +138,7 @@ export default function SignInForm({
id={field.name}
name={field.name}
type="email"
+ aria-label={t("auth.email")}
placeholder={t("auth.emailPlaceholder")}
value={field.state.value}
onBlur={field.handleBlur}
@@ -242,6 +243,7 @@ export default function SignInForm({
id={field.name}
name={field.name}
type="password"
+ aria-label={t("auth.password")}
placeholder={t("auth.passwordPlaceholder")}
value={field.state.value}
onBlur={field.handleBlur}
diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx
index b3fcc59..982530c 100644
--- a/apps/web/src/components/sign-up-form.tsx
+++ b/apps/web/src/components/sign-up-form.tsx
@@ -120,6 +120,7 @@ export default function SignUpForm({
+
@@ -31,7 +34,12 @@ export function PageLoadingState({
-
+
{resolvedTitle}
diff --git a/apps/web/src/lib/i18n/resolveLocale.ts b/apps/web/src/lib/i18n/resolveLocale.ts
index bb7a049..faf1ad5 100644
--- a/apps/web/src/lib/i18n/resolveLocale.ts
+++ b/apps/web/src/lib/i18n/resolveLocale.ts
@@ -43,7 +43,10 @@ function parseAcceptLanguage(header: string | null | undefined): Locale | null {
weight: Number.isFinite(weight) ? weight : 1,
};
})
- .filter((entry): entry is { code: string; weight: number } => Boolean(entry.code))
+ .filter(
+ (entry): entry is { code: string; weight: number } =>
+ Boolean(entry.code) && entry.weight > 0,
+ )
.sort((a, b) => b.weight - a.weight);
for (const { code } of weightedCodes) {
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 14c02cd..c45739f 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -120,9 +120,14 @@ export const Route = createRootRouteWithContext()({
.split("; ")
.find((entry) => entry.startsWith("bv_locale="))
?.split("=")[1] ?? null;
- const decodedCookieLocale = cookieLocale
- ? decodeURIComponent(cookieLocale)
- : null;
+ let decodedCookieLocale: string | null = null;
+ if (cookieLocale) {
+ try {
+ decodedCookieLocale = decodeURIComponent(cookieLocale);
+ } catch {
+ decodedCookieLocale = null;
+ }
+ }
if (
existing.locale &&
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx
index 6e7890d..304a0b1 100644
--- a/apps/web/src/routes/activitate.tsx
+++ b/apps/web/src/routes/activitate.tsx
@@ -26,6 +26,21 @@ import {
Sparkles,
} from "lucide-react";
+const TOPIC_INFERENCE_DEFAULTS = {
+ minScore: 4.5,
+ confidenceRatio: 0.55,
+ maxTopics: 3,
+} as const;
+
+function getNumericConfigValue(
+ row: { value: unknown } | null | undefined,
+ fallback: number,
+) {
+ return typeof row?.value === "number" && Number.isFinite(row.value)
+ ? row.value
+ : fallback;
+}
+
function formatReadDuration(
t: ReturnType,
seconds?: number,
@@ -130,20 +145,50 @@ function AuthorizedDashboard({
api.clustering.getRecentTopicInferenceDiagnosticsForAdmin,
isAdmin ? { limit: 10 } : "skip",
);
- const setConfig = useConvexMutationHook(api.config.set);
+ const minScoreConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_min_score" } : "skip",
+ );
+ const confidenceRatioConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_confidence_ratio" } : "skip",
+ );
+ const maxTopicsConfig = useQuery(
+ api.config.get,
+ isAdmin ? { key: "topic_inference_max_topics" } : "skip",
+ );
+ const setTopicInferenceSettings = useConvexMutationHook(
+ api.config.setTopicInferenceSettings,
+ );
const [minScoreInput, setMinScoreInput] = useState("");
const [confidenceRatioInput, setConfidenceRatioInput] = useState("");
const [maxTopicsInput, setMaxTopicsInput] = useState("");
const [configMessage, setConfigMessage] = useState("");
const [isSavingConfig, setIsSavingConfig] = useState(false);
- const currentSettings = topicDiagnostics?.[0]?.settings;
+ const currentSettings = {
+ minScore: getNumericConfigValue(
+ minScoreConfig,
+ TOPIC_INFERENCE_DEFAULTS.minScore,
+ ),
+ confidenceRatio: getNumericConfigValue(
+ confidenceRatioConfig,
+ TOPIC_INFERENCE_DEFAULTS.confidenceRatio,
+ ),
+ maxTopics: getNumericConfigValue(
+ maxTopicsConfig,
+ TOPIC_INFERENCE_DEFAULTS.maxTopics,
+ ),
+ };
useEffect(() => {
- if (!currentSettings) return;
setMinScoreInput(String(currentSettings.minScore));
setConfidenceRatioInput(String(currentSettings.confidenceRatio));
setMaxTopicsInput(String(currentSettings.maxTopics));
- }, [currentSettings]);
+ }, [
+ currentSettings.minScore,
+ currentSettings.confidenceRatio,
+ currentSettings.maxTopics,
+ ]);
if (dashboardOverview === undefined || isAdmin === undefined) {
return (
@@ -156,13 +201,11 @@ function AuthorizedDashboard({
}
const hasConfigChanges =
- !!currentSettings &&
- (minScoreInput !== String(currentSettings.minScore) ||
+ minScoreInput !== String(currentSettings.minScore) ||
confidenceRatioInput !== String(currentSettings.confidenceRatio) ||
- maxTopicsInput !== String(currentSettings.maxTopics));
+ maxTopicsInput !== String(currentSettings.maxTopics);
const handleResetConfig = () => {
- if (!currentSettings) return;
setMinScoreInput(String(currentSettings.minScore));
setConfidenceRatioInput(String(currentSettings.confidenceRatio));
setMaxTopicsInput(String(currentSettings.maxTopics));
@@ -171,7 +214,7 @@ function AuthorizedDashboard({
const handleSaveConfig = async (event: FormEvent) => {
event.preventDefault();
- if (!currentSettings || isSavingConfig) return;
+ if (isSavingConfig) return;
const minScore = Number(minScoreInput);
const confidenceRatio = Number(confidenceRatioInput);
@@ -198,26 +241,11 @@ function AuthorizedDashboard({
setConfigMessage("");
try {
- await Promise.all([
- setConfig({
- key: "topic_inference_min_score",
- value: JSON.stringify(minScore),
- description:
- "Minimum weighted lexical score required before a topic is attached to a clustered event.",
- }),
- setConfig({
- key: "topic_inference_confidence_ratio",
- value: JSON.stringify(confidenceRatio),
- description:
- "Relative score threshold for keeping additional inferred topics alongside the top-scoring topic.",
- }),
- setConfig({
- key: "topic_inference_max_topics",
- value: JSON.stringify(maxTopics),
- description:
- "Maximum number of inferred topics attached to an event during clustering.",
- }),
- ]);
+ await setTopicInferenceSettings({
+ minScore,
+ confidenceRatio,
+ maxTopics,
+ });
setConfigMessage(t("activity.admin.saved"));
} catch (error) {
console.error("Failed to save topic inference settings:", error);
@@ -619,7 +647,7 @@ function AuthorizedDashboard({
{/* Admin: Topic Diagnostics */}
- {isAdmin && topicDiagnostics && topicDiagnostics.length > 0 && (
+ {isAdmin && (
@@ -633,105 +661,104 @@ function AuthorizedDashboard({
- {currentSettings && (
-