Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions src/lib/front-page-rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null, { n: number; score: number; at: number }>();
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<T>(list: T[], test: (item: T) => boolean, within = list.length) {
const index = list.slice(0, within).findIndex(test);
Expand All @@ -140,6 +169,7 @@ function takePreferring<T>(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.
Expand All @@ -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,
};
}
9 changes: 7 additions & 2 deletions src/lib/front-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
Expand Down
15 changes: 15 additions & 0 deletions tests/front-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down Expand Up @@ -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();
Expand Down
Loading