@stats-forge/github-stats-forge-core 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +0 -10
  2. package/build/api/api-result.d.ts +6 -7
  3. package/build/api/api-result.d.ts.map +1 -1
  4. package/build/api/api-result.js +4 -5
  5. package/build/api/contributed-to.d.ts +23 -0
  6. package/build/api/contributed-to.d.ts.map +1 -0
  7. package/build/api/contributed-to.js +43 -0
  8. package/build/api/gist.d.ts +8 -22
  9. package/build/api/gist.d.ts.map +1 -1
  10. package/build/api/gist.js +14 -36
  11. package/build/api/handler.d.ts +16 -0
  12. package/build/api/handler.d.ts.map +1 -0
  13. package/build/api/handler.js +29 -0
  14. package/build/api/index.d.ts +13 -12
  15. package/build/api/index.d.ts.map +1 -1
  16. package/build/api/index.js +1 -0
  17. package/build/api/params.d.ts +21 -12
  18. package/build/api/params.d.ts.map +1 -1
  19. package/build/api/params.js +41 -19
  20. package/build/api/pin.d.ts +11 -31
  21. package/build/api/pin.d.ts.map +1 -1
  22. package/build/api/pin.js +35 -63
  23. package/build/api/stats.d.ts +15 -16
  24. package/build/api/stats.d.ts.map +1 -1
  25. package/build/api/stats.js +60 -99
  26. package/build/api/top-langs.d.ts +13 -18
  27. package/build/api/top-langs.d.ts.map +1 -1
  28. package/build/api/top-langs.js +35 -72
  29. package/build/api/wakatime.d.ts +11 -17
  30. package/build/api/wakatime.d.ts.map +1 -1
  31. package/build/api/wakatime.js +27 -64
  32. package/build/calculateRank.d.ts +0 -9
  33. package/build/calculateRank.d.ts.map +1 -1
  34. package/build/calculateRank.js +12 -17
  35. package/build/cards/contributed-to.d.ts +19 -0
  36. package/build/cards/contributed-to.d.ts.map +1 -0
  37. package/build/cards/contributed-to.js +242 -0
  38. package/build/cards/gist.d.ts +3 -4
  39. package/build/cards/gist.d.ts.map +1 -1
  40. package/build/cards/gist.js +38 -43
  41. package/build/cards/options.d.ts +1 -1
  42. package/build/cards/repo.d.ts +14 -5
  43. package/build/cards/repo.d.ts.map +1 -1
  44. package/build/cards/repo.js +93 -103
  45. package/build/cards/stats.d.ts +18 -11
  46. package/build/cards/stats.d.ts.map +1 -1
  47. package/build/cards/stats.js +191 -222
  48. package/build/cards/top-languages.d.ts +18 -33
  49. package/build/cards/top-languages.d.ts.map +1 -1
  50. package/build/cards/top-languages.js +196 -387
  51. package/build/cards/wakatime.d.ts +17 -8
  52. package/build/cards/wakatime.d.ts.map +1 -1
  53. package/build/cards/wakatime.js +133 -187
  54. package/build/common/Card.d.ts +20 -59
  55. package/build/common/Card.d.ts.map +1 -1
  56. package/build/common/Card.js +131 -198
  57. package/build/common/I18n.d.ts +1 -7
  58. package/build/common/I18n.d.ts.map +1 -1
  59. package/build/common/I18n.js +7 -8
  60. package/build/common/brand.d.ts +76 -0
  61. package/build/common/brand.d.ts.map +1 -0
  62. package/build/common/brand.js +73 -0
  63. package/build/common/color.d.ts +4 -17
  64. package/build/common/color.d.ts.map +1 -1
  65. package/build/common/color.js +67 -49
  66. package/build/common/config.d.ts +4 -14
  67. package/build/common/config.d.ts.map +1 -1
  68. package/build/common/config.js +3 -16
  69. package/build/common/constants.d.ts +6 -2
  70. package/build/common/constants.d.ts.map +1 -1
  71. package/build/common/constants.js +5 -1
  72. package/build/common/date.d.ts +50 -5
  73. package/build/common/date.d.ts.map +1 -1
  74. package/build/common/date.js +104 -4
  75. package/build/common/emojiMap.d.ts +8 -0
  76. package/build/common/emojiMap.d.ts.map +1 -0
  77. package/build/common/emojiMap.js +10 -0
  78. package/build/common/emojiMap.json +1915 -0
  79. package/build/common/error.d.ts +1 -10
  80. package/build/common/error.d.ts.map +1 -1
  81. package/build/common/error.js +1 -10
  82. package/build/common/fmt.d.ts +3 -15
  83. package/build/common/fmt.d.ts.map +1 -1
  84. package/build/common/fmt.js +7 -35
  85. package/build/common/http.d.ts +2 -7
  86. package/build/common/http.d.ts.map +1 -1
  87. package/build/common/http.js +8 -17
  88. package/build/common/icons.d.ts +20 -20
  89. package/build/common/icons.d.ts.map +1 -1
  90. package/build/common/icons.js +108 -67
  91. package/build/common/languageColors.d.ts +0 -1
  92. package/build/common/languageColors.d.ts.map +1 -1
  93. package/build/common/languageColors.js +1 -4
  94. package/build/common/languageColors.json +18 -2
  95. package/build/common/ops.d.ts +11 -17
  96. package/build/common/ops.d.ts.map +1 -1
  97. package/build/common/ops.js +22 -39
  98. package/build/common/render.d.ts +34 -82
  99. package/build/common/render.d.ts.map +1 -1
  100. package/build/common/render.js +225 -259
  101. package/build/common/retryer.d.ts +2 -7
  102. package/build/common/retryer.d.ts.map +1 -1
  103. package/build/common/retryer.js +4 -11
  104. package/build/fetchers/contributed-to.d.ts +62 -0
  105. package/build/fetchers/contributed-to.d.ts.map +1 -0
  106. package/build/fetchers/contributed-to.js +184 -0
  107. package/build/fetchers/gist.d.ts +2 -5
  108. package/build/fetchers/gist.d.ts.map +1 -1
  109. package/build/fetchers/gist.js +1 -5
  110. package/build/fetchers/graphql-error.d.ts +13 -0
  111. package/build/fetchers/graphql-error.d.ts.map +1 -0
  112. package/build/fetchers/graphql-error.js +29 -0
  113. package/build/fetchers/index.d.ts +13 -12
  114. package/build/fetchers/index.d.ts.map +1 -1
  115. package/build/fetchers/index.js +1 -0
  116. package/build/fetchers/repo.d.ts +2 -11
  117. package/build/fetchers/repo.d.ts.map +1 -1
  118. package/build/fetchers/repo.js +20 -33
  119. package/build/fetchers/stats.d.ts +16 -40
  120. package/build/fetchers/stats.d.ts.map +1 -1
  121. package/build/fetchers/stats.js +89 -271
  122. package/build/fetchers/top-languages.d.ts +2 -9
  123. package/build/fetchers/top-languages.d.ts.map +1 -1
  124. package/build/fetchers/top-languages.js +16 -47
  125. package/build/fetchers/types.d.ts +25 -1
  126. package/build/fetchers/types.d.ts.map +1 -1
  127. package/build/fetchers/wakatime.d.ts +2 -6
  128. package/build/fetchers/wakatime.d.ts.map +1 -1
  129. package/build/fetchers/wakatime.js +1 -5
  130. package/build/graphql/contributionsCollection.d.ts +9 -0
  131. package/build/graphql/contributionsCollection.d.ts.map +1 -0
  132. package/build/graphql/contributionsCollection.js +12 -0
  133. package/build/graphql/contributionsDocument.d.ts +6 -6
  134. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  135. package/build/graphql/contributionsDocument.js +8 -16
  136. package/build/graphql/generated/contributed-to.d.ts +22 -0
  137. package/build/graphql/generated/contributed-to.d.ts.map +1 -0
  138. package/build/graphql/generated/contributed-to.js +11 -0
  139. package/build/graphql/generated/gist.d.ts +1 -1
  140. package/build/graphql/generated/repo.d.ts +1 -1
  141. package/build/graphql/generated/stats.d.ts +16 -4
  142. package/build/graphql/generated/stats.d.ts.map +1 -1
  143. package/build/graphql/generated/stats.js +2 -2
  144. package/build/graphql/generated/top-languages.d.ts +2 -2
  145. package/build/graphql/graphqlDocument.d.ts +1 -2
  146. package/build/graphql/graphqlDocument.d.ts.map +1 -1
  147. package/build/graphql/graphqlDocument.js +0 -1
  148. package/build/graphql/reposContributedToDocument.d.ts +4 -5
  149. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  150. package/build/graphql/reposContributedToDocument.js +11 -7
  151. package/build/markup/css.d.ts +35 -0
  152. package/build/markup/css.d.ts.map +1 -0
  153. package/build/markup/css.js +64 -0
  154. package/build/markup/element.d.ts +41 -0
  155. package/build/markup/element.d.ts.map +1 -0
  156. package/build/markup/element.js +127 -0
  157. package/build/markup/html.d.ts +13 -0
  158. package/build/markup/html.d.ts.map +1 -0
  159. package/build/markup/html.js +27 -0
  160. package/build/markup/index.d.ts +9 -0
  161. package/build/markup/index.d.ts.map +1 -0
  162. package/build/markup/index.js +6 -0
  163. package/build/markup/serialize.d.ts +16 -0
  164. package/build/markup/serialize.d.ts.map +1 -0
  165. package/build/markup/serialize.js +17 -0
  166. package/build/themes/index.d.ts +332 -333
  167. package/build/themes/index.d.ts.map +1 -1
  168. package/build/themes/index.js +1 -1
  169. package/build/translations.d.ts +66 -7
  170. package/build/translations.d.ts.map +1 -1
  171. package/build/translations.js +686 -629
  172. package/package.json +17 -26
  173. package/src/api/api-result.ts +10 -9
  174. package/src/api/contributed-to.ts +84 -0
  175. package/src/api/gist.ts +22 -46
  176. package/src/api/handler.ts +42 -0
  177. package/src/api/index.ts +13 -12
  178. package/src/api/params.ts +74 -29
  179. package/src/api/pin.ts +38 -65
  180. package/src/api/stats.ts +81 -118
  181. package/src/api/top-langs.ts +38 -80
  182. package/src/api/wakatime.ts +35 -73
  183. package/src/calculateRank.ts +12 -23
  184. package/src/cards/contributed-to.ts +373 -0
  185. package/src/cards/gist.ts +55 -49
  186. package/src/cards/options.ts +1 -1
  187. package/src/cards/repo.ts +134 -120
  188. package/src/cards/stats.ts +262 -252
  189. package/src/cards/top-languages.ts +315 -422
  190. package/src/cards/wakatime.ts +207 -225
  191. package/src/common/Card.ts +207 -214
  192. package/src/common/I18n.ts +7 -8
  193. package/src/common/brand.ts +109 -0
  194. package/src/common/color.ts +80 -57
  195. package/src/common/config.ts +5 -18
  196. package/src/common/constants.ts +8 -2
  197. package/src/common/date.ts +135 -5
  198. package/src/common/emojiMap.json +1915 -0
  199. package/src/common/emojiMap.ts +13 -0
  200. package/src/common/error.ts +2 -11
  201. package/src/common/fmt.ts +7 -44
  202. package/src/common/http.ts +9 -15
  203. package/src/common/icons.ts +122 -42
  204. package/src/common/languageColors.json +18 -2
  205. package/src/common/languageColors.ts +1 -4
  206. package/src/common/ops.ts +31 -48
  207. package/src/common/render.ts +334 -284
  208. package/src/common/retryer.ts +8 -15
  209. package/src/fetchers/contributed-to.ts +271 -0
  210. package/src/fetchers/gist.ts +8 -12
  211. package/src/fetchers/graphql-error.ts +36 -0
  212. package/src/fetchers/index.ts +15 -12
  213. package/src/fetchers/repo.ts +27 -40
  214. package/src/fetchers/stats.ts +157 -366
  215. package/src/fetchers/top-languages.ts +27 -55
  216. package/src/fetchers/types.ts +27 -1
  217. package/src/fetchers/wakatime.ts +5 -9
  218. package/src/graphql/contributionsCollection.ts +18 -0
  219. package/src/graphql/contributionsDocument.ts +14 -19
  220. package/src/graphql/generated/contributed-to.ts +25 -0
  221. package/src/graphql/generated/gist.ts +1 -1
  222. package/src/graphql/generated/repo.ts +1 -1
  223. package/src/graphql/generated/stats.ts +21 -8
  224. package/src/graphql/generated/top-languages.ts +2 -2
  225. package/src/graphql/graphqlDocument.ts +1 -2
  226. package/src/graphql/queries/contributed-to.graphql +8 -0
  227. package/src/graphql/queries/stats.graphql +14 -2
  228. package/src/graphql/reposContributedToDocument.ts +16 -15
  229. package/src/markup/css.ts +118 -0
  230. package/src/markup/element.ts +246 -0
  231. package/src/markup/html.ts +34 -0
  232. package/src/markup/index.ts +9 -0
  233. package/src/markup/serialize.ts +27 -0
  234. package/src/themes/index.ts +1 -1
  235. package/src/translations.ts +699 -629
  236. package/build/cards/index.d.ts +0 -11
  237. package/build/cards/index.d.ts.map +0 -1
  238. package/build/cards/index.js +0 -7
  239. package/build/common/html.d.ts +0 -8
  240. package/build/common/html.d.ts.map +0 -1
  241. package/build/common/html.js +0 -14
  242. package/build/index.d.ts +0 -15
  243. package/build/index.d.ts.map +0 -1
  244. package/build/index.js +0 -10
  245. package/src/_emoji-name-map.d.ts +0 -10
  246. package/src/_github-username-regex.d.ts +0 -4
  247. package/src/cards/index.ts +0 -21
  248. package/src/common/html.ts +0 -17
  249. package/src/index.ts +0 -18
@@ -1,27 +1,75 @@
1
- import { Card } from '../common/Card.js';
2
- import { getLightDarkColors } from '../common/color.js';
3
- import type { CardColors } from '../common/color.js';
4
- import { CardError } from '../common/error.js';
5
- import { I18n } from '../common/I18n.js';
6
- import { icons, rankIcon } from '../common/icons.js';
7
- import { buildSearchFilter, clampValue } from '../common/ops.js';
8
- import { createTextNode, flexLayout, measureText } from '../common/render.js';
9
- import type { StatsData } from '../fetchers/types.js';
10
- import { statCardLocales, wakatimeCardLocales } from '../translations.js';
11
-
12
- import type { CardOptions, CommonCardOptions } from './options.js';
1
+ import {
2
+ CARD_ICON,
3
+ CARD_WIDTH,
4
+ FONT_SIZE,
5
+ FONT_WEIGHT,
6
+ firefoxFontSize,
7
+ font,
8
+ } from '../common/brand.ts';
9
+ import { Card } from '../common/Card.ts';
10
+ import { getLightDarkColors } from '../common/color.ts';
11
+ import type { CardColors } from '../common/color.ts';
12
+ import type { GitHubDateRange } from '../common/date.ts';
13
+ import { formatRange } from '../common/date.ts';
14
+ import { CardError } from '../common/error.ts';
15
+ import { I18n } from '../common/I18n.ts';
16
+ import { icons, rankIcon } from '../common/icons.ts';
17
+ import { buildSearchFilter, clampValue } from '../common/ops.ts';
18
+ import { NUMBER_FORMATS, createTextNode, flexLayout, measureText } from '../common/render.ts';
19
+ import type { StatsData } from '../fetchers/types.ts';
20
+ import type { Child, CssChild } from '../markup/index.ts';
21
+ import { atRule, cssComment, el, rule } from '../markup/index.ts';
22
+ import { statCardLocales, wakatimeCardLocales } from '../translations.ts';
23
+
24
+ import type { CardOptions, CommonCardOptions } from './options.ts';
13
25
 
14
26
  const CARD_MIN_WIDTH = 287;
15
- const CARD_DEFAULT_WIDTH = 287;
16
- const RANK_CARD_MIN_WIDTH = 420;
17
- const RANK_CARD_DEFAULT_WIDTH = 450;
18
- const RANK_ONLY_CARD_MIN_WIDTH = 290;
19
- const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
27
+ const CARD_DEFAULT_WIDTH = CARD_WIDTH.compact;
28
+ const RANK_CARD_DEFAULT_WIDTH = CARD_WIDTH.wide;
29
+ const RANK_ONLY_CARD_DEFAULT_WIDTH = CARD_WIDTH.compact;
30
+
31
+ const STAT_FONT_SIZE = FONT_SIZE.body;
32
+ /** Padding the card keeps at its edges; matches `Card`'s own `paddingX`. */
33
+ const CARD_PADDING_X = 25;
34
+ /** How far a stat row is translated into the card; see `createTextNode`. */
35
+ const STAT_ROW_X = 25;
36
+ /** Room the rank ring needs at the right edge, so a value never runs into it. */
37
+ const RANK_GUTTER = 120;
38
+ /** The ring's own `cx`, which its group's translation has to make up for. */
39
+ const RANK_CIRCLE_CX_OFFSET = 10;
40
+ /** `createTextNode`'s own label offset, which it applies only when icons are shown. */
41
+ const LABEL_X_OFFSET = 25;
42
+ /** Smallest gap kept between the longest label and its value. */
43
+ const LABEL_VALUE_GAP = 16;
20
44
 
21
45
  /** Rank indicators the card can draw; the api validates `rank_icon` against this. */
22
46
  const RANK_ICONS = ['default', 'github', 'percentile'] as const;
23
47
  type RankIcon = (typeof RANK_ICONS)[number];
24
48
 
49
+ /** Stats the card draws only when `show` names them. */
50
+ const SHOW_STATS = [
51
+ 'contributions',
52
+ 'prs_merged',
53
+ 'prs_merged_percentage',
54
+ 'reviews',
55
+ 'discussions_started',
56
+ 'discussions_answered',
57
+ 'prs_authored',
58
+ 'prs_commented',
59
+ 'prs_reviewed',
60
+ 'issues_authored',
61
+ 'issues_commented',
62
+ 'all_time_contribs',
63
+ ] as const;
64
+ type ShowStat = (typeof SHOW_STATS)[number];
65
+
66
+ /** Stats the card always draws, and so the ones `hide` has anything to remove. */
67
+ const HIDE_STATS = ['stars', 'commits', 'prs', 'issues', 'contribs'] as const;
68
+ type HideStat = (typeof HIDE_STATS)[number];
69
+
70
+ /** Every stat the card can draw: the ones it always draws, plus the ones `show` adds. */
71
+ type StatId = HideStat | ShowStat;
72
+
25
73
  interface StatCardOptions extends CommonCardOptions {
26
74
  locale: string;
27
75
  hide: Array<string>;
@@ -30,7 +78,6 @@ interface StatCardOptions extends CommonCardOptions {
30
78
  card_width: number;
31
79
  hide_rank: boolean;
32
80
  include_all_commits: boolean;
33
- commits_year: number;
34
81
  line_height: number | string;
35
82
  custom_title: string;
36
83
  disable_animations: boolean;
@@ -44,7 +91,7 @@ interface StatCardOptions extends CommonCardOptions {
44
91
 
45
92
  /** Meta data for a stat, used to build its text node and accessibility label. */
46
93
  interface StatItem {
47
- icon: string;
94
+ icon: Child;
48
95
  label: string;
49
96
  value: number | string;
50
97
  id: string;
@@ -53,7 +100,7 @@ interface StatItem {
53
100
  }
54
101
 
55
102
  /** Long locales that need more space for text. Keep sorted alphabetically. */
56
- const LONG_LOCALES: Array<string> = [
103
+ const LONG_LOCALES: ReadonlySet<string> = new Set([
57
104
  'az',
58
105
  'bg',
59
106
  'cs',
@@ -80,58 +127,37 @@ const LONG_LOCALES: Array<string> = [
80
127
  'uk-ua',
81
128
  'uz',
82
129
  'zh-tw',
83
- ];
130
+ ]);
84
131
 
85
132
  /**
86
133
  * Calculates progress along the boundary of the circle, i.e. its circumference.
87
134
  *
88
- * @param value The rank value to calculate progress for.
89
135
  * @returns Progress value.
90
136
  */
91
137
  const calculateCircleProgress = (value: number): number => {
92
138
  const radius = 40;
93
- const c = Math.PI * (radius * 2);
94
-
95
- if (value < 0) {
96
- value = 0;
97
- }
98
- if (value > 100) {
99
- value = 100;
100
- }
139
+ const circumference = Math.PI * (radius * 2);
140
+ const clamped = Math.min(Math.max(value, 0), 100);
101
141
 
102
- return ((100 - value) / 100) * c;
142
+ return ((100 - clamped) / 100) * circumference;
103
143
  };
104
144
 
105
145
  /**
106
146
  * Retrieves the animation to display progress along the circumference of circle
107
147
  * from the beginning to the given value in a clockwise direction.
108
148
  *
109
- * @param props The props object.
110
- * @param props.progress The progress value to animate to.
111
149
  * @returns Progress animation css.
112
150
  */
113
- const getProgressAnimation = ({ progress }: { progress: number }): string => {
114
- return `
115
- @keyframes rankAnimation {
116
- from {
117
- stroke-dashoffset: ${calculateCircleProgress(0)};
118
- }
119
- to {
120
- stroke-dashoffset: ${calculateCircleProgress(progress)};
121
- }
122
- }
123
- `;
124
- };
151
+ const getProgressAnimation = ({ progress }: { progress: number }): CssChild =>
152
+ atRule(
153
+ '@keyframes rankAnimation',
154
+ rule('from', { 'stroke-dashoffset': calculateCircleProgress(0) }),
155
+ rule('to', { 'stroke-dashoffset': calculateCircleProgress(progress) }),
156
+ );
125
157
 
126
158
  /**
127
159
  * Retrieves CSS styles for a card.
128
160
  *
129
- * @param colors The colors to use for the card.
130
- * @param colors.textColor The text color.
131
- * @param colors.iconColor The icon color.
132
- * @param colors.ringColor The ring color.
133
- * @param colors.show_icons Whether to show icons.
134
- * @param colors.progress The progress value to animate to.
135
161
  * @returns Card CSS styles.
136
162
  */
137
163
  const getStyles = ({
@@ -146,88 +172,69 @@ const getStyles = ({
146
172
  ringColor: string;
147
173
  show_icons: boolean;
148
174
  progress: number;
149
- }): string => {
150
- return `
151
- .stat {
152
- font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
153
- }
154
- @supports(-moz-appearance: auto) {
155
- /* Selector detects Firefox */
156
- .stat { font-size:12px; }
157
- }
158
- .stagger {
159
- opacity: 0;
160
- animation: fadeInAnimation 0.3s ease-in-out forwards;
161
- }
162
- .rank-text {
163
- font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
164
- animation: scaleInAnimation 0.3s ease-in-out forwards;
165
- }
166
- .rank-percentile-header {
167
- font-size: 14px;
168
- }
169
- .rank-percentile-text {
170
- font-size: 16px;
171
- }
172
-
173
- .not_bold { font-weight: 400 }
174
- .bold { font-weight: 700 }
175
- .icon {
176
- fill: ${iconColor};
177
- display: ${show_icons ? 'block' : 'none'};
178
- }
179
-
180
- .rank-circle-rim {
181
- stroke: ${ringColor};
182
- fill: none;
183
- stroke-width: 6;
184
- opacity: 0.2;
185
- }
186
- .rank-circle {
187
- stroke: ${ringColor};
188
- stroke-dasharray: 250;
189
- fill: none;
190
- stroke-width: 6;
191
- stroke-linecap: round;
192
- opacity: 0.8;
193
- transform-origin: -10px 8px;
194
- transform: rotate(-90deg);
195
- animation: rankAnimation 1s forwards ease-in-out;
196
- }
197
- ${getProgressAnimation({ progress })}
198
- `;
199
- };
175
+ }): Array<CssChild> => [
176
+ rule('.stat', {
177
+ font: font('regular', 'body'),
178
+ fill: textColor,
179
+ 'font-variant-numeric': 'tabular-nums',
180
+ }),
181
+ firefoxFontSize(['.stat'], 'small'),
182
+ rule('.stagger', { opacity: 0, animation: 'fadeInAnimation 0.3s ease-in-out forwards' }),
183
+ rule('.rank-text', {
184
+ font: font('bold', 'display'),
185
+ fill: textColor,
186
+ animation: 'scaleInAnimation 0.3s ease-in-out forwards',
187
+ }),
188
+ rule('.rank-percentile-header', { 'font-size': `${String(FONT_SIZE.body)}px` }),
189
+ rule('.rank-percentile-text', { 'font-size': `${String(FONT_SIZE.lead)}px` }),
190
+ cssComment('Labels recede so that the values read first.'),
191
+ rule('.not_bold', { 'font-weight': FONT_WEIGHT.regular, opacity: 0.75 }),
192
+ rule('.bold', { 'font-weight': FONT_WEIGHT.semibold }),
193
+ rule('.icon', {
194
+ fill: iconColor,
195
+ opacity: 0.75,
196
+ display: show_icons ? 'block' : 'none',
197
+ }),
198
+ rule('.rank-circle-rim', {
199
+ stroke: ringColor,
200
+ fill: 'none',
201
+ 'stroke-width': 4,
202
+ opacity: 0.15,
203
+ }),
204
+ rule('.rank-circle', {
205
+ stroke: ringColor,
206
+ 'stroke-dasharray': 250,
207
+ fill: 'none',
208
+ 'stroke-width': 4,
209
+ 'stroke-linecap': 'round',
210
+ opacity: 1,
211
+ 'transform-origin': '-10px 8px',
212
+ transform: 'rotate(-90deg)',
213
+ animation: 'rankAnimation 0.8s forwards ease-in-out',
214
+ }),
215
+ getProgressAnimation({ progress }),
216
+ ];
200
217
 
201
218
  /**
202
- * Return the label for commits according to the selected options
203
- *
204
- * @param include_all_commits Option to include all years
205
- * @param commits_year Option to include only selected year
206
- * @param i18n The I18n instance.
207
- * @returns The label corresponding to the options.
219
+ * @returns What the commit count covers, for the stat's label.
208
220
  */
209
- const getTotalCommitsYearLabel = (
221
+ const getTotalCommitsRangeLabel = (
210
222
  include_all_commits: boolean,
211
- commits_year: number | undefined,
223
+ commitsRange: GitHubDateRange | undefined,
212
224
  i18n: I18n,
213
225
  ): string =>
214
226
  include_all_commits
215
227
  ? ''
216
- : commits_year
217
- ? ` (${commits_year})`
228
+ : commitsRange
229
+ ? ` (${formatRange(commitsRange)})`
218
230
  : ` (${i18n.t('wakatimecard.lastyear')})`;
219
231
 
220
232
  /**
221
233
  * Renders the stats card.
222
234
  *
223
- * @param stats The stats data.
224
- * @param options The card options.
225
- * @param username GitHub username, used to build stat search links.
226
- * @param repo Repositories to scope the search links to.
227
- * @param owner Owners to scope the search links to.
228
235
  * @returns The stats card SVG object.
229
236
  */
230
- const renderStatsCard = (
237
+ const renderCard = (
231
238
  stats: StatsData,
232
239
  options: CardOptions<StatCardOptions> = {},
233
240
  username?: string,
@@ -238,6 +245,7 @@ const renderStatsCard = (
238
245
  name,
239
246
  totalStars,
240
247
  totalCommits,
248
+ commitsRange,
241
249
  totalIssues,
242
250
  totalPRs,
243
251
  totalPRsMerged,
@@ -263,7 +271,6 @@ const renderStatsCard = (
263
271
  card_width,
264
272
  hide_rank = false,
265
273
  include_all_commits = false,
266
- commits_year,
267
274
  line_height = 25,
268
275
  text_bold = true,
269
276
  custom_title,
@@ -276,10 +283,13 @@ const renderStatsCard = (
276
283
  show = [],
277
284
  } = options;
278
285
 
279
- const lheight = parseInt(String(line_height), 10);
286
+ const lheight = Number.parseInt(String(line_height), 10);
280
287
 
281
288
  const { lightColors, darkColors } = getLightDarkColors(options);
282
289
 
290
+ // Typed against the exported list, so a stat drawn here cannot be missing from it.
291
+ const shows = (stat: ShowStat): boolean => show.includes(stat);
292
+
283
293
  const apostrophe = /s$/i.test(name.trim()) ? '' : 's';
284
294
  const i18n = new I18n({
285
295
  locale,
@@ -290,16 +300,16 @@ const renderStatsCard = (
290
300
  });
291
301
 
292
302
  // Meta data for creating text nodes with createTextNode function
293
- const STATS: Record<string, StatItem> = {};
294
-
295
- STATS['stars'] = {
296
- icon: icons.star,
297
- label: i18n.t('statcard.totalstars'),
298
- value: totalStars,
299
- id: 'stars',
303
+ const STATS: Partial<Record<StatId, StatItem>> = {
304
+ stars: {
305
+ icon: icons.star,
306
+ label: i18n.t('statcard.totalstars'),
307
+ value: totalStars,
308
+ id: 'stars',
309
+ },
300
310
  };
301
311
 
302
- if (show.includes('contributions')) {
312
+ if (shows('contributions')) {
303
313
  STATS['contributions'] = {
304
314
  icon: icons.contributions,
305
315
  label: i18n.t('statcard.contributions'),
@@ -310,9 +320,9 @@ const renderStatsCard = (
310
320
 
311
321
  STATS['commits'] = {
312
322
  icon: icons.commits,
313
- label: `${i18n.t('statcard.commits')}${getTotalCommitsYearLabel(
323
+ label: `${i18n.t('statcard.commits')}${getTotalCommitsRangeLabel(
314
324
  include_all_commits,
315
- commits_year,
325
+ commitsRange,
316
326
  i18n,
317
327
  )}`,
318
328
  value: totalCommits,
@@ -325,7 +335,7 @@ const renderStatsCard = (
325
335
  id: 'prs',
326
336
  };
327
337
 
328
- if (show.includes('prs_merged')) {
338
+ if (shows('prs_merged')) {
329
339
  STATS['prs_merged'] = {
330
340
  icon: icons.prs_merged,
331
341
  label: i18n.t('statcard.prs-merged'),
@@ -334,7 +344,7 @@ const renderStatsCard = (
334
344
  };
335
345
  }
336
346
 
337
- if (show.includes('prs_merged_percentage')) {
347
+ if (shows('prs_merged_percentage')) {
338
348
  STATS['prs_merged_percentage'] = {
339
349
  icon: icons.prs_merged_percentage,
340
350
  label: i18n.t('statcard.prs-merged-percentage'),
@@ -348,7 +358,7 @@ const renderStatsCard = (
348
358
  };
349
359
  }
350
360
 
351
- if (show.includes('reviews')) {
361
+ if (shows('reviews')) {
352
362
  STATS['reviews'] = {
353
363
  icon: icons.reviews,
354
364
  label: i18n.t('statcard.reviews'),
@@ -364,7 +374,7 @@ const renderStatsCard = (
364
374
  id: 'issues',
365
375
  };
366
376
 
367
- if (show.includes('discussions_started')) {
377
+ if (shows('discussions_started')) {
368
378
  STATS['discussions_started'] = {
369
379
  icon: icons.discussions_started,
370
380
  label: i18n.t('statcard.discussions-started'),
@@ -372,7 +382,7 @@ const renderStatsCard = (
372
382
  id: 'discussions_started',
373
383
  };
374
384
  }
375
- if (show.includes('discussions_answered')) {
385
+ if (shows('discussions_answered')) {
376
386
  STATS['discussions_answered'] = {
377
387
  icon: icons.discussions_answered,
378
388
  label: i18n.t('statcard.discussions-answered'),
@@ -383,7 +393,7 @@ const renderStatsCard = (
383
393
 
384
394
  const repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
385
395
  const encodedUsername = encodeURIComponent(username ?? '');
386
- if (show.includes('prs_authored')) {
396
+ if (shows('prs_authored')) {
387
397
  STATS['prs_authored'] = {
388
398
  icon: icons.prs,
389
399
  label: i18n.t('statcard.prs-authored'),
@@ -392,7 +402,7 @@ const renderStatsCard = (
392
402
  link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=pullrequests`,
393
403
  };
394
404
  }
395
- if (show.includes('prs_commented')) {
405
+ if (shows('prs_commented')) {
396
406
  STATS['prs_commented'] = {
397
407
  icon: icons.comments,
398
408
  label: i18n.t('statcard.prs-commented'),
@@ -401,7 +411,7 @@ const renderStatsCard = (
401
411
  link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
402
412
  };
403
413
  }
404
- if (show.includes('prs_reviewed')) {
414
+ if (shows('prs_reviewed')) {
405
415
  STATS['prs_reviewed'] = {
406
416
  icon: icons.reviews,
407
417
  label: i18n.t('statcard.prs-reviewed'),
@@ -410,7 +420,7 @@ const renderStatsCard = (
410
420
  link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
411
421
  };
412
422
  }
413
- if (show.includes('issues_authored')) {
423
+ if (shows('issues_authored')) {
414
424
  STATS['issues_authored'] = {
415
425
  icon: icons.issues,
416
426
  label: i18n.t('statcard.issues-authored'),
@@ -419,7 +429,7 @@ const renderStatsCard = (
419
429
  link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=issues`,
420
430
  };
421
431
  }
422
- if (show.includes('issues_commented')) {
432
+ if (shows('issues_commented')) {
423
433
  STATS['issues_commented'] = {
424
434
  icon: icons.discussions_started,
425
435
  label: i18n.t('statcard.issues-commented'),
@@ -430,103 +440,107 @@ const renderStatsCard = (
430
440
  }
431
441
 
432
442
  STATS['contribs'] = {
433
- icon: icons.contribs,
443
+ icon: icons.repo,
434
444
  label: i18n.t('statcard.contribs'),
435
445
  value: contributedTo,
436
446
  id: 'contribs',
437
447
  };
438
448
 
439
- if (show.includes('all_time_contribs')) {
449
+ if (shows('all_time_contribs')) {
440
450
  STATS['all_time_contribs'] = {
441
- icon: icons.contribs,
451
+ icon: icons.repo,
442
452
  label: i18n.t('statcard.all-time-contribs'),
443
453
  value: allTimeContributedTo,
444
454
  id: 'all_time_contribs',
445
455
  };
446
456
  }
447
457
 
448
- const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
458
+ const isLongLocale = locale ? LONG_LOCALES.has(locale) : false;
449
459
 
450
- // check if all used labels are short
451
- const longLabels = Object.entries(STATS)
452
- .filter(([key]) => !hide.includes(key))
453
- .some(([, stat]) => stat.label.length > 18);
454
-
455
- // filter out hidden stats defined by user & create the text nodes
456
- const statItems = Object.entries(STATS)
457
- .filter(([key]) => !hide.includes(key))
458
- // pass index so that we can calculate the line spacing
459
- .map(([, stat], index) =>
460
- createTextNode({
461
- icon: stat.icon,
462
- label: stat.label,
463
- value: stat.value,
464
- id: stat.id,
465
- unitSymbol: stat.unitSymbol,
466
- index,
467
- showIcons: show_icons,
468
- shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0),
469
- bold: text_bold,
470
- numberFormat: number_format,
471
- numberPrecision: number_precision,
472
- link: stat.link,
473
- }),
474
- );
460
+ // filter out hidden stats defined by user
461
+ const visibleStats = Object.entries(STATS).filter(([key]) => !hide.includes(key));
475
462
 
476
- if (statItems.length === 0 && hide_rank) {
463
+ if (visibleStats.length === 0 && hide_rank) {
477
464
  throw new CardError('Could not render stats card.', {
478
465
  code: 'invalid_param',
479
466
  secondaryMessage: 'Either stats or rank are required.',
480
467
  });
481
468
  }
482
469
 
483
- // Calculate the card height depending on how many items there are
484
- // but if rank circle is visible clamp the minimum height to `150`
470
+ // check if all used labels are short
471
+ const longLabels = visibleStats.some(([, stat]) => stat.label.length > 18);
472
+
473
+ // The rank ring sets the floor: 150 beside the stats, 180 when it is the whole card.
485
474
  const height = Math.max(
486
- 45 + (statItems.length + 1) * lheight,
487
- hide_rank ? 0 : statItems.length ? 150 : 180,
475
+ 45 + (visibleStats.length + 1) * lheight,
476
+ hide_rank ? 0 : visibleStats.length > 0 ? 150 : 180,
488
477
  );
489
478
 
490
479
  // the lower the user's percentile the better
491
480
  const progress = 100 - rank.percentile;
492
481
 
493
- const calculateTextWidth = () => {
494
- return measureText(
495
- custom_title
496
- ? custom_title
497
- : statItems.length
498
- ? i18n.t('statcard.title')
499
- : i18n.t('statcard.ranktitle'),
482
+ const calculateTextWidth = (): number =>
483
+ measureText(
484
+ custom_title ||
485
+ (visibleStats.length > 0 ? i18n.t('statcard.title') : i18n.t('statcard.ranktitle')),
500
486
  );
501
- };
502
487
 
503
- /*
504
- When hide_rank=true, the minimum card width is 270 px + the title length and padding.
505
- When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
506
- Numbers are picked by looking at existing dimensions on production.
507
- */
508
- const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0;
509
- const minCardWidth =
510
- (hide_rank
511
- ? clampValue(50 /* padding */ + calculateTextWidth() * 2, CARD_MIN_WIDTH, Infinity)
512
- : statItems.length
513
- ? RANK_CARD_MIN_WIDTH
514
- : RANK_ONLY_CARD_MIN_WIDTH) + iconWidth;
515
- const defaultCardWidth =
516
- (hide_rank
517
- ? CARD_DEFAULT_WIDTH
518
- : statItems.length
519
- ? RANK_CARD_DEFAULT_WIDTH
520
- : RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth;
521
- const width = card_width
522
- ? isNaN(card_width)
523
- ? Math.max(defaultCardWidth, minCardWidth)
524
- : card_width
525
- : Math.max(defaultCardWidth, minCardWidth);
488
+ const iconWidth = show_icons && visibleStats.length > 0 ? 16 + /* padding */ 1 : 0;
489
+ // The icons ride inside the step's own slack, so a default card lands on the width grid;
490
+ // only the minimum, which has no slack, still has to make room for them.
491
+ const defaultCardWidth = hide_rank
492
+ ? CARD_DEFAULT_WIDTH
493
+ : visibleStats.length > 0
494
+ ? RANK_CARD_DEFAULT_WIDTH
495
+ : RANK_ONLY_CARD_DEFAULT_WIDTH;
496
+ // Only a card sized by its own title can outgrow its step: the ranked layouts are fixed,
497
+ // so their step is also their floor.
498
+ const fittedWidth = hide_rank
499
+ ? Math.max(
500
+ defaultCardWidth,
501
+ clampValue(50 /* padding */ + calculateTextWidth() * 2, CARD_MIN_WIDTH, Infinity) +
502
+ iconWidth,
503
+ )
504
+ : defaultCardWidth;
505
+ const width = card_width ? (Number.isNaN(card_width) ? fittedWidth : card_width) : fittedWidth;
506
+
507
+ // A value ends at the card's inner edge, clear of the rank ring — or right after
508
+ // the longest label, on a card too narrow for that.
509
+ const widestLabel =
510
+ visibleStats.length > 0
511
+ ? Math.max(...visibleStats.map(([, stat]) => measureText(`${stat.label}:`, STAT_FONT_SIZE)))
512
+ : 0;
513
+ const valueAnchorX = Math.round(
514
+ Math.max(
515
+ width - CARD_PADDING_X - STAT_ROW_X - (hide_rank ? 0 : RANK_GUTTER),
516
+ (show_icons ? LABEL_X_OFFSET : 0) + widestLabel + LABEL_VALUE_GAP,
517
+ ),
518
+ );
519
+
520
+ // pass index so that we can calculate the line spacing
521
+ const statItems = visibleStats.map(([, stat], index) =>
522
+ createTextNode({
523
+ icon: stat.icon,
524
+ label: stat.label,
525
+ value: stat.value,
526
+ id: stat.id,
527
+ unitSymbol: stat.unitSymbol,
528
+ index,
529
+ showIcons: show_icons,
530
+ shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0),
531
+ valueAnchorX,
532
+ bold: text_bold,
533
+ labelBold: false,
534
+ numberFormat: number_format,
535
+ numberPrecision: number_precision,
536
+ link: stat.link,
537
+ }),
538
+ );
526
539
 
527
540
  const card = new Card({
528
541
  customTitle: custom_title,
529
- defaultTitle: statItems.length ? i18n.t('statcard.title') : i18n.t('statcard.ranktitle'),
542
+ defaultTitle: visibleStats.length > 0 ? i18n.t('statcard.title') : i18n.t('statcard.ranktitle'),
543
+ titlePrefixIcon: CARD_ICON.stats,
530
544
  width,
531
545
  height,
532
546
  border_radius,
@@ -535,7 +549,7 @@ const renderStatsCard = (
535
549
 
536
550
  card.setHideBorder(hide_border);
537
551
  card.setHideTitle(hide_title);
538
- const cardStyles = ({ ringColor, textColor, iconColor }: CardColors): string =>
552
+ const cardStyles = ({ ringColor, textColor, iconColor }: CardColors): Array<CssChild> =>
539
553
  getStyles({ ringColor, textColor, iconColor, show_icons, progress });
540
554
 
541
555
  card.setCSS({ light: cardStyles, dark: cardStyles });
@@ -545,49 +559,37 @@ const renderStatsCard = (
545
559
  }
546
560
 
547
561
  /**
548
- * Calculates the right rank circle translation values such that the rank circle
549
- * keeps respecting the following padding:
550
- *
551
- * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
552
- * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
553
- * equally from a certain minimum at RANK_CARD_MIN_WIDTH.
562
+ * The ring sits centred in the same gutter the values are kept clear of, so the gap
563
+ * between them holds at every width; a rank-only card has no values, so it centres.
554
564
  *
555
565
  * @returns Rank circle translation value.
556
566
  */
557
- const calculateRankXTranslation = (): number => {
558
- if (statItems.length) {
559
- const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
560
- if (width > RANK_CARD_DEFAULT_WIDTH) {
561
- const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
562
- return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
563
- } else {
564
- return minXTranslation + (width - minCardWidth) / 2;
565
- }
566
- } else {
567
- return width / 2 + 20 - 10;
568
- }
569
- };
567
+ const calculateRankXTranslation = (): number =>
568
+ visibleStats.length > 0
569
+ ? width - CARD_PADDING_X - RANK_GUTTER / 2 + RANK_CIRCLE_CX_OFFSET
570
+ : width / 2 + 20 - 10;
570
571
 
571
572
  // Conditionally rendered elements
572
- const rankCircle = hide_rank
573
- ? ''
574
- : `<g data-testid="rank-circle"
575
- transform="translate(${calculateRankXTranslation()}, ${height / 2 - 50})">
576
- <circle class="rank-circle-rim" cx="-10" cy="8" r="40" />
577
- <circle class="rank-circle" cx="-10" cy="8" r="40" />
578
- <g class="rank-text">
579
- ${rankIcon(rank_icon, rank.level, rank.percentile)}
580
- </g>
581
- </g>`;
573
+ const rankCircle =
574
+ !hide_rank &&
575
+ el(
576
+ 'g',
577
+ {
578
+ 'data-testid': 'rank-circle',
579
+ transform: `translate(${calculateRankXTranslation()}, ${height / 2 - 50})`,
580
+ },
581
+ el('circle', { class: 'rank-circle-rim', cx: -RANK_CIRCLE_CX_OFFSET, cy: 8, r: 40 }),
582
+ el('circle', { class: 'rank-circle', cx: -RANK_CIRCLE_CX_OFFSET, cy: 8, r: 40 }),
583
+ el('g', { class: 'rank-text' }, rankIcon(rank_icon, rank.level, rank.percentile)),
584
+ );
582
585
 
583
586
  // Accessibility Labels
584
- const labels = Object.entries(STATS)
585
- .filter(([key]) => !hide.includes(key))
587
+ const labels = visibleStats
586
588
  .map(([key, stat]) => {
587
589
  if (key === 'commits') {
588
- return `${i18n.t('statcard.commits')} ${getTotalCommitsYearLabel(
590
+ return `${i18n.t('statcard.commits')} ${getTotalCommitsRangeLabel(
589
591
  include_all_commits,
590
- commits_year,
592
+ commitsRange,
591
593
  i18n,
592
594
  )} : ${stat.value}`;
593
595
  }
@@ -596,20 +598,28 @@ const renderStatsCard = (
596
598
  .join(', ');
597
599
 
598
600
  card.setAccessibilityLabel({
599
- title: `${card.title}, Rank: ${rank.level}`,
601
+ title: `${card.title}, ${i18n.t('statcard.rank')}: ${rank.level}`,
600
602
  desc: labels,
601
603
  });
602
604
 
603
- return card.render(`
604
- ${rankCircle}
605
- <svg x="0" y="0">
606
- ${flexLayout({
607
- items: statItems,
608
- gap: lheight,
609
- direction: 'column',
610
- }).join('')}
611
- </svg>
612
- `);
605
+ return card.render([
606
+ rankCircle,
607
+ el('svg', { x: 0, y: 0 }, flexLayout({ items: statItems, gap: lheight, direction: 'column' })),
608
+ ]);
613
609
  };
614
610
 
615
- export { RANK_ICONS, renderStatsCard };
611
+ /**
612
+ * The card, and the values each of its options accepts, keyed by the option's own name.
613
+ * They ride on the renderer so a list cannot be found without what draws it;
614
+ * the api handler forwards them onto its own export, which is what a UI reads.
615
+ */
616
+ const renderStatsCard = Object.assign(renderCard, {
617
+ OPTIONS: {
618
+ rank_icon: RANK_ICONS,
619
+ show: SHOW_STATS,
620
+ hide: HIDE_STATS,
621
+ number_format: NUMBER_FORMATS,
622
+ },
623
+ });
624
+
625
+ export { renderStatsCard };