@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.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 +24 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/bin/nl2sql-mcp.mjs +103 -0
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/index.js +17 -1
- package/lib/client.js +1680 -6
- package/package.json +7 -2
- package/service/alerts.js +20 -6
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +3 -2
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nl2sql-mcp": "bin/nl2sql-mcp.mjs"
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
".": "./index.js",
|
|
8
11
|
"./client": "./lib/client.js",
|
|
@@ -10,6 +13,8 @@
|
|
|
10
13
|
"./icon": "./icon.svg"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"docs/mcp.md",
|
|
13
18
|
"index.js",
|
|
14
19
|
"service/*.js",
|
|
15
20
|
"channels/*.js",
|
|
@@ -85,7 +90,7 @@
|
|
|
85
90
|
"wecom"
|
|
86
91
|
],
|
|
87
92
|
"engines": {
|
|
88
|
-
"node": ">=
|
|
93
|
+
"node": ">=18"
|
|
89
94
|
},
|
|
90
95
|
"repository": {
|
|
91
96
|
"type": "git",
|
package/service/alerts.js
CHANGED
|
@@ -94,11 +94,13 @@ export const methods = {
|
|
|
94
94
|
let a = draft ? structuredClone(draft) : { ...found }
|
|
95
95
|
const ds = this.requireDatasource(a.datasourceId)
|
|
96
96
|
let result
|
|
97
|
+
// Chat-created alerts (service/subscriptions.js) run as their creator, re-resolved now.
|
|
98
|
+
const actor = a.chatTarget && this.chatAlertActor ? await this.chatAlertActor(a) : undefined
|
|
97
99
|
if (a.source === 'metric') {
|
|
98
100
|
const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
|
|
99
|
-
result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
|
|
101
|
+
result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert', actor })
|
|
100
102
|
} else {
|
|
101
|
-
result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
|
|
103
|
+
result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true, actor })
|
|
102
104
|
}
|
|
103
105
|
// Series: first column = time/category, last numeric column = value.
|
|
104
106
|
const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
|
|
@@ -120,9 +122,12 @@ export const methods = {
|
|
|
120
122
|
a.lastFiredAt = Date.now()
|
|
121
123
|
const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
|
|
122
124
|
this.records.put('alertEvent', event)
|
|
123
|
-
if (a.channelId) {
|
|
125
|
+
if (a.channelId || (a.chatTarget && this.deliverAlertToChat)) {
|
|
124
126
|
try {
|
|
125
|
-
|
|
127
|
+
// Optional extra Markdown from feature modules (e.g. change attribution, service/proactive.js).
|
|
128
|
+
const extra = await Promise.resolve(this.beforeAlertPush?.(a, result)).catch(() => null)
|
|
129
|
+
const msg = { channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**${extra ? `\n\n${extra}` : ''}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' }
|
|
130
|
+
if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg)
|
|
126
131
|
pushed = true
|
|
127
132
|
event.pushed = true
|
|
128
133
|
} catch (error) {
|
|
@@ -133,8 +138,9 @@ export const methods = {
|
|
|
133
138
|
}
|
|
134
139
|
} else if (a.state === 'firing') {
|
|
135
140
|
a.state = 'ok'
|
|
136
|
-
if (!dryRun && a.channelId && a.notifyRecovery !== false) {
|
|
137
|
-
|
|
141
|
+
if (!dryRun && (a.channelId || a.chatTarget) && a.notifyRecovery !== false) {
|
|
142
|
+
const msg = { channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }
|
|
143
|
+
try { if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg) } catch {}
|
|
138
144
|
}
|
|
139
145
|
} else {
|
|
140
146
|
a.state = 'ok'
|
|
@@ -221,6 +227,14 @@ export const methods = {
|
|
|
221
227
|
ran.push({ id: a.id, ok: false, error: error.message })
|
|
222
228
|
}
|
|
223
229
|
}))
|
|
230
|
+
// Jobs registered by other modules (digest, reconciliation, freshness, quality, goals…):
|
|
231
|
+
// service.schedulerJobs() returns [{ id, due(now: Date) -> boolean, run() -> Promise }].
|
|
232
|
+
for (const job of (typeof this.schedulerJobs === 'function' ? this.schedulerJobs(now) : [])) {
|
|
233
|
+
let isDue = false
|
|
234
|
+
try { isDue = Boolean(job.due(now)) } catch {}
|
|
235
|
+
if (!isDue) continue
|
|
236
|
+
try { await this.withJobLock(`job:${job.id}`, () => job.run()); ran.push({ id: job.id, ok: true }) } catch (error) { if (error.code !== 'nl2sql/busy') ran.push({ id: job.id, ok: false, error: error.message }) }
|
|
237
|
+
}
|
|
224
238
|
// Daily schema watch (catches up once after downtime).
|
|
225
239
|
const at = this.state.settings.schemaCheckDaily
|
|
226
240
|
if (at) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature modules. Each entry: { name, methods, rpc?(service), tools?(ctx, service, helpers), start?(ctx, service) }.
|
|
3
|
+
* - methods: mixed into the service prototype (`this` = service). Methods named `jobs_<name>(now)` return
|
|
4
|
+
* scheduler jobs [{ id, due(now), run() }] run by the in-process scheduler (service/alerts.js).
|
|
5
|
+
* - rpc: page RPC methods; tools: agent tools; start: called after apply (e.g. servers).
|
|
6
|
+
* New features add a file under service/ and one line here, so they do not edit index.js.
|
|
7
|
+
*/
|
|
8
|
+
import { FEATURE as subscriptions } from './subscriptions.js'
|
|
9
|
+
import { FEATURE as reconcile } from './reconcile.js'
|
|
10
|
+
import { FEATURE as present } from './present.js'
|
|
11
|
+
import { FEATURE as mcp } from './mcp.js'
|
|
12
|
+
import { FEATURE as proactive } from './proactive.js'
|
|
13
|
+
import { FEATURE as freshness } from './freshness.js'
|
|
14
|
+
export const FEATURES = [
|
|
15
|
+
present,
|
|
16
|
+
mcp,
|
|
17
|
+
reconcile,
|
|
18
|
+
subscriptions,
|
|
19
|
+
proactive,
|
|
20
|
+
freshness,
|
|
21
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forecasting (pure functions, no I/O).
|
|
3
|
+
*
|
|
4
|
+
* - Holt-Winters additive with weekly seasonality (m = 7) for daily series with ≥ 21 points;
|
|
5
|
+
* - otherwise Holt's linear trend.
|
|
6
|
+
* Parameters are fitted by a small grid search minimising the one-step-ahead SSE.
|
|
7
|
+
* Prediction intervals: residual stddev × 1.96 × sqrt(h).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const GRID = [0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9]
|
|
11
|
+
const GRID_TREND = [0.01, 0.05, 0.1, 0.2, 0.3]
|
|
12
|
+
const GRID_SEASON = [0.05, 0.1, 0.2, 0.3, 0.5]
|
|
13
|
+
|
|
14
|
+
/** Holt's linear trend: one-step fitted values, final level/trend. */
|
|
15
|
+
export function holtFit(y, alpha, beta) {
|
|
16
|
+
const n = y.length
|
|
17
|
+
if (n < 2) return { fitted: y.map(() => null), level: y[0] ?? 0, trend: 0, sse: 0, count: 0 }
|
|
18
|
+
let level = y[0]
|
|
19
|
+
let trend = y[1] - y[0]
|
|
20
|
+
const fitted = [null]
|
|
21
|
+
let sse = 0
|
|
22
|
+
let count = 0
|
|
23
|
+
for (let t = 1; t < n; t++) {
|
|
24
|
+
const f = level + trend
|
|
25
|
+
fitted.push(f)
|
|
26
|
+
if (t >= 2) { sse += (y[t] - f) ** 2; count++ }
|
|
27
|
+
const prev = level
|
|
28
|
+
level = alpha * y[t] + (1 - alpha) * (level + trend)
|
|
29
|
+
trend = beta * (level - prev) + (1 - beta) * trend
|
|
30
|
+
}
|
|
31
|
+
return { fitted, level, trend, sse, count }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Holt-Winters additive with season length m (needs ≥ 2 m points). */
|
|
35
|
+
export function holtWintersFit(y, alpha, beta, gamma, m = 7) {
|
|
36
|
+
const n = y.length
|
|
37
|
+
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length
|
|
38
|
+
const s1 = mean(y.slice(0, m))
|
|
39
|
+
const s2 = mean(y.slice(m, 2 * m))
|
|
40
|
+
let level = s1
|
|
41
|
+
let trend = (s2 - s1) / m
|
|
42
|
+
// Initial seasonal indices: average deviation from each full season's mean.
|
|
43
|
+
const seasons = Math.floor(n / m)
|
|
44
|
+
const season = new Array(m).fill(0)
|
|
45
|
+
for (let k = 0; k < seasons; k++) {
|
|
46
|
+
const mk = mean(y.slice(k * m, (k + 1) * m))
|
|
47
|
+
for (let i = 0; i < m; i++) season[i] += (y[k * m + i] - mk) / seasons
|
|
48
|
+
}
|
|
49
|
+
// Level at the end of the first season: start the recursion at t = m.
|
|
50
|
+
level = s1 + trend * (m - 1) / 2
|
|
51
|
+
const fitted = new Array(m).fill(null)
|
|
52
|
+
let sse = 0
|
|
53
|
+
let count = 0
|
|
54
|
+
for (let t = m; t < n; t++) {
|
|
55
|
+
const si = t % m
|
|
56
|
+
const f = level + trend + season[si]
|
|
57
|
+
fitted.push(f)
|
|
58
|
+
sse += (y[t] - f) ** 2
|
|
59
|
+
count++
|
|
60
|
+
const prev = level
|
|
61
|
+
level = alpha * (y[t] - season[si]) + (1 - alpha) * (level + trend)
|
|
62
|
+
trend = beta * (level - prev) + (1 - beta) * trend
|
|
63
|
+
season[si] = gamma * (y[t] - level) + (1 - gamma) * season[si]
|
|
64
|
+
}
|
|
65
|
+
return { fitted, level, trend, season, sse, count, n, m }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fit and forecast `horizon` steps ahead.
|
|
70
|
+
* @param {number[]} values evenly spaced observations (no nulls)
|
|
71
|
+
* @param {{ horizon?: number, seasonal?: boolean, period?: number }} opts seasonal: allow weekly seasonality
|
|
72
|
+
* @returns {{ method, params, fitted, forecast: { h, value, lower, upper }[], sigma }}
|
|
73
|
+
*/
|
|
74
|
+
export function forecastSeries(values, { horizon = 7, seasonal = true, period = 7 } = {}) {
|
|
75
|
+
const y = values.map(Number)
|
|
76
|
+
if (y.length < 3 || y.some(v => !Number.isFinite(v))) throw new Error('至少需要 3 个有效数值才能预测')
|
|
77
|
+
const H = Math.max(1, Math.min(365, Math.floor(horizon)))
|
|
78
|
+
const useHW = seasonal && y.length >= 3 * period
|
|
79
|
+
let best
|
|
80
|
+
if (useHW) {
|
|
81
|
+
for (const a of GRID) for (const b of GRID_TREND) for (const g of GRID_SEASON) {
|
|
82
|
+
const fit = holtWintersFit(y, a, b, g, period)
|
|
83
|
+
if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b, gamma: g } }
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
for (const a of GRID) for (const b of GRID_TREND) {
|
|
87
|
+
const fit = holtFit(y, a, b)
|
|
88
|
+
if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b } }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const { fit, params } = best
|
|
92
|
+
const sigma = fit.count > 1 ? Math.sqrt(fit.sse / (fit.count - 1)) : 0
|
|
93
|
+
const forecast = []
|
|
94
|
+
for (let h = 1; h <= H; h++) {
|
|
95
|
+
const value = useHW
|
|
96
|
+
? fit.level + h * fit.trend + fit.season[(y.length + h - 1) % period]
|
|
97
|
+
: fit.level + h * fit.trend
|
|
98
|
+
const w = 1.96 * sigma * Math.sqrt(h)
|
|
99
|
+
forecast.push({ h, value, lower: value - w, upper: value + w })
|
|
100
|
+
}
|
|
101
|
+
return { method: useHW ? 'holt-winters' : 'holt', params, fitted: fit.fitted, forecast, sigma }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------ time axis
|
|
105
|
+
|
|
106
|
+
const DAY = 86400000
|
|
107
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
108
|
+
const fmtDay = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
109
|
+
|
|
110
|
+
/** Parse a time label to { kind, ms } (UTC-based arithmetic, display unchanged). */
|
|
111
|
+
export function parseTimeLabel(v) {
|
|
112
|
+
const s = String(v ?? '').trim()
|
|
113
|
+
let m
|
|
114
|
+
// Dates serialized as midnight ISO timestamps (driver Date → ISO) are days.
|
|
115
|
+
if ((m = /^(\d{4})-(\d{2})-(\d{2})T00:00:00(?:\.0+)?Z?$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
|
|
116
|
+
if ((m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(s))) return { kind: 'hour', ms: Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) }
|
|
117
|
+
if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
|
|
118
|
+
if ((m = /^(\d{4})-(\d{2})$/.exec(s))) return { kind: 'month', ms: Date.UTC(+m[1], +m[2] - 1, 1) }
|
|
119
|
+
if ((m = /^(\d{4})-W(\d{2})$/.exec(s))) return { kind: 'week', ms: Date.UTC(+m[1], 0, 1) + (+m[2]) * 7 * DAY }
|
|
120
|
+
if ((m = /^(\d{4})$/.exec(s))) return { kind: 'year', ms: Date.UTC(+m[1], 0, 1) }
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Turn [{ x, y }] (sorted or not) into an evenly spaced series. Daily series get missing days
|
|
126
|
+
* filled with 0 (metrics like counts/sums have no row on days without data).
|
|
127
|
+
* Returns { kind, xs, ys, filled }.
|
|
128
|
+
*/
|
|
129
|
+
export function regularize(points) {
|
|
130
|
+
const parsed = points.map(p => ({ ...p, t: parseTimeLabel(p.x) })).filter(p => p.y !== null && p.y !== undefined && Number.isFinite(Number(p.y)))
|
|
131
|
+
const timed = parsed.length >= 2 && parsed.every(p => p.t && p.t.kind === parsed[0].t.kind)
|
|
132
|
+
if (!timed) return { kind: 'index', xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
|
|
133
|
+
parsed.sort((a, b) => a.t.ms - b.t.ms)
|
|
134
|
+
const kind = parsed[0].t.kind
|
|
135
|
+
if (kind !== 'day') return { kind, xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
|
|
136
|
+
const byDay = new Map()
|
|
137
|
+
for (const p of parsed) byDay.set(p.t.ms, (byDay.get(p.t.ms) ?? 0) + Number(p.y))
|
|
138
|
+
const xs = []
|
|
139
|
+
const ys = []
|
|
140
|
+
let filled = 0
|
|
141
|
+
for (let ms = parsed[0].t.ms; ms <= parsed.at(-1).t.ms; ms += DAY) {
|
|
142
|
+
xs.push(fmtDay(new Date(ms)))
|
|
143
|
+
if (byDay.has(ms)) ys.push(byDay.get(ms)); else { ys.push(0); filled++ }
|
|
144
|
+
}
|
|
145
|
+
return { kind, xs, ys, filled }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Labels for the next `horizon` steps after the last label. */
|
|
149
|
+
export function futureLabels(lastLabel, kind, horizon) {
|
|
150
|
+
const t = parseTimeLabel(lastLabel)
|
|
151
|
+
const out = []
|
|
152
|
+
for (let h = 1; h <= horizon; h++) {
|
|
153
|
+
if (!t || kind === 'index' || kind === 'week') { out.push(`+${h}`); continue }
|
|
154
|
+
const d = new Date(t.ms)
|
|
155
|
+
if (kind === 'day') out.push(fmtDay(new Date(t.ms + h * DAY)))
|
|
156
|
+
else if (kind === 'hour') { const x = new Date(t.ms + h * 3600000); out.push(`${fmtDay(x)} ${pad(x.getUTCHours())}:00`) }
|
|
157
|
+
else if (kind === 'month') { const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + h, 1)); out.push(`${x.getUTCFullYear()}-${pad(x.getUTCMonth() + 1)}`) }
|
|
158
|
+
else if (kind === 'year') out.push(String(d.getUTCFullYear() + h))
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Forecast a labelled series: [{ x, y }] → history + labelled forecast. */
|
|
164
|
+
export function forecastPoints(points, { horizon = 7 } = {}) {
|
|
165
|
+
const reg = regularize(points)
|
|
166
|
+
const res = forecastSeries(reg.ys, { horizon, seasonal: reg.kind === 'day' })
|
|
167
|
+
const labels = futureLabels(reg.xs.at(-1), reg.kind, res.forecast.length)
|
|
168
|
+
return {
|
|
169
|
+
method: res.method,
|
|
170
|
+
params: res.params,
|
|
171
|
+
sigma: res.sigma,
|
|
172
|
+
kind: reg.kind,
|
|
173
|
+
filled: reg.filled,
|
|
174
|
+
history: reg.xs.map((x, i) => ({ x, y: reg.ys[i] })),
|
|
175
|
+
forecast: res.forecast.map((f, i) => ({ ...f, x: labels[i] })),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------ digest statistics
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Daily comparison for the digest: `values` is a day-indexed array ending with the day
|
|
183
|
+
* of interest. Compares the last value with the mean / stddev of the 7 days before it
|
|
184
|
+
* and with the same weekday one week earlier.
|
|
185
|
+
*/
|
|
186
|
+
export function dailyStats(values) {
|
|
187
|
+
const y = values.map(Number)
|
|
188
|
+
const cur = y.at(-1)
|
|
189
|
+
const prev = y.slice(-8, -1)
|
|
190
|
+
const avg = prev.length ? prev.reduce((s, v) => s + v, 0) / prev.length : null
|
|
191
|
+
const sd = prev.length > 1 ? Math.sqrt(prev.reduce((s, v) => s + (v - avg) ** 2, 0) / (prev.length - 1)) : null
|
|
192
|
+
const lastWeek = y.length >= 8 ? y.at(-8) : null
|
|
193
|
+
const pct = (a, b) => (b === null || b === undefined || b === 0 ? null : (a - b) / Math.abs(b))
|
|
194
|
+
return {
|
|
195
|
+
value: cur,
|
|
196
|
+
avg7: avg,
|
|
197
|
+
sd7: sd,
|
|
198
|
+
z: sd ? (cur - avg) / sd : null,
|
|
199
|
+
vsAvg: pct(cur, avg),
|
|
200
|
+
lastWeek,
|
|
201
|
+
vsLastWeek: pct(cur, lastWeek),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Is a daily comparison notable? |z| > zLimit or |Δ% vs 7-day avg| > pctLimit. */
|
|
206
|
+
export function isNotable(stats, { z = 2, pct = 0.3 } = {}) {
|
|
207
|
+
return (stats.z !== null && Math.abs(stats.z) > z) || (stats.vsAvg !== null && Math.abs(stats.vsAvg) > pct)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ------------------------------------------------------------ goals
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Period bounds (local calendar) containing `now`: { start, end } as Date (end exclusive).
|
|
214
|
+
*/
|
|
215
|
+
export function periodBounds(period, now = new Date()) {
|
|
216
|
+
const y = now.getFullYear()
|
|
217
|
+
const m = now.getMonth()
|
|
218
|
+
if (period === 'year') return { start: new Date(y, 0, 1), end: new Date(y + 1, 0, 1) }
|
|
219
|
+
if (period === 'quarter') { const q = Math.floor(m / 3) * 3; return { start: new Date(y, q, 1), end: new Date(y, q + 3, 1) } }
|
|
220
|
+
return { start: new Date(y, m, 1), end: new Date(y, m + 1, 1) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Run-rate projection. `daily`: [{ date: Date (local midnight), value }] for complete days
|
|
225
|
+
* from the period start up to yesterday. Remaining days (today .. end-1) are projected with
|
|
226
|
+
* the per-weekday mean when every weekday has a sample, else the overall daily mean.
|
|
227
|
+
* Returns { projected, weekdayAware, remainingDays } (projected null without data).
|
|
228
|
+
*/
|
|
229
|
+
export function projectRunRate({ daily, actualComplete, today, end }) {
|
|
230
|
+
const remaining = []
|
|
231
|
+
for (let d = new Date(today); d < end; d.setDate(d.getDate() + 1)) remaining.push(new Date(d))
|
|
232
|
+
if (!daily.length) return { projected: null, weekdayAware: false, remainingDays: remaining.length }
|
|
233
|
+
const all = daily.reduce((s, d) => s + d.value, 0) / daily.length
|
|
234
|
+
const byWd = new Map()
|
|
235
|
+
for (const d of daily) { const w = d.date.getDay(); const e = byWd.get(w) ?? { s: 0, n: 0 }; e.s += d.value; e.n++; byWd.set(w, e) }
|
|
236
|
+
const weekdayAware = byWd.size === 7
|
|
237
|
+
let rest = 0
|
|
238
|
+
for (const d of remaining) rest += weekdayAware ? byWd.get(d.getDay()).s / byWd.get(d.getDay()).n : all
|
|
239
|
+
return { projected: actualComplete + rest, weekdayAware, remainingDays: remaining.length }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Goal status from actual-to-date and projection. */
|
|
243
|
+
export function goalStatus({ direction = 'up', target, actual, projected, ended = false }) {
|
|
244
|
+
if (direction === 'down') {
|
|
245
|
+
if (actual > target) return 'missed'
|
|
246
|
+
if (ended) return 'achieved'
|
|
247
|
+
return projected === null || projected <= target ? 'on-track' : 'at-risk'
|
|
248
|
+
}
|
|
249
|
+
if (actual >= target) return 'achieved'
|
|
250
|
+
if (ended) return 'missed'
|
|
251
|
+
return projected !== null && projected >= target ? 'on-track' : 'at-risk'
|
|
252
|
+
}
|