@grainulation/harvest 1.0.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/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/harvest.js +284 -0
- package/lib/analyzer.js +88 -0
- package/lib/calibration.js +153 -0
- package/lib/dashboard.js +126 -0
- package/lib/decay.js +124 -0
- package/lib/farmer.js +107 -0
- package/lib/patterns.js +199 -0
- package/lib/report.js +125 -0
- package/lib/server.js +494 -0
- package/lib/templates.js +80 -0
- package/lib/velocity.js +177 -0
- package/package.json +51 -0
- package/public/index.html +982 -0
- package/templates/dashboard.html +1230 -0
- package/templates/retrospective.html +315 -0
package/lib/velocity.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sprint timing and phase analysis.
|
|
5
|
+
*
|
|
6
|
+
* Uses git log timestamps and claim metadata to understand:
|
|
7
|
+
* - How long sprints take end-to-end
|
|
8
|
+
* - Which phases take the most time
|
|
9
|
+
* - Where sprints stall (gaps between commits)
|
|
10
|
+
* - Claims-per-day throughput
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PHASE_PREFIXES = {
|
|
14
|
+
cal: 'calibration',
|
|
15
|
+
burn: 'control-burn',
|
|
16
|
+
d: 'define',
|
|
17
|
+
r: 'research',
|
|
18
|
+
p: 'prototype',
|
|
19
|
+
e: 'evaluate',
|
|
20
|
+
f: 'feedback',
|
|
21
|
+
x: 'challenge',
|
|
22
|
+
w: 'witness',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function measureVelocity(sprints) {
|
|
26
|
+
const results = [];
|
|
27
|
+
|
|
28
|
+
for (const sprint of sprints) {
|
|
29
|
+
const claims = sprint.claims;
|
|
30
|
+
const gitLog = sprint.gitLog || [];
|
|
31
|
+
|
|
32
|
+
// Extract timestamps from claims
|
|
33
|
+
const claimDates = claims
|
|
34
|
+
.map(c => c.created || c.date || c.timestamp)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map(d => new Date(d))
|
|
37
|
+
.filter(d => !isNaN(d.getTime()))
|
|
38
|
+
.sort((a, b) => a - b);
|
|
39
|
+
|
|
40
|
+
// Extract timestamps from git log
|
|
41
|
+
const gitDates = gitLog
|
|
42
|
+
.map(g => new Date(g.date))
|
|
43
|
+
.filter(d => !isNaN(d.getTime()))
|
|
44
|
+
.sort((a, b) => a - b);
|
|
45
|
+
|
|
46
|
+
// Use whichever source has data
|
|
47
|
+
const allDates = [...claimDates, ...gitDates].sort((a, b) => a - b);
|
|
48
|
+
|
|
49
|
+
if (allDates.length < 2) {
|
|
50
|
+
results.push({
|
|
51
|
+
sprint: sprint.name,
|
|
52
|
+
durationDays: null,
|
|
53
|
+
claimsPerDay: null,
|
|
54
|
+
phases: extractPhaseTimings(claims),
|
|
55
|
+
stalls: [],
|
|
56
|
+
note: 'Insufficient timestamp data.',
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const startDate = allDates[0];
|
|
62
|
+
const endDate = allDates[allDates.length - 1];
|
|
63
|
+
const durationDays = Math.max(1, Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)));
|
|
64
|
+
const claimsPerDay = Math.round(claims.length / durationDays * 10) / 10;
|
|
65
|
+
|
|
66
|
+
// Detect stalls: gaps > 2 days between consecutive activity
|
|
67
|
+
const stalls = [];
|
|
68
|
+
for (let i = 1; i < allDates.length; i++) {
|
|
69
|
+
const gap = (allDates[i] - allDates[i - 1]) / (1000 * 60 * 60 * 24);
|
|
70
|
+
if (gap > 2) {
|
|
71
|
+
stalls.push({
|
|
72
|
+
afterDate: allDates[i - 1].toISOString().split('T')[0],
|
|
73
|
+
beforeDate: allDates[i].toISOString().split('T')[0],
|
|
74
|
+
gapDays: Math.round(gap * 10) / 10,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
results.push({
|
|
80
|
+
sprint: sprint.name,
|
|
81
|
+
startDate: startDate.toISOString().split('T')[0],
|
|
82
|
+
endDate: endDate.toISOString().split('T')[0],
|
|
83
|
+
durationDays,
|
|
84
|
+
totalClaims: claims.length,
|
|
85
|
+
claimsPerDay,
|
|
86
|
+
phases: extractPhaseTimings(claims),
|
|
87
|
+
stalls,
|
|
88
|
+
gitCommits: gitLog.length,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Aggregate stats
|
|
93
|
+
const validResults = results.filter(r => r.durationDays !== null);
|
|
94
|
+
const avgDuration = validResults.length > 0
|
|
95
|
+
? Math.round(validResults.reduce((a, r) => a + r.durationDays, 0) / validResults.length * 10) / 10
|
|
96
|
+
: null;
|
|
97
|
+
const avgClaimsPerDay = validResults.length > 0
|
|
98
|
+
? Math.round(validResults.reduce((a, r) => a + r.claimsPerDay, 0) / validResults.length * 10) / 10
|
|
99
|
+
: null;
|
|
100
|
+
const totalStalls = results.reduce((a, r) => a + r.stalls.length, 0);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
summary: {
|
|
104
|
+
sprintsAnalyzed: validResults.length,
|
|
105
|
+
avgDurationDays: avgDuration,
|
|
106
|
+
avgClaimsPerDay,
|
|
107
|
+
totalStalls,
|
|
108
|
+
},
|
|
109
|
+
sprints: results,
|
|
110
|
+
insight: generateVelocityInsight(validResults, totalStalls),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function extractPhaseTimings(claims) {
|
|
115
|
+
const phases = {};
|
|
116
|
+
|
|
117
|
+
for (const claim of claims) {
|
|
118
|
+
if (!claim.id) continue;
|
|
119
|
+
// Extract prefix — try multi-character prefixes first, then single-character
|
|
120
|
+
const match = claim.id.match(/^([a-z]+)/);
|
|
121
|
+
if (!match) continue;
|
|
122
|
+
const letters = match[1];
|
|
123
|
+
const prefix = Object.keys(PHASE_PREFIXES).find(k => letters.startsWith(k)) || letters.charAt(0);
|
|
124
|
+
const phaseName = PHASE_PREFIXES[prefix] || prefix;
|
|
125
|
+
|
|
126
|
+
if (!phases[phaseName]) {
|
|
127
|
+
phases[phaseName] = { count: 0, firstDate: null, lastDate: null };
|
|
128
|
+
}
|
|
129
|
+
phases[phaseName].count++;
|
|
130
|
+
|
|
131
|
+
const date = claim.created || claim.date || claim.timestamp;
|
|
132
|
+
if (date) {
|
|
133
|
+
const d = new Date(date);
|
|
134
|
+
if (!isNaN(d.getTime())) {
|
|
135
|
+
if (!phases[phaseName].firstDate || d < new Date(phases[phaseName].firstDate)) {
|
|
136
|
+
phases[phaseName].firstDate = d.toISOString().split('T')[0];
|
|
137
|
+
}
|
|
138
|
+
if (!phases[phaseName].lastDate || d > new Date(phases[phaseName].lastDate)) {
|
|
139
|
+
phases[phaseName].lastDate = d.toISOString().split('T')[0];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return phases;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function generateVelocityInsight(results, totalStalls) {
|
|
149
|
+
const parts = [];
|
|
150
|
+
|
|
151
|
+
if (results.length === 0) {
|
|
152
|
+
return 'No sprint timing data available.';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const avgDuration = results.reduce((a, r) => a + r.durationDays, 0) / results.length;
|
|
156
|
+
parts.push(`Average sprint duration: ${Math.round(avgDuration * 10) / 10} days.`);
|
|
157
|
+
|
|
158
|
+
if (totalStalls > 0) {
|
|
159
|
+
parts.push(`${totalStalls} stall(s) detected across sprints (gaps > 2 days between activity).`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Find the slowest phase across all sprints
|
|
163
|
+
const phaseTotals = {};
|
|
164
|
+
for (const r of results) {
|
|
165
|
+
for (const [phase, data] of Object.entries(r.phases)) {
|
|
166
|
+
phaseTotals[phase] = (phaseTotals[phase] || 0) + data.count;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const topPhase = Object.entries(phaseTotals).sort((a, b) => b[1] - a[1])[0];
|
|
170
|
+
if (topPhase) {
|
|
171
|
+
parts.push(`Most active phase: ${topPhase[0]} (${topPhase[1]} claims across all sprints).`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return parts.join(' ');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { measureVelocity };
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grainulation/harvest",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Analytics and retrospective layer for research sprints -- learn from every decision you've made",
|
|
5
|
+
"main": "lib/analyzer.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./lib/analyzer.js",
|
|
8
|
+
"./server": "./lib/server.js",
|
|
9
|
+
"./calibration": "./lib/calibration.js",
|
|
10
|
+
"./decay": "./lib/decay.js",
|
|
11
|
+
"./patterns": "./lib/patterns.js",
|
|
12
|
+
"./velocity": "./lib/velocity.js",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"harvest": "bin/harvest.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/basic.test.js",
|
|
20
|
+
"start": "node bin/harvest.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"analytics",
|
|
24
|
+
"retrospective",
|
|
25
|
+
"research",
|
|
26
|
+
"sprints",
|
|
27
|
+
"calibration",
|
|
28
|
+
"decisions"
|
|
29
|
+
],
|
|
30
|
+
"author": "grainulation contributors",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"files": [
|
|
33
|
+
"bin/",
|
|
34
|
+
"lib/",
|
|
35
|
+
"public/",
|
|
36
|
+
"templates/",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/grainulation/harvest.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/grainulation/harvest/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://harvest.grainulation.com"
|
|
51
|
+
}
|