diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts index ec5cc1c..f08da33 100644 --- a/src/lib/weekly-report.ts +++ b/src/lib/weekly-report.ts @@ -1,11 +1,18 @@ -import { listPublishedBetween } from '~/data/repositories/archive' +import { listPublishedBetween, loadPostCovers, type PostCover } from '~/data/repositories/archive' import { entryMark } from '~/components/ui/Plate' -import { postPath } from './routes' +import { mediaUrl, mediaVariant } from './media-url' +import { postPath, projectPath } from './routes' import type { PostListItem } from '~/data/repositories/posts' import type { ViewerScope } from '~/domain/types' export const reportDays = 7 +export const cardsPerProject = 3 + +export const maxCards = 9 + +export const teaserLength = 220 + export type ReportProject = { slug: string name: string @@ -54,44 +61,130 @@ export function groupByProject(items: PostListItem[]): ReportProject[] { }) } + for (const project of projects.values()) { + project.items.sort((left, right) => (right.number ?? 0) - (left.number ?? 0)) + } + return [...projects.values()] } -function line(item: PostListItem, origin: string): string { - const mark = entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number) - const url = `${origin}${postPath(item.projectSlug, item.slug)}` +export function shorten(text: string, limit = teaserLength): string { + const clean = text.replace(/\s+/gu, ' ').trim() - return `\`${mark}\` ${item.type.labelDe}: <${url}|${item.title}>` + if (clean.length <= limit) { + return clean + } + + const cut = clean.slice(0, limit) + const boundary = cut.lastIndexOf(' ') + + return `${cut.slice(0, boundary > limit * 0.6 ? boundary : limit).trimEnd()}...` } -export function buildMessage(report: Omit, origin: string): SlackMessage | null { +function count(total: number, one: string, many: string): string { + return total === 1 ? `1 ${one}` : `${total} ${many}` +} + +function image(cover: PostCover | undefined, origin: string, alt: string): unknown | undefined { + const variant = cover ? mediaVariant({ variants: cover.variants }, 480) : undefined + + if (!variant) { + return undefined + } + + return { + type: 'image', + image_url: `${origin}${mediaUrl(variant.path)}`, + alt_text: cover?.alt?.trim() || alt, + } +} + +function card(item: PostListItem, origin: string, cover: PostCover | undefined): unknown { + const url = `${origin}${postPath(item.projectSlug, item.slug)}` + const mark = entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number) + const lines = [`*<${url}|${item.title}>*`, `\`${mark}\` ${item.type.labelDe}`] + + if (item.teaser) { + lines.splice(1, 0, shorten(item.teaser)) + } + + const accessory = image(cover, origin, item.title) + + return { + type: 'section', + text: { type: 'mrkdwn', text: lines.join('\n\n') }, + ...(accessory ? { accessory } : {}), + } +} + +function compact(items: PostListItem[], origin: string): unknown { + return { + type: 'section', + text: { + type: 'mrkdwn', + text: items + .map(item => { + const url = `${origin}${postPath(item.projectSlug, item.slug)}` + const mark = entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number) + + return `\`${mark}\` <${url}|${item.title}>` + }) + .join('\n'), + }, + } +} + +export function buildMessage( + report: Omit, + origin: string, + covers: Map = new Map(), +): SlackMessage | null { if (report.total === 0) { return null } const period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}` - const count = report.total === 1 ? '1 Eintrag' : `${report.total} Einträge` - const text = `Logbuch, ${period}: ${count}` + const entries = count(report.total, 'Eintrag', 'Einträge') + const text = `Logbuch, ${period}: ${entries}` const blocks: unknown[] = [ { type: 'header', text: { type: 'plain_text', text: 'Logbuch' } }, { type: 'context', - elements: [{ type: 'mrkdwn', text: `${period} · ${count}` }], + elements: [{ + type: 'mrkdwn', + text: `${period} · ${entries} in ${count(report.projects.length, 'Projekt', 'Projekten')}`, + }], }, ] + 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) + + budget -= cards.length + blocks.push({ type: 'divider' }) blocks.push({ type: 'section', text: { type: 'mrkdwn', - text: [`*${project.name}*`, ...project.items.map(item => line(item, origin))].join('\n'), + text: `*<${origin}${projectPath(project.slug)}|${project.name}>* · ${count(project.items.length, 'Eintrag', 'Einträge')}`, }, }) + + for (const item of cards) { + blocks.push(card(item, origin, covers.get(item.id))) + } + + if (rest.length > 0) { + blocks.push(compact(rest, origin)) + } } + blocks.push({ type: 'divider' }) blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: `<${origin}|Alles im Logbuch nachlesen>` }], @@ -110,6 +203,8 @@ export async function buildWeeklyReport(args: { 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) - return { ...base, message: buildMessage(base, args.origin) } + return { ...base, message: buildMessage(base, args.origin, covers) } } diff --git a/tests/lib/weekly-report.test.ts b/tests/lib/weekly-report.test.ts index 376ce8b..4cd19ec 100644 --- a/tests/lib/weekly-report.test.ts +++ b/tests/lib/weekly-report.test.ts @@ -65,8 +65,8 @@ describe('buildMessage', () => { const dump = JSON.stringify(message?.blocks) expect(dump).toContain('27.07.2026 bis 03.08.2026') - expect(dump).toContain('*Trakk*') - expect(dump).toContain('*Orbit*') + expect(dump).toContain('|Trakk>*') + expect(dump).toContain('|Orbit>*') expect(dump).toContain('TRK-0142') expect(dump).toContain('https://logbuch.nyo.de/trakk/regel-engine') }) @@ -83,3 +83,53 @@ describe('buildMessage', () => { expect(JSON.stringify(message)).not.toMatch(/\p{Extended_Pictographic}/u) }) }) + +describe('Karten', () => { + const base = { + from: new Date('2026-07-27T06:00:00.000Z'), + to: new Date('2026-08-03T06:00:00.000Z'), + } + + it('setzt Anreißer und Bild an den Eintrag', () => { + const cover = new Map([['c0ffee00-0000-4000-8000-000000000001', { + postId: 'c0ffee00-0000-4000-8000-000000000001', + mediaId: 'm1', + alt: 'Regelmaske mit Bedingung und Aktion', + caption: null, + width: 1600, + height: 900, + variants: [{ width: 480, format: 'webp', path: 'trakk/m1/480.webp' }], + }]]) + + const projects = groupByProject([item({ teaser: 'Tickets reagieren jetzt selbst.' })]) + const message = buildMessage({ ...base, total: 1, projects }, origin, cover as never) + const dump = JSON.stringify(message?.blocks) + + expect(dump).toContain('Tickets reagieren jetzt selbst.') + expect(dump).toContain('https://logbuch.nyo.de/api/v1/media/file/trakk/m1/480.webp') + expect(dump).toContain('Regelmaske mit Bedingung und Aktion') + }) + + it('kürzt lange Anreißer', () => { + const long = `${'Wort '.repeat(80)}Ende` + const projects = groupByProject([item({ teaser: long })]) + const dump = JSON.stringify(buildMessage({ ...base, total: 1, projects }, origin)?.blocks) + + expect(dump).toContain('...') + expect(dump).not.toContain('Ende') + }) + + it('zeigt höchstens drei Karten je Projekt, der Rest kommt als Liste', () => { + const many = Array.from({ length: 5 }, (_, index) => item({ + id: `c0ffee00-0000-4000-8000-00000000000${index}`, + slug: `beitrag-${index}`, + number: 140 + index, + title: `Beitrag ${index}`, + })) + + const message = buildMessage({ ...base, total: 5, projects: groupByProject(many) }, origin) + const sections = (message?.blocks ?? []).filter(block => (block as { type: string }).type === 'section') + + expect(sections).toHaveLength(5) + }) +})