Make the digest read like a newsletter
Each project leads with up to three cards: title, teaser and the cover image as a thumbnail, the rest follows as a compact list. Entries sort by number inside a project instead of jumping, and the project name links to its archive.
This commit is contained in:
+107
-12
@@ -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 { 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 { PostListItem } from '~/data/repositories/posts'
|
||||||
import type { ViewerScope } from '~/domain/types'
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
export const reportDays = 7
|
export const reportDays = 7
|
||||||
|
|
||||||
|
export const cardsPerProject = 3
|
||||||
|
|
||||||
|
export const maxCards = 9
|
||||||
|
|
||||||
|
export const teaserLength = 220
|
||||||
|
|
||||||
export type ReportProject = {
|
export type ReportProject = {
|
||||||
slug: string
|
slug: string
|
||||||
name: 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()]
|
return [...projects.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
function line(item: PostListItem, origin: string): string {
|
export function shorten(text: string, limit = teaserLength): string {
|
||||||
const mark = entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)
|
const clean = text.replace(/\s+/gu, ' ').trim()
|
||||||
const url = `${origin}${postPath(item.projectSlug, item.slug)}`
|
|
||||||
|
|
||||||
return `\`${mark}\` ${item.type.labelDe}: <${url}|${item.title}>`
|
if (clean.length <= limit) {
|
||||||
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMessage(report: Omit<WeeklyReport, 'message'>, origin: string): SlackMessage | null {
|
const cut = clean.slice(0, limit)
|
||||||
|
const boundary = cut.lastIndexOf(' ')
|
||||||
|
|
||||||
|
return `${cut.slice(0, boundary > limit * 0.6 ? boundary : limit).trimEnd()}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WeeklyReport, 'message'>,
|
||||||
|
origin: string,
|
||||||
|
covers: Map<string, PostCover> = new Map(),
|
||||||
|
): SlackMessage | null {
|
||||||
if (report.total === 0) {
|
if (report.total === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}`
|
const period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}`
|
||||||
const count = report.total === 1 ? '1 Eintrag' : `${report.total} Einträge`
|
const entries = count(report.total, 'Eintrag', 'Einträge')
|
||||||
const text = `Logbuch, ${period}: ${count}`
|
const text = `Logbuch, ${period}: ${entries}`
|
||||||
|
|
||||||
const blocks: unknown[] = [
|
const blocks: unknown[] = [
|
||||||
{ type: 'header', text: { type: 'plain_text', text: 'Logbuch' } },
|
{ type: 'header', text: { type: 'plain_text', text: 'Logbuch' } },
|
||||||
{
|
{
|
||||||
type: 'context',
|
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) {
|
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: 'divider' })
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'section',
|
type: 'section',
|
||||||
text: {
|
text: {
|
||||||
type: 'mrkdwn',
|
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({
|
blocks.push({
|
||||||
type: 'context',
|
type: 'context',
|
||||||
elements: [{ type: 'mrkdwn', text: `<${origin}|Alles im Logbuch nachlesen>` }],
|
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 items = await listPublishedBetween({ scope: args.scope ?? 'internal', from, to })
|
||||||
const projects = groupByProject(items)
|
const projects = groupByProject(items)
|
||||||
const base = { from, to, total: items.length, projects }
|
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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ describe('buildMessage', () => {
|
|||||||
const dump = JSON.stringify(message?.blocks)
|
const dump = JSON.stringify(message?.blocks)
|
||||||
|
|
||||||
expect(dump).toContain('27.07.2026 bis 03.08.2026')
|
expect(dump).toContain('27.07.2026 bis 03.08.2026')
|
||||||
expect(dump).toContain('*Trakk*')
|
expect(dump).toContain('|Trakk>*')
|
||||||
expect(dump).toContain('*Orbit*')
|
expect(dump).toContain('|Orbit>*')
|
||||||
expect(dump).toContain('TRK-0142')
|
expect(dump).toContain('TRK-0142')
|
||||||
expect(dump).toContain('https://logbuch.nyo.de/trakk/regel-engine')
|
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)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user