From 1d819d30ba1abe73603bf5d2b999e96f1a3739e4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 11:55:59 +1100 Subject: [PATCH] feat(weekly): draw the braille spark on email trending rows DESIGN.md says the braille spark marks the email's trending section, as it does on the board since #431. The email still showed counts only, so a reader could not tell a burst of posts on one day from a steady week. --- layers/identity/server/utils/weekly-select.ts | 6 +- .../identity/server/utils/weekly-template.ts | 87 ++++++++++++++++--- server/routes/_dev/weekly.get.ts | 17 ++-- test/unit/weekly-template.test.ts | 49 +++++++++++ 4 files changed, 136 insertions(+), 23 deletions(-) diff --git a/layers/identity/server/utils/weekly-select.ts b/layers/identity/server/utils/weekly-select.ts index ea209979..d14f5991 100644 --- a/layers/identity/server/utils/weekly-select.ts +++ b/layers/identity/server/utils/weekly-select.ts @@ -206,6 +206,7 @@ function reasonFor(entry: { attribution: 'social' | 'github' | 'both' social: { authorCount: number, mentionCount: number, latestMentionAt: number } | null github: { latestGain: number, observedDay: number } | null + mentionsByDay: number[] | null }): WeeklyReason { const authorCount = entry.social?.authorCount ?? 0 const mentionCount = entry.social?.mentionCount ?? 0 @@ -214,10 +215,11 @@ function reasonFor(entry: { const latestAt = entry.social?.latestMentionAt ?? 0 const gain = entry.github?.latestGain ?? 0 const day = entry.github?.observedDay ?? 0 + const mentionsByDay = entry.mentionsByDay if (entry.attribution === 'both' && entry.social && entry.github) - return { _tag: 'named-and-stars', authorCount, mentionCount, latestAt, gain, day } + return { _tag: 'named-and-stars', authorCount, mentionCount, latestAt, mentionsByDay, gain, day } if (entry.attribution === 'github' && entry.github) return { _tag: 'stars', gain, day } - return { _tag: 'named', authorCount, mentionCount, latestAt } + return { _tag: 'named', authorCount, mentionCount, latestAt, mentionsByDay } } diff --git a/layers/identity/server/utils/weekly-template.ts b/layers/identity/server/utils/weekly-template.ts index 985fdc16..54b240cb 100644 --- a/layers/identity/server/utils/weekly-template.ts +++ b/layers/identity/server/utils/weekly-template.ts @@ -12,6 +12,7 @@ */ import type { EmailPlacement } from './email-clicks' +import { brailleSpark, SPARK_BARS, sparkCount, sparkLevels, sparkWeek } from '#shared/braille-spark' import { githubAvatarProxyUrl } from '#shared/image-proxy' import { canonicalRepoSkillPath } from '#shared/skill-routes' import { countedEmailUrl } from './email-clicks' @@ -77,6 +78,12 @@ const PALETTE: Record = { const SANS = `'Plus Jakarta Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif` const MONO = `'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace` +/** + * Faces that carry braille, one per platform: Apple Mail and iOS, Windows, + * Android, then Linux. IBM Plex Mono has none, and a mail client rarely loads + * it anyway. + */ +const BRAILLE = `'Apple Braille','Segoe UI Symbol','Noto Sans Symbols 2','DejaVu Sans Mono',ui-monospace,monospace` /** * Why a skill is on the trending list, as one value rather than a bag of @@ -90,17 +97,25 @@ const MONO = `'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospa * reason. */ export type WeeklyReason - = { _tag: 'named', authorCount: number, mentionCount: number, latestAt: number } - | { _tag: 'stars', gain: number, day: number } - | { - _tag: 'named-and-stars' - authorCount: number - mentionCount: number - latestAt: number - gain: number - day: number - } - | { _tag: 'popular', stars: number } + = { + _tag: 'named' + authorCount: number + mentionCount: number + latestAt: number + /** Counted mentions per rolling day, oldest first, for the braille spark. */ + mentionsByDay: number[] | null + } + | { _tag: 'stars', gain: number, day: number } + | { + _tag: 'named-and-stars' + authorCount: number + mentionCount: number + latestAt: number + mentionsByDay: number[] | null + gain: number + day: number + } + | { _tag: 'popular', stars: number } export interface WeeklyEvidence { url: string @@ -307,6 +322,43 @@ function trendingMeta(skill: WeeklyTrendingSkill, now: number): string { return joinMeta([`${skill.owner}/${skill.repo}`, reasonLine(skill.reason, now), stars]) } +/** + * The seven days a row's spark draws, or null when it draws none. + * + * Only a row somebody posted about has days to draw. A week of zeros means + * every counted post is older than the spark, and seven blank cells beside + * "0 mentions" say nothing, so that row draws none, as on the board. + */ +function sparkDays(reason: WeeklyReason): number[] | null { + if (reason._tag !== 'named' && reason._tag !== 'named-and-stars') + return null + const week = sparkWeek(reason.mentionsByDay ?? []) + return week.some(count => count > 0) ? week : null +} + +function sparkTotal(days: readonly number[]): string { + return sparkCount(days.reduce((sum, count) => sum + count, 0)) +} + +/** + * The braille spark for the HTML half, in stone. + * + * The site draws today in rose. The email spends its rose on the button and + * the footer rule, so seven rose cells would break the one-rose budget. A day + * with no count draws the lowest bar faintly as a track, as the site does, so + * a lone bar still reads as one day of seven. + * + * The label carries the numbers and the bars are hidden from screen readers, + * because a braille cell read aloud is noise. + */ +function sparkHtml(t: Tokens, days: readonly number[]): string { + const label = `${sparkTotal(days)} in 7 days. Per day, oldest first: ${days.join(', ')}.` + const bars = sparkLevels(days) + .map(level => level === 0 ? `${SPARK_BARS[1]}` : SPARK_BARS[level]) + .join('') + return ` ` +} + /** * A skill description cut to something a person reads in one glance. * @@ -449,6 +501,8 @@ function trendingRow(t: Tokens, options: { description: string | null meta: string evidence: WeeklyEvidence | null + /** Seven days of mentions, or null for a row nobody posted about. */ + spark: number[] | null }): string { const description = trimDescription(options.description) const evidence = options.evidence @@ -463,7 +517,12 @@ function trendingRow(t: Tokens, options: { style="width:28px;height:28px;border-radius:14px;display:block;border:1px solid ${t.border};background:${t.quote};" /> - ${esc(options.title)} + + + + ${options.spark ? `` : ''} + +
${esc(options.title)}${sparkHtml(t, options.spark)}
${description ? `
${esc(description)}
` : ''}
${esc(options.meta)}${evidence}
@@ -715,6 +774,7 @@ export function renderWeekly(input: WeeklyRenderInput): WeeklyRender { description: skill.description, meta: trendingMeta(skill, now), evidence: skill.evidence, + spark: sparkDays(skill.reason), }) }).join('') @@ -843,7 +903,8 @@ export function renderWeeklyText(input: WeeklyRenderInput): string { if (input.trending.length) { lines.push('', 'TRENDING THIS WEEK', '') for (const skill of input.trending) { - lines.push(`- ${skill.owner}/${skill.repo} ${skill.canonicalName}`) + const spark = sparkDays(skill.reason) + lines.push(`- ${skill.owner}/${skill.repo} ${skill.canonicalName}${spark ? ` ${brailleSpark(spark)} ${sparkTotal(spark)}` : ''}`) const description = trimDescription(skill.description) if (description) lines.push(` ${description}`) diff --git a/server/routes/_dev/weekly.get.ts b/server/routes/_dev/weekly.get.ts index a09f7335..bc767d76 100644 --- a/server/routes/_dev/weekly.get.ts +++ b/server/routes/_dev/weekly.get.ts @@ -101,7 +101,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'ship', description: 'Take a change from branch to merged without babysitting it.', stars: 12_400, - reason: { _tag: 'named', authorCount: 4, mentionCount: 9, latestAt: WINDOW_END - 2 * 86_400 }, + reason: { _tag: 'named', authorCount: 4, mentionCount: 9, latestAt: WINDOW_END - 2 * 86_400, mentionsByDay: [0, 1, 1, 0, 2, 5, 0] }, evidence: { url: 'https://x.com/garrytan/status/1', authorHandle: 'garrytan', @@ -116,7 +116,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'swiftui-liquid-glass', description: 'Build the new material effects without fighting the layout system.', stars: 865, - reason: { _tag: 'named-and-stars', authorCount: 2, mentionCount: 3, latestAt: WINDOW_END - 86_400, gain: 412, day: WINDOW_END - 2 * 86_400 }, + reason: { _tag: 'named-and-stars', authorCount: 2, mentionCount: 3, latestAt: WINDOW_END - 86_400, mentionsByDay: [0, 0, 0, 0, 1, 2, 0], gain: 412, day: WINDOW_END - 2 * 86_400 }, evidence: { url: 'https://bsky.app/profile/dimillian/post/1', authorHandle: 'dimillian.bsky.social', @@ -141,7 +141,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'vue-debug-guides', description: null, stars: 210, - reason: { _tag: 'named', authorCount: 1, mentionCount: 1, latestAt: WINDOW_END - 5 * 86_400 }, + reason: { _tag: 'named', authorCount: 1, mentionCount: 1, latestAt: WINDOW_END - 5 * 86_400, mentionsByDay: [0, 1, 0, 0, 0, 0, 0] }, evidence: null, }, { @@ -161,7 +161,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'web-interface-guidelines', description: 'Review interface code against practical usability rules.', stars: 19_200, - reason: { _tag: 'named', authorCount: 3, mentionCount: 4, latestAt: WINDOW_END - 3 * 86_400 }, + reason: { _tag: 'named', authorCount: 3, mentionCount: 4, latestAt: WINDOW_END - 3 * 86_400, mentionsByDay: [1, 0, 0, 2, 1, 0, 0] }, evidence: null, }, { @@ -214,7 +214,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'nuxt-seo', description: null, stars: null, - reason: { _tag: 'named', authorCount: 1, mentionCount: 1, latestAt: 0 }, + reason: { _tag: 'named', authorCount: 1, mentionCount: 1, latestAt: 0, mentionsByDay: null }, evidence: null, }], }), @@ -254,7 +254,7 @@ const SCENARIOS: Record WeeklyRenderInput> = { canonicalName: 'a-skill-with-an-unusually-long-canonical-name', description: 'Unicode and entities: quotes "like this", ampersands & angle brackets
, emoji dashes, and accented names such as Renée Müller.', stars: 1_284_000, - reason: { _tag: 'named-and-stars', authorCount: 17, mentionCount: 42, latestAt: WINDOW_END - 3600, gain: 9_814, day: WINDOW_END - 3 * 86_400 }, + reason: { _tag: 'named-and-stars', authorCount: 17, mentionCount: 42, latestAt: WINDOW_END - 3600, mentionsByDay: [2, 3, 5, 4, 8, 9, 11], gain: 9_814, day: WINDOW_END - 3 * 86_400 }, evidence: { url: 'https://x.com/someone/status/2', authorHandle: 'a_very_long_handle_indeed', @@ -287,6 +287,7 @@ export async function liveScenario(): Promise { starGain: number | null starGainDay: number | null evidence: { url: string, authorHandle: string, text: string, platform: 'x' | 'bsky' } | null + mentionsByDay: number[] | null }> }>('https://skilld.dev/api/feed/trending', { query: { limit: MAX_TRENDING, window: 24 * 7 } }) @@ -303,10 +304,10 @@ export async function liveScenario(): Promise { description: skill.description, stars: skill.stars, reason: skill.attribution === 'both' && skill.starGain !== null && skill.starGainDay !== null - ? { _tag: 'named-and-stars' as const, authorCount: skill.authorCount, mentionCount: skill.mentionCount, latestAt: 0, gain: skill.starGain, day: skill.starGainDay } + ? { _tag: 'named-and-stars' as const, authorCount: skill.authorCount, mentionCount: skill.mentionCount, latestAt: 0, mentionsByDay: skill.mentionsByDay, gain: skill.starGain, day: skill.starGainDay } : skill.attribution === 'github' && skill.starGain !== null && skill.starGainDay !== null ? { _tag: 'stars' as const, gain: skill.starGain, day: skill.starGainDay } - : { _tag: 'named' as const, authorCount: skill.authorCount, mentionCount: skill.mentionCount, latestAt: 0 }, + : { _tag: 'named' as const, authorCount: skill.authorCount, mentionCount: skill.mentionCount, latestAt: 0, mentionsByDay: skill.mentionsByDay }, evidence: skill.evidence, })), } diff --git a/test/unit/weekly-template.test.ts b/test/unit/weekly-template.test.ts index 45501e6e..8e04dcfd 100644 --- a/test/unit/weekly-template.test.ts +++ b/test/unit/weekly-template.test.ts @@ -1,6 +1,7 @@ import type { WeeklyRenderInput, WeeklyTrendingSkill } from '../../layers/identity/server/utils/weekly-template' import { describe, expect, it } from 'vitest' import { formatWindow, renderWeekly } from '../../layers/identity/server/utils/weekly-template' +import { SPARK_BARS } from '../../shared/braille-spark' const WINDOW_END = 1_755_648_000 const WINDOW_START = WINDOW_END - 7 * 86_400 @@ -589,3 +590,51 @@ describe('weekly theme', () => { expect(html).not.toContain('\u{1F525}') }) }) + +describe('weekly trending spark', () => { + // Every glyph a spark can print, the blank cell included. + const BRAILLE = new RegExp(`[${SPARK_BARS.join('')}]`) + const week = [0, 1, 0, 2, 0, 3, 6] + + it('draws a week of mentions as a braille spark beside its count', () => { + const { html, text } = renderWeekly(input({ + trending: [trending({ reason: { _tag: 'named', authorCount: 3, mentionCount: 12, latestAt: WINDOW_END, mentionsByDay: week } })], + })) + + expect(text).toContain('⠀⣀⠀⣀⠀⣤⣿ 12 mentions') + expect(html).toContain('aria-label="12 mentions in 7 days. Per day, oldest first: 0, 1, 0, 2, 0, 3, 6."') + expect(html).toMatch(BRAILLE) + }) + + it('draws the spark on a row that was named and surged', () => { + const { text } = renderWeekly(input({ + trending: [trending({ + reason: { _tag: 'named-and-stars', authorCount: 1, mentionCount: 1, latestAt: WINDOW_END, gain: 40, day: WINDOW_END, mentionsByDay: [0, 0, 0, 0, 0, 0, 1] }, + })], + })) + + expect(text).toContain('⠀⠀⠀⠀⠀⠀⣿ 1 mention') + }) + + it.each([ + { name: 'a star surge', reason: { _tag: 'stars' as const, gain: 865, day: WINDOW_END } }, + { name: 'a popular filler row', reason: { _tag: 'popular' as const, stars: 46_712 } }, + { name: 'a named row with no counted days', reason: { _tag: 'named' as const, authorCount: 1, mentionCount: 1, latestAt: 0, mentionsByDay: null } }, + { name: 'a named row whose mentions are older than the spark', reason: { _tag: 'named' as const, authorCount: 1, mentionCount: 1, latestAt: 0, mentionsByDay: [0, 0, 0, 0, 0, 0, 0] } }, + ])('draws no spark for $name', ({ reason }) => { + const { html, text } = renderWeekly(input({ trending: [trending({ reason })] })) + + expect(html).not.toMatch(BRAILLE) + expect(text).not.toMatch(BRAILLE) + }) + + it('keeps the spark out of the rose budget', () => { + const { card } = renderWeekly(input({ + edition: 'weekly', + trending: [trending({ reason: { _tag: 'named', authorCount: 3, mentionCount: 12, latestAt: WINDOW_END, mentionsByDay: week } })], + })) + const plain = renderWeekly(input({ edition: 'weekly', trending: [trending()] })).card + + expect(card.match(/#e11d48|#be123c|#fb7185/gi)?.length ?? 0).toBe(plain.match(/#e11d48|#be123c|#fb7185/gi)?.length ?? 0) + }) +})