@goodandready/dsh-cron 0.3.4 → 0.3.5

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/lib/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculate24hTimeline } from './timeline.js';
1
2
  import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
2
3
  import { sanitizeAdaptiveConfig } from './adaptive.js';
3
4
  import { logStreamer } from './log-streamer.js';
@@ -91,6 +92,10 @@ export function createCronApiHandler(store, scheduler, collection) {
91
92
  await handleSchedulePreview({ req, res, url, store });
92
93
  return;
93
94
  }
95
+ if (scope === 'schedule' && id === 'timeline-24h') {
96
+ await handleScheduleTimeline({ req, res, url, store, scheduler });
97
+ return;
98
+ }
94
99
 
95
100
  if (!id) {
96
101
  await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
package/lib/client.js CHANGED
@@ -285,6 +285,12 @@ en: {
285
285
  'form.adaptiveEnabled': 'Adaptive Polling', 'form.adaptiveHint': 'Exponential backoff when no changes are detected',
286
286
  'liveLog.modalTitle': 'Real-Time Streaming Execution Logs', 'liveLog.viewLog': 'Live Stream',
287
287
  'liveLog.autoScroll': 'Auto-scroll', 'liveLog.connecting': 'Connecting to log stream...', 'liveLog.waiting': 'Waiting for runner output...', 'liveLog.completed': 'Execution stream completed', 'liveLog.close': 'Close Stream',
288
+ 'timeline.modalTitle': '24-Hour Schedule Timeline', 'timeline.buttonLabel': 'Timeline',
289
+ 'timeline.buttonTitle': 'View 24-hour projected schedule and concurrency timeline',
290
+ 'timeline.tabTimeline': 'Schedule Timeline', 'timeline.legendDesc': 'Projected run distribution across 24 hourly buckets:',
291
+ 'timeline.peakWarning': 'Peak concurrency warning',
292
+ 'heatmap.modalTitle': 'Cost & Token Burn Heatmap', 'heatmap.buttonLabel': 'Heatmap',
293
+ 'heatmap.tabHeatmap': 'Cost Heatmap', 'heatmap.matrixDesc': 'Weekly hour-of-day cost concentration (7 days × 24 hours):',
288
294
  'digest.modalTitle': 'Automation & Cost Digest', 'digest.buttonLabel': 'Digest',
289
295
  'digest.buttonTitle': 'View 7-day or 30-day automation analytics and cost digest',
290
296
  'digest.period7': 'Last 7 Days', 'digest.period30': 'Last 30 Days',
@@ -461,6 +467,12 @@ zh: {
461
467
  'form.adaptiveEnabled': '自适应轮询 (Adaptive Polling)', 'form.adaptiveHint': '数据无变化时指数级延长执行间隔',
462
468
  'liveLog.modalTitle': '实时任务执行日志流', 'liveLog.viewLog': '实时日志',
463
469
  'liveLog.autoScroll': '自动滚动', 'liveLog.connecting': '正在连接日志流...', 'liveLog.waiting': '等待运行器输出...', 'liveLog.completed': '日志流已结束', 'liveLog.close': '关闭日志流',
470
+ 'timeline.modalTitle': '24小时调度时间线', 'timeline.buttonLabel': '时间线',
471
+ 'timeline.buttonTitle': '查看24小时预测任务调度与并发时间线',
472
+ 'timeline.tabTimeline': '调度时间线', 'timeline.legendDesc': '未来24小时各时段任务预计执行分布:',
473
+ 'timeline.peakWarning': '并发峰值预警',
474
+ 'heatmap.modalTitle': '成本与Token消耗热力图', 'heatmap.buttonLabel': '热力图',
475
+ 'heatmap.tabHeatmap': '成本热力图', 'heatmap.matrixDesc': '每周各时段成本分布矩阵(7天 × 24小时):',
464
476
  'digest.modalTitle': '自动化与成本分析摘要', 'digest.buttonLabel': '分析摘要',
465
477
  'digest.buttonTitle': '查看 7 天或 30 天运行统计与花费摘要',
466
478
  'digest.period7': '最近 7 天', 'digest.period30': '最近 30 天',
@@ -1481,6 +1493,191 @@ style: { padding: '6px 14px', fontSize: '12px' }
1481
1493
  )
1482
1494
  );
1483
1495
  }
1496
+ function renderTimelineHeatmapModal({
1497
+ isOpen,
1498
+ onClose,
1499
+ activeTab,
1500
+ onSelectTab,
1501
+ timelineData,
1502
+ heatmapData,
1503
+ isLoading,
1504
+ t
1505
+ }) {
1506
+ if (!isOpen) return null;
1507
+ const tab = activeTab || 'timeline';
1508
+ const timeline = timelineData || { buckets: [], totalRuns: 0, peakHoursCount: 0, maxConcurrent: 3 };
1509
+ const heatmap = heatmapData || { matrix: [], dayLabels: [], hourLabels: [], totalCostUsd: 0, totalTokens: 0, maxCostHour: 0 };
1510
+ return React.createElement('div', {
1511
+ className: 'dsh-cron-modal-overlay',
1512
+ onClick: onClose
1513
+ },
1514
+ React.createElement('div', {
1515
+ className: 'dsh-cron-modal',
1516
+ style: { maxWidth: '960px', width: '94vw', maxHeight: '88vh', display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base, #111)', color: 'var(--dsw-alias-label-primary, #ddd)', border: '1px solid var(--dsw-alias-border-l2, #333)', borderRadius: '8px' },
1517
+ role: 'dialog',
1518
+ 'aria-modal': 'true',
1519
+ onClick: (e) => e.stopPropagation()
1520
+ },
1521
+ React.createElement('div', {
1522
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid var(--dsw-alias-border-l2, #333)', background: 'var(--dsw-alias-bg-layer-2, #111)' }
1523
+ },
1524
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
1525
+ React.createElement('span', { style: { fontSize: '20px' } }, tab === 'timeline' ? '⏱' : '🔥'),
1526
+ React.createElement('div', null,
1527
+ React.createElement('div', { style: { fontWeight: '600', fontSize: '15px', color: 'var(--dsw-alias-label-primary, #fff)' } },
1528
+ tab === 'timeline' ? (T_KEY(t, 'timeline.modalTitle') || '24-Hour Schedule Timeline') : (T_KEY(t, 'heatmap.modalTitle') || 'Cost & Token Burn Heatmap')
1529
+ ),
1530
+ React.createElement('div', { style: { fontSize: '11.5px', color: 'var(--dsw-alias-label-secondary, #888)', marginTop: '2px' } },
1531
+ tab === 'timeline'
1532
+ ? (timeline.totalRuns + ' runs projected · ' + (timeline.peakHoursCount ? '⚠️ ' + timeline.peakHoursCount + ' peak hours' : '✓ balanced concurrency'))
1533
+ : ('$' + Number(heatmap.totalCostUsd || 0).toFixed(4) + ' USD · ' + Number(heatmap.totalTokens || 0).toLocaleString() + ' tokens')
1534
+ )
1535
+ )
1536
+ ),
1537
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1538
+ React.createElement('button', {
1539
+ type: 'button',
1540
+ className: 'dsh-cron-btn-secondary',
1541
+ style: { padding: '4px 12px', fontSize: '12px', fontWeight: tab === 'timeline' ? '600' : '400', background: tab === 'timeline' ? 'var(--dsw-alias-bg-layer-3, #222)' : 'transparent' },
1542
+ onClick: () => onSelectTab && onSelectTab('timeline')
1543
+ }, '⏱ ' + (T_KEY(t, 'timeline.tabTimeline') || 'Timeline')),
1544
+ React.createElement('button', {
1545
+ type: 'button',
1546
+ className: 'dsh-cron-btn-secondary',
1547
+ style: { padding: '4px 12px', fontSize: '12px', fontWeight: tab === 'heatmap' ? '600' : '400', background: tab === 'heatmap' ? 'var(--dsw-alias-bg-layer-3, #222)' : 'transparent' },
1548
+ onClick: () => onSelectTab && onSelectTab('heatmap')
1549
+ }, '🔥 ' + (T_KEY(t, 'heatmap.tabHeatmap') || 'Heatmap')),
1550
+ React.createElement('button', {
1551
+ type: 'button',
1552
+ className: 'dsh-cron-icon-btn',
1553
+ onClick: onClose,
1554
+ style: { marginLeft: '8px' }
1555
+ }, React.createElement('span', null, '✕'))
1556
+ )
1557
+ ),
1558
+ React.createElement('div', {
1559
+ style: { padding: '16px 18px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: '14px' }
1560
+ },
1561
+ isLoading ? React.createElement('div', { style: { padding: '40px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #888)' } }, 'Loading visualization data...') : (
1562
+ tab === 'timeline' ? (
1563
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
1564
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #888)', display: 'flex', justifyContent: 'space-between' } },
1565
+ React.createElement('span', null, T_KEY(t, 'timeline.legendDesc') || 'Projected run distribution across 24 hourly buckets:'),
1566
+ React.createElement('span', { style: { color: 'var(--dsh-cron-warning, #fa0)' } }, '⚠️ ' + (T_KEY(t, 'timeline.peakWarning') || 'Peak hours exceed max concurrent limit') + ' (' + timeline.maxConcurrent + ')')
1567
+ ),
1568
+ React.createElement('div', {
1569
+ style: { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: '8px', marginTop: '6px' }
1570
+ },
1571
+ (timeline.buckets || []).map((b) => {
1572
+ const isPeak = b.isPeak;
1573
+ const hasRuns = b.count > 0;
1574
+ return React.createElement('div', {
1575
+ key: b.index,
1576
+ style: {
1577
+ background: isPeak
1578
+ ? 'color-mix(in srgb, var(--dsh-cron-danger, #e33) 12%, var(--dsw-alias-bg-layer-2, #111))'
1579
+ : (hasRuns ? 'var(--dsw-alias-bg-layer-2, #111)' : 'var(--dsw-alias-bg-base, #111)'),
1580
+ border: isPeak
1581
+ ? '1px solid var(--dsh-cron-danger, #e33)'
1582
+ : (hasRuns ? '1px solid var(--dsw-alias-border-l2, #333)' : '1px dashed var(--dsw-alias-border-l2, #222)'),
1583
+ borderRadius: '6px',
1584
+ padding: '8px',
1585
+ minHeight: '75px',
1586
+ display: 'flex',
1587
+ flexDirection: 'column',
1588
+ justifyContent: 'space-between'
1589
+ }
1590
+ },
1591
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '11px' } },
1592
+ React.createElement('span', { style: { fontWeight: '600', color: isPeak ? 'var(--dsh-cron-danger, #e33)' : 'var(--dsw-alias-label-primary, #fff)' } }, b.label),
1593
+ hasRuns ? React.createElement('span', {
1594
+ style: { fontSize: '10px', padding: '1px 5px', borderRadius: '8px', background: isPeak ? 'var(--dsh-cron-danger, #e33)' : 'var(--dsw-alias-bg-layer-3, #222)', color: '#fff' }
1595
+ }, b.count) : null
1596
+ ),
1597
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px', marginTop: '6px' } },
1598
+ (b.runs || []).slice(0, 3).map((r, rIdx) => React.createElement('div', {
1599
+ key: rIdx,
1600
+ title: r.title + ' (at ' + new Date(r.runAt).toLocaleTimeString() + ')',
1601
+ style: {
1602
+ fontSize: '10px',
1603
+ padding: '2px 4px',
1604
+ borderRadius: '3px',
1605
+ background: 'var(--dsw-alias-bg-layer-3, #222)',
1606
+ color: 'var(--dsw-alias-label-primary, #ddd)',
1607
+ overflow: 'hidden',
1608
+ textOverflow: 'ellipsis',
1609
+ whiteSpace: 'nowrap'
1610
+ }
1611
+ }, r.title)),
1612
+ b.count > 3 ? React.createElement('div', { style: { fontSize: '9px', color: 'var(--dsw-alias-label-secondary, #888)' } }, '+' + (b.count - 3) + ' more') : null
1613
+ )
1614
+ );
1615
+ })
1616
+ )
1617
+ )
1618
+ ) : (
1619
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
1620
+ React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #888)', display: 'flex', justifyContent: 'space-between' } },
1621
+ React.createElement('span', null, T_KEY(t, 'heatmap.matrixDesc') || 'Weekly hour-of-day cost concentration (7 days × 24 hours):'),
1622
+ React.createElement('span', null, 'Max hour spend: $' + Number(heatmap.maxCostHour || 0).toFixed(4))
1623
+ ),
1624
+ React.createElement('div', {
1625
+ style: { overflowX: 'auto', paddingBottom: '8px' }
1626
+ },
1627
+ React.createElement('table', {
1628
+ style: { borderCollapse: 'collapse', width: '100%', minWidth: '700px', fontSize: '10.5px' }
1629
+ },
1630
+ React.createElement('thead', null,
1631
+ React.createElement('tr', null,
1632
+ React.createElement('th', { style: { padding: '4px 6px', textAlign: 'left', color: 'var(--dsw-alias-label-secondary, #888)' } }, 'Day'),
1633
+ Array.from({ length: 24 }, (_, h) => React.createElement('th', {
1634
+ key: h,
1635
+ style: { padding: '4px 2px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #888)', fontWeight: '400' }
1636
+ }, String(h).padStart(2, '0')))
1637
+ )
1638
+ ),
1639
+ React.createElement('tbody', null,
1640
+ (heatmap.matrix || []).map((row, dIdx) => React.createElement('tr', { key: dIdx },
1641
+ React.createElement('td', { style: { padding: '4px 6px', fontWeight: '600', color: 'var(--dsw-alias-label-primary, #ddd)' } }, (heatmap.dayLabels && heatmap.dayLabels[dIdx]) || 'Day'),
1642
+ row.map((cell, hIdx) => {
1643
+ const intensity = cell.costIntensity || 0;
1644
+ const hasSpend = cell.costUsd > 0;
1645
+ const bg = hasSpend
1646
+ ? 'color-mix(in srgb, var(--dsh-cron-primary, #4af) ' + Math.max(15, Math.round(intensity * 100)) + '%, transparent)'
1647
+ : 'transparent';
1648
+ return React.createElement('td', {
1649
+ key: hIdx,
1650
+ title: cell.dayLabel + ' ' + cell.hourLabel + ': $' + cell.costUsd + ' (' + cell.tokens.toLocaleString() + ' tokens, ' + cell.runs + ' runs)',
1651
+ style: {
1652
+ padding: '3px',
1653
+ textAlign: 'center',
1654
+ background: bg,
1655
+ border: '1px solid var(--dsw-alias-border-l2, #222)',
1656
+ cursor: hasSpend ? 'pointer' : 'default'
1657
+ }
1658
+ }, hasSpend ? React.createElement('span', { style: { fontSize: '9px', color: intensity > 0.5 ? '#fff' : 'var(--dsw-alias-label-primary, #ddd)' } }, '$' + (cell.costUsd >= 0.01 ? cell.costUsd.toFixed(2) : cell.costUsd.toFixed(3).slice(1))) : '·');
1659
+ })
1660
+ ))
1661
+ )
1662
+ )
1663
+ )
1664
+ )
1665
+ )
1666
+ )
1667
+ ),
1668
+ React.createElement('div', {
1669
+ style: { display: 'flex', justifyContent: 'flex-end', padding: '12px 18px', borderTop: '1px solid var(--dsw-alias-border-l2, #333)', background: 'var(--dsw-alias-bg-layer-2, #111)' }
1670
+ },
1671
+ React.createElement('button', {
1672
+ type: 'button',
1673
+ className: 'dsh-cron-btn-secondary',
1674
+ onClick: onClose,
1675
+ style: { padding: '6px 16px', fontSize: '12px' }
1676
+ }, T_KEY(t, 'chainGraph.close') || 'Close')
1677
+ )
1678
+ )
1679
+ );
1680
+ }
1484
1681
  function CronScreen(props) {
1485
1682
  const { ctx, toggle, onClose } = props;
1486
1683
  const t = props.t || translate;
@@ -2711,6 +2908,16 @@ React.createElement('button', {
2711
2908
  type: 'button',
2712
2909
  className: 'dsh-cron-btn-secondary',
2713
2910
  style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 12px' },
2911
+ title: T_KEY(t, 'timeline.buttonTitle'),
2912
+ onClick: () => openTimelineModal('timeline')
2913
+ },
2914
+ React.createElement('span', { style: { fontSize: '14px' } }, '⏱'),
2915
+ T_KEY(t, 'timeline.buttonLabel')
2916
+ ),
2917
+ React.createElement('button', {
2918
+ type: 'button',
2919
+ className: 'dsh-cron-btn-secondary',
2920
+ style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 12px' },
2714
2921
  title: T_KEY(t, 'digest.buttonTitle'),
2715
2922
  onClick: openAnalyticsDigest
2716
2923
  },
package/lib/routes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculateCostHeatmap } from './timeline.js';
1
2
  import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
2
3
  import { listVerifiedRecipes } from './recipes-catalog.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -111,6 +112,26 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
111
112
  const path = url.pathname;
112
113
 
113
114
  if (req.method === 'GET') {
115
+ if (path === '/dsh-cron/analytics/heatmap') {
116
+ const modelFilter = url.searchParams.get('model') || null;
117
+ const providerFilter = url.searchParams.get('provider') || null;
118
+ const costLedger = store.costLedger || [];
119
+ let history = [];
120
+ if (store.history) {
121
+ if (typeof store.history.values === 'function') {
122
+ for (const runs of store.history.values()) {
123
+ if (Array.isArray(runs)) history.push(...runs);
124
+ }
125
+ } else if (typeof store.history === 'object') {
126
+ for (const runs of Object.values(store.history)) {
127
+ if (Array.isArray(runs)) history.push(...runs);
128
+ }
129
+ }
130
+ }
131
+ const heatmap = calculateCostHeatmap({ costLedger, history, modelFilter, providerFilter });
132
+ sendJson(res, 200, { ok: true, heatmap });
133
+ return;
134
+ }
114
135
  const days = parseInt(url.searchParams.get('days') || '7', 10);
115
136
  const tasks = store.list ? store.list() : Object.values(store.tasks || {});
116
137
  let history = [];
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Visual Schedule Timeline & Cost Heatmap Engine (#332, #333)
3
+ *
4
+ * 1. 24-hour schedule projection with collision and peak concurrency detection.
5
+ * 2. 7x24 weekly hour-of-day cost and token burn heatmap matrix.
6
+ */
7
+
8
+ import { Cron } from 'croner';
9
+
10
+ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
11
+
12
+ export function calculate24hTimeline({
13
+ tasks = [],
14
+ maxConcurrent = 3,
15
+ fromTimestamp = Date.now(),
16
+ hoursCount = 24,
17
+ }) {
18
+ const limit = Math.max(1, Number(maxConcurrent) || 3);
19
+ const activeTasks = (Array.isArray(tasks) ? tasks : Object.values(tasks || {})).filter(
20
+ (t) => t && t.status === 'active'
21
+ );
22
+
23
+ const startMs = typeof fromTimestamp === 'number' ? fromTimestamp : Date.now();
24
+ const endMs = startMs + hoursCount * 3600000;
25
+
26
+ const buckets = Array.from({ length: hoursCount }, (_, i) => {
27
+ const bucketStart = startMs + i * 3600000;
28
+ const d = new Date(bucketStart);
29
+ const hourPad = String(d.getHours()).padStart(2, '0');
30
+ return {
31
+ index: i,
32
+ label: `${hourPad}:00`,
33
+ startMs: bucketStart,
34
+ endMs: bucketStart + 3600000,
35
+ runs: [],
36
+ count: 0,
37
+ isPeak: false,
38
+ };
39
+ });
40
+
41
+ let totalRuns = 0;
42
+
43
+ for (const task of activeTasks) {
44
+ if (task.oneShot) {
45
+ const runAt = Number(task.nextRunAt || task.targetTimestamp);
46
+ if (runAt >= startMs && runAt < endMs) {
47
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
48
+ if (bIdx >= 0 && bIdx < hoursCount) {
49
+ buckets[bIdx].runs.push({
50
+ taskId: task.id,
51
+ title: task.title || task.id,
52
+ runAt,
53
+ durationEstMs: task.avgDuration || 2000,
54
+ type: task.type || 'llm',
55
+ });
56
+ totalRuns += 1;
57
+ }
58
+ }
59
+ continue;
60
+ }
61
+
62
+ if (task.schedule) {
63
+ try {
64
+ const c = new Cron(task.schedule, { timezone: task.timezone });
65
+ let cursor = new Date(startMs - 1);
66
+ // Bound to max 96 occurrences per task in 24h window
67
+ for (let step = 0; step < 96; step++) {
68
+ const nextDate = c.nextRun(cursor);
69
+ if (!nextDate) break;
70
+ const runMs = nextDate.getTime();
71
+ if (runMs >= endMs) break;
72
+
73
+ const bIdx = Math.floor((runMs - startMs) / 3600000);
74
+ if (bIdx >= 0 && bIdx < hoursCount) {
75
+ buckets[bIdx].runs.push({
76
+ taskId: task.id,
77
+ title: task.title || task.id,
78
+ runAt: runMs,
79
+ durationEstMs: task.avgDuration || 2000,
80
+ type: task.type || 'llm',
81
+ });
82
+ totalRuns += 1;
83
+ }
84
+ cursor = new Date(runMs + 1000);
85
+ }
86
+ } catch (err) {
87
+ // Fallback for custom or invalid cron schedules
88
+ const runAt = Number(task.nextRunAt);
89
+ if (runAt >= startMs && runAt < endMs) {
90
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
91
+ if (bIdx >= 0 && bIdx < hoursCount) {
92
+ buckets[bIdx].runs.push({
93
+ taskId: task.id,
94
+ title: task.title || task.id,
95
+ runAt,
96
+ durationEstMs: task.avgDuration || 2000,
97
+ type: task.type || 'llm',
98
+ });
99
+ totalRuns += 1;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const peakHours = [];
107
+
108
+ for (const b of buckets) {
109
+ b.count = b.runs.length;
110
+ b.isPeak = b.count > limit;
111
+ if (b.isPeak) {
112
+ peakHours.push(b.label);
113
+ }
114
+ }
115
+
116
+ return {
117
+ fromTimestamp: startMs,
118
+ toTimestamp: endMs,
119
+ maxConcurrent: limit,
120
+ totalRuns,
121
+ peakHoursCount: peakHours.length,
122
+ peakHours,
123
+ buckets,
124
+ };
125
+ }
126
+
127
+ export function calculateCostHeatmap({
128
+ costLedger = [],
129
+ history = [],
130
+ modelFilter = null,
131
+ providerFilter = null,
132
+ }) {
133
+ const ledger = Array.isArray(costLedger) ? costLedger : [];
134
+ const historyList = Array.isArray(history) ? history : [];
135
+
136
+ // Matrix: 7 rows (Mon..Sun) x 24 columns (00..23h)
137
+ const matrix = Array.from({ length: 7 }, (_, dIdx) =>
138
+ Array.from({ length: 24 }, (_, hIdx) => ({
139
+ day: dIdx,
140
+ dayLabel: DAY_LABELS[dIdx],
141
+ hour: hIdx,
142
+ hourLabel: `${String(hIdx).padStart(2, '0')}:00`,
143
+ tokens: 0,
144
+ costUsd: 0,
145
+ runs: 0,
146
+ costIntensity: 0,
147
+ tokenIntensity: 0,
148
+ }))
149
+ );
150
+
151
+ let totalCostUsd = 0;
152
+ let totalTokens = 0;
153
+ let totalRuns = 0;
154
+
155
+ // Aggregate costLedger
156
+ for (const entry of ledger) {
157
+ if (!entry || typeof entry.timestamp !== 'number') continue;
158
+ if (modelFilter && entry.model && entry.model !== modelFilter) continue;
159
+ if (providerFilter && entry.provider && entry.provider !== providerFilter) continue;
160
+
161
+ const d = new Date(entry.timestamp);
162
+ const dayOfWeek = (d.getUTCDay() + 6) % 7; // Convert Sun(0) to 6, Mon(1) to 0
163
+ const hour = d.getUTCHours();
164
+
165
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
166
+ const cell = matrix[dayOfWeek][hour];
167
+ const cost = typeof entry.costUsd === 'number' ? entry.costUsd : 0;
168
+ const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0;
169
+
170
+ cell.costUsd += cost;
171
+ cell.tokens += tokens;
172
+ totalCostUsd += cost;
173
+ totalTokens += tokens;
174
+ }
175
+ }
176
+
177
+ // Aggregate run counts from history
178
+ for (const r of historyList) {
179
+ if (!r) continue;
180
+ const t = r.startedAt || r.completedAt || r.timestamp;
181
+ if (typeof t !== 'number') continue;
182
+ if (modelFilter && r.model && r.model !== modelFilter) continue;
183
+ if (providerFilter && r.provider && r.provider !== providerFilter) continue;
184
+
185
+ const d = new Date(t);
186
+ const dayOfWeek = (d.getUTCDay() + 6) % 7;
187
+ const hour = d.getUTCHours();
188
+
189
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
190
+ matrix[dayOfWeek][hour].runs += 1;
191
+ totalRuns += 1;
192
+ }
193
+ }
194
+
195
+ // Calculate maximums for intensity normalisation
196
+ let maxCostHour = 0;
197
+ let maxTokensHour = 0;
198
+
199
+ for (let d = 0; d < 7; d++) {
200
+ for (let h = 0; h < 24; h++) {
201
+ const cell = matrix[d][h];
202
+ cell.costUsd = Number(cell.costUsd.toFixed(4));
203
+ if (cell.costUsd > maxCostHour) maxCostHour = cell.costUsd;
204
+ if (cell.tokens > maxTokensHour) maxTokensHour = cell.tokens;
205
+ }
206
+ }
207
+
208
+ // Normalise intensity (0.0 to 1.0)
209
+ const costDenom = maxCostHour > 0 ? maxCostHour : 1;
210
+ const tokenDenom = maxTokensHour > 0 ? maxTokensHour : 1;
211
+
212
+ for (let d = 0; d < 7; d++) {
213
+ for (let h = 0; h < 24; h++) {
214
+ const cell = matrix[d][h];
215
+ cell.costIntensity = Number((cell.costUsd / costDenom).toFixed(3));
216
+ cell.tokenIntensity = Number((cell.tokens / tokenDenom).toFixed(3));
217
+ }
218
+ }
219
+
220
+ return {
221
+ dayLabels: DAY_LABELS,
222
+ hourLabels: Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
223
+ matrix,
224
+ maxCostHour,
225
+ maxTokensHour,
226
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
227
+ totalTokens,
228
+ totalRuns,
229
+ };
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",