daqc commited on
Commit
657bb2e
·
1 Parent(s): ad19202

Add full-year pagination and UI refinements

Browse files
apps/web/modules/marketing/home/components/StoryScroller.tsx CHANGED
@@ -167,6 +167,19 @@ const archetypeAccents: Record<ArchetypeKey, string> = {
167
  "HF Explorer": "from-emerald-400 to-lime-300",
168
  };
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  function imageForSlide(
171
  slide: StorySlide,
172
  wrapped: WrappedResult,
@@ -260,118 +273,133 @@ function pickBadge(activity: WrappedResult["activity"]): string {
260
  return "HF Explorer";
261
  }
262
 
263
- function badgeReason(badge: string): string {
264
- switch (badge) {
265
- case "Model Powerhouse":
266
- return "1M+ downloads across your work";
267
- case "Community Favorite":
268
- return "5k+ likes from the community";
269
- case "Research Beacon":
270
- return "Shared multiple research papers";
271
- case "Spaces Trailblazer":
272
- return "Built 3+ interactive spaces";
273
- case "Data Shaper":
274
- return "Published 5+ datasets";
275
- case "Model Builder":
276
- return "Created 3+ models";
277
- case "HF Explorer":
278
- default:
279
- return "Exploring across repos and topics";
280
- }
281
  }
282
 
283
- function buildBadgeMetrics(
284
  badge: string,
285
- wrapped: WrappedResult,
286
  fmt: Intl.NumberFormat,
287
- ): { label: string; value: string }[] {
288
- const activity = wrapped.activity;
 
289
 
290
  switch (badge) {
291
  case "Model Powerhouse":
292
- return [
293
- {
294
- label: "Downloads",
295
- value: fmt.format(activity.totalDownloads),
296
- },
297
- {
298
- label: "Models",
299
- value: fmt.format(activity.models.length || 1),
300
- },
301
- ];
 
 
302
  case "Community Favorite":
303
- return [
304
- {
305
- label: "Likes",
306
- value: fmt.format(activity.totalLikes),
307
- },
308
- {
309
- label: "Repos",
310
- value: fmt.format(activity.totalRepos),
311
- },
312
- ];
 
 
313
  case "Research Beacon":
314
- return [
315
- {
316
- label: "Papers",
317
- value: fmt.format(activity.papers.length),
318
- },
319
- {
320
- label: "Repos",
321
- value: fmt.format(activity.totalRepos),
322
- },
323
- ];
324
- case "Spaces Trailblazer":
325
- return [
326
- {
327
- label: "Spaces",
328
- value: fmt.format(activity.spaces.length),
329
- },
330
- {
331
- label: "Likes",
332
- value: fmt.format(activity.totalLikes),
333
- },
334
- ];
335
- case "Data Shaper":
336
- return [
337
- {
338
- label: "Datasets",
339
- value: fmt.format(activity.datasets.length),
340
- },
341
- {
342
- label: "Downloads",
343
- value: fmt.format(activity.totalDownloads),
344
- },
345
- ];
346
- case "Model Builder":
347
- return [
348
- {
349
- label: "Models",
350
- value: fmt.format(activity.models.length),
351
- },
352
- {
353
- label: "Downloads",
354
- value: fmt.format(activity.totalDownloads),
355
- },
356
- ];
357
- case "HF Explorer":
358
- default:
359
- return [
360
- {
361
- label: "Repos",
362
- value: fmt.format(activity.totalRepos),
363
- },
364
- {
365
- label: "Downloads",
366
- value: fmt.format(activity.totalDownloads),
367
- },
368
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  }
 
 
 
 
 
370
  }
371
 
372
  function buildSlides(wrapped: WrappedResult): StorySlide[] {
373
  const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
 
 
374
  const badge = pickBadge(wrapped.activity);
 
375
  const topModels = wrapped.activity.models.slice(0, 3);
376
  const topDatasets = wrapped.activity.datasets.slice(0, 3);
377
  const topSpaces = wrapped.activity.spaces.slice(0, 3);
@@ -403,19 +431,21 @@ function buildSlides(wrapped: WrappedResult): StorySlide[] {
403
  metrics: [
404
  {
405
  label: "Models",
406
- value: wrapped.activity.models.length.toString(),
407
  },
408
  {
409
  label: "Datasets",
410
- value: wrapped.activity.datasets.length.toString(),
 
 
411
  },
412
  {
413
  label: "Spaces",
414
- value: wrapped.activity.spaces.length.toString(),
415
  },
416
  {
417
  label: "Papers",
418
- value: wrapped.activity.papers.length.toString(),
419
  },
420
  ],
421
  highlights: [
@@ -510,10 +540,10 @@ function buildSlides(wrapped: WrappedResult): StorySlide[] {
510
  {
511
  id: "badges",
512
  kind: "badges",
513
- title: "Your badge this year",
514
  subtitle: badge,
515
- metrics: buildBadgeMetrics(badge, wrapped, fmt),
516
- highlights: [badgeReason(badge)],
517
  },
518
  {
519
  id: "share",
@@ -522,40 +552,34 @@ function buildSlides(wrapped: WrappedResult): StorySlide[] {
522
  subtitle: `Your Hugging Face 🤗 in ${wrapped.year} `,
523
  metrics: [
524
  {
525
- label: "Badge",
526
- value: badge,
 
 
 
 
 
 
 
 
 
 
527
  },
528
  {
529
  label: "Archetype",
530
  value: wrapped.archetype,
531
  },
532
  {
533
- label:
534
- wrapped.activity.papers.length >
535
- Math.max(
536
- wrapped.activity.models.length,
537
- wrapped.activity.datasets.length,
538
- wrapped.activity.spaces.length,
539
- )
540
- ? "Papers"
541
- : "Repos",
542
- value:
543
- wrapped.activity.papers.length >
544
- Math.max(
545
- wrapped.activity.models.length,
546
- wrapped.activity.datasets.length,
547
- wrapped.activity.spaces.length,
548
- )
549
- ? fmt.format(wrapped.activity.papers.length)
550
- : fmt.format(wrapped.activity.totalRepos),
551
  },
552
  {
553
  label: "Downloads",
554
- value: fmt.format(wrapped.activity.totalDownloads),
555
  },
556
  {
557
  label: "Likes",
558
- value: fmt.format(wrapped.activity.totalLikes),
559
  },
560
  {
561
  label: "Top model",
@@ -616,7 +640,8 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
616
  }
617
  const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
618
  setPrefersReducedMotion(mql.matches);
619
- const handler = (event: MediaQueryListEvent) => setPrefersReducedMotion(event.matches);
 
620
  mql.addEventListener("change", handler);
621
  return () => mql.removeEventListener("change", handler);
622
  }, []);
@@ -908,7 +933,20 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
908
  archetypeKey
909
  ] ?? palette.accent
910
  }`;
911
- const hasAccentRail = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
912
  const isSingleColumn =
913
  slide.kind === "archetype" ||
914
  slide.kind === "badges";
@@ -986,8 +1024,7 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
986
  style={{
987
  willChange:
988
  "transform, opacity",
989
- backgroundImage:
990
- palette.gradient,
991
  }}
992
  >
993
  {hasAccentRail ? (
@@ -1547,41 +1584,53 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
1547
  </ul>
1548
  ) : isShare ? (
1549
  <div className="space-y-3">
1550
- <div className="grid grid-cols-1 gap-3">
1551
- {(
1552
- slide.metrics ??
1553
- []
1554
- )
1555
- .filter(
1556
- (
1557
- m,
1558
- ) =>
1559
- m.label ===
1560
- "Badge",
1561
- )
1562
- .map(
1563
- (
1564
- metric,
1565
- ) => (
 
 
 
 
 
 
 
 
 
 
1566
  <div
1567
  key={
1568
  metric.label
1569
  }
1570
- className="rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-inner shadow-black/10"
1571
  >
1572
- <p className="text-base uppercase tracking-wide text-white/80">
1573
  {
1574
  metric.label
1575
  }
1576
  </p>
1577
- <p className="truncate whitespace-nowrap text-2xl font-semibold leading-tight text-white">
1578
- {
1579
- metric.value
1580
- }
 
1581
  </p>
1582
  </div>
1583
- ),
1584
- )}
 
1585
  </div>
1586
  <div className="grid grid-cols-3 gap-3">
1587
  {((
@@ -1629,7 +1678,7 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
1629
  key={
1630
  metric.label
1631
  }
1632
- className="rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-inner shadow-black/10"
1633
  >
1634
  <p className="truncate text-sm uppercase tracking-wide text-white/75">
1635
  {
@@ -1697,26 +1746,43 @@ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
1697
  </div>
1698
  </div>
1699
  ) : (
1700
- <ul className="grid grid-cols-2 gap-4">
1701
  {slide.metrics.map(
1702
  (
1703
  metric,
1704
  ) => (
1705
  <li
1706
  key={`${slide.id}-${metric.label}`}
1707
- className="group relative overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1708
  data-story-metric
1709
  >
1710
- <p className="truncate text-sm uppercase tracking-wide text-white/70">
1711
- {
1712
- metric.label
1713
- }
1714
- </p>
1715
- <p className="text-2xl font-semibold text-white">
1716
- {
1717
- metric.value
1718
- }
1719
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1720
  </li>
1721
  ),
1722
  )}
 
167
  "HF Explorer": "from-emerald-400 to-lime-300",
168
  };
169
 
170
+ const shareOverlayByArchetype: Record<ArchetypeKey, string> = {
171
+ "Model Maestro":
172
+ "radial-gradient(circle at 22% 26%, rgba(60,90,255,0.38), transparent 34%), radial-gradient(circle at 78% 18%, rgba(60,90,255,0.24), transparent 36%)",
173
+ "Dataset Architect":
174
+ "radial-gradient(circle at 20% 30%, rgba(232,80,72,0.30), transparent 34%), radial-gradient(circle at 78% 16%, rgba(232,80,72,0.18), transparent 36%)",
175
+ "Space Storyteller":
176
+ "radial-gradient(circle at 21% 25%, rgba(14,165,233,0.30), transparent 34%), radial-gradient(circle at 75% 14%, rgba(14,165,233,0.18), transparent 36%)",
177
+ "Research Curator":
178
+ "radial-gradient(circle at 22% 30%, rgba(168,85,247,0.30), transparent 34%), radial-gradient(circle at 78% 14%, rgba(168,85,247,0.18), transparent 36%)",
179
+ "HF Explorer":
180
+ "radial-gradient(circle at 22% 28%, rgba(52,211,153,0.30), transparent 34%), radial-gradient(circle at 76% 16%, rgba(52,211,153,0.18), transparent 36%)",
181
+ };
182
+
183
  function imageForSlide(
184
  slide: StorySlide,
185
  wrapped: WrappedResult,
 
273
  return "HF Explorer";
274
  }
275
 
276
+ function pluralize(label: string, count: number): string {
277
+ return count === 1 ? label : `${label}s`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  }
279
 
280
+ function buildBadgeDetails(
281
  badge: string,
282
+ activity: WrappedResult["activity"],
283
  fmt: Intl.NumberFormat,
284
+ ): { metrics: { label: string; value: string }[]; highlights: string[] } {
285
+ const highlights: string[] = [];
286
+ const metrics: { label: string; value: string }[] = [];
287
 
288
  switch (badge) {
289
  case "Model Powerhouse":
290
+ metrics.push({
291
+ label: "Downloads",
292
+ value: fmt.format(activity.totalDownloads),
293
+ });
294
+ metrics.push({
295
+ label: "Likes",
296
+ value: fmt.format(activity.totalLikes),
297
+ });
298
+ highlights.push(
299
+ `Shipped ${activity.models.length} ${pluralize("model", activity.models.length)} with 1M+ downloads`,
300
+ );
301
+ break;
302
  case "Community Favorite":
303
+ metrics.push({
304
+ label: "Likes",
305
+ value: fmt.format(activity.totalLikes),
306
+ });
307
+ metrics.push({
308
+ label: "Repos",
309
+ value: fmt.format(activity.totalRepos),
310
+ });
311
+ highlights.push(
312
+ `Earned ${fmt.format(activity.totalLikes)} likes across your work`,
313
+ );
314
+ break;
315
  case "Research Beacon":
316
+ metrics.push({
317
+ label: "Papers",
318
+ value: fmt.format(activity.papers.length),
319
+ });
320
+ highlights.push(
321
+ `Published ${fmt.format(activity.papers.length)} research paper${activity.papers.length === 1 ? "" : "s"}`,
322
+ );
323
+ break;
324
+ case "Spaces Trailblazer": {
325
+ const spaces = activity.spaces.length;
326
+ metrics.push({
327
+ label: "Spaces",
328
+ value: fmt.format(spaces),
329
+ });
330
+ const topSpace = activity.spaces[0]?.name;
331
+ if (topSpace) {
332
+ metrics.push({
333
+ label: "Top Space",
334
+ value: topSpace,
335
+ });
336
+ }
337
+ highlights.push(
338
+ `Built ${fmt.format(spaces)} interactive ${pluralize("space", spaces)}`,
339
+ );
340
+ break;
341
+ }
342
+ case "Data Shaper": {
343
+ const datasets = activity.datasets.length;
344
+ metrics.push({
345
+ label: "Datasets",
346
+ value: fmt.format(datasets),
347
+ });
348
+ const topDataset = activity.datasets[0]?.name;
349
+ if (topDataset) {
350
+ metrics.push({
351
+ label: "Top dataset",
352
+ value: topDataset,
353
+ });
354
+ }
355
+ highlights.push(
356
+ `Shaped ${fmt.format(datasets)} ${pluralize("dataset", datasets)} this year`,
357
+ );
358
+ break;
359
+ }
360
+ case "Model Builder": {
361
+ const models = activity.models.length;
362
+ metrics.push({
363
+ label: "Models",
364
+ value: fmt.format(models),
365
+ });
366
+ const topModel = activity.models[0]?.name;
367
+ if (topModel) {
368
+ metrics.push({
369
+ label: "Top model",
370
+ value: topModel,
371
+ });
372
+ }
373
+ highlights.push(
374
+ `Built ${fmt.format(models)} ${pluralize("model", models)} this year`,
375
+ );
376
+ break;
377
+ }
378
+ default: {
379
+ metrics.push({
380
+ label: "Repos",
381
+ value: fmt.format(activity.totalRepos),
382
+ });
383
+ metrics.push({
384
+ label: "Downloads",
385
+ value: fmt.format(activity.totalDownloads),
386
+ });
387
+ highlights.push("Explorer mode: tried a bit of everything");
388
+ }
389
  }
390
+
391
+ return {
392
+ metrics,
393
+ highlights: sanitizeHighlights(highlights),
394
+ };
395
  }
396
 
397
  function buildSlides(wrapped: WrappedResult): StorySlide[] {
398
  const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
399
+ const fmtCompactMaybePlus = (value: number): string =>
400
+ value === 1000 ? "+1K" : fmt.format(value);
401
  const badge = pickBadge(wrapped.activity);
402
+ const badgeInfo = buildBadgeDetails(badge, wrapped.activity, fmt);
403
  const topModels = wrapped.activity.models.slice(0, 3);
404
  const topDatasets = wrapped.activity.datasets.slice(0, 3);
405
  const topSpaces = wrapped.activity.spaces.slice(0, 3);
 
431
  metrics: [
432
  {
433
  label: "Models",
434
+ value: fmtCompactMaybePlus(wrapped.activity.models.length),
435
  },
436
  {
437
  label: "Datasets",
438
+ value: fmtCompactMaybePlus(
439
+ wrapped.activity.datasets.length,
440
+ ),
441
  },
442
  {
443
  label: "Spaces",
444
+ value: fmtCompactMaybePlus(wrapped.activity.spaces.length),
445
  },
446
  {
447
  label: "Papers",
448
+ value: fmtCompactMaybePlus(wrapped.activity.papers.length),
449
  },
450
  ],
451
  highlights: [
 
540
  {
541
  id: "badges",
542
  kind: "badges",
543
+ title: `Your ${wrapped.year} badge`,
544
  subtitle: badge,
545
+ metrics: badgeInfo.metrics,
546
+ highlights: badgeInfo.highlights,
547
  },
548
  {
549
  id: "share",
 
552
  subtitle: `Your Hugging Face 🤗 in ${wrapped.year} `,
553
  metrics: [
554
  {
555
+ label: "Models",
556
+ value: fmtCompactMaybePlus(wrapped.activity.models.length),
557
+ },
558
+ {
559
+ label: "Datasets",
560
+ value: fmtCompactMaybePlus(
561
+ wrapped.activity.datasets.length,
562
+ ),
563
+ },
564
+ {
565
+ label: "Spaces",
566
+ value: fmtCompactMaybePlus(wrapped.activity.spaces.length),
567
  },
568
  {
569
  label: "Archetype",
570
  value: wrapped.archetype,
571
  },
572
  {
573
+ label: "Papers",
574
+ value: fmtCompactMaybePlus(wrapped.activity.papers.length),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  },
576
  {
577
  label: "Downloads",
578
+ value: fmtCompactMaybePlus(wrapped.activity.totalDownloads),
579
  },
580
  {
581
  label: "Likes",
582
+ value: fmtCompactMaybePlus(wrapped.activity.totalLikes),
583
  },
584
  {
585
  label: "Top model",
 
640
  }
641
  const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
642
  setPrefersReducedMotion(mql.matches);
643
+ const handler = (event: MediaQueryListEvent) =>
644
+ setPrefersReducedMotion(event.matches);
645
  mql.addEventListener("change", handler);
646
  return () => mql.removeEventListener("change", handler);
647
  }, []);
 
933
  archetypeKey
934
  ] ?? palette.accent
935
  }`;
936
+ const hasAccentRail =
937
+ slide.kind === "share";
938
+ const overlay =
939
+ isShare &&
940
+ shareOverlayByArchetype[
941
+ archetypeKey
942
+ ]
943
+ ? shareOverlayByArchetype[
944
+ archetypeKey
945
+ ]
946
+ : undefined;
947
+ const backgroundImage = overlay
948
+ ? `${overlay}, ${palette.gradient}`
949
+ : palette.gradient;
950
  const isSingleColumn =
951
  slide.kind === "archetype" ||
952
  slide.kind === "badges";
 
1024
  style={{
1025
  willChange:
1026
  "transform, opacity",
1027
+ backgroundImage,
 
1028
  }}
1029
  >
1030
  {hasAccentRail ? (
 
1584
  </ul>
1585
  ) : isShare ? (
1586
  <div className="space-y-3">
1587
+ <div className="grid grid-cols-3 gap-3">
1588
+ {[
1589
+ "Models",
1590
+ "Datasets",
1591
+ "Spaces",
1592
+ ].map(
1593
+ (
1594
+ label,
1595
+ ) => {
1596
+ const metric =
1597
+ (
1598
+ slide.metrics ??
1599
+ []
1600
+ ).find(
1601
+ (
1602
+ m,
1603
+ ) =>
1604
+ m.label ===
1605
+ label,
1606
+ );
1607
+ if (
1608
+ !metric
1609
+ ) {
1610
+ return null;
1611
+ }
1612
+ return (
1613
  <div
1614
  key={
1615
  metric.label
1616
  }
1617
+ className="rounded-2xl border border-white/12 bg-white/5 px-5 py-4 shadow-inner shadow-black/10"
1618
  >
1619
+ <p className="truncate text-sm uppercase tracking-wide text-white/75">
1620
  {
1621
  metric.label
1622
  }
1623
  </p>
1624
+ <p className="truncate whitespace-nowrap text-3xl font-semibold text-white">
1625
+ {ellipsize(
1626
+ metric.value,
1627
+ 40,
1628
+ )}
1629
  </p>
1630
  </div>
1631
+ );
1632
+ },
1633
+ )}
1634
  </div>
1635
  <div className="grid grid-cols-3 gap-3">
1636
  {((
 
1678
  key={
1679
  metric.label
1680
  }
1681
+ className="rounded-2xl border border-white/12 bg-white/5 px-5 py-4 shadow-inner shadow-black/10"
1682
  >
1683
  <p className="truncate text-sm uppercase tracking-wide text-white/75">
1684
  {
 
1746
  </div>
1747
  </div>
1748
  ) : (
1749
+ <ul className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4">
1750
  {slide.metrics.map(
1751
  (
1752
  metric,
1753
  ) => (
1754
  <li
1755
  key={`${slide.id}-${metric.label}`}
1756
+ className="group relative w-full min-w-0 overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1757
  data-story-metric
1758
  >
1759
+ <div className="flex min-w-0 w-full flex-col gap-1">
1760
+ <p className="truncate w-full min-w-0 text-sm uppercase tracking-wide text-white/70">
1761
+ {
1762
+ metric.label
1763
+ }
1764
+ </p>
1765
+ <p
1766
+ className="w-full min-w-0 text-2xl font-semibold text-white"
1767
+ style={{
1768
+ display:
1769
+ "-webkit-box",
1770
+ WebkitLineClamp: 1,
1771
+ WebkitBoxOrient:
1772
+ "vertical",
1773
+ overflow:
1774
+ "hidden",
1775
+ textOverflow:
1776
+ "ellipsis",
1777
+ wordBreak:
1778
+ "break-word",
1779
+ }}
1780
+ >
1781
+ {
1782
+ metric.value
1783
+ }
1784
+ </p>
1785
+ </div>
1786
  </li>
1787
  ),
1788
  )}
packages/wrapped/application/generate.ts CHANGED
@@ -40,6 +40,7 @@ export async function generateWrapped(
40
  normalized.subjectType === "auto"
41
  ? "user"
42
  : (normalized.subjectType ?? "user"),
 
43
  );
44
 
45
  const snapshot = buildActivitySnapshot(
 
40
  normalized.subjectType === "auto"
41
  ? "user"
42
  : (normalized.subjectType ?? "user"),
43
+ normalized.year,
44
  );
45
 
46
  const snapshot = buildActivitySnapshot(
packages/wrapped/domain/aggregate.ts CHANGED
@@ -1,6 +1,7 @@
1
  import type {
2
  ActivitySnapshot,
3
  PaperStats,
 
4
  RepoStats,
5
  StorySlide,
6
  WrappedProfile,
@@ -65,9 +66,13 @@ function findBusiestMonth(repos: RepoStats[]): string | undefined {
65
  const monthHits = new Array(12).fill(0);
66
  repos.forEach((repo) => {
67
  const dateString = repo.updatedAt ?? repo.createdAt;
68
- if (!dateString) return;
 
 
69
  const date = new Date(dateString);
70
- if (Number.isNaN(date.valueOf())) return;
 
 
71
  monthHits[date.getUTCMonth()] += 1;
72
  });
73
  const topIndex = monthHits.findIndex(
@@ -142,9 +147,12 @@ export function buildSlides(params: {
142
  const { profile, year, activity, archetype, badges } = params;
143
  const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
144
 
145
- const topModels = activity.models.slice(0, 3);
146
- const topDatasets = activity.datasets.slice(0, 3);
147
- const topSpaces = activity.spaces.slice(0, 3);
 
 
 
148
 
149
  const slides: StorySlide[] = [
150
  {
@@ -264,8 +272,16 @@ export function buildSlides(params: {
264
  subtitle: "Download the slides or share your Space link",
265
  metrics: [
266
  {
267
- label: "Story count",
268
- value: `${clamp(slidesCount(activity), 5, 10)} slides`,
 
 
 
 
 
 
 
 
269
  },
270
  ],
271
  },
@@ -274,18 +290,50 @@ export function buildSlides(params: {
274
  return slides;
275
  }
276
 
277
- function slidesCount(activity: ActivitySnapshot): number {
278
- const buckets = [
279
- activity.models.length,
280
- activity.datasets.length,
281
- activity.spaces.length,
282
- ]
283
- .map((count) => (count > 0 ? 1 : 0))
284
- .reduce<number>((acc, current) => acc + current, 0);
285
- return clamp(5 + buckets, 5, 10);
286
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
- function clamp(value: number, min: number, max: number): number {
289
- if (Number.isNaN(value)) return min;
290
- return Math.min(Math.max(value, min), max);
291
  }
 
1
  import type {
2
  ActivitySnapshot,
3
  PaperStats,
4
+ RepoKind,
5
  RepoStats,
6
  StorySlide,
7
  WrappedProfile,
 
66
  const monthHits = new Array(12).fill(0);
67
  repos.forEach((repo) => {
68
  const dateString = repo.updatedAt ?? repo.createdAt;
69
+ if (!dateString) {
70
+ return;
71
+ }
72
  const date = new Date(dateString);
73
+ if (Number.isNaN(date.valueOf())) {
74
+ return;
75
+ }
76
  monthHits[date.getUTCMonth()] += 1;
77
  });
78
  const topIndex = monthHits.findIndex(
 
147
  const { profile, year, activity, archetype, badges } = params;
148
  const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
149
 
150
+ const topModels = sortReposForRanking(activity.models, "model").slice(0, 3);
151
+ const topDatasets = sortReposForRanking(activity.datasets, "dataset").slice(
152
+ 0,
153
+ 3,
154
+ );
155
+ const topSpaces = sortReposForRanking(activity.spaces, "space").slice(0, 3);
156
 
157
  const slides: StorySlide[] = [
158
  {
 
272
  subtitle: "Download the slides or share your Space link",
273
  metrics: [
274
  {
275
+ label: "Models",
276
+ value: activity.models.length.toString(),
277
+ },
278
+ {
279
+ label: "Datasets",
280
+ value: activity.datasets.length.toString(),
281
+ },
282
+ {
283
+ label: "Spaces",
284
+ value: activity.spaces.length.toString(),
285
  },
286
  ],
287
  },
 
290
  return slides;
291
  }
292
 
293
+ function sortReposForRanking(repos: RepoStats[], kind: RepoKind): RepoStats[] {
294
+ const withKindPriority = [...repos];
295
+
296
+ const createdAtMs = (repo: RepoStats) => {
297
+ const ts = repo.createdAt ?? repo.updatedAt;
298
+ const ms = ts ? Date.parse(ts) : Number.NaN;
299
+ if (Number.isNaN(ms)) {
300
+ return 0;
301
+ }
302
+ return ms;
303
+ };
304
+
305
+ withKindPriority.sort((a, b) => {
306
+ const downloadsDiff = (b.downloads ?? 0) - (a.downloads ?? 0);
307
+ const likesDiff = (b.likes ?? 0) - (a.likes ?? 0);
308
+ const recencyDiff = createdAtMs(b) - createdAtMs(a);
309
+ const nameDiff = a.name.localeCompare(b.name);
310
+
311
+ if (kind === "space") {
312
+ // Spaces focus on engagement (likes), then downloads if present.
313
+ if (likesDiff !== 0) {
314
+ return likesDiff;
315
+ }
316
+ if (downloadsDiff !== 0) {
317
+ return downloadsDiff;
318
+ }
319
+ if (recencyDiff !== 0) {
320
+ return recencyDiff;
321
+ }
322
+ return nameDiff;
323
+ }
324
+
325
+ // Models/datasets: downloads first, likes second, recency third.
326
+ if (downloadsDiff !== 0) {
327
+ return downloadsDiff;
328
+ }
329
+ if (likesDiff !== 0) {
330
+ return likesDiff;
331
+ }
332
+ if (recencyDiff !== 0) {
333
+ return recencyDiff;
334
+ }
335
+ return nameDiff;
336
+ });
337
 
338
+ return withKindPriority;
 
 
339
  }
packages/wrapped/infrastructure/hub-client.ts CHANGED
@@ -7,7 +7,8 @@ import type {
7
  } from "../domain/types";
8
 
9
  const HUB_BASE_URL = "https://huggingface.co";
10
- const DEFAULT_LIMIT = 50;
 
11
 
12
  function buildHandleCandidates(handle: string): string[] {
13
  const trimmed = handle.trim();
@@ -99,6 +100,7 @@ export async function detectSubjectType(
99
  export async function fetchHubActivity(
100
  handle: string,
101
  subjectType: SubjectType,
 
102
  profileOverride?: WrappedProfile,
103
  ): Promise<HubActivityResponse> {
104
  let profile: WrappedProfile;
@@ -145,27 +147,31 @@ export async function fetchHubActivity(
145
  );
146
 
147
  const [models, datasets, spaces, papers] = await Promise.all([
148
- fetchReposWithFallback("model", authorCandidates),
149
- fetchReposWithFallback("dataset", authorCandidates),
150
- fetchReposWithFallback("space", authorCandidates),
151
  fetchPapersWithFallback(authorCandidates),
152
  ]);
153
 
 
 
 
 
154
  console.log(
155
  "[wrapped] fetchHubActivity results",
156
  JSON.stringify({
157
- models: models.length,
158
- datasets: datasets.length,
159
- spaces: spaces.length,
160
  papers: papers.length,
161
  }),
162
  );
163
 
164
  return {
165
  profile: { ...profile, subjectType: resolvedSubjectType },
166
- models,
167
- datasets,
168
- spaces,
169
  papers,
170
  };
171
  }
@@ -173,32 +179,85 @@ export async function fetchHubActivity(
173
  async function fetchRepos(
174
  kind: RepoKind,
175
  author: string,
 
176
  ): Promise<RepoStats[]> {
177
- const url = `${HUB_BASE_URL}/api/${kind}s?author=${author}&limit=${DEFAULT_LIMIT}&full=true&sort=downloads&direction=-1`;
178
- const repos = await safeJsonFetch<HubRepoResponse[]>(url);
179
- if (!repos) return [];
180
- return repos.map((repo) => ({
181
- id: repo.id,
182
- name: repo.id.split("/")[1] ?? repo.id,
183
- kind,
184
- author,
185
- likes: repo.likes ?? 0,
186
- downloads: repo.downloads ?? 0,
187
- tags: repo.tags,
188
- private: repo.private,
189
- updatedAt: repo.lastModified,
190
- createdAt: repo.createdAt,
191
- task: repo.task,
192
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  }
194
 
195
  async function fetchReposWithFallback(
196
  kind: RepoKind,
197
  authors: string[],
 
198
  ): Promise<RepoStats[]> {
199
  for (const author of authors) {
200
  try {
201
- const repos = await fetchRepos(kind, author);
202
  console.log(
203
  `[wrapped] fetchRepos ${kind}`,
204
  JSON.stringify({ author, count: repos.length }),
@@ -233,7 +292,9 @@ async function fetchPapers(handle: string): Promise<PaperStats[]> {
233
  url?: string;
234
  }>
235
  >(url);
236
- if (!papers) return [];
 
 
237
  return papers.map((paper) => ({
238
  id: paper.arxivId,
239
  title: paper.title,
@@ -244,7 +305,9 @@ async function fetchPapers(handle: string): Promise<PaperStats[]> {
244
  }));
245
  }
246
 
247
- async function fetchPapersWithFallback(handles: string[]): Promise<PaperStats[]> {
 
 
248
  for (const handle of handles) {
249
  try {
250
  const papers = await fetchPapers(handle);
@@ -287,3 +350,39 @@ async function safeJsonFetch<T>(url: string): Promise<T | null> {
287
  );
288
  }
289
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  } from "../domain/types";
8
 
9
  const HUB_BASE_URL = "https://huggingface.co";
10
+ // To cap page size, set a number (e.g., 200). Leave undefined to fetch all pages.
11
+ const DEFAULT_LIMIT: number | undefined = undefined;
12
 
13
  function buildHandleCandidates(handle: string): string[] {
14
  const trimmed = handle.trim();
 
100
  export async function fetchHubActivity(
101
  handle: string,
102
  subjectType: SubjectType,
103
+ year: number,
104
  profileOverride?: WrappedProfile,
105
  ): Promise<HubActivityResponse> {
106
  let profile: WrappedProfile;
 
147
  );
148
 
149
  const [models, datasets, spaces, papers] = await Promise.all([
150
+ fetchReposWithFallback("model", authorCandidates, year),
151
+ fetchReposWithFallback("dataset", authorCandidates, year),
152
+ fetchReposWithFallback("space", authorCandidates, year),
153
  fetchPapersWithFallback(authorCandidates),
154
  ]);
155
 
156
+ const filteredModels = collectYearSortedDesc(models, year);
157
+ const filteredDatasets = collectYearSortedDesc(datasets, year);
158
+ const filteredSpaces = collectYearSortedDesc(spaces, year);
159
+
160
  console.log(
161
  "[wrapped] fetchHubActivity results",
162
  JSON.stringify({
163
+ models: filteredModels.length,
164
+ datasets: filteredDatasets.length,
165
+ spaces: filteredSpaces.length,
166
  papers: papers.length,
167
  }),
168
  );
169
 
170
  return {
171
  profile: { ...profile, subjectType: resolvedSubjectType },
172
+ models: filteredModels,
173
+ datasets: filteredDatasets,
174
+ spaces: filteredSpaces,
175
  papers,
176
  };
177
  }
 
179
  async function fetchRepos(
180
  kind: RepoKind,
181
  author: string,
182
+ year?: number,
183
  ): Promise<RepoStats[]> {
184
+ const results: RepoStats[] = [];
185
+ let nextUrl: string | undefined;
186
+ const limitParam =
187
+ typeof DEFAULT_LIMIT === "number" ? `&limit=${DEFAULT_LIMIT}` : "";
188
+ const baseUrl = `${HUB_BASE_URL}/api/${kind}s?author=${author}${limitParam}&full=true&sort=createdAt&direction=-1`;
189
+ nextUrl = baseUrl;
190
+
191
+ while (true) {
192
+ if (!nextUrl) {
193
+ break;
194
+ }
195
+
196
+ const raw = await safeJsonFetch<
197
+ | HubRepoResponse[]
198
+ | { items?: HubRepoResponse[]; next?: string; cursor?: string }
199
+ >(nextUrl);
200
+ if (!raw) {
201
+ break;
202
+ }
203
+
204
+ const { items, nextCursor } = normalizeRepoPage(raw);
205
+ if (!items || items.length === 0) {
206
+ break;
207
+ }
208
+
209
+ results.push(
210
+ ...items.map((repo) => ({
211
+ id: repo.id,
212
+ name: repo.id.split("/")[1] ?? repo.id,
213
+ kind,
214
+ author,
215
+ likes: repo.likes ?? 0,
216
+ downloads: repo.downloads ?? 0,
217
+ tags: repo.tags,
218
+ private: repo.private,
219
+ updatedAt: repo.lastModified,
220
+ createdAt: repo.createdAt,
221
+ task: repo.task,
222
+ })),
223
+ );
224
+
225
+ if (year) {
226
+ const lastWithDate = [...items]
227
+ .reverse()
228
+ .find((repo) => typeof repo.createdAt === "string");
229
+ if (lastWithDate?.createdAt) {
230
+ const lastYear = new Date(
231
+ lastWithDate.createdAt,
232
+ ).getUTCFullYear();
233
+ if (!Number.isNaN(lastYear) && lastYear < year) {
234
+ break;
235
+ }
236
+ }
237
+ }
238
+
239
+ if (!nextCursor) {
240
+ break;
241
+ }
242
+
243
+ if (nextCursor.startsWith("http")) {
244
+ nextUrl = nextCursor;
245
+ } else {
246
+ nextUrl = `${baseUrl}&cursor=${encodeURIComponent(nextCursor)}`;
247
+ }
248
+ }
249
+
250
+ return results;
251
  }
252
 
253
  async function fetchReposWithFallback(
254
  kind: RepoKind,
255
  authors: string[],
256
+ year?: number,
257
  ): Promise<RepoStats[]> {
258
  for (const author of authors) {
259
  try {
260
+ const repos = await fetchRepos(kind, author, year);
261
  console.log(
262
  `[wrapped] fetchRepos ${kind}`,
263
  JSON.stringify({ author, count: repos.length }),
 
292
  url?: string;
293
  }>
294
  >(url);
295
+ if (!papers) {
296
+ return [];
297
+ }
298
  return papers.map((paper) => ({
299
  id: paper.arxivId,
300
  title: paper.title,
 
305
  }));
306
  }
307
 
308
+ async function fetchPapersWithFallback(
309
+ handles: string[],
310
+ ): Promise<PaperStats[]> {
311
  for (const handle of handles) {
312
  try {
313
  const papers = await fetchPapers(handle);
 
350
  );
351
  }
352
  }
353
+
354
+ function normalizeRepoPage(
355
+ raw:
356
+ | HubRepoResponse[]
357
+ | { items?: HubRepoResponse[]; next?: string; cursor?: string },
358
+ ): { items: HubRepoResponse[]; nextCursor?: string } {
359
+ if (Array.isArray(raw)) {
360
+ return { items: raw, nextCursor: undefined };
361
+ }
362
+ const items = Array.isArray(raw.items) ? raw.items : [];
363
+ const nextCursor = typeof raw.next === "string" ? raw.next : raw.cursor;
364
+ return { items, nextCursor };
365
+ }
366
+
367
+ function collectYearSortedDesc<T extends { createdAt?: string }>(
368
+ items: T[],
369
+ year: number,
370
+ ): T[] {
371
+ const results: T[] = [];
372
+ for (const item of items) {
373
+ if (!item.createdAt) {
374
+ continue;
375
+ }
376
+ const yr = new Date(item.createdAt).getUTCFullYear();
377
+ if (Number.isNaN(yr)) {
378
+ continue;
379
+ }
380
+ if (yr < year) {
381
+ break;
382
+ }
383
+ if (yr === year) {
384
+ results.push(item);
385
+ }
386
+ }
387
+ return results;
388
+ }