@diagrammo/dgmo 0.8.16 → 0.8.18

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.
@@ -17,7 +17,7 @@ import {
17
17
  MULTIPLE_PIPE_ERROR,
18
18
  parseFirstLine,
19
19
  } from '../utils/parsing';
20
- import { parseOffset } from '../utils/duration';
20
+ import { parseOffset, parseDuration } from '../utils/duration';
21
21
  import type { PaletteColors } from '../palettes';
22
22
  import { getSeriesColors } from '../palettes';
23
23
  import type {
@@ -35,14 +35,14 @@ import type {
35
35
  // ── Regexes ─────────────────────────────────────────────────
36
36
 
37
37
  /** Duration task: `30d Label`, `1.5w Label`, `10bd? Label`, `2h Label`, `90min Label` */
38
- const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
38
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)(\?)?\s+(.+)$/;
39
39
 
40
40
  /** Explicit date task: `2024-01-15 Label` or `2024-01-15 14:30 Label` */
41
41
  const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s+(.+)$/;
42
42
 
43
43
  /** Timeline migration syntax: `2024-01-15 -> 30d Label` or `2024-01-15 14:30 -> 2h Label` */
44
44
  const TIMELINE_DURATION_RE =
45
- /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
45
+ /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)(\?)?\s+(.+)$/;
46
46
 
47
47
  /** Group container: `[GroupName]` with optional pipe metadata */
48
48
  const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
@@ -138,6 +138,10 @@ export function parseGantt(
138
138
  activeTag: null,
139
139
  optionLineNumbers: {},
140
140
  holidaysLineNumber: null,
141
+ sprintLength: null,
142
+ sprintNumber: null,
143
+ sprintStart: null,
144
+ sprintMode: null,
141
145
  },
142
146
  diagnostics,
143
147
  error: null,
@@ -650,6 +654,57 @@ export function parseGantt(
650
654
  case 'active-tag':
651
655
  result.options.activeTag = value;
652
656
  break;
657
+ case 'sprint-length': {
658
+ const dur = parseDuration(value);
659
+ if (!dur) {
660
+ warn(
661
+ lineNumber,
662
+ `Invalid sprint-length value: "${value}". Expected a duration like "2w" or "10d".`
663
+ );
664
+ } else if (dur.unit !== 'd' && dur.unit !== 'w') {
665
+ warn(
666
+ lineNumber,
667
+ `sprint-length only accepts "d" or "w" units, got "${dur.unit}".`
668
+ );
669
+ } else if (dur.amount <= 0) {
670
+ warn(lineNumber, `sprint-length must be greater than 0.`);
671
+ } else if (
672
+ !Number.isInteger(dur.amount * (dur.unit === 'w' ? 7 : 1))
673
+ ) {
674
+ warn(
675
+ lineNumber,
676
+ `sprint-length must resolve to a whole number of days.`
677
+ );
678
+ } else {
679
+ result.options.sprintLength = dur;
680
+ }
681
+ break;
682
+ }
683
+ case 'sprint-number': {
684
+ const n = Number(value);
685
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
686
+ warn(
687
+ lineNumber,
688
+ `sprint-number must be a positive integer, got "${value}".`
689
+ );
690
+ } else {
691
+ result.options.sprintNumber = n;
692
+ }
693
+ break;
694
+ }
695
+ case 'sprint-start': {
696
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
697
+ warn(
698
+ lineNumber,
699
+ `sprint-start requires a full date (YYYY-MM-DD), got "${value}".`
700
+ );
701
+ } else if (Number.isNaN(new Date(value + 'T00:00:00').getTime())) {
702
+ warn(lineNumber, `sprint-start is not a valid date: "${value}".`);
703
+ } else {
704
+ result.options.sprintStart = value;
705
+ }
706
+ break;
707
+ }
653
708
  }
654
709
  continue;
655
710
  }
@@ -879,6 +934,31 @@ export function parseGantt(
879
934
 
880
935
  validateTagGroupNames(result.tagGroups, warn);
881
936
 
937
+ // ── Sprint mode detection ──────────────────────────────
938
+ const hasSprintOption =
939
+ result.options.sprintLength !== null ||
940
+ result.options.sprintNumber !== null ||
941
+ result.options.sprintStart !== null;
942
+
943
+ const hasSprintUnit = hasSprintDurationUnit(result.nodes);
944
+
945
+ if (hasSprintOption) {
946
+ result.options.sprintMode = 'explicit';
947
+ } else if (hasSprintUnit) {
948
+ result.options.sprintMode = 'auto';
949
+ }
950
+
951
+ // Apply defaults when sprint mode is active
952
+ if (result.options.sprintMode) {
953
+ if (!result.options.sprintLength) {
954
+ result.options.sprintLength = { amount: 2, unit: 'w' };
955
+ }
956
+ if (result.options.sprintNumber === null) {
957
+ result.options.sprintNumber = 1;
958
+ }
959
+ // sprintStart defaults to chart start or today — handled in calculator
960
+ }
961
+
882
962
  return result;
883
963
 
884
964
  // ── Helper: create a task ───────────────────────────────
@@ -1041,6 +1121,9 @@ const KNOWN_OPTIONS = new Set([
1041
1121
  'chart',
1042
1122
  'sort',
1043
1123
  'active-tag',
1124
+ 'sprint-length',
1125
+ 'sprint-number',
1126
+ 'sprint-start',
1044
1127
  ]);
1045
1128
 
1046
1129
  /** Boolean options that can appear as bare keywords or with `no-` prefix. */
@@ -1053,3 +1136,15 @@ const KNOWN_BOOLEANS = new Set([
1053
1136
  function isKnownOption(key: string): boolean {
1054
1137
  return KNOWN_OPTIONS.has(key);
1055
1138
  }
1139
+
1140
+ /** Check if any task in the tree uses the `s` (sprint) duration unit. */
1141
+ function hasSprintDurationUnit(nodes: GanttNode[]): boolean {
1142
+ for (const node of nodes) {
1143
+ if (node.kind === 'task') {
1144
+ if (node.duration?.unit === 's') return true;
1145
+ } else if (node.kind === 'group' || node.kind === 'parallel') {
1146
+ if (hasSprintDurationUnit(node.children)) return true;
1147
+ }
1148
+ }
1149
+ return false;
1150
+ }
@@ -272,11 +272,16 @@ export function renderGantt(
272
272
  const topDateLabelReserve = 22; // tick (6) + gap (4) + label height (~12)
273
273
  const hasOverheadLabels =
274
274
  resolved.markers.length > 0 || resolved.eras.length > 0;
275
- const markerLabelReserve = hasOverheadLabels ? 18 : 0; // markers/eras extend above date labels
275
+ const markerLabelReserve = hasOverheadLabels ? 28 : 0; // markers/eras get own row above sprint labels
276
+ const sprintLabelReserve = resolved.sprints.length > 0 ? 16 : 0; // sprint hover label above date labels
276
277
  const CONTENT_TOP_PAD = 16; // breathing room between scale labels and first row
277
278
 
278
279
  const marginTop =
279
- titleHeight + tagLegendReserve + topDateLabelReserve + markerLabelReserve;
280
+ titleHeight +
281
+ tagLegendReserve +
282
+ topDateLabelReserve +
283
+ markerLabelReserve +
284
+ sprintLabelReserve;
280
285
 
281
286
  // Content area
282
287
  const contentH = isTagMode
@@ -286,7 +291,10 @@ export function renderGantt(
286
291
  const outerHeight = marginTop + innerHeight + BOTTOM_MARGIN;
287
292
 
288
293
  const containerWidth = exportDims?.width ?? (container.clientWidth || 800);
289
- const innerWidth = containerWidth - leftMargin - RIGHT_MARGIN;
294
+ // Extra right margin when sprints present so hover date labels aren't clipped
295
+ const sprintRightPad = resolved.sprints.length > 0 ? 50 : 0;
296
+ const innerWidth =
297
+ containerWidth - leftMargin - RIGHT_MARGIN - sprintRightPad;
290
298
 
291
299
  // ── Create SVG ──────────────────────────────────────────
292
300
 
@@ -418,6 +426,7 @@ export function renderGantt(
418
426
  onClickItem
419
427
  );
420
428
  renderErasAndMarkers(g, svg, resolved, xScale, innerHeight, palette);
429
+ renderSprintBands(g, svg, resolved, xScale, innerHeight, palette);
421
430
 
422
431
  // ── Today marker (line rendered before rows so it paints behind task bars) ──
423
432
 
@@ -2192,7 +2201,7 @@ function renderErasAndMarkers(
2192
2201
  .append('text')
2193
2202
  .attr('class', 'gantt-era-label')
2194
2203
  .attr('x', (sx + ex) / 2)
2195
- .attr('y', -24)
2204
+ .attr('y', -34)
2196
2205
  .attr('text-anchor', 'middle')
2197
2206
  .attr('font-size', '10px')
2198
2207
  .attr('fill', color)
@@ -2257,8 +2266,8 @@ function renderErasAndMarkers(
2257
2266
  const mx = xScale(parseDateToFractionalYear(marker.date));
2258
2267
  const markerDate = parseDateStringToDate(marker.date);
2259
2268
  const diamondSize = 5;
2260
- const labelY = -24;
2261
- const diamondY = labelY + 14;
2269
+ const labelY = -34;
2270
+ const diamondY = -2; // below date indicator labels
2262
2271
 
2263
2272
  const markerG = g
2264
2273
  .append('g')
@@ -2374,6 +2383,250 @@ function renderErasAndMarkers(
2374
2383
  }
2375
2384
  }
2376
2385
 
2386
+ // ── Sprint band rendering ──────────────────────────────────
2387
+
2388
+ const SPRINT_BAND_OPACITY = 0.05;
2389
+ const SPRINT_HOVER_OPACITY = 0.12;
2390
+ const SPRINT_BOUNDARY_OPACITY = 0.3;
2391
+
2392
+ function renderSprintBands(
2393
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2394
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2395
+ resolved: ResolvedSchedule,
2396
+ xScale: d3Scale.ScaleLinear<number, number>,
2397
+ innerHeight: number,
2398
+ palette: PaletteColors
2399
+ ): void {
2400
+ if (resolved.sprints.length === 0) return;
2401
+
2402
+ // When both eras and sprints defined, eras win — don't render sprint bands
2403
+ if (resolved.eras.length > 0) return;
2404
+
2405
+ const bandColor = palette.textMuted || palette.text || '#888';
2406
+
2407
+ // Chart content area starts at x=0 in the g coordinate space
2408
+ const chartMinX = 0;
2409
+
2410
+ for (let i = 0; i < resolved.sprints.length; i++) {
2411
+ const sprint = resolved.sprints[i];
2412
+ const rawSx = xScale(dateToFractionalYear(sprint.startDate));
2413
+ const rawEx = xScale(dateToFractionalYear(sprint.endDate));
2414
+ if (rawEx <= rawSx) continue;
2415
+
2416
+ // Clip to chart content area — prevent bands from extending into swimlane margin
2417
+ const sx = Math.max(rawSx, chartMinX);
2418
+ const ex = rawEx;
2419
+ const bandWidth = ex - sx;
2420
+ if (bandWidth <= 0) continue;
2421
+
2422
+ const sprintG = g
2423
+ .append('g')
2424
+ .attr('class', 'gantt-sprint-group')
2425
+ .style('cursor', 'pointer');
2426
+
2427
+ // Alternating shaded bands (consistent by sprint number)
2428
+ const sprintRect = sprintG
2429
+ .append('rect')
2430
+ .attr('class', 'gantt-sprint-band')
2431
+ .attr('x', sx)
2432
+ .attr('y', 0)
2433
+ .attr('width', bandWidth)
2434
+ .attr('height', innerHeight)
2435
+ .attr('fill', bandColor)
2436
+ .attr('opacity', sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0);
2437
+
2438
+ // Invisible hit rect for hover on unshaded bands
2439
+ if (sprint.number % 2 !== 0) {
2440
+ sprintG
2441
+ .append('rect')
2442
+ .attr('x', sx)
2443
+ .attr('y', 0)
2444
+ .attr('width', bandWidth)
2445
+ .attr('height', innerHeight)
2446
+ .attr('fill', 'transparent');
2447
+ }
2448
+
2449
+ // Persistent sprint number label — always visible, turns into "Sprint X" on hover
2450
+ const sprintLabel = sprintG
2451
+ .append('text')
2452
+ .attr('class', 'gantt-sprint-label')
2453
+ .attr('x', (sx + ex) / 2)
2454
+ .attr('y', -22)
2455
+ .attr('text-anchor', 'middle')
2456
+ .attr('font-size', '10px')
2457
+ .attr('font-weight', '600')
2458
+ .attr('fill', bandColor)
2459
+ .attr('opacity', 0.4)
2460
+ .text(String(sprint.number));
2461
+
2462
+ // Dashed boundary line at sprint start (skip for first visible band)
2463
+ if (i > 0 && rawSx >= chartMinX) {
2464
+ sprintG
2465
+ .append('line')
2466
+ .attr('class', 'gantt-sprint-boundary')
2467
+ .attr('x1', sx)
2468
+ .attr('y1', -6)
2469
+ .attr('x2', sx)
2470
+ .attr('y2', innerHeight)
2471
+ .attr('stroke', bandColor)
2472
+ .attr('stroke-width', 1)
2473
+ .attr('stroke-dasharray', '3 3')
2474
+ .attr('opacity', SPRINT_BOUNDARY_OPACITY);
2475
+ }
2476
+
2477
+ // Determine which tasks and groups overlap this sprint (start-inclusive, end-exclusive)
2478
+ const sprintStartMs = sprint.startDate.getTime();
2479
+ const sprintEndMs = sprint.endDate.getTime();
2480
+ const overlappingTaskIds = new Set<string>();
2481
+ for (const rt of resolved.tasks) {
2482
+ const taskStart = rt.startDate.getTime();
2483
+ const taskEnd = rt.endDate.getTime();
2484
+ // Task overlaps sprint if it starts before sprint ends AND ends after sprint starts
2485
+ if (taskStart < sprintEndMs && taskEnd > sprintStartMs) {
2486
+ overlappingTaskIds.add(rt.task.id);
2487
+ }
2488
+ // Milestones (zero duration): include if they fall within [sprintStart, sprintEnd)
2489
+ if (
2490
+ taskStart === taskEnd &&
2491
+ taskStart >= sprintStartMs &&
2492
+ taskStart < sprintEndMs
2493
+ ) {
2494
+ overlappingTaskIds.add(rt.task.id);
2495
+ }
2496
+ }
2497
+ const overlappingGroupNames = new Set<string>();
2498
+ for (const rg of resolved.groups) {
2499
+ const gStart = rg.startDate.getTime();
2500
+ const gEnd = rg.endDate.getTime();
2501
+ if (gStart < sprintEndMs && gEnd > sprintStartMs) {
2502
+ overlappingGroupNames.add(rg.name);
2503
+ }
2504
+ }
2505
+
2506
+ // Hover: highlight band, keep overlapping tasks visible, show dates + sprint label
2507
+ const baseOpacity = sprint.number % 2 === 0 ? SPRINT_BAND_OPACITY : 0;
2508
+ sprintG
2509
+ .on('mouseenter', () => {
2510
+ // Dim tasks NOT in this sprint, keep overlapping ones visible
2511
+ g.selectAll<SVGGElement, unknown>('.gantt-task').each(function () {
2512
+ const el = d3Selection.select(this);
2513
+ const id = el.attr('data-task-id');
2514
+ el.attr(
2515
+ 'opacity',
2516
+ id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2517
+ );
2518
+ });
2519
+ g.selectAll<SVGElement, unknown>('.gantt-milestone').each(function () {
2520
+ const el = d3Selection.select(this);
2521
+ const id = el.attr('data-task-id');
2522
+ el.attr(
2523
+ 'opacity',
2524
+ id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2525
+ );
2526
+ });
2527
+ svg
2528
+ .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2529
+ .each(function () {
2530
+ const el = d3Selection.select(this);
2531
+ const id = el.attr('data-task-id');
2532
+ el.attr(
2533
+ 'opacity',
2534
+ id && overlappingTaskIds.has(id) ? 1 : FADE_OPACITY
2535
+ );
2536
+ });
2537
+ g.selectAll<SVGElement, unknown>(
2538
+ '.gantt-group-bar, .gantt-group-summary'
2539
+ ).each(function () {
2540
+ const el = d3Selection.select(this);
2541
+ const name = el.attr('data-group');
2542
+ el.attr(
2543
+ 'opacity',
2544
+ name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
2545
+ );
2546
+ });
2547
+ svg
2548
+ .selectAll<SVGGElement, unknown>('.gantt-group-label')
2549
+ .each(function () {
2550
+ const el = d3Selection.select(this);
2551
+ const name = el.attr('data-group');
2552
+ el.attr(
2553
+ 'opacity',
2554
+ name && overlappingGroupNames.has(name) ? 1 : FADE_OPACITY
2555
+ );
2556
+ });
2557
+ svg
2558
+ .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2559
+ .attr('opacity', FADE_OPACITY);
2560
+ g.selectAll<SVGElement, unknown>(
2561
+ '.gantt-lane-band, .gantt-lane-accent, .gantt-lane-band-group'
2562
+ ).attr('opacity', FADE_OPACITY);
2563
+ g.selectAll<SVGElement, unknown>(
2564
+ '.gantt-dep-arrow, .gantt-dep-arrowhead'
2565
+ ).attr('opacity', FADE_OPACITY);
2566
+ g.selectAll<SVGElement, unknown>('.gantt-marker-group').attr(
2567
+ 'opacity',
2568
+ FADE_OPACITY
2569
+ );
2570
+ sprintRect.attr('opacity', SPRINT_HOVER_OPACITY);
2571
+ // Only show start date indicator if it's within the visible chart area
2572
+ const startVisible = rawSx >= chartMinX;
2573
+ if (startVisible) {
2574
+ showGanttDateIndicators(
2575
+ g,
2576
+ xScale,
2577
+ sprint.startDate,
2578
+ sprint.endDate,
2579
+ innerHeight,
2580
+ bandColor
2581
+ );
2582
+ } else {
2583
+ // Only show end date indicator (start is clipped/off-screen)
2584
+ showGanttDateIndicators(
2585
+ g,
2586
+ xScale,
2587
+ sprint.endDate,
2588
+ null,
2589
+ innerHeight,
2590
+ bandColor
2591
+ );
2592
+ }
2593
+ // Swap persistent label to full "Sprint X" on hover
2594
+ const accentColor = palette.accent || palette.text || bandColor;
2595
+ sprintLabel
2596
+ .text(`Sprint ${sprint.number}`)
2597
+ .attr('font-size', '13px')
2598
+ .attr('font-weight', '700')
2599
+ .attr('fill', accentColor)
2600
+ .attr('opacity', 1);
2601
+ })
2602
+ .on('mouseleave', () => {
2603
+ resetHighlight(g, svg);
2604
+ sprintRect.attr('opacity', baseOpacity);
2605
+ sprintLabel
2606
+ .text(String(sprint.number))
2607
+ .attr('font-size', '10px')
2608
+ .attr('font-weight', '600')
2609
+ .attr('fill', bandColor)
2610
+ .attr('opacity', 0.4);
2611
+ hideGanttDateIndicators(g);
2612
+ });
2613
+ }
2614
+
2615
+ // Boundary line at end of last sprint
2616
+ const lastSprint = resolved.sprints[resolved.sprints.length - 1];
2617
+ const lastEx = xScale(dateToFractionalYear(lastSprint.endDate));
2618
+ g.append('line')
2619
+ .attr('class', 'gantt-sprint-boundary')
2620
+ .attr('x1', lastEx)
2621
+ .attr('y1', -6)
2622
+ .attr('x2', lastEx)
2623
+ .attr('y2', innerHeight)
2624
+ .attr('stroke', bandColor)
2625
+ .attr('stroke-width', 1)
2626
+ .attr('stroke-dasharray', '3 3')
2627
+ .attr('opacity', SPRINT_BOUNDARY_OPACITY);
2628
+ }
2629
+
2377
2630
  /**
2378
2631
  * Parse a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a Date object.
2379
2632
  * Used for eras and markers which store dates as strings.
@@ -7,8 +7,17 @@ import type { TagGroup } from '../utils/tag-groups';
7
7
 
8
8
  // ── Duration ────────────────────────────────────────────────
9
9
 
10
- /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. */
11
- export type DurationUnit = 'd' | 'bd' | 'w' | 'm' | 'q' | 'y' | 'h' | 'min';
10
+ /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. s = sprints. */
11
+ export type DurationUnit =
12
+ | 'd'
13
+ | 'bd'
14
+ | 'w'
15
+ | 'm'
16
+ | 'q'
17
+ | 'y'
18
+ | 'h'
19
+ | 'min'
20
+ | 's';
12
21
 
13
22
  export interface Duration {
14
23
  amount: number;
@@ -119,6 +128,11 @@ export interface GanttOptions {
119
128
  /** Line numbers for option/block keywords — maps key to source line */
120
129
  optionLineNumbers: Record<string, number>;
121
130
  holidaysLineNumber: number | null;
131
+ // ── Sprint options ─────────────────────────────────────────
132
+ sprintLength: Duration | null; // default { amount: 2, unit: 'w' } when sprint mode active
133
+ sprintNumber: number | null; // which sprint the chart starts at (default 1)
134
+ sprintStart: string | null; // YYYY-MM-DD — date that sprintNumber begins
135
+ sprintMode: 'auto' | 'explicit' | null; // auto = activated by `s` unit, explicit = sprint-* option present
122
136
  }
123
137
 
124
138
  // ── Parsed Result ───────────────────────────────────────────
@@ -158,6 +172,12 @@ export interface ResolvedGroup {
158
172
  depth: number;
159
173
  }
160
174
 
175
+ export interface ResolvedSprint {
176
+ number: number;
177
+ startDate: Date;
178
+ endDate: Date;
179
+ }
180
+
161
181
  export interface ResolvedSchedule {
162
182
  tasks: ResolvedTask[];
163
183
  groups: ResolvedGroup[];
@@ -167,6 +187,7 @@ export interface ResolvedSchedule {
167
187
  tagGroups: TagGroup[];
168
188
  eras: GanttEra[];
169
189
  markers: GanttMarker[];
190
+ sprints: ResolvedSprint[];
170
191
  options: GanttOptions;
171
192
  diagnostics: DgmoError[];
172
193
  error: string | null;
package/src/sharing.ts CHANGED
@@ -1,6 +1,7 @@
1
- import lzString from 'lz-string';
2
- const { compressToEncodedURIComponent, decompressFromEncodedURIComponent } =
3
- lzString;
1
+ import {
2
+ compressToEncodedURIComponent,
3
+ decompressFromEncodedURIComponent,
4
+ } from 'lz-string';
4
5
 
5
6
  const DEFAULT_BASE_URL = 'https://online.diagrammo.app';
6
7
  const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
@@ -120,7 +120,8 @@ export function addGanttDuration(
120
120
  duration: Duration,
121
121
  holidays: GanttHolidays,
122
122
  holidaySet: Set<string>,
123
- direction: 1 | -1 = 1
123
+ direction: 1 | -1 = 1,
124
+ opts?: { sprintLength?: Duration }
124
125
  ): Date {
125
126
  const { amount, unit } = duration;
126
127
 
@@ -197,6 +198,19 @@ export function addGanttDuration(
197
198
  result.setTime(result.getTime() + amount * 60000 * direction);
198
199
  return result;
199
200
  }
201
+
202
+ case 's': {
203
+ if (!opts?.sprintLength) {
204
+ throw new Error(
205
+ 'Sprint duration unit "s" requires sprintLength configuration'
206
+ );
207
+ }
208
+ const sl = opts.sprintLength;
209
+ const totalDays = amount * sl.amount * (sl.unit === 'w' ? 7 : 1);
210
+ const result = new Date(startDate);
211
+ result.setDate(result.getDate() + Math.round(totalDays) * direction);
212
+ return result;
213
+ }
200
214
  }
201
215
  }
202
216
 
@@ -204,7 +218,7 @@ export function addGanttDuration(
204
218
  * Parse a duration string like "3bd" or "5d".
205
219
  */
206
220
  export function parseDuration(s: string): Duration | null {
207
- const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)$/);
221
+ const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)$/);
208
222
  if (!match) return null;
209
223
  return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
210
224
  }
@@ -107,14 +107,16 @@ function capsuleWidth(
107
107
  }
108
108
 
109
109
  // Multi-row: compute how many entries fit per row
110
- const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD * 2;
110
+ // Right boundary leaves one LEGEND_CAPSULE_PAD for right padding;
111
+ // left padding is already baked into the starting rowX.
112
+ const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD;
111
113
  let row = 1;
112
- let rowX = pw + 4;
114
+ let rowX = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
113
115
  let visible = 0;
114
116
 
115
117
  for (let i = 0; i < entries.length; i++) {
116
118
  const ew2 = entryWidth(entries[i].value);
117
- if (rowX + ew2 > rowWidth && rowX > pw + 4) {
119
+ if (rowX + ew2 > rowWidth && i > 0) {
118
120
  row++;
119
121
  rowX = 0;
120
122
  if (row > LEGEND_MAX_ENTRY_ROWS) {
@@ -323,7 +325,9 @@ function buildCapsuleLayout(
323
325
  let ex = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
324
326
  let ey = 0;
325
327
  let rowX = ex;
326
- const maxRowW = containerWidth - LEGEND_CAPSULE_PAD * 2;
328
+ // Right boundary: one LEGEND_CAPSULE_PAD for right padding.
329
+ // Left padding is already in ex/rowX starting position.
330
+ const maxRowW = containerWidth - LEGEND_CAPSULE_PAD;
327
331
  let currentRow = 0;
328
332
 
329
333
  for (let i = 0; i < info.visibleEntries; i++) {