No repositories have qualified yet.
@@ -773,18 +791,26 @@ const boardChunks = computed(() => [
}
/*
- * Below 64rem the invitation sits after row five, so the header's aside is
- * empty. Its wrapper still took a grid gap, 24px of nothing above the fold.
+ * The skilld install sits under the invitation on wide screens and alone under
+ * the heading on narrow ones, where the invitation waits until row five.
*/
-@media (max-width: 63.999rem) {
- .compact-page-header :deep(.compact-page-header__aside) {
- display: none;
- }
+.trending-aside {
+ display: grid;
+ gap: 1rem;
+}
+
+.trending-teach {
+ display: grid;
+ gap: 0.5rem;
+ min-inline-size: 0;
}
@media (min-width: 64rem) {
.trending-cta--aside {
display: block;
+ }
+
+ .trending-aside {
inline-size: 26rem;
}
diff --git a/server/api/feed/trending.get.ts b/server/api/feed/trending.get.ts
index 70b9073a..e4150d33 100644
--- a/server/api/feed/trending.get.ts
+++ b/server/api/feed/trending.get.ts
@@ -60,6 +60,8 @@ export interface FallbackFeedItem {
description: string | null
stars: number
starsGained: number | null
+ /** Skills in the repository. The board prints a run command only when it is 1, because the pick is arbitrary otherwise. */
+ repoSkillCount: number
/** Daily star totals across the window, oldest first, for the sparkline. */
starSeries: StarPoint[]
}
@@ -123,6 +125,12 @@ export interface TrendingSkillFeedItem {
* first. The board scrolls through them after the quoted one.
*/
morePosts: TrendingPostFeedItem[]
+ /**
+ * Counted mentions per rolling 24-hour day across the last seven, oldest
+ * first, for the braille spark. Null on a star-only skill, which has no
+ * mentions to draw.
+ */
+ mentionsByDay: number[] | null
/** Daily star totals across the window, oldest first, for the sparkline. */
starSeries: StarPoint[]
}
@@ -174,6 +182,7 @@ function toSkillItem(entry: TrendingSkill, starSeries: StarPoint[]): TrendingSki
starGainDay: entry.github?.observedDay ?? null,
evidence: entry.evidence ? toPostItem(entry.evidence) : null,
morePosts: entry.morePosts.map(toPostItem),
+ mentionsByDay: entry.mentionsByDay,
starSeries,
}
}
@@ -210,6 +219,7 @@ function toFallbackItem(entry: FallbackSkill, starSeries: StarPoint[]): Fallback
description: entry.description,
stars: entry.stars,
starsGained: entry.starsGained,
+ repoSkillCount: entry.repoSkillCount,
starSeries,
}
}
diff --git a/shared/mention-days.ts b/shared/mention-days.ts
new file mode 100644
index 00000000..d3b0a451
--- /dev/null
+++ b/shared/mention-days.ts
@@ -0,0 +1,31 @@
+/**
+ * Mentions per day for the braille spark on a trending row.
+ *
+ * Days are rolling 24-hour windows that end at the board's clock, so the last
+ * bar is the past 24 hours. Calendar days would make the last bar a partial
+ * day, and every Skill would seem to fade each morning in UTC.
+ *
+ * Imports nothing, so the weekly email and a script can load it too.
+ */
+
+const DAY = 86_400
+
+/** Days in one spark, oldest first. Matches `SPARK_DAYS` in `braille-spark.ts`. */
+export const MENTION_DAYS = 7
+
+/**
+ * Count timestamps into seven rolling days, oldest first, the last 24 hours last.
+ *
+ * A timestamp after `now` counts as the last day: a post cannot come from the
+ * future, so it is clock skew between the ingest and the board. A timestamp
+ * older than seven days counts nowhere.
+ */
+export function mentionsByDay(postedAt: readonly number[], now: number): number[] {
+ const days = Array.from({ length: MENTION_DAYS }).fill(0)
+ for (const at of postedAt) {
+ const ago = Math.max(0, Math.floor((now - at) / DAY))
+ if (ago < MENTION_DAYS)
+ days[MENTION_DAYS - 1 - ago]! += 1
+ }
+ return days
+}
diff --git a/shared/server/trending-skills.ts b/shared/server/trending-skills.ts
index 9126aaee..f3c3199e 100644
--- a/shared/server/trending-skills.ts
+++ b/shared/server/trending-skills.ts
@@ -21,6 +21,7 @@
*/
import type { SkillTrendInput, SkillTrendScore } from '#shared/trending-skill-score'
+import { mentionsByDay } from '#shared/mention-days'
import { canonicalRepoSkillPath } from '#shared/skill-routes'
import { rankSkillTrends } from '#shared/trending-skill-score'
@@ -31,6 +32,12 @@ export interface TrendingSkill extends SkillTrendScore {
* Dedicated posts first, then by likes, at most {@link MAX_MORE_POSTS}.
*/
morePosts: TrendingSkillEvidence[]
+ /**
+ * Counted mentions per rolling day across the last seven, oldest first, for
+ * the braille spark. Null when no post put the skill here: a star surge has
+ * no mentions to draw.
+ */
+ mentionsByDay: number[] | null
/** Current stars on the skill's repository. Display only, never ranked. */
stars: number | null
/** The skill's own description, from its SKILL.md frontmatter. */
@@ -206,6 +213,7 @@ interface SocialEntry {
input: SkillTrendInput
evidence: TrendingSkillEvidence
morePosts: TrendingSkillEvidence[]
+ mentionsByDay: number[]
}
async function loadSocialEvidence(
@@ -250,6 +258,8 @@ async function loadSocialEvidence(
evidenceBreadth: number
authors: Map
countedPosts: Set
+ /** When each counted post went out, for the per-day spark. */
+ countedAt: number[]
/** Each author's narrowest post about the skill, for the posts beyond the quote. */
postsByAuthor: Map
}>()
@@ -276,6 +286,7 @@ async function loadSocialEvidence(
evidenceBreadth: breadthOf(row.post_id),
authors: new Map(),
countedPosts: new Set(),
+ countedAt: [],
postsByAuthor: new Map(),
}
grouped.set(key, entry)
@@ -300,6 +311,7 @@ async function loadSocialEvidence(
if (entry.countedPosts.has(row.post_id))
continue
entry.countedPosts.add(row.post_id)
+ entry.countedAt.push(row.posted_at)
const social = entry.input.social!
social.mentionCount += 1
@@ -324,7 +336,7 @@ async function loadSocialEvidence(
.sort((a, b) => a.breadth - b.breadth)
.slice(0, MAX_MORE_POSTS)
.map(held => held.post)
- out.set(key, { input: entry.input, evidence: entry.evidence, morePosts })
+ out.set(key, { input: entry.input, evidence: entry.evidence, morePosts, mentionsByDay: mentionsByDay(entry.countedAt, options.now) })
}
return out
}
@@ -411,6 +423,7 @@ export async function loadTrendingSkills(
input: SkillTrendInput
evidence: TrendingSkillEvidence | null
morePosts: TrendingSkillEvidence[]
+ mentionsByDay: number[] | null
}>()
for (const [key, entry] of social)
@@ -421,7 +434,7 @@ export async function loadTrendingSkills(
if (held)
held.input.github = input.github
else
- merged.set(key, { input, evidence: null, morePosts: [] })
+ merged.set(key, { input, evidence: null, morePosts: [], mentionsByDay: null })
}
const deprioritized = options.deprioritizeRepositories ?? new Set()
@@ -441,6 +454,7 @@ export async function loadTrendingSkills(
...scored,
evidence: merged.get(skillKey(scored))?.evidence ?? null,
morePosts: merged.get(skillKey(scored))?.morePosts ?? [],
+ mentionsByDay: merged.get(skillKey(scored))?.mentionsByDay ?? null,
stars: stars.get(`${scored.owner}/${scored.repo}`) ?? null,
description: descriptions.get(skillKey(scored)) ?? null,
repoSkillCount,
diff --git a/shared/trending-range.ts b/shared/trending-range.ts
index cb69d6e6..faef26bc 100644
--- a/shared/trending-range.ts
+++ b/shared/trending-range.ts
@@ -290,10 +290,18 @@ export interface TrendingPost {
* the star count carries it.
*/
export type TrendingReason
- = | { _tag: 'posts', posts: readonly TrendingPost[] }
- | { _tag: 'surge', gain: number, when: string | null }
- | { _tag: 'filler' }
- | { _tag: 'reviewed', skillCount: number, updated: string | null }
+ = | {
+ _tag: 'posts'
+ posts: readonly TrendingPost[]
+ /**
+ * Counted mentions per rolling day across the last seven, oldest first,
+ * for the braille spark. Null when the feed did not carry them.
+ */
+ mentionsByDay: readonly number[] | null
+ }
+ | { _tag: 'surge', gain: number, when: string | null }
+ | { _tag: 'filler' }
+ | { _tag: 'reviewed', skillCount: number, updated: string | null }
/**
* A board row, in the one shape the template renders.
@@ -318,9 +326,33 @@ export interface TrendingBoardRow {
starSeries: readonly StarPoint[]
/** Names the Skill answers to, so a post can mark where it names it. */
names: readonly string[]
+ /**
+ * The one Skill the row stands for, which the run command targets. Null when
+ * the row picked a Skill out of a repository that holds several.
+ */
+ skill: BoardSkill | null
reason: TrendingReason
}
+/** One Skill, as the run command addresses it. */
+export interface BoardSkill {
+ owner: string
+ repo: string
+ /** The Skill directory name, the last segment of `owner/repo/name`. */
+ name: string
+}
+
+/**
+ * The Skill a row may print a run command for.
+ *
+ * Only a repository that holds exactly one Skill counts. Filler and the
+ * `all` range pick one Skill out of a repository by a fixed rule, and a run
+ * command would present that pick as the thing the stars belong to.
+ */
+export function singleSkill(owner: string, repo: string, name: string, repoSkillCount: number): BoardSkill | null {
+ return repoSkillCount === 1 ? { owner, repo, name } : null
+}
+
/**
* Rows that earned the page its place in the index.
*
@@ -412,6 +444,7 @@ export function leaderboardBoardRows(
stars: item.stars,
starSeries: [],
names: [item.topSkill.name],
+ skill: singleSkill(item.owner, item.repo, item.topSkill.name, item.skillCount),
reason: { _tag: 'reviewed', skillCount: item.skillCount, updated: day },
}
})
diff --git a/test/a11y.spec.ts b/test/a11y.spec.ts
index c4994b81..da9da674 100644
--- a/test/a11y.spec.ts
+++ b/test/a11y.spec.ts
@@ -465,6 +465,18 @@ describe('accessibility: components', () => {
wrapper.unmount()
})
+ it('trendingMark has no violations and stays out of the accessibility tree', async () => {
+ const container = createIsolatedContainer()
+ const wrapper = await mountSuspended(
+ await loadComponent('TrendingMark'),
+ { attachTo: container },
+ )
+ const results = await runAxe(container)
+ expect(results.violations, formatViolations(results)).toHaveLength(0)
+ expect(container.querySelector('.trending-mark')?.getAttribute('aria-hidden')).toBe('true')
+ wrapper.unmount()
+ })
+
it('commandChip has no violations and keeps the command copyable as one string', async () => {
const container = createIsolatedContainer()
const command = 'npx skilld run mattpocock/skills/tdd'
diff --git a/test/unit/mention-days.test.ts b/test/unit/mention-days.test.ts
new file mode 100644
index 00000000..444fb63c
--- /dev/null
+++ b/test/unit/mention-days.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { mentionsByDay } from '../../shared/mention-days'
+
+const NOW = 1_760_000_000
+const HOUR = 3600
+const DAY = 24 * HOUR
+
+describe('mentions by day', () => {
+ it('counts the past 24 hours as the last day', () => {
+ expect(mentionsByDay([NOW - HOUR, NOW - 23 * HOUR], NOW)).toEqual([0, 0, 0, 0, 0, 0, 2])
+ })
+
+ it('puts a post from 25 hours ago on the day before', () => {
+ expect(mentionsByDay([NOW - 25 * HOUR], NOW)).toEqual([0, 0, 0, 0, 0, 1, 0])
+ })
+
+ it('puts the oldest day first', () => {
+ expect(mentionsByDay([NOW - 6 * DAY - HOUR], NOW)).toEqual([1, 0, 0, 0, 0, 0, 0])
+ })
+
+ it('drops a post older than seven days', () => {
+ expect(mentionsByDay([NOW - 7 * DAY, NOW - 20 * DAY], NOW)).toEqual([0, 0, 0, 0, 0, 0, 0])
+ })
+
+ it('counts a post stamped after the clock as the last day', () => {
+ expect(mentionsByDay([NOW + HOUR], NOW)).toEqual([0, 0, 0, 0, 0, 0, 1])
+ })
+})
diff --git a/test/unit/trending-range.test.ts b/test/unit/trending-range.test.ts
index 31453ca4..cb52ed88 100644
--- a/test/unit/trending-range.test.ts
+++ b/test/unit/trending-range.test.ts
@@ -5,6 +5,7 @@ import {
leaderboardBoardRows,
resolveTrendingPage,
resolveTrendingRange,
+ singleSkill,
SKILLS_NOUN,
TRENDING_RANGES,
trendingRangeDescription,
@@ -185,6 +186,26 @@ describe('leaderboard rows on the trending board', () => {
it('carries no posts, since a star ranking has none to show', () => {
expect(leaderboardBoardRows([leaderboardRow()])[0]!.reason._tag).toBe('reviewed')
})
+
+ it('gives a run command target to a repository with one skill', () => {
+ expect(leaderboardBoardRows([leaderboardRow({ skillCount: 1 })])[0]!.skill)
+ .toEqual({ owner: 'anthropics', repo: 'skills', name: 'pdf-processing' })
+ })
+
+ it('gives no run command target when the skill was picked from several', () => {
+ expect(leaderboardBoardRows([leaderboardRow({ skillCount: 9 })])[0]!.skill).toBeNull()
+ })
+})
+
+describe('the skill a row may print a run command for', () => {
+ it('is the skill when its repository holds only that one', () => {
+ expect(singleSkill('pbakaus', 'impeccable', 'impeccable', 1))
+ .toEqual({ owner: 'pbakaus', repo: 'impeccable', name: 'impeccable' })
+ })
+
+ it.each([0, 2, 40])('is none when the repository holds %i skills', (count) => {
+ expect(singleSkill('mattpocock', 'skills', 'tdd', count)).toBeNull()
+ })
})
/**
diff --git a/test/unit/trending-skills.test.ts b/test/unit/trending-skills.test.ts
index d61bda64..77c1b804 100644
--- a/test/unit/trending-skills.test.ts
+++ b/test/unit/trending-skills.test.ts
@@ -87,6 +87,35 @@ function nameAlso(postId: string, skill: { owner: string, repo: string, slug: st
).run(postId, skill.owner, skill.repo, skill.slug, skill.slug, NOW)
}
+describe('mentions per day on a trending skill', () => {
+ it('counts each counted post on the rolling day it went out', async () => {
+ mention({ owner: 'a', repo: 'r', slug: 'tdd', handle: 'p1', postedAt: NOW - HOUR })
+ mention({ owner: 'a', repo: 'r', slug: 'tdd', handle: 'p2', postedAt: NOW - 2 * HOUR })
+ mention({ owner: 'a', repo: 'r', slug: 'tdd', handle: 'p3', postedAt: NOW - 50 * HOUR })
+ // Below the likes floor, so it is not a counted mention and draws no bar.
+ mention({ owner: 'a', repo: 'r', slug: 'tdd', handle: 'p4', postedAt: NOW - 3 * HOUR, likes: 0 })
+
+ const [skill] = await loadTrendingSkills({ db: db().db, now: NOW })
+ expect(skill?.mentionsByDay).toEqual([0, 0, 0, 0, 1, 0, 2])
+ })
+
+ it('draws no mentions for a skill only a star surge put on the board', async () => {
+ db().raw.prepare(`INSERT INTO repos (owner, repo, stars) VALUES ('solo', 'one', 500)`).run()
+ db().raw.prepare(
+ `INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved, rendered_skill_path)
+ VALUES ('solo', 'one', 'one', 'one', 'one', 1, 'SKILL.md')`,
+ ).run()
+ db().raw.prepare(
+ `INSERT INTO repo_star_surges (owner, repo, observed_day, latest_gain, baseline_gain, stars, detected_at)
+ VALUES ('solo', 'one', ${Math.floor((NOW - 86400) / 86400) * 86400}, 400, 5, 500, ${NOW - HOUR})`,
+ ).run()
+
+ const [skill] = await loadTrendingSkills({ db: db().db, now: NOW })
+ expect(skill?.slug).toBe('one')
+ expect(skill?.mentionsByDay).toBeNull()
+ })
+})
+
describe('loadTrendingSkills ranking', () => {
it('does not publish verified mentions before the Skill is indexed', async () => {
mention({ owner: 'pending', repo: 'repo', slug: 'not-indexed', handle: 'p1', likes: 50 })