From 17e4ffa2d12643dcf2f9c40a23bf6b825bf9b847 Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:30:47 -0300 Subject: [PATCH] =?UTF-8?q?Impede=20que=20data=20futura,=20pasta=20errada?= =?UTF-8?q?=20ou=20contagem=20de=20linhas=20distor=C3=A7am=20a=20capa.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capa ignora publicações muito à frente, escolhe a seção pela maioria das cópias e conta histórias. Co-authored-by: Cursor --- src/lib/front-page-rank.ts | 34 ++++++++++++++++++++++++++++++++-- src/lib/front-page.ts | 9 +++++++-- tests/front-page.test.ts | 15 +++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/lib/front-page-rank.ts b/src/lib/front-page-rank.ts index b62bd3f..7cfb661 100644 --- a/src/lib/front-page-rank.ts +++ b/src/lib/front-page-rank.ts @@ -125,13 +125,42 @@ export function buildStories(rows: FrontRow[], now: Date): FrontStory[] { related, sourceCount: seenFeeds.size, updatedAt: new Date(Math.max(...group.map((r) => r.publishedAt.getTime()))), - folderId: lead.folderId, + folderId: sectionFolder(group), score: storyScore(group, now), }); } return stories.sort((a, b) => b.score - a.score || +b.updatedAt - +a.updatedAt || (a.lead.id < b.lead.id ? -1 : 1)); } +/** Pasta da história: a da maioria das cópias. Empate fica com a nota da IA e a data, não com a foto. */ +function sectionFolder(group: FrontRow[]): string | null { + const buckets = new Map(); + for (const row of group) { + const cur = buckets.get(row.folderId) ?? { n: 0, score: -1, at: 0 }; + cur.n += 1; + const score = row.priorityScore ?? -1; + const at = row.publishedAt.getTime(); + if (score > cur.score || (score === cur.score && at > cur.at)) { + cur.score = score; + cur.at = at; + } + buckets.set(row.folderId, cur); + } + let best: string | null = null; + let bestN = -1; + let bestScore = -1; + let bestAt = -1; + for (const [folderId, cur] of buckets) { + if (cur.n > bestN || (cur.n === bestN && (cur.score > bestScore || (cur.score === bestScore && cur.at > bestAt)))) { + best = folderId; + bestN = cur.n; + bestScore = cur.score; + bestAt = cur.at; + } + } + return best; +} + /** Tira do array (in place) o primeiro item que satisfaz `test`, ou o primeiro de todos. */ function takePreferring(list: T[], test: (item: T) => boolean, within = list.length) { const index = list.slice(0, within).findIndex(test); @@ -140,6 +169,7 @@ function takePreferring(list: T[], test: (item: T) => boolean, within = list. export function rankStories(rows: FrontRow[], folders: { id: string; name: string }[], now: Date): FrontPage { const pool = buildStories(rows, now); + const storyCount = pool.length; const withPhoto = (s: FrontStory) => hasImage(s.lead) || !s.lead.imageChecked; // Manchete: a mais importante, preferindo uma com foto entre as três primeiras. @@ -164,7 +194,7 @@ export function rankStories(rows: FrontRow[], folders: { id: string; name: strin highlights, strip, sections, - articleCount: rows.length, + articleCount: storyCount, sourceCount: new Set(rows.map((r) => r.feed.id)).size, }; } diff --git a/src/lib/front-page.ts b/src/lib/front-page.ts index 0d73d15..a2b2620 100644 --- a/src/lib/front-page.ts +++ b/src/lib/front-page.ts @@ -7,6 +7,8 @@ export type { FrontArticle, FrontStory, FrontSection, FrontPage } from "@/lib/fr /** Janela da capa: o noticiário das últimas horas, como num portal. */ const WINDOW_MS = 72 * 3600_000; +/** Folga de relógio. Datas mais à frente que isto (feed carimbado em 2030) não entram na capa. */ +const FUTURE_SKEW_MS = 24 * 3600_000; const MAX_ROWS = 600; /** Com pouca coisa na janela (feeds parados, instalação nova), cai para os mais recentes. */ const MIN_ROWS = 30; @@ -27,17 +29,20 @@ export async function getFrontPage(userId: string, now = new Date()) { states: { where: { userId }, select: { isRead: true, isSaved: true, isHighlighted: true, priorityScore: true } }, } as const; + const latest = new Date(now.getTime() + FUTURE_SKEW_MS); const [folders, recent] = await Promise.all([ db.folder.findMany({ where: { userId }, orderBy: [{ position: "asc" }, { name: "asc" }], select: { id: true, name: true } }), db.article.findMany({ - where: { ...subscribed, publishedAt: { gte: new Date(now.getTime() - WINDOW_MS) } }, + where: { ...subscribed, publishedAt: { gte: new Date(now.getTime() - WINDOW_MS), lte: latest } }, select, orderBy: { publishedAt: "desc" }, take: MAX_ROWS, }), ]); const rows = - recent.length >= MIN_ROWS ? recent : await db.article.findMany({ where: subscribed, select, orderBy: { publishedAt: "desc" }, take: FALLBACK_ROWS }); + recent.length >= MIN_ROWS + ? recent + : await db.article.findMany({ where: { ...subscribed, publishedAt: { lte: latest } }, select, orderBy: { publishedAt: "desc" }, take: FALLBACK_ROWS }); const flat: FrontRow[] = rows.map((a) => { const sub = a.feed.subscriptions[0]; diff --git a/tests/front-page.test.ts b/tests/front-page.test.ts index fd38ea9..f047120 100644 --- a/tests/front-page.test.ts +++ b/tests/front-page.test.ts @@ -50,6 +50,15 @@ describe("buildStories", () => { expect(buildStories([plain, photo], now)[0].lead.id).toBe(photo.id); }); + it("a seção segue a pasta da maioria, não a cópia com foto", () => { + const tech = row({ storyId: "s", feed: "a", folderId: "tec" }); + const tech2 = row({ storyId: "s", feed: "b", folderId: "tec" }); + const sports = row({ storyId: "s", feed: "c", folderId: "esp", imageUrl: "https://img/x.jpg" }); + const [story] = buildStories([tech, tech2, sports], now); + expect(story.lead.id).toBe(sports.id); + expect(story.folderId).toBe("tec"); + }); + it("rebaixa o que já foi lido", () => { const read = row({ isRead: true, publishedAt: hoursAgo(0) }); const unread = row({ publishedAt: hoursAgo(3) }); @@ -78,6 +87,12 @@ describe("rankStories", () => { expect(page.sections.map((s) => s.name)).toEqual(["Tecnologia", "Esportes", null]); }); + it("conta histórias, não cada cópia do mesmo fato", () => { + const page = rankStories([row({ storyId: "s", feed: "a" }), row({ storyId: "s", feed: "b" })], [], now); + expect(page.articleCount).toBe(1); + expect(page.sourceCount).toBe(2); + }); + it("sem notícias, não há manchete", () => { const page = rankStories([], [], now); expect(page.headline).toBeNull();