locmeter 0.1.3 → 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.
package/README.md CHANGED
@@ -12,6 +12,8 @@ npx locmeter
12
12
 
13
13
  ![Example locmeter output](./examples/jojo-weekly.png)
14
14
 
15
+ ![Example locmeter added/deleted/sum output](./examples/jojo-weekly-added-deleted-sum.png)
16
+
15
17
  ## Requirements
16
18
 
17
19
  - Node.js 18+
@@ -41,6 +43,7 @@ npm install -g locmeter
41
43
  Common options:
42
44
 
43
45
  - default bucket: `week`
46
+ - default mode: `sum`
44
47
  - default `--to`: today
45
48
  - default `--from`: one year before `--to`
46
49
  - default author identity: auto-detected from your current `gh` login
@@ -50,6 +53,7 @@ Common options:
50
53
  - `--to YYYY-MM-DD`
51
54
  - `--days N`
52
55
  - `--bucket day|week|month`
56
+ - `--mode sum|added|deleted|added/deleted|added/deleted/sum`
53
57
  - `--root /path/to/repos`
54
58
  - `--search-depth N`
55
59
  - `--author-email you@example.com`
@@ -57,37 +61,28 @@ Common options:
57
61
  - `--output chart.png`
58
62
  - `--json-output data.json`
59
63
 
60
- Example:
64
+ Modes:
61
65
 
62
- ```bash
63
- locmeter \
64
- --from 2025-01-01 \
65
- --to 2025-12-31 \
66
- --bucket week
67
- ```
66
+ - `sum`
67
+ - `added`
68
+ - `deleted`
69
+ - `added/deleted`
70
+ - `added/deleted/sum`
68
71
 
69
- Real example generated from your usage:
72
+ Example:
70
73
 
71
74
  ```bash
72
75
  locmeter \
73
76
  --root ~/Developer \
74
- --output examples/jojo-weekly.png \
75
- --json-output examples/jojo-weekly.json
77
+ --bucket week \
78
+ --mode added/deleted/sum \
79
+ --output examples/jojo-weekly-added-deleted-sum.png \
80
+ --json-output examples/jojo-weekly-added-deleted-sum.json
76
81
  ```
77
82
 
78
- That example produced:
79
-
80
- - `examples/jojo-weekly.png`
81
- - `examples/jojo-weekly.json`
82
- - date range: `2025-03-13` to `2026-03-13`
83
- - bucket: `week`
84
- - total lines changed: `1,059,347`
85
- - peak week: `207,431`
86
-
87
83
  The CLI prints the generated PNG path and JSON path on success.
88
84
 
89
85
  ## Notes
90
86
 
91
87
  - `locmeter` is intended for global CLI usage.
92
- - The npm package metadata is set to `MIT`; add the full MIT license text in a `LICENSE` file before publishing.
93
- - The published package only ships the example PNG, not the example JSON.
88
+ - JSON output includes separate `added`, `deleted`, and `sum` series.
package/bin/locmeter.js CHANGED
@@ -13,9 +13,31 @@ const BG = [247, 248, 250];
13
13
  const PLOT_BG = [255, 255, 255];
14
14
  const GRID = [226, 232, 240];
15
15
  const AXIS = [100, 116, 139];
16
- const LINE = [15, 23, 42];
17
- const FILL = [191, 219, 254];
16
+ const SERIES_STYLES = {
17
+ sum: {
18
+ line: [15, 23, 42],
19
+ fill: [191, 219, 254],
20
+ legend: "SUM"
21
+ },
22
+ added: {
23
+ line: [22, 163, 74],
24
+ fill: [187, 247, 208],
25
+ legend: "ADDED"
26
+ },
27
+ deleted: {
28
+ line: [220, 38, 38],
29
+ fill: [254, 202, 202],
30
+ legend: "DELETED"
31
+ }
32
+ };
18
33
  const TEXT = [30, 41, 59];
34
+ const MODES = {
35
+ sum: ["sum"],
36
+ added: ["added"],
37
+ deleted: ["deleted"],
38
+ "added/deleted": ["added", "deleted"],
39
+ "added/deleted/sum": ["added", "deleted", "sum"]
40
+ };
19
41
 
20
42
  const FONT = {
21
43
  "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
@@ -108,6 +130,7 @@ function logStep(message) {
108
130
  function parseArgs(argv) {
109
131
  const args = {
110
132
  bucket: "week",
133
+ mode: "sum",
111
134
  searchDepth: 3,
112
135
  authorEmail: [],
113
136
  authorName: [],
@@ -129,6 +152,7 @@ function parseArgs(argv) {
129
152
  else if (arg === "--from") args.fromDate = next();
130
153
  else if (arg === "--to") args.toDate = next();
131
154
  else if (arg === "--bucket") args.bucket = next();
155
+ else if (arg === "--mode") args.mode = next();
132
156
  else if (arg === "--root") args.root = next();
133
157
  else if (arg === "--search-depth") args.searchDepth = Number(next());
134
158
  else if (arg === "--author-email") args.authorEmail.push(next());
@@ -146,6 +170,9 @@ function parseArgs(argv) {
146
170
  if (!["day", "week", "month"].includes(args.bucket)) {
147
171
  throw new Error("--bucket must be day, week, or month");
148
172
  }
173
+ if (!Object.hasOwn(MODES, args.mode)) {
174
+ throw new Error(`--mode must be one of ${Object.keys(MODES).join(", ")}`);
175
+ }
149
176
  if (args.days !== undefined && (!Number.isInteger(args.days) || args.days <= 0)) {
150
177
  throw new Error("--days must be a positive integer");
151
178
  }
@@ -163,6 +190,7 @@ function printHelp() {
163
190
  "",
164
191
  "Defaults:",
165
192
  " --bucket week",
193
+ " --mode sum",
166
194
  " --to today",
167
195
  " --from one year before --to",
168
196
  " --author-email/--author-name auto-detected from current gh login",
@@ -173,6 +201,7 @@ function printHelp() {
173
201
  " --from YYYY-MM-DD",
174
202
  " --to YYYY-MM-DD",
175
203
  " --bucket day|week|month",
204
+ " --mode sum|added|deleted|added/deleted|added/deleted/sum",
176
205
  " --root /path/to/repos",
177
206
  " --search-depth N",
178
207
  " --author-email you@example.com",
@@ -497,16 +526,30 @@ async function aggregate(repoPaths, startDate, endDate, bucket, authorEmails, au
497
526
  const rawDaily = new Map();
498
527
  const seen = new Set();
499
528
 
529
+ function ensureTotals(store, key) {
530
+ if (!store.has(key)) {
531
+ store.set(key, { added: 0, deleted: 0, sum: 0 });
532
+ }
533
+ return store.get(key);
534
+ }
535
+
500
536
  for (const output of outputs) {
501
537
  let current = null;
502
- let runningLines = 0;
538
+ let runningTotals = { added: 0, deleted: 0, sum: 0 };
503
539
 
504
540
  for (const line of output.split("\n")) {
505
541
  if (line.startsWith("COMMIT\t")) {
506
542
  if (current && !seen.has(current.sha)) {
507
543
  seen.add(current.sha);
508
- rawDaily.set(current.date, (rawDaily.get(current.date) || 0) + runningLines);
509
- bucketTotals.set(current.bucket, (bucketTotals.get(current.bucket) || 0) + runningLines);
544
+ const daily = ensureTotals(rawDaily, current.date);
545
+ daily.added += runningTotals.added;
546
+ daily.deleted += runningTotals.deleted;
547
+ daily.sum += runningTotals.sum;
548
+
549
+ const bucketTotal = ensureTotals(bucketTotals, current.bucket);
550
+ bucketTotal.added += runningTotals.added;
551
+ bucketTotal.deleted += runningTotals.deleted;
552
+ bucketTotal.sum += runningTotals.sum;
510
553
  }
511
554
  const [, sha, dateStr] = line.split("\t", 5);
512
555
  const commitDate = parseDate(dateStr);
@@ -515,7 +558,7 @@ async function aggregate(repoPaths, startDate, endDate, bucket, authorEmails, au
515
558
  date: dateStr,
516
559
  bucket: dateIso(bucketStart(commitDate, bucket))
517
560
  };
518
- runningLines = 0;
561
+ runningTotals = { added: 0, deleted: 0, sum: 0 };
519
562
  continue;
520
563
  }
521
564
 
@@ -523,14 +566,29 @@ async function aggregate(repoPaths, startDate, endDate, bucket, authorEmails, au
523
566
  const parts = line.split("\t");
524
567
  if (parts.length !== 3) continue;
525
568
  const [added, deleted] = parts;
526
- if (added !== "-") runningLines += Number(added);
527
- if (deleted !== "-") runningLines += Number(deleted);
569
+ if (added !== "-") {
570
+ const addedValue = Number(added);
571
+ runningTotals.added += addedValue;
572
+ runningTotals.sum += addedValue;
573
+ }
574
+ if (deleted !== "-") {
575
+ const deletedValue = Number(deleted);
576
+ runningTotals.deleted += deletedValue;
577
+ runningTotals.sum += deletedValue;
578
+ }
528
579
  }
529
580
 
530
581
  if (current && !seen.has(current.sha)) {
531
582
  seen.add(current.sha);
532
- rawDaily.set(current.date, (rawDaily.get(current.date) || 0) + runningLines);
533
- bucketTotals.set(current.bucket, (bucketTotals.get(current.bucket) || 0) + runningLines);
583
+ const daily = ensureTotals(rawDaily, current.date);
584
+ daily.added += runningTotals.added;
585
+ daily.deleted += runningTotals.deleted;
586
+ daily.sum += runningTotals.sum;
587
+
588
+ const bucketTotal = ensureTotals(bucketTotals, current.bucket);
589
+ bucketTotal.added += runningTotals.added;
590
+ bucketTotal.deleted += runningTotals.deleted;
591
+ bucketTotal.sum += runningTotals.sum;
534
592
  }
535
593
  }
536
594
 
@@ -539,15 +597,29 @@ async function aggregate(repoPaths, startDate, endDate, bucket, authorEmails, au
539
597
  const last = bucketStart(endDate, bucket);
540
598
  while (cursor <= last) {
541
599
  const key = dateIso(cursor);
542
- ordered.set(key, bucketTotals.get(key) || 0);
600
+ ordered.set(key, bucketTotals.get(key) || { added: 0, deleted: 0, sum: 0 });
543
601
  if (bucket === "day") cursor = addDays(cursor, 1);
544
602
  else if (bucket === "week") cursor = addDays(cursor, 7);
545
603
  else cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1));
546
604
  }
547
605
 
606
+ const bucketed = { added: {}, deleted: {}, sum: {} };
607
+ for (const [date, totals] of ordered.entries()) {
608
+ bucketed.added[date] = totals.added;
609
+ bucketed.deleted[date] = totals.deleted;
610
+ bucketed.sum[date] = totals.sum;
611
+ }
612
+
613
+ const daily = { added: {}, deleted: {}, sum: {} };
614
+ for (const [date, totals] of [...rawDaily.entries()].sort(([a], [b]) => a.localeCompare(b))) {
615
+ daily.added[date] = totals.added;
616
+ daily.deleted[date] = totals.deleted;
617
+ daily.sum[date] = totals.sum;
618
+ }
619
+
548
620
  return {
549
- series: Object.fromEntries(ordered),
550
- rawDaily: Object.fromEntries([...rawDaily.entries()].sort(([a], [b]) => a.localeCompare(b)))
621
+ bucketed,
622
+ daily
551
623
  };
552
624
  }
553
625
 
@@ -723,7 +795,7 @@ function formatCompact(value) {
723
795
  }
724
796
 
725
797
  function formatGrouped(value) {
726
- return new Intl.NumberFormat("de-AT").format(value);
798
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
727
799
  }
728
800
 
729
801
  function xLabel(date, bucket) {
@@ -732,9 +804,34 @@ function xLabel(date, bucket) {
732
804
  return `${month} ${date.getUTCDate()}`;
733
805
  }
734
806
 
735
- function renderChart(series, login, startDate, endDate, bucket, outputPath) {
736
- const dates = Object.keys(series).sort();
737
- const values = dates.map((date) => series[date]);
807
+ function modeLabel(mode) {
808
+ return mode.replace(/\//g, " + ");
809
+ }
810
+
811
+ function sumValues(values) {
812
+ return values.reduce((sum, value) => sum + value, 0);
813
+ }
814
+
815
+ function renderLegend(canvas, plotRight, top, seriesKeys) {
816
+ let cursorX = plotRight;
817
+ for (let i = seriesKeys.length - 1; i >= 0; i -= 1) {
818
+ const key = seriesKeys[i];
819
+ const style = SERIES_STYLES[key];
820
+ const label = style.legend;
821
+ const labelWidth = textWidth(label, 2);
822
+ const itemWidth = 24 + 10 + labelWidth;
823
+ cursorX -= itemWidth;
824
+ drawLine(canvas, cursorX, top, cursorX + 20, top, style.line, 4);
825
+ drawText(canvas, cursorX + 30, top - 8, label, TEXT, 2);
826
+ cursorX -= 28;
827
+ }
828
+ }
829
+
830
+ function renderChart(seriesByKey, mode, login, startDate, endDate, bucket, outputPath) {
831
+ const seriesKeys = MODES[mode];
832
+ const primarySeries = seriesByKey[seriesKeys[0]];
833
+ const dates = Object.keys(primarySeries).sort();
834
+ const valuesByKey = Object.fromEntries(seriesKeys.map((key) => [key, dates.map((date) => seriesByKey[key][date])]));
738
835
  const width = 1800;
739
836
  const height = 980;
740
837
  const left = 130;
@@ -747,7 +844,8 @@ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
747
844
  const plotBottom = height - bottom;
748
845
  const plotWidth = plotRight - plotLeft;
749
846
  const plotHeight = plotBottom - plotTop;
750
- const maxValue = values.length ? Math.max(...values) : 0;
847
+ const allValues = seriesKeys.flatMap((key) => valuesByKey[key]);
848
+ const maxValue = allValues.length ? Math.max(...allValues) : 0;
751
849
  const upper = niceUpperBound(Math.max(maxValue, 10));
752
850
 
753
851
  const canvas = createCanvas(width, height, BG);
@@ -763,15 +861,18 @@ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
763
861
  drawLine(canvas, plotLeft, plotBottom, plotRight, plotBottom, AXIS, 2);
764
862
  drawLine(canvas, plotLeft, plotTop, plotLeft, plotBottom, AXIS, 2);
765
863
 
766
- const points = values.map((value, idx) => {
767
- const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, values.length - 1));
768
- const y = plotBottom - Math.floor((value / upper) * plotHeight);
769
- return [x, y];
770
- });
771
- if (points.length) {
772
- drawPolyFill(canvas, points, plotBottom, FILL);
864
+ for (const key of seriesKeys) {
865
+ const style = SERIES_STYLES[key];
866
+ const points = valuesByKey[key].map((value, idx) => {
867
+ const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, dates.length - 1));
868
+ const y = plotBottom - Math.floor((value / upper) * plotHeight);
869
+ return [x, y];
870
+ });
871
+ if (points.length && seriesKeys.length === 1) {
872
+ drawPolyFill(canvas, points, plotBottom, style.fill);
873
+ }
773
874
  for (let i = 0; i < points.length - 1; i += 1) {
774
- drawLine(canvas, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1], LINE, 3);
875
+ drawLine(canvas, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1], style.line, 3);
775
876
  }
776
877
  }
777
878
 
@@ -783,7 +884,7 @@ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
783
884
 
784
885
  let drawnUntil = -1;
785
886
  for (const idx of tickIndexes) {
786
- const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, values.length - 1));
887
+ const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, dates.length - 1));
787
888
  const label = xLabel(parseDate(dates[idx]), bucket);
788
889
  const widthPx = textWidth(label, 2);
789
890
  const labelX = Math.max(plotLeft, Math.min(x - Math.floor(widthPx / 2), plotRight - widthPx));
@@ -793,16 +894,16 @@ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
793
894
  drawnUntil = labelX + widthPx;
794
895
  }
795
896
 
796
- drawText(canvas, 24, 24, `${login} lines per ${bucket}`, TEXT, 5);
897
+ drawText(canvas, 24, 24, `${login} ${modeLabel(mode)} per ${bucket}`, TEXT, 5);
797
898
  drawText(canvas, 24, 74, `${dateIso(startDate)} to ${dateIso(endDate)}`, AXIS, 3);
798
- drawText(
799
- canvas,
800
- 24,
801
- 104,
802
- `Total: ${formatGrouped(values.reduce((sum, value) => sum + value, 0))} Peak: ${formatGrouped(maxValue)}`,
803
- AXIS,
804
- 3
805
- );
899
+ const summary = seriesKeys
900
+ .map((key) => {
901
+ const values = valuesByKey[key];
902
+ return `${SERIES_STYLES[key].legend}: ${formatGrouped(sumValues(values))}/${formatGrouped(values.length ? Math.max(...values) : 0)}`;
903
+ })
904
+ .join(" ");
905
+ drawText(canvas, 24, 104, `Total/Peak: ${summary}`, AXIS, 3);
906
+ if (seriesKeys.length > 1) renderLegend(canvas, plotRight, 56, seriesKeys);
806
907
 
807
908
  savePng(canvas, outputPath);
808
909
  }
@@ -849,7 +950,7 @@ async function main() {
849
950
  }
850
951
 
851
952
  logStep(`Fetching commits from ${repoPaths.length} repos...`);
852
- const { series, rawDaily } = await aggregate(
953
+ const { bucketed, daily } = await aggregate(
853
954
  repoPaths,
854
955
  startDate,
855
956
  endDate,
@@ -858,10 +959,14 @@ async function main() {
858
959
  authorNames
859
960
  );
860
961
 
861
- logStep(`Crunching numbers for ${args.bucket} buckets from ${dateIso(startDate)} to ${dateIso(endDate)}...`);
862
- renderChart(series, login, startDate, endDate, args.bucket, output);
962
+ logStep(
963
+ `Crunching numbers for ${args.bucket} buckets from ${dateIso(startDate)} to ${dateIso(endDate)} in ${args.mode} mode...`
964
+ );
965
+ renderChart(bucketed, args.mode, login, startDate, endDate, args.bucket, output);
863
966
 
864
- const values = Object.values(series);
967
+ const values = Object.values(bucketed.sum);
968
+ const addedValues = Object.values(bucketed.added);
969
+ const deletedValues = Object.values(bucketed.deleted);
865
970
  fs.writeFileSync(
866
971
  jsonOutput,
867
972
  JSON.stringify(
@@ -870,16 +975,27 @@ async function main() {
870
975
  from: dateIso(startDate),
871
976
  to: dateIso(endDate),
872
977
  bucket: args.bucket,
978
+ mode: args.mode,
873
979
  author_emails: authorEmails,
874
980
  author_names: authorNames,
875
981
  root_used: root,
876
982
  searched_roots: searchedRoots,
877
983
  local_repositories_used: repoPaths.map(([name]) => name),
878
984
  missing_repositories: missing,
985
+ total_lines_added: sumValues(addedValues),
986
+ peak_lines_added: addedValues.length ? Math.max(...addedValues) : 0,
987
+ total_lines_deleted: sumValues(deletedValues),
988
+ peak_lines_deleted: deletedValues.length ? Math.max(...deletedValues) : 0,
879
989
  total_lines_changed: values.reduce((sum, value) => sum + value, 0),
880
990
  peak_lines_changed: values.length ? Math.max(...values) : 0,
881
- bucketed_lines_changed: series,
882
- daily_lines_changed: rawDaily
991
+ bucketed_lines_added: bucketed.added,
992
+ bucketed_lines_deleted: bucketed.deleted,
993
+ bucketed_lines_changed: bucketed.sum,
994
+ bucketed_series: bucketed,
995
+ daily_lines_added: daily.added,
996
+ daily_lines_deleted: daily.deleted,
997
+ daily_lines_changed: daily.sum,
998
+ daily_series: daily
883
999
  },
884
1000
  null,
885
1001
  2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locmeter",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Render a PNG chart of lines changed over time from your GitHub contribution repos.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -17,7 +17,8 @@
17
17
  "files": [
18
18
  "bin",
19
19
  "README.md",
20
- "examples/jojo-weekly.png"
20
+ "examples/jojo-weekly.png",
21
+ "examples/jojo-weekly-added-deleted-sum.png"
21
22
  ],
22
23
  "keywords": [
23
24
  "cli",