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.
This commit is contained in:
Matthias G
2026-08-03 13:24:46 +02:00
parent 78ff7d2c3f
commit c1cfd41ad3
8 changed files with 133 additions and 16 deletions
+5 -3
View File
@@ -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": {
+5 -3
View File
@@ -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": {
+5
View File
@@ -72,6 +72,11 @@ export default async function AdminSettingsPage() {
<section className="flex flex-col gap-4">
<SectionLabel>{t('schedule')}</SectionLabel>
<p className="m-0 max-w-3xl text-pretty text-ink-2">{t('scheduleText')}</p>
<p className="m-0 max-w-3xl text-pretty text-ink-2">
{config.lastSentAt
? t('lastSent', { when: format.dateTime(config.lastSentAt, dateTimeFormat) })
: t('neverSent')}
</p>
</section>
<section className="flex flex-col gap-4">
+6 -1
View File
@@ -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<Map<SettingKey, string>> {
const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys))
+14 -2
View File
@@ -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<SlackConfig> {
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<SlackConfig> {
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,
+54 -7
View File
@@ -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<string, PostCover>, 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<WeeklyReport, 'message'>,
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<WeeklyReport> {
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) }
}
+22
View File
@@ -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')
})
})
+22
View File
@@ -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')
})
})