From c1cfd41ad3cdf67c131bf2793becda6f0d635a36 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Mon, 3 Aug 2026 13:24:46 +0200 Subject: [PATCH] Only send what is new, and lead with a picture The window now starts at the last dispatch, so pressing send twice does not repeat a digest. Cards prefer entries that have a cover, the newest one of them opens the message as a full width image, and the page says when the last digest went out. --- messages/de.json | 8 ++-- messages/en.json | 8 ++-- src/app/admin/settings/page.tsx | 5 +++ src/data/repositories/settings.ts | 7 +++- src/lib/report-settings.ts | 16 +++++++- src/lib/weekly-report.ts | 61 +++++++++++++++++++++++++++---- tests/lib/report-settings.test.ts | 22 +++++++++++ tests/lib/weekly-report.test.ts | 22 +++++++++++ 8 files changed, 133 insertions(+), 16 deletions(-) diff --git a/messages/de.json b/messages/de.json index d0ffa6a..1c5adfc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -727,7 +727,7 @@ "webhookFromEnv": "Kommt aus der Umgebungsvariable SLACK_WEBHOOK_URL.", "replaceWebhook": "Anderen Webhook eintragen", "schedule": "Zeitplan", - "scheduleText": "Der Lauf startet montags um 08:00 als Aufgabe in Coolify. Er veröffentlicht erst fällige Termine und schickt danach den Bericht. Nichts veröffentlicht heißt keine Nachricht.", + "scheduleText": "Der Lauf startet montags um 08:00 als Aufgabe in Coolify. Er veröffentlicht erst fällige Termine und schickt danach den Bericht.", "history": "Verlauf", "historyMeta": "{count, plural, =0 {kein Lauf} one {# Lauf} other {# Läufe}}", "historyEmpty": "Noch kein Lauf vermerkt.", @@ -755,7 +755,7 @@ "hints": { "webhook": "Aus Slack unter Incoming Webhooks. Die Adresse hängt fest an einem Kanal.", "includeInternal": "Slack ist intern, deshalb standardmäßig an. Aus heißt nur Kunden- und öffentliche Beiträge.", - "sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal.", + "sendNow": "Schickt alles, was seit dem letzten Versand erschienen ist. Ist nichts dazugekommen, geht nichts raus.", "botToken": "Aus der Slack-App unter OAuth & Permissions, beginnt mit xoxb. Mit Bot-Token lassen sich Nachrichten später wieder löschen.", "channel": "Am besten die Kanal-ID, etwa C01234567. In Slack: Kanal öffnen, auf den Namen klicken, unten steht die Kanal-ID. Ein Name wie logbuch geht auch, dafür braucht der Bot zusätzlich channels:read." }, @@ -773,7 +773,9 @@ "pathBot": "Versand über den Bot-Token. Nachrichten lassen sich hier wieder löschen.", "pathWebhook": "Versand über den Webhook. Nachrichten lassen sich danach nicht mehr löschen, dafür braucht es einen Bot-Token.", "pathNone": "Noch kein Weg hinterlegt, es geht nichts raus.", - "messageNoToken": "Ohne Bot-Token nicht löschbar" + "messageNoToken": "Ohne Bot-Token nicht löschbar", + "lastSent": "Zuletzt verschickt am {when}. Der nächste Bericht beginnt dort und enthält nur, was seitdem erschienen ist.", + "neverSent": "Noch nichts verschickt. Der erste Bericht umfasst die letzten sieben Tage." } }, "filter": { diff --git a/messages/en.json b/messages/en.json index 46ccf10..5048748 100644 --- a/messages/en.json +++ b/messages/en.json @@ -727,7 +727,7 @@ "webhookFromEnv": "Comes from the SLACK_WEBHOOK_URL variable.", "replaceWebhook": "Enter a different webhook", "schedule": "Schedule", - "scheduleText": "The run starts Mondays at 08:00 as a Coolify task. It publishes due entries first, then sends the digest. Nothing published means no message.", + "scheduleText": "The run starts Mondays at 08:00 as a Coolify task. It publishes due entries first, then sends the digest.", "history": "History", "historyMeta": "{count, plural, =0 {no run} one {# run} other {# runs}}", "historyEmpty": "No run recorded yet.", @@ -755,7 +755,7 @@ "hints": { "webhook": "From Slack under Incoming Webhooks. The address is bound to one channel.", "includeInternal": "Slack is internal, so this is on by default. Off means customer and public entries only.", - "sendNow": "Sends the last seven days into the channel right away.", + "sendNow": "Sends everything published since the last digest. Nothing new means nothing goes out.", "botToken": "From the Slack app under OAuth & Permissions, starts with xoxb. A bot token lets messages be deleted later.", "channel": "Prefer the channel id, for example C01234567. In Slack open the channel, click its name, the id sits at the bottom. A name like logbuch works too, but then the bot also needs channels:read." }, @@ -773,7 +773,9 @@ "pathBot": "Sending through the bot token. Messages can be deleted from here.", "pathWebhook": "Sending through the webhook. Messages cannot be deleted afterwards, that needs a bot token.", "pathNone": "No route stored yet, nothing goes out.", - "messageNoToken": "Not deletable without a bot token" + "messageNoToken": "Not deletable without a bot token", + "lastSent": "Last sent on {when}. The next digest starts there and only carries what appeared since.", + "neverSent": "Nothing sent yet. The first digest covers the last seven days." } }, "filter": { diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 40d48a9..c4e36ba 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -72,6 +72,11 @@ export default async function AdminSettingsPage() {
{t('schedule')}

{t('scheduleText')}

+

+ {config.lastSentAt + ? t('lastSent', { when: format.dateTime(config.lastSentAt, dateTimeFormat) }) + : t('neverSent')} +

diff --git a/src/data/repositories/settings.ts b/src/data/repositories/settings.ts index 3de727f..7543eeb 100644 --- a/src/data/repositories/settings.ts +++ b/src/data/repositories/settings.ts @@ -2,7 +2,12 @@ import { desc, eq, inArray } from 'drizzle-orm' import { db } from '../db' import { appSettings, reportRuns, type ReportRun } from '../schema' -export type SettingKey = 'slack.webhook' | 'slack.botToken' | 'slack.channel' | 'slack.includeInternal' +export type SettingKey = + | 'slack.webhook' + | 'slack.botToken' + | 'slack.channel' + | 'slack.includeInternal' + | 'slack.lastSentAt' export async function readSettings(keys: SettingKey[]): Promise> { const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys)) diff --git a/src/lib/report-settings.ts b/src/lib/report-settings.ts index 7190399..5e73d5f 100644 --- a/src/lib/report-settings.ts +++ b/src/lib/report-settings.ts @@ -1,4 +1,4 @@ -import { readSettings, recordReportRun } from '~/data/repositories/settings' +import { readSettings, recordReportRun, writeSetting } from '~/data/repositories/settings' import { sendToSlack, slackWebhook, type SlackTarget } from './slack' import { buildWeeklyReport, reportDays } from './weekly-report' import type { ReportRun } from '~/data/schema' @@ -10,6 +10,7 @@ export type SlackConfig = { hasBot: boolean channel: string includeInternal: boolean + lastSentAt: Date | undefined } export type ReportOutcome = { @@ -19,7 +20,14 @@ export type ReportOutcome = { } export async function readSlackConfig(): Promise { - const values = await readSettings(['slack.webhook', 'slack.botToken', 'slack.channel', 'slack.includeInternal']) + const values = await readSettings([ + 'slack.webhook', + 'slack.botToken', + 'slack.channel', + 'slack.includeInternal', + 'slack.lastSentAt', + ]) + const last = values.get('slack.lastSentAt') const token = values.get('slack.botToken')?.trim() const channel = values.get('slack.channel')?.trim() ?? '' const stored = values.get('slack.webhook')?.trim() @@ -38,6 +46,7 @@ export async function readSlackConfig(): Promise { hasBot: Boolean(token), channel, includeInternal: values.get('slack.includeInternal') !== 'false', + lastSentAt: last ? new Date(last) : undefined, } } @@ -56,6 +65,7 @@ export async function runWeeklyReport(args: { origin: args.origin, days: args.days ?? reportDays, now: args.now, + since: args.days === undefined ? config.lastSentAt : undefined, scope: reportScope(config), }) @@ -95,6 +105,8 @@ export async function runWeeklyReport(args: { } } + await writeSetting('slack.lastSentAt', report.to.toISOString()) + return { run: await recordReportRun({ ...base, diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts index 23e53b8..1e2dc76 100644 --- a/src/lib/weekly-report.ts +++ b/src/lib/weekly-report.ts @@ -41,9 +41,10 @@ export function reportText(args: { from: Date, to: Date, total: number }): strin return `Logbuch, ${period}: ${args.total === 1 ? '1 Eintrag' : `${args.total} Einträge`}` } -export function windowFor(now: Date, days = reportDays): { from: Date, to: Date } { +export function windowFor(now: Date, days = reportDays, since?: Date): { from: Date, to: Date } { const to = new Date(now) - const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) + const earliest = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) + const from = since && since > earliest ? new Date(since) : earliest return { from, to } } @@ -140,6 +141,42 @@ function compact(items: PostListItem[], origin: string): unknown { } } +function pickCards(items: PostListItem[], covers: Map, room: number): { + cards: PostListItem[] + rest: PostListItem[] +} { + if (room <= 0) { + return { cards: [], rest: items } + } + + const withCover = items.filter(item => covers.has(item.id)) + const cards = [...withCover.slice(0, room)] + + for (const item of items) { + if (cards.length >= room) { + break + } + + if (!cards.includes(item)) { + cards.push(item) + } + } + + const chosen = new Set(cards) + + return { cards: items.filter(item => chosen.has(item)), rest: items.filter(item => !chosen.has(item)) } +} + +function lead(item: PostListItem, origin: string, cover: PostCover): unknown { + const variant = mediaVariant({ variants: cover.variants }, 960) + + return { + type: 'image', + image_url: `${origin}${mediaUrl(variant ? variant.path : '')}`, + alt_text: cover.alt?.trim() || item.title, + } +} + export function buildMessage( report: Omit, origin: string, @@ -164,11 +201,21 @@ export function buildMessage( }, ] + const newest = report.projects + .flatMap(project => project.items) + .filter(item => covers.has(item.id)) + .sort((left, right) => (right.publishAt?.getTime() ?? 0) - (left.publishAt?.getTime() ?? 0))[0] + + const headline = newest ? covers.get(newest.id) : undefined + + if (newest && headline && mediaVariant({ variants: headline.variants }, 960)) { + blocks.push(lead(newest, origin, headline)) + } + let budget = maxCards for (const project of report.projects) { - const cards = project.items.slice(0, Math.min(cardsPerProject, Math.max(budget, 0))) - const rest = project.items.slice(cards.length) + const { cards, rest } = pickCards(project.items, covers, Math.min(cardsPerProject, Math.max(budget, 0))) budget -= cards.length @@ -203,14 +250,14 @@ export async function buildWeeklyReport(args: { origin: string now?: Date days?: number + since?: Date scope?: ViewerScope }): Promise { - const { from, to } = windowFor(args.now ?? new Date(), args.days ?? reportDays) + const { from, to } = windowFor(args.now ?? new Date(), args.days ?? reportDays, args.since) const items = await listPublishedBetween({ scope: args.scope ?? 'internal', from, to }) const projects = groupByProject(items) const base = { from, to, total: items.length, projects } - const wanted = projects.flatMap(project => project.items.slice(0, cardsPerProject)).map(item => item.id) - const covers = await loadPostCovers(wanted) + const covers = await loadPostCovers(items.map(item => item.id)) return { ...base, message: buildMessage(base, args.origin, covers) } } diff --git a/tests/lib/report-settings.test.ts b/tests/lib/report-settings.test.ts index 2ce1081..94db0bb 100644 --- a/tests/lib/report-settings.test.ts +++ b/tests/lib/report-settings.test.ts @@ -121,3 +121,25 @@ describe('Bot-Token', () => { expect(outcome.run).toMatchObject({ sent: false, detail: 'channel_not_found' }) }) }) + +describe('Doppelter Versand', () => { + it('schickt nach einem Versand nichts Zweites, solange nichts Neues erscheint', async () => { + const brand = await makeBrand() + const project = await makeProject(brand.id) + + await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() }) + await writeSetting('slack.botToken', 'xoxb-1234') + await writeSetting('slack.channel', 'C123') + + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, channel: 'C123', ts: '1785600000.000100' }), + { headers: { 'content-type': 'application/json' } }, + ))) + + const first = await runWeeklyReport({ origin, trigger: 'test' }) + const second = await runWeeklyReport({ origin, trigger: 'test' }) + + expect(first.reason).toBe('sent') + expect(second.reason).toBe('nothing_published') + }) +}) diff --git a/tests/lib/weekly-report.test.ts b/tests/lib/weekly-report.test.ts index 4cd19ec..1b306e6 100644 --- a/tests/lib/weekly-report.test.ts +++ b/tests/lib/weekly-report.test.ts @@ -133,3 +133,25 @@ describe('Karten', () => { expect(sections).toHaveLength(5) }) }) + +describe('windowFor mit letztem Versand', () => { + it('beginnt beim letzten Versand, wenn der jünger ist', () => { + const { from } = windowFor( + new Date('2026-08-03T06:00:00.000Z'), + 7, + new Date('2026-08-01T09:00:00.000Z'), + ) + + expect(from.toISOString()).toBe('2026-08-01T09:00:00.000Z') + }) + + it('bleibt bei sieben Tagen, wenn der letzte Versand älter ist', () => { + const { from } = windowFor( + new Date('2026-08-03T06:00:00.000Z'), + 7, + new Date('2026-06-01T09:00:00.000Z'), + ) + + expect(from.toISOString()).toBe('2026-07-27T06:00:00.000Z') + }) +})