@taylorwong/ichartjs 2.0.1
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/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/agent-recipes/diagrams/agent-orchestration.json +18 -0
- package/agent-recipes/diagrams/approval-process.json +16 -0
- package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
- package/agent-recipes/diagrams/workflow.json +19 -0
- package/agent-recipes/foundational-analysis.json +18 -0
- package/agent-recipes/project-management.json +47 -0
- package/agent-recipes/trend-line.json +21 -0
- package/docs/agent/README.md +62 -0
- package/docs/agent/charting-scenario.md +82 -0
- package/docs/agent/coding-agent-integration.md +88 -0
- package/docs/agent/development/2.0-release-readiness.md +65 -0
- package/docs/agent/development/iteration-2.md +9 -0
- package/docs/agent/development/iteration-3.md +142 -0
- package/docs/agent/development/iteration-4.md +261 -0
- package/docs/agent/development/iteration-5.md +43 -0
- package/docs/agent/development/iteration-6.md +130 -0
- package/docs/agent/development/iteration-7.md +117 -0
- package/docs/agent/development/iteration-8-acceptance.md +68 -0
- package/docs/agent/development/iteration-8.md +116 -0
- package/docs/agent/development/iteration-9.md +44 -0
- package/docs/agent/development/playground-plan.md +143 -0
- package/docs/agent/development/prompt-contract.md +22 -0
- package/docs/agent/development/rc-1-acceptance.md +43 -0
- package/docs/agent/development/roadmap.md +259 -0
- package/docs/agent/development-guide.md +60 -0
- package/docs/agent/diagram-scenario.md +82 -0
- package/docs/agent/editing-contract.md +71 -0
- package/docs/agent/frontend-integration.md +66 -0
- package/docs/agent/project-scenario.md +89 -0
- package/docs/agent/quickstart.md +205 -0
- package/docs/agent/runtime-contract.md +132 -0
- package/docs/agent/theme-guide.md +59 -0
- package/docs/agent/zh-CN/README.md +26 -0
- package/docs/agent/zh-CN/charting-scenario.md +62 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
- package/docs/agent/zh-CN/development-guide.md +31 -0
- package/docs/agent/zh-CN/diagram-scenario.md +37 -0
- package/docs/agent/zh-CN/editing-contract.md +27 -0
- package/docs/agent/zh-CN/frontend-integration.md +31 -0
- package/docs/agent/zh-CN/project-scenario.md +40 -0
- package/docs/agent/zh-CN/quickstart.md +84 -0
- package/docs/agent/zh-CN/runtime-contract.md +85 -0
- package/docs/agent/zh-CN/theme-guide.md +50 -0
- package/docs/manifests/capabilities.json +79 -0
- package/docs/manifests/commands.json +30 -0
- package/docs/manifests/schemas.json +13 -0
- package/examples/agent-workflow.mjs +94 -0
- package/package.json +55 -0
- package/skills/ichartjs/SKILL.md +72 -0
- package/skills/ichartjs/agents/openai.yaml +4 -0
- package/skills/ichartjs/references/agent-contract.md +39 -0
- package/skills/ichartjs/references/chart-selection.md +22 -0
- package/src/capabilities.mjs +190 -0
- package/src/charts.mjs +234 -0
- package/src/command.mjs +93 -0
- package/src/data.mjs +75 -0
- package/src/diagram-interaction.mjs +195 -0
- package/src/diagram.mjs +135 -0
- package/src/edit-controller.mjs +192 -0
- package/src/edit.mjs +369 -0
- package/src/format.mjs +19 -0
- package/src/history.mjs +14 -0
- package/src/index.mjs +562 -0
- package/src/plugin.mjs +18 -0
- package/src/project-analytics.mjs +357 -0
- package/src/project-linking.mjs +72 -0
- package/src/project.mjs +370 -0
- package/src/recipes.mjs +18 -0
- package/src/renderer.mjs +77 -0
- package/src/scale.mjs +9 -0
- package/src/scene.mjs +18 -0
- package/src/schema.mjs +142 -0
- package/src/spec.mjs +127 -0
- package/src/theme.mjs +232 -0
- package/src/transforms.mjs +34 -0
- package/src/validation.mjs +106 -0
- package/types/index.d.ts +134 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project analytics, adapters, and deterministic schedule calculations.
|
|
3
|
+
* Keeps derived values separate from source rows and prefers explicit rules.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON, issue } from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
const day = 86400000;
|
|
8
|
+
const weekdaySet = new Set([1, 2, 3, 4, 5]);
|
|
9
|
+
const dateValue = value => {
|
|
10
|
+
if (value == null || value === '') return null;
|
|
11
|
+
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(value)) ? new Date(`${value}T00:00:00.000Z`) : new Date(value);
|
|
12
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
13
|
+
};
|
|
14
|
+
const dateMs = value => dateValue(value)?.getTime() ?? NaN;
|
|
15
|
+
const dateLabel = value => dateValue(value)?.toISOString().slice(0, 10) ?? null;
|
|
16
|
+
const diffDays = (left, right) => {
|
|
17
|
+
const start = dateMs(left), end = dateMs(right);
|
|
18
|
+
return Number.isFinite(start) && Number.isFinite(end) ? Math.round((end - start) / day) : null;
|
|
19
|
+
};
|
|
20
|
+
const statusDone = value => value === 'done';
|
|
21
|
+
|
|
22
|
+
function normalizeArray(value) { return Array.isArray(value) ? copyJSON(value) : []; }
|
|
23
|
+
|
|
24
|
+
export function normalizeProjectCalendar(input = {}) {
|
|
25
|
+
const calendar = copyJSON({
|
|
26
|
+
timezone: input.timezone || 'UTC',
|
|
27
|
+
workingWeekdays: Array.isArray(input.workingWeekdays) ? input.workingWeekdays : [...weekdaySet],
|
|
28
|
+
holidays: Array.isArray(input.holidays) ? input.holidays : [],
|
|
29
|
+
nonWorkingDayPolicy: input.nonWorkingDayPolicy || 'next-working-day'
|
|
30
|
+
});
|
|
31
|
+
const warnings = [];
|
|
32
|
+
if (calendar.timezone !== 'UTC') {
|
|
33
|
+
warnings.push(issue('UNSUPPORTED_TIMEZONE', 'calendar.timezone', `Unsupported timezone ${calendar.timezone}.`, 'Use UTC for deterministic calendar calculations.'));
|
|
34
|
+
calendar.timezone = 'UTC';
|
|
35
|
+
}
|
|
36
|
+
const weekdays = calendar.workingWeekdays.filter(value => Number.isInteger(value) && value >= 0 && value <= 6);
|
|
37
|
+
if (!weekdays.length) {
|
|
38
|
+
warnings.push(issue('INVALID_WORKING_WEEKDAYS', 'calendar.workingWeekdays', 'Working weekdays were empty or invalid.', 'Use weekday integers 0-6.'));
|
|
39
|
+
calendar.workingWeekdays = [...weekdaySet];
|
|
40
|
+
} else calendar.workingWeekdays = [...new Set(weekdays)].sort((left, right) => left - right);
|
|
41
|
+
calendar.holidays = calendar.holidays.map(dateLabel).filter(Boolean);
|
|
42
|
+
if (!['next-working-day', 'previous-working-day', 'allow'].includes(calendar.nonWorkingDayPolicy)) {
|
|
43
|
+
warnings.push(issue('INVALID_NON_WORKING_POLICY', 'calendar.nonWorkingDayPolicy', `Unsupported policy ${calendar.nonWorkingDayPolicy}.`, 'Use next-working-day, previous-working-day, or allow.'));
|
|
44
|
+
calendar.nonWorkingDayPolicy = 'next-working-day';
|
|
45
|
+
}
|
|
46
|
+
return { calendar, warnings };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isWorkingDay(date, calendar) {
|
|
50
|
+
return calendar.workingWeekdays.includes(date.getUTCDay()) && !calendar.holidays.includes(date.toISOString().slice(0, 10));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyWorkingCalendar(value, options = {}) {
|
|
54
|
+
const { calendar } = normalizeProjectCalendar(options.calendar || options);
|
|
55
|
+
const source = dateValue(value);
|
|
56
|
+
if (!source) return { date: null, adjusted: false, warnings: [issue('INVALID_DATE', 'date', 'Calendar adjustment requires a valid date.')] };
|
|
57
|
+
if (calendar.nonWorkingDayPolicy === 'allow' || isWorkingDay(source, calendar)) return { date: source.toISOString().slice(0, 10), adjusted: false, warnings: [] };
|
|
58
|
+
const direction = calendar.nonWorkingDayPolicy === 'previous-working-day' ? -1 : 1;
|
|
59
|
+
const probe = new Date(source);
|
|
60
|
+
for (let step = 0; step < 370; step += 1) {
|
|
61
|
+
probe.setUTCDate(probe.getUTCDate() + direction);
|
|
62
|
+
if (isWorkingDay(probe, calendar)) return { date: probe.toISOString().slice(0, 10), adjusted: true, warnings: [] };
|
|
63
|
+
}
|
|
64
|
+
return { date: source.toISOString().slice(0, 10), adjusted: false, warnings: [issue('CALENDAR_RESOLUTION_FAILED', 'date', 'Working calendar could not find a valid working day within one year.')] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addWorkingDays(value, amount, calendar) {
|
|
68
|
+
const source = dateValue(value);
|
|
69
|
+
if (!source || !Number.isFinite(amount)) return null;
|
|
70
|
+
const direction = amount < 0 ? -1 : 1;
|
|
71
|
+
let remaining = Math.abs(Math.round(amount));
|
|
72
|
+
const probe = new Date(source);
|
|
73
|
+
while (remaining > 0) {
|
|
74
|
+
probe.setUTCDate(probe.getUTCDate() + direction);
|
|
75
|
+
if (isWorkingDay(probe, calendar)) remaining -= 1;
|
|
76
|
+
}
|
|
77
|
+
return probe.toISOString().slice(0, 10);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function workingSpanDays(start, end, calendar) {
|
|
81
|
+
const first = dateValue(start), last = dateValue(end);
|
|
82
|
+
if (!first || !last || last < first) return null;
|
|
83
|
+
let days = 0;
|
|
84
|
+
const probe = new Date(first);
|
|
85
|
+
while (probe <= last) {
|
|
86
|
+
if (isWorkingDay(probe, calendar)) days += 1;
|
|
87
|
+
probe.setUTCDate(probe.getUTCDate() + 1);
|
|
88
|
+
}
|
|
89
|
+
return Math.max(1, days);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function normalizeDependencies(rows, options = {}) {
|
|
93
|
+
const warnings = [];
|
|
94
|
+
const normalized = rows.map(row => {
|
|
95
|
+
const dependencies = (row.dependencies || []).map((dependency, index) => {
|
|
96
|
+
if (typeof dependency === 'string') return { id: dependency, type: 'finish-to-start', lag: 0, lead: 0 };
|
|
97
|
+
if (dependency && typeof dependency === 'object' && typeof dependency.id === 'string') {
|
|
98
|
+
const type = dependency.type || 'finish-to-start';
|
|
99
|
+
if (!['finish-to-start', 'start-to-start', 'finish-to-finish', 'start-to-finish'].includes(type)) warnings.push(issue('UNSUPPORTED_DEPENDENCY_TYPE', `rows.${row.id || index}.dependencies.${index}.type`, `Unsupported dependency type ${type}.`, 'Use finish-to-start, start-to-start, finish-to-finish, or start-to-finish.'));
|
|
100
|
+
return { id: dependency.id, type: ['finish-to-start', 'start-to-start', 'finish-to-finish', 'start-to-finish'].includes(type) ? type : 'finish-to-start', lag: Number(dependency.lag || 0), lead: Number(dependency.lead || 0) };
|
|
101
|
+
}
|
|
102
|
+
warnings.push(issue('INVALID_DEPENDENCY', `rows.${row.id || index}.dependencies.${index}`, 'Dependency must be a string id or object with id.'));
|
|
103
|
+
return null;
|
|
104
|
+
}).filter(Boolean);
|
|
105
|
+
return { ...copyJSON(row), dependencies };
|
|
106
|
+
});
|
|
107
|
+
return { rows: normalized, warnings };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function predecessorConstraint(predecessor, dependency, duration, calendar) {
|
|
111
|
+
const offset = Number(dependency.lag || 0) - Number(dependency.lead || 0);
|
|
112
|
+
const type = dependency.type || 'finish-to-start';
|
|
113
|
+
if (type === 'start-to-start') return predecessor.earliestStart ? addWorkingDays(predecessor.earliestStart, offset, calendar) : null;
|
|
114
|
+
if (type === 'finish-to-finish') {
|
|
115
|
+
const finish = predecessor.earliestFinish ? addWorkingDays(predecessor.earliestFinish, offset, calendar) : null;
|
|
116
|
+
return finish ? addWorkingDays(finish, -(duration - 1), calendar) : null;
|
|
117
|
+
}
|
|
118
|
+
if (type === 'start-to-finish') {
|
|
119
|
+
const finish = predecessor.earliestStart ? addWorkingDays(predecessor.earliestStart, offset, calendar) : null;
|
|
120
|
+
return finish ? addWorkingDays(finish, -(duration - 1), calendar) : null;
|
|
121
|
+
}
|
|
122
|
+
return predecessor.earliestFinish ? addWorkingDays(predecessor.earliestFinish, 1 + offset, calendar) : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function successorConstraint(successor, dependency, duration, calendar) {
|
|
126
|
+
const offset = Number(dependency.lag || 0) - Number(dependency.lead || 0);
|
|
127
|
+
const type = dependency.type || 'finish-to-start';
|
|
128
|
+
if (type === 'start-to-start') return successor.latestStart ? addWorkingDays(successor.latestStart, -offset, calendar) : null;
|
|
129
|
+
if (type === 'finish-to-finish') {
|
|
130
|
+
const finish = successor.latestFinish ? addWorkingDays(successor.latestFinish, -offset, calendar) : null;
|
|
131
|
+
return finish ? addWorkingDays(finish, -(duration - 1), calendar) : null;
|
|
132
|
+
}
|
|
133
|
+
if (type === 'start-to-finish') {
|
|
134
|
+
const finish = successor.latestFinish ? addWorkingDays(successor.latestFinish, -offset, calendar) : null;
|
|
135
|
+
return finish || null;
|
|
136
|
+
}
|
|
137
|
+
const latestFinish = successor.latestStart ? addWorkingDays(successor.latestStart, -1 - offset, calendar) : null;
|
|
138
|
+
return latestFinish ? addWorkingDays(latestFinish, -(duration - 1), calendar) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function analyzeSchedule(inputRows, options = {}) {
|
|
142
|
+
const rows = normalizeArray(inputRows);
|
|
143
|
+
const { calendar, warnings: calendarWarnings } = normalizeProjectCalendar(options.calendar || {});
|
|
144
|
+
const { rows: normalizedRows, warnings: dependencyWarnings } = normalizeDependencies(rows, options);
|
|
145
|
+
const warnings = [...calendarWarnings, ...dependencyWarnings];
|
|
146
|
+
const taskMap = new Map();
|
|
147
|
+
normalizedRows.forEach((row, index) => {
|
|
148
|
+
const startAdjustment = applyWorkingCalendar(row.start, { calendar });
|
|
149
|
+
const endAdjustment = applyWorkingCalendar(row.end, { calendar });
|
|
150
|
+
if (!row.start || !row.end) warnings.push(issue('INCOMPLETE_DATES', `rows.${index}`, 'Task requires both start and end dates.'));
|
|
151
|
+
if (row.start && row.end && dateMs(row.end) < dateMs(row.start)) warnings.push(issue('INVERTED_RANGE', `rows.${index}`, 'Task end precedes task start.'));
|
|
152
|
+
if (row.status && statusDone(row.status) && Number(row.progress || 0) < 100) warnings.push(issue('INCONSISTENT_COMPLETION', `rows.${index}`, 'Done task has progress below 100.'));
|
|
153
|
+
if (row.progress >= 100 && row.status && !statusDone(row.status)) warnings.push(issue('INCONSISTENT_COMPLETION', `rows.${index}`, 'Progress is 100 but status is not done.'));
|
|
154
|
+
const duration = workingSpanDays(startAdjustment.date || row.start, endAdjustment.date || row.end, calendar) || 1;
|
|
155
|
+
taskMap.set(row.id, {
|
|
156
|
+
...row,
|
|
157
|
+
dependencies: row.dependencies || [],
|
|
158
|
+
adjustedStart: startAdjustment.date || row.start || null,
|
|
159
|
+
adjustedEnd: endAdjustment.date || row.end || null,
|
|
160
|
+
duration,
|
|
161
|
+
successors: [],
|
|
162
|
+
warnings: [...startAdjustment.warnings, ...endAdjustment.warnings]
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
taskMap.forEach(task => task.dependencies.forEach(dependency => {
|
|
166
|
+
const predecessor = taskMap.get(dependency.id);
|
|
167
|
+
if (!predecessor) warnings.push(issue('MISSING_DEPENDENCY', `rows.${task.id}.dependencies`, `Dependency ${dependency.id} was not found.`, 'Use an existing task id.'));
|
|
168
|
+
else predecessor.successors.push({ id: task.id, dependency });
|
|
169
|
+
}));
|
|
170
|
+
const pending = new Map([...taskMap.values()].map(task => [task.id, task.dependencies.filter(dependency => taskMap.has(dependency.id)).length]));
|
|
171
|
+
const queue = [...taskMap.keys()].filter(id => pending.get(id) === 0);
|
|
172
|
+
const order = [];
|
|
173
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
174
|
+
const current = taskMap.get(queue[index]);
|
|
175
|
+
current.earliestStart = current.adjustedStart;
|
|
176
|
+
current.dependencies.forEach(dependency => {
|
|
177
|
+
const predecessor = taskMap.get(dependency.id);
|
|
178
|
+
if (!predecessor) return;
|
|
179
|
+
const candidate = predecessorConstraint(predecessor, dependency, current.duration, calendar);
|
|
180
|
+
if (candidate && dateMs(candidate) > dateMs(current.earliestStart)) current.earliestStart = candidate;
|
|
181
|
+
});
|
|
182
|
+
current.earliestFinish = addWorkingDays(current.earliestStart, current.duration - 1, calendar) || current.adjustedEnd || current.earliestStart;
|
|
183
|
+
order.push(current);
|
|
184
|
+
current.successors.forEach(successor => {
|
|
185
|
+
pending.set(successor.id, pending.get(successor.id) - 1);
|
|
186
|
+
if (pending.get(successor.id) === 0) queue.push(successor.id);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (order.length !== taskMap.size) warnings.push(issue('CYCLIC_DEPENDENCY', 'rows', 'Dependencies contain a cycle.', 'Dependencies must form a directed acyclic graph.'));
|
|
190
|
+
const projectFinish = order.reduce((latest, task) => {
|
|
191
|
+
const finish = dateMs(task.earliestFinish);
|
|
192
|
+
return !Number.isFinite(finish) || finish <= latest ? latest : finish;
|
|
193
|
+
}, -Infinity);
|
|
194
|
+
[...order].reverse().forEach(task => {
|
|
195
|
+
const successorStarts = task.successors.map(successor => successorConstraint(taskMap.get(successor.id), successor.dependency, task.duration, calendar)).filter(Boolean);
|
|
196
|
+
task.latestStart = successorStarts.length ? successorStarts.sort((left, right) => dateMs(left) - dateMs(right))[0] : addWorkingDays(dateLabel(projectFinish), -(task.duration - 1), calendar);
|
|
197
|
+
task.latestFinish = addWorkingDays(task.latestStart, task.duration - 1, calendar) || task.latestStart;
|
|
198
|
+
task.float = Math.max(0, diffDays(task.earliestStart, task.latestStart) ?? 0);
|
|
199
|
+
task.slack = task.float;
|
|
200
|
+
task.critical = task.float === 0;
|
|
201
|
+
task.baselineVarianceDays = rowVariance(task.baselineEnd, task.actualEnd || task.adjustedEnd || task.end);
|
|
202
|
+
task.startVarianceDays = rowVariance(task.baselineStart || task.start, task.actualStart || task.adjustedStart || task.start);
|
|
203
|
+
task.endVarianceDays = rowVariance(task.baselineEnd || task.end, task.actualEnd || task.adjustedEnd || task.end);
|
|
204
|
+
});
|
|
205
|
+
const tasks = order.map(task => ({
|
|
206
|
+
id: task.id,
|
|
207
|
+
earliestStart: task.earliestStart,
|
|
208
|
+
earliestFinish: task.earliestFinish,
|
|
209
|
+
latestStart: task.latestStart,
|
|
210
|
+
latestFinish: task.latestFinish,
|
|
211
|
+
float: task.float,
|
|
212
|
+
slack: task.slack,
|
|
213
|
+
critical: task.critical,
|
|
214
|
+
baselineVarianceDays: task.baselineVarianceDays,
|
|
215
|
+
startVarianceDays: task.startVarianceDays,
|
|
216
|
+
endVarianceDays: task.endVarianceDays,
|
|
217
|
+
dependencies: task.dependencies,
|
|
218
|
+
adjustedStart: task.adjustedStart,
|
|
219
|
+
adjustedEnd: task.adjustedEnd
|
|
220
|
+
}));
|
|
221
|
+
const criticalIds = tasks.filter(task => task.critical).map(task => task.id);
|
|
222
|
+
const criticalEdges = tasks.flatMap(task => task.dependencies.filter(dependency => criticalIds.includes(task.id) && criticalIds.includes(dependency.id || dependency)).map(dependency => ({ from: dependency.id || dependency, to: task.id })));
|
|
223
|
+
const assumptions = [
|
|
224
|
+
`Schedule calculations use timezone ${calendar.timezone}.`,
|
|
225
|
+
`Working weekdays: ${calendar.workingWeekdays.join(', ')}.`,
|
|
226
|
+
`Non-working day policy: ${calendar.nonWorkingDayPolicy}.`
|
|
227
|
+
];
|
|
228
|
+
const projectStart = tasks.reduce((earliest, task) => {
|
|
229
|
+
const start = dateMs(task.earliestStart);
|
|
230
|
+
return !Number.isFinite(start) || (Number.isFinite(earliest) && start >= earliest) ? earliest : start;
|
|
231
|
+
}, Infinity);
|
|
232
|
+
const duration = Number.isFinite(projectFinish) && Number.isFinite(projectStart) ? Math.max(0, diffDays(dateLabel(projectStart), dateLabel(projectFinish)) ?? 0) : 0;
|
|
233
|
+
return { calendar, tasks, criticalIds, criticalEdges, duration, assumptions, warnings, rows: normalizedRows.map(row => ({ ...row })) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function rowVariance(source, target) {
|
|
237
|
+
if (!source || !target) return null;
|
|
238
|
+
const diff = diffDays(source, target);
|
|
239
|
+
return diff == null ? null : diff;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function analyzeBurndownSeries(inputRows, options = {}) {
|
|
243
|
+
const rows = normalizeArray(inputRows).map((row, dataIndex) => ({ ...row, dataIndex, time: dateMs(row.date), remaining: Number(row.remaining ?? row.actual ?? row.value), scopeChange: Number(row.scopeChange || 0) })).filter(row => Number.isFinite(row.time)).sort((left, right) => left.time - right.time);
|
|
244
|
+
if (!rows.length) return { samples: [], forecast: { date: null, reason: 'insufficient-data' }, assumptions: ['Forecast requires at least one dated sample.'], warnings: [issue('INSUFFICIENT_DATA', 'rows', 'Burndown forecast requires dated samples.')] };
|
|
245
|
+
const first = rows[0], last = rows.at(-1), initialScope = options.initialScope ?? first.remaining;
|
|
246
|
+
let scope = initialScope;
|
|
247
|
+
rows.forEach(row => { scope += row.scopeChange; row.scope = scope; row.completed = scope - row.remaining; });
|
|
248
|
+
const elapsedDays = (last.time - first.time) / day;
|
|
249
|
+
const completed = last.completed - first.completed;
|
|
250
|
+
const velocity = elapsedDays > 0 ? completed / elapsedDays : 0;
|
|
251
|
+
let forecast = { date: null, velocity, reason: rows.length < 2 ? 'insufficient-data' : 'non-positive-velocity' };
|
|
252
|
+
if (last.remaining === 0) forecast = { date: dateLabel(last.time), velocity, reason: 'complete' };
|
|
253
|
+
else if (velocity > 0) forecast = { date: dateLabel(last.time + last.remaining / velocity * day), time: last.time + last.remaining / velocity * day, velocity, reason: 'estimated' };
|
|
254
|
+
const start = options.start ? dateMs(options.start) : first.time;
|
|
255
|
+
const end = options.end ? dateMs(options.end) : last.time;
|
|
256
|
+
rows.forEach(row => { row.ideal = row.ideal ?? Math.max(0, initialScope * (1 - (row.time - start) / Math.max(day, end - start))); });
|
|
257
|
+
return { samples: rows, initialScope, start, end, forecast, assumptions: [`Forecast velocity uses ${rows.length} burndown samples.`], warnings: [] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function analyzeCapacity(inputRows, options = {}) {
|
|
261
|
+
const rows = normalizeArray(inputRows);
|
|
262
|
+
const warnings = [];
|
|
263
|
+
const byKey = new Map();
|
|
264
|
+
const defaultCapacity = Number(options.defaultCapacity ?? 1);
|
|
265
|
+
rows.forEach((row, index) => {
|
|
266
|
+
const key = row.resource || row.owner;
|
|
267
|
+
if (!key) { warnings.push(issue('MISSING_RESOURCE', `rows.${index}`, 'Task is missing owner/resource for capacity analysis.', 'Add owner or resource fields.')); return; }
|
|
268
|
+
const start = row.actualStart || row.start, end = row.actualEnd || row.end;
|
|
269
|
+
const duration = diffDays(start, end);
|
|
270
|
+
const load = Number(row.load ?? row.points ?? 1) * Math.max(1, (duration ?? 0) + 1);
|
|
271
|
+
const entry = byKey.get(key) || { id: key, name: key, load: 0, capacity: Number(row.capacity ?? defaultCapacity), taskIds: [] };
|
|
272
|
+
entry.load += load;
|
|
273
|
+
entry.capacity = Number(row.capacity ?? entry.capacity ?? defaultCapacity);
|
|
274
|
+
entry.taskIds.push(row.id);
|
|
275
|
+
byKey.set(key, entry);
|
|
276
|
+
});
|
|
277
|
+
const rowsOut = [...byKey.values()].map(item => ({ ...item, overload: Math.max(0, item.load - item.capacity), available: Math.max(0, item.capacity - item.load), status: item.load > item.capacity ? 'overloaded' : 'balanced' }));
|
|
278
|
+
rowsOut.forEach((row, index) => { if (!(row.capacity > 0)) warnings.push(issue('MISSING_CAPACITY', `capacity.${index}`, 'Capacity must be positive to evaluate overload.')); });
|
|
279
|
+
return { rows: rowsOut, warnings, assumptions: [`Default capacity per resource: ${defaultCapacity}.`] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function buildCapacityView(inputRows, options = {}) {
|
|
283
|
+
const analysis = analyzeCapacity(inputRows, options);
|
|
284
|
+
return { ...analysis, chartType: options.chartType || 'column', values: analysis.rows.map(row => ({ name: row.name, value: row.load, capacity: row.capacity, overload: row.overload, status: row.status, recordIds: row.taskIds })) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function buildCumulativeFlowSeries(inputRows, options = {}) {
|
|
288
|
+
const rows = normalizeArray(inputRows);
|
|
289
|
+
const warnings = [];
|
|
290
|
+
const grouped = new Map();
|
|
291
|
+
rows.forEach((row, index) => {
|
|
292
|
+
const date = dateLabel(row.date || row.snapshotDate);
|
|
293
|
+
if (!date) { warnings.push(issue('INVALID_DATE', `rows.${index}`, 'Status history row requires a date.')); return; }
|
|
294
|
+
const target = grouped.get(date) || { date };
|
|
295
|
+
if (row.counts && typeof row.counts === 'object') Object.entries(row.counts).forEach(([status, value]) => { target[status] = Number(target[status] || 0) + Number(value || 0); });
|
|
296
|
+
else if (row.status) target[row.status] = Number(target[row.status] || 0) + Number(row.count || 1);
|
|
297
|
+
grouped.set(date, target);
|
|
298
|
+
});
|
|
299
|
+
const values = [...grouped.values()].sort((left, right) => dateMs(left.date) - dateMs(right.date)).map(row => ({ ...row, total: Object.entries(row).filter(([key]) => key !== 'date').reduce((sum, [, value]) => sum + Number(value || 0), 0) }));
|
|
300
|
+
return { values, chartType: options.chartType || 'area', warnings, assumptions: ['Cumulative flow is generated from explicit status counts or status snapshots.'] };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function buildVelocitySeries(inputRows, options = {}) {
|
|
304
|
+
const rows = normalizeArray(inputRows);
|
|
305
|
+
const sprintField = options.sprintField || 'sprint', pointsField = options.pointsField || 'points', statusField = options.statusField || 'status';
|
|
306
|
+
const grouped = new Map(), warnings = [];
|
|
307
|
+
rows.forEach((row, index) => {
|
|
308
|
+
const sprint = row[sprintField] || 'Unscheduled';
|
|
309
|
+
const target = grouped.get(sprint) || { sprint, completed: 0, scope: 0, count: 0 };
|
|
310
|
+
const points = Number(row[pointsField] ?? 1);
|
|
311
|
+
if (!Number.isFinite(points) || points < 0) { warnings.push(issue('INVALID_POINTS', `rows.${index}.${pointsField}`, 'Velocity points must be a finite non-negative number.')); return; }
|
|
312
|
+
target.scope += points;
|
|
313
|
+
if (statusDone(row[statusField])) target.completed += points;
|
|
314
|
+
target.count += 1;
|
|
315
|
+
grouped.set(sprint, target);
|
|
316
|
+
});
|
|
317
|
+
const values = [...grouped.values()].map(row => ({ name: row.sprint, value: row.completed, scope: row.scope, completionRate: row.scope ? row.completed / row.scope : 0 }));
|
|
318
|
+
return { values, chartType: options.chartType || 'column', warnings, assumptions: [`Velocity uses ${pointsField} grouped by ${sprintField}.`] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function buildReleaseForecast(inputRows, options = {}) {
|
|
322
|
+
const burndown = analyzeBurndownSeries(inputRows, options);
|
|
323
|
+
const values = burndown.samples.map(sample => ({ date: sample.date, remaining: sample.remaining, scope: sample.scope, forecast: burndown.forecast.date }));
|
|
324
|
+
return { ...burndown, values, chartType: options.chartType || 'burndown', warnings: burndown.warnings, assumptions: [...burndown.assumptions, 'Release forecast is an estimate, not a commitment.'] };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function buildRiskMatrixSeries(inputRows, options = {}) {
|
|
328
|
+
const rows = normalizeArray(inputRows);
|
|
329
|
+
const warnings = [];
|
|
330
|
+
const values = rows.map((row, index) => {
|
|
331
|
+
const probability = Number(row.probability ?? row.likelihood ?? 0);
|
|
332
|
+
const impact = Number(row.impact ?? row.severity ?? 0);
|
|
333
|
+
if (![probability, impact].every(value => Number.isFinite(value) && value >= 0)) { warnings.push(issue('INVALID_RISK_VALUE', `rows.${index}`, 'Risk probability and impact must be finite non-negative numbers.')); return null; }
|
|
334
|
+
const score = probability * impact;
|
|
335
|
+
const quadrant = probability >= (options.probabilityThreshold ?? 3) && impact >= (options.impactThreshold ?? 3) ? 'high-high' : probability >= (options.probabilityThreshold ?? 3) ? 'high-low' : impact >= (options.impactThreshold ?? 3) ? 'low-high' : 'low-low';
|
|
336
|
+
return { name: row.title || row.id || `Risk ${index + 1}`, x: probability, y: impact, value: score, quadrant, recordId: row.id || `risk-${index}` };
|
|
337
|
+
}).filter(Boolean);
|
|
338
|
+
return { values, chartType: options.chartType || 'scatter', warnings, assumptions: ['Risk matrix uses probability on x and impact on y.'] };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function buildIssueAgingSeries(inputRows, options = {}) {
|
|
342
|
+
const rows = normalizeArray(inputRows);
|
|
343
|
+
const now = dateValue(options.today);
|
|
344
|
+
const warnings = [];
|
|
345
|
+
const buckets = options.buckets || [{ label: '0-7', max: 7 }, { label: '8-14', max: 14 }, { label: '15-30', max: 30 }, { label: '31+', max: Infinity }];
|
|
346
|
+
const counts = new Map(buckets.map(bucket => [bucket.label, 0]));
|
|
347
|
+
if (!now) warnings.push(issue('MISSING_REFERENCE_DATE', 'options.today', 'Issue aging requires an explicit valid today reference.', 'Pass today as an ISO date for reproducible results.'));
|
|
348
|
+
const values = now ? rows.map((row, index) => {
|
|
349
|
+
const created = dateValue(row.createdAt || row.created || row.openedAt || row.date);
|
|
350
|
+
if (!created) { warnings.push(issue('INVALID_CREATED_DATE', `rows.${index}`, 'Issue aging requires a valid created/opened date.')); return null; }
|
|
351
|
+
const ageDays = Math.max(0, Math.floor((now.getTime() - created.getTime()) / day));
|
|
352
|
+
const bucket = buckets.find(candidate => ageDays <= candidate.max) || buckets.at(-1);
|
|
353
|
+
counts.set(bucket.label, counts.get(bucket.label) + 1);
|
|
354
|
+
return { ...row, name: row.title || row.id || `Issue ${index + 1}`, ageDays, bucket: bucket.label, recordId: row.id || `issue-${index}` };
|
|
355
|
+
}).filter(Boolean) : [];
|
|
356
|
+
return { values: [...counts.entries()].map(([name, value]) => ({ name, value })), details: values, chartType: options.chartType || 'bar', warnings, assumptions: ['Issue aging uses created/opened date against the provided today reference.'] };
|
|
357
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral linked filters and selection helpers for project views.
|
|
3
|
+
* Keeps linked state deterministic and based on stable record identifiers.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON } from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
function normalizeList(value) {
|
|
8
|
+
return [...new Set((Array.isArray(value) ? value : []).map(item => String(item)).filter(Boolean))];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rowLabels(row) {
|
|
12
|
+
const labels = row?.labels;
|
|
13
|
+
return Array.isArray(labels) ? labels.map(item => String(item)).filter(Boolean) : typeof labels === 'string' ? [labels] : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function recordId(row, index) {
|
|
17
|
+
return row?.id || row?.recordId || row?.date || `record-${index}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeLinkedFilters(input = {}) {
|
|
21
|
+
return copyJSON({
|
|
22
|
+
owner: normalizeList(input.owner || input.owners),
|
|
23
|
+
status: normalizeList(input.status || input.statuses),
|
|
24
|
+
priority: normalizeList(input.priority || input.priorities),
|
|
25
|
+
sprint: normalizeList(input.sprint || input.sprints),
|
|
26
|
+
label: normalizeList(input.label || input.labels)
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeLinkedSelection(input = []) {
|
|
31
|
+
return normalizeList(input);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function matchesLinkedFilters(row, filters = {}) {
|
|
35
|
+
const normalized = normalizeLinkedFilters(filters);
|
|
36
|
+
const labels = rowLabels(row);
|
|
37
|
+
if (normalized.owner.length && !normalized.owner.includes(String(row?.owner || row?.resource || ''))) return false;
|
|
38
|
+
if (normalized.status.length && !normalized.status.includes(String(row?.status || ''))) return false;
|
|
39
|
+
if (normalized.priority.length && !normalized.priority.includes(String(row?.priority || ''))) return false;
|
|
40
|
+
if (normalized.sprint.length && !normalized.sprint.includes(String(row?.sprint || ''))) return false;
|
|
41
|
+
if (normalized.label.length && !normalized.label.some(label => labels.includes(label))) return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function filterProjectRows(inputRows, linked = {}) {
|
|
46
|
+
const rows = Array.isArray(inputRows) ? inputRows.map(row => copyJSON(row)) : [];
|
|
47
|
+
const filters = normalizeLinkedFilters(linked.filters || linked);
|
|
48
|
+
return rows.filter(row => matchesLinkedFilters(row, filters));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createLinkedProjectState(inputRows, linked = {}) {
|
|
52
|
+
const rows = Array.isArray(inputRows) ? inputRows : [];
|
|
53
|
+
const filters = normalizeLinkedFilters(linked.filters || linked);
|
|
54
|
+
const selection = normalizeLinkedSelection(linked.selection || linked.selectedRecordIds || []);
|
|
55
|
+
const indexedRows = rows.map((row, index) => ({ row, index }));
|
|
56
|
+
const visibleRows = indexedRows.filter(({ row }) => matchesLinkedFilters(row, filters));
|
|
57
|
+
const sourceRecordIds = indexedRows.map(({ row, index }) => recordId(row, index));
|
|
58
|
+
const visibleRecordIds = visibleRows.map(({ row, index }) => recordId(row, index));
|
|
59
|
+
return {
|
|
60
|
+
filters,
|
|
61
|
+
selection,
|
|
62
|
+
selectedCount: selection.length,
|
|
63
|
+
sourceCount: rows.length,
|
|
64
|
+
visibleCount: visibleRows.length,
|
|
65
|
+
sourceRecordIds,
|
|
66
|
+
visibleRecordIds
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function linkedRecordId(row, index) {
|
|
71
|
+
return recordId(row, index);
|
|
72
|
+
}
|