skill-calorie 0.1.1 → 0.2.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/SKILL.md +207 -0
- package/dist/analysis/anomaly/common.d.ts.map +1 -1
- package/dist/analysis/anomaly/common.js +2 -1
- package/dist/analysis/anomaly/common.js.map +1 -1
- package/dist/analysis/cross.d.ts.map +1 -1
- package/dist/analysis/cross.js +3 -2
- package/dist/analysis/cross.js.map +1 -1
- package/dist/analysis/deficit.d.ts.map +1 -1
- package/dist/analysis/deficit.js +4 -0
- package/dist/analysis/deficit.js.map +1 -1
- package/dist/analysis/diet.js +2 -2
- package/dist/analysis/diet.js.map +1 -1
- package/dist/analysis/exercise.js +5 -5
- package/dist/analysis/exercise.js.map +1 -1
- package/dist/analysis/review.js +2 -2
- package/dist/analysis/review.js.map +1 -1
- package/dist/analysis/series.d.ts +7 -1
- package/dist/analysis/series.d.ts.map +1 -1
- package/dist/analysis/series.js +22 -6
- package/dist/analysis/series.js.map +1 -1
- package/dist/analysis/utils.d.ts +7 -0
- package/dist/analysis/utils.d.ts.map +1 -1
- package/dist/analysis/utils.js +7 -0
- package/dist/analysis/utils.js.map +1 -1
- package/dist/analysis/weightCompare3.js +2 -2
- package/dist/analysis/weightCompare3.js.map +1 -1
- package/dist/cli/cmd_read.d.ts +21 -3
- package/dist/cli/cmd_read.d.ts.map +1 -1
- package/dist/cli/cmd_read.js +480 -53
- package/dist/cli/cmd_read.js.map +1 -1
- package/dist/cli/keys.d.ts +89 -1
- package/dist/cli/keys.d.ts.map +1 -1
- package/dist/cli/keys.js +30 -0
- package/dist/cli/keys.js.map +1 -1
- package/dist/cli/write.d.ts +19 -1
- package/dist/cli/write.d.ts.map +1 -1
- package/dist/cli/write.js +148 -52
- package/dist/cli/write.js.map +1 -1
- package/dist/db/readonly.d.ts +3 -0
- package/dist/db/readonly.d.ts.map +1 -0
- package/dist/db/readonly.js +22 -0
- package/dist/db/readonly.js.map +1 -0
- package/dist/output.d.ts +82 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +305 -0
- package/dist/output.js.map +1 -0
- package/dist/render/copy.d.ts +20 -0
- package/dist/render/copy.d.ts.map +1 -0
- package/dist/render/copy.js +36 -0
- package/dist/render/copy.js.map +1 -0
- package/dist/render/dietDocs.d.ts +45 -0
- package/dist/render/dietDocs.d.ts.map +1 -0
- package/dist/render/dietDocs.js +561 -0
- package/dist/render/dietDocs.js.map +1 -0
- package/dist/render/envelope.d.ts +32 -6
- package/dist/render/envelope.d.ts.map +1 -1
- package/dist/render/envelope.js +71 -0
- package/dist/render/envelope.js.map +1 -1
- package/dist/render/exercisePort.d.ts +161 -0
- package/dist/render/exercisePort.d.ts.map +1 -0
- package/dist/render/exercisePort.js +395 -0
- package/dist/render/exercisePort.js.map +1 -0
- package/dist/render/goal.d.ts.map +1 -1
- package/dist/render/goal.js +13 -2
- package/dist/render/goal.js.map +1 -1
- package/dist/render/goalPlate.d.ts +1 -1
- package/dist/render/goalPlate.d.ts.map +1 -1
- package/dist/render/goalPlate.js +5 -1
- package/dist/render/goalPlate.js.map +1 -1
- package/dist/render/helpCenter.d.ts +104 -0
- package/dist/render/helpCenter.d.ts.map +1 -0
- package/dist/render/helpCenter.js +386 -0
- package/dist/render/helpCenter.js.map +1 -0
- package/dist/render/html.d.ts +12 -0
- package/dist/render/html.d.ts.map +1 -1
- package/dist/render/html.js +21 -5
- package/dist/render/html.js.map +1 -1
- package/dist/render/index.d.ts +8 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +5 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/nutritionPort.d.ts +89 -0
- package/dist/render/nutritionPort.d.ts.map +1 -0
- package/dist/render/nutritionPort.js +183 -0
- package/dist/render/nutritionPort.js.map +1 -0
- package/dist/render/nutritionPortDocs.d.ts +6 -0
- package/dist/render/nutritionPortDocs.d.ts.map +1 -0
- package/dist/render/nutritionPortDocs.js +317 -0
- package/dist/render/nutritionPortDocs.js.map +1 -0
- package/dist/render/photo.d.ts +6 -1
- package/dist/render/photo.d.ts.map +1 -1
- package/dist/render/photo.js +13 -4
- package/dist/render/photo.js.map +1 -1
- package/dist/render/planPlate.d.ts +2 -1
- package/dist/render/planPlate.d.ts.map +1 -1
- package/dist/render/planPlate.js +6 -1
- package/dist/render/planPlate.js.map +1 -1
- package/dist/render/receipt.d.ts +47 -1
- package/dist/render/receipt.d.ts.map +1 -1
- package/dist/render/receipt.js +67 -3
- package/dist/render/receipt.js.map +1 -1
- package/dist/render/sportDocs.d.ts +14 -0
- package/dist/render/sportDocs.d.ts.map +1 -0
- package/dist/render/sportDocs.js +540 -0
- package/dist/render/sportDocs.js.map +1 -0
- package/dist/render/sportPortDocs.d.ts +8 -0
- package/dist/render/sportPortDocs.d.ts.map +1 -0
- package/dist/render/sportPortDocs.js +468 -0
- package/dist/render/sportPortDocs.js.map +1 -0
- package/dist/render/templates.d.ts +4 -0
- package/dist/render/templates.d.ts.map +1 -0
- package/dist/render/templates.js +29 -0
- package/dist/render/templates.js.map +1 -0
- package/dist/render/trendDocs.d.ts +11 -0
- package/dist/render/trendDocs.d.ts.map +1 -0
- package/dist/render/trendDocs.js +445 -0
- package/dist/render/trendDocs.js.map +1 -0
- package/dist/render/trendMiscPort.d.ts +127 -0
- package/dist/render/trendMiscPort.d.ts.map +1 -0
- package/dist/render/trendMiscPort.js +277 -0
- package/dist/render/trendMiscPort.js.map +1 -0
- package/dist/render/trendMiscPortDocs.d.ts +10 -0
- package/dist/render/trendMiscPortDocs.d.ts.map +1 -0
- package/dist/render/trendMiscPortDocs.js +397 -0
- package/dist/render/trendMiscPortDocs.js.map +1 -0
- package/dist/render/wizardPort.d.ts +125 -0
- package/dist/render/wizardPort.d.ts.map +1 -0
- package/dist/render/wizardPort.js +393 -0
- package/dist/render/wizardPort.js.map +1 -0
- package/dist/render/wizardPortDocs.d.ts +6 -0
- package/dist/render/wizardPortDocs.d.ts.map +1 -0
- package/dist/render/wizardPortDocs.js +274 -0
- package/dist/render/wizardPortDocs.js.map +1 -0
- package/dist/triggers/routing.d.ts +141 -0
- package/dist/triggers/routing.d.ts.map +1 -0
- package/dist/triggers/routing.js +678 -0
- package/dist/triggers/routing.js.map +1 -0
- package/package.json +5 -3
- package/templates/diet.html +40 -0
- package/templates/exercise.html +40 -0
- package/templates/goal.html +40 -0
- package/templates/help.html +40 -0
- package/templates/home.html +40 -0
- package/templates/photo-gallery.html +40 -0
package/dist/cli/cmd_read.js
CHANGED
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
* 组合键为 registry 合法点式(见 cli/keys.ts;内部 VIEW_KEYS 下划线键仅渲染层复用,不直接登记)。
|
|
8
8
|
* 缺失阻断不返空:空库/空窗/无目标一律抛(CalorieRenderError missing-data / FetchError),exit 4,不返空数组冒充正常。
|
|
9
9
|
* 仅 type-only 消费 link-core(零运行时依赖);envelope 手工装配,形状校验本地镜像 link-core。
|
|
10
|
-
* HTML
|
|
10
|
+
* HTML 默认落盘(utf8,见下行 #87)+ 可 --output/--html 显式覆盖:视图键走 render/html.ts 专属模板(与 T8/T9/T10 快照同源),其余走通用 section。
|
|
11
|
+
* #87 · 输出命名规范复刻(M10):不给 --output/--html 时默认落
|
|
12
|
+
* <SKILLS_DB_PATH>/calorie_html/<中文command>_<YYYYMMDD>_<HHMMSS>[_N].html(同秒冲突自动加后缀),
|
|
13
|
+
* 中文 command 取 CALORIE_COMBOS[key].title;显式 --output(--html 为 legacy 别名)覆盖任意路径。
|
|
14
|
+
* 落点随 envelope 的 data.output 回传(additive 字段,六形状守卫不校验 data 额外键)。
|
|
15
|
+
* #91 · `calorie.help.center` 承载**全量速查台**(Q9):`--params '{"mode":"file|inline|text"}'` 显式选交付形态
|
|
16
|
+
* (D6,缺省 `file`);`q`/`keyword` 保留**照片 10 键**语义(非空=现找、空串=全量 10 键)。envelope 恒五字段
|
|
17
|
+
* `version/skill/shape/key/data`(Q8:**无 `status`**),`data` 只回索引与落点/字节数,不回 1 MB 产物。
|
|
11
18
|
*/
|
|
12
|
-
import {
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
13
20
|
import { join } from 'node:path';
|
|
14
21
|
import { openDb } from '../schema.js';
|
|
15
22
|
import { DB_FILENAME } from '../paths.js';
|
|
@@ -28,17 +35,35 @@ import { buildGoalExpiringView, buildGoalPredictView, buildGoalVsActualView } fr
|
|
|
28
35
|
import { buildPredictView, buildAnomalyView, buildContraView, buildDedupeView } from '../render/insightPlate.js';
|
|
29
36
|
import { buildProfileView } from '../render/profilePlate.js';
|
|
30
37
|
import { buildCombinedAnalysis, buildDeficitPlate, buildDietReview } from '../render/analysisPlate.js';
|
|
38
|
+
import { dietFoodRanking, dietMacroRatio } from '../analysis/diet.js';
|
|
39
|
+
import { buildAllRankingsDoc, buildDedupeDoc, buildDietReviewDoc, buildHealthDoc, buildLibraryDoc, buildRankingDoc, buildSearchDoc, buildTodayDietDoc, buildViewDietDoc, } from '../render/dietDocs.js';
|
|
40
|
+
import { buildBodyCompositionDoc, buildBodyMeasureDoc, buildExerciseDoc, buildExerciseGoalDoc, buildVolatilityDoc, buildWeightCompareDoc, buildWeightDoc, buildWeightHistoryDoc, buildWeightReviewDoc, } from '../render/sportDocs.js';
|
|
41
|
+
import { buildCardioDoc, buildDistributionDoc, buildRecapDoc, buildReviewDoc, buildStrengthDoc, buildTrendDoc, } from '../render/sportPortDocs.js';
|
|
42
|
+
import { buildCardioView, buildDistributionView, buildRecapView, buildReviewView, buildStrengthView, buildTrendView, } from '../render/exercisePort.js';
|
|
43
|
+
import { buildNutritionDetailView, buildNutritionRatioView, buildSourceStatsView, buildTodayWaterView, } from '../render/nutritionPort.js';
|
|
44
|
+
import { buildNutritionDetailDoc, buildNutritionRatioDoc, buildSourceStatsDoc, buildTodayWaterDoc, } from '../render/nutritionPortDocs.js';
|
|
45
|
+
import { buildBatchImportPreviewView, buildCalorieTrendView, buildLintHealthView, buildLongTrendView, buildNutritionAnalysisView, buildProcessProgressView, buildReviewTemplateView, buildSixFactorsView, } from '../render/trendMiscPort.js';
|
|
46
|
+
import { buildBatchImportPreviewDoc, buildCalorieTrendDoc, buildLintHealthDoc, buildLongTrendDoc, buildNutritionAnalysisDoc, buildProcessProgressDoc, buildReviewTemplateDoc, buildSixFactorsDoc, } from '../render/trendMiscPortDocs.js';
|
|
47
|
+
import { buildCompositionWizardView, buildGifPlannerView, buildMeasureWizardView, buildPhotoLogWizardView, } from '../render/wizardPort.js';
|
|
48
|
+
import { buildCompositionWizardDoc, buildGifPlannerDoc, buildMeasureWizardDoc, buildPhotoLogWizardDoc, } from '../render/wizardPortDocs.js';
|
|
49
|
+
import { buildAnomalyDoc, buildCombinedDoc, buildContraDoc, buildDeficitDoc, buildGoalPredictDoc, buildPredictDoc, } from '../render/trendDocs.js';
|
|
31
50
|
import { buildHealthPlate } from '../render/health.js';
|
|
32
51
|
import { buildAllRankings, buildFoodRankingPlate } from '../render/ranking.js';
|
|
33
52
|
import { buildProductLibrary, buildProductSearch, buildProductStats } from '../render/library.js';
|
|
34
53
|
import { buildCompareData, buildGalleryData, buildGifTask, buildViewerData } from '../render/photo.js';
|
|
35
54
|
import { buildPhotoHelp, lookupPhotoHelp } from '../render/help.js';
|
|
36
|
-
|
|
37
|
-
import {
|
|
55
|
+
// #91 · 全量速查台(Q9):只读消费 #88 的 `render/helpCenter.js`(三态同源,零改动)。
|
|
56
|
+
import { HELP_CENTER_MODES, buildHelpSceneData, renderHelpCenterHtml } from '../render/helpCenter.js';
|
|
57
|
+
import { renderGalleryHtml, renderCompareHtml, renderViewerHtml, renderGifHtml, renderPhotoHelpHtml, renderHelpLookupHtml, renderErrorHtml, renderGoalConfigHtml, renderGoalRecommendHtml, renderGoalWeightHtml, renderGoalProgressHtml, renderGoalStatusHtml, renderGoalHtml, renderHomeHtml, renderPlanHtml, renderPlanWizardHtml, renderGoalExpiringHtml, renderGoalVsActualHtml, renderProfileHtml, } from '../render/html.js';
|
|
58
|
+
import { assertStatMetrics, buildDelivery, withDelivery } from '../render/envelope.js';
|
|
59
|
+
import { buildErrorReceipt } from '../render/receipt.js';
|
|
60
|
+
import { buildDataText } from 'base-paint';
|
|
38
61
|
import { CalorieRenderError } from '../render/errors.js';
|
|
39
62
|
import { TRIGGERS, searchHelp } from '../triggers/index.js';
|
|
40
63
|
import { shiftISODate, todayISO } from '../analysis/utils.js';
|
|
41
64
|
import { CALORIE_COMBOS, ENVELOPE_VERSION, CALORIE_SKILL, calorieShapeFor, isCalorieWriteKey } from './keys.js';
|
|
65
|
+
import { HTML_DIR_NAME, deliverHtml, resolveReceiptHtmlPath, } from '../output.js';
|
|
66
|
+
import { openDbReadOnly } from '../db/readonly.js';
|
|
42
67
|
import { dispatchWrite } from './write.js';
|
|
43
68
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
44
69
|
function fail(code, msg) {
|
|
@@ -59,22 +84,25 @@ function preflight() {
|
|
|
59
84
|
fail(1, 'SKILLS_DB_PATH 未设置(无默认值,必设)');
|
|
60
85
|
return p;
|
|
61
86
|
}
|
|
87
|
+
const USAGE = '用法:cmd_read <calorie.key> [--params JSON对象] [--output 输出路径] [--html 输出路径(--output 别名)] [--timeout 毫秒]';
|
|
62
88
|
function parseArgs(a) {
|
|
63
89
|
const o = {
|
|
64
|
-
key: a[0], params: undefined, html: undefined, timeout: DEFAULT_TIMEOUT_MS,
|
|
90
|
+
key: a[0], params: undefined, html: undefined, output: undefined, timeout: DEFAULT_TIMEOUT_MS,
|
|
65
91
|
};
|
|
66
92
|
for (let i = 1; i < a.length; i++) {
|
|
67
93
|
if (a[i] === '--params' && i + 1 < a.length)
|
|
68
94
|
o.params = a[++i];
|
|
69
95
|
else if (a[i] === '--html' && i + 1 < a.length)
|
|
70
96
|
o.html = a[++i];
|
|
97
|
+
else if (a[i] === '--output' && i + 1 < a.length)
|
|
98
|
+
o.output = a[++i];
|
|
71
99
|
else if (a[i] === '--timeout' && i + 1 < a.length) {
|
|
72
100
|
o.timeout = Number(a[++i]);
|
|
73
101
|
if (!Number.isFinite(o.timeout) || o.timeout <= 0)
|
|
74
102
|
fail(2, '--timeout 须为正数毫秒');
|
|
75
103
|
}
|
|
76
104
|
else
|
|
77
|
-
fail(2, '未知参数:' + a[i] + '
|
|
105
|
+
fail(2, '未知参数:' + a[i] + '(' + USAGE + ')');
|
|
78
106
|
}
|
|
79
107
|
return o;
|
|
80
108
|
}
|
|
@@ -113,7 +141,7 @@ function latestFoodDate(db) {
|
|
|
113
141
|
return null;
|
|
114
142
|
}
|
|
115
143
|
}
|
|
116
|
-
function defaultRange(db, params) {
|
|
144
|
+
function defaultRange(db, params, defDays = 7) {
|
|
117
145
|
let end = optStr(params, 'end') ?? optStr(params, 'date') ?? optStr(params, 'today') ?? undefined;
|
|
118
146
|
let start = optStr(params, 'start') ?? undefined;
|
|
119
147
|
if (end)
|
|
@@ -133,7 +161,7 @@ function defaultRange(db, params) {
|
|
|
133
161
|
fail(2, 'start 不得晚于 end');
|
|
134
162
|
return { start, end: end };
|
|
135
163
|
}
|
|
136
|
-
return { start: shiftISODate(end, -
|
|
164
|
+
return { start: shiftISODate(end, -(defDays - 1)), end: end };
|
|
137
165
|
}
|
|
138
166
|
/** 本地 envelope 形状校验(镜像 link-core assertShapeData,不运行时 import)。 */
|
|
139
167
|
function assertEnvelopeData(shape, data) {
|
|
@@ -194,6 +222,28 @@ function photosDirOf(params) {
|
|
|
194
222
|
const e = process.env.CALORIE_PHOTOS_DIR;
|
|
195
223
|
return e ? e : undefined;
|
|
196
224
|
}
|
|
225
|
+
/** #91 · 全量速查台的信封载荷:**10 分组索引**(不把 1 MB 产物塞进 envelope)。
|
|
226
|
+
*
|
|
227
|
+
* `items` 恒为 10 条分组(`total` = `items.length`,与 `list` 形语义一致);
|
|
228
|
+
* `sceneTotal`/`subgroupTotal` 把「436 场景/54 子功能」如实回传,避免只报 10 丢掉全量口径。
|
|
229
|
+
*/
|
|
230
|
+
function helpCenterIndex(data) {
|
|
231
|
+
let sceneTotal = 0;
|
|
232
|
+
let subgroupTotal = 0;
|
|
233
|
+
const items = data.groups.map((group) => {
|
|
234
|
+
const sceneCount = group.subgroups.reduce((n, sub) => n + sub.scenes.length, 0);
|
|
235
|
+
sceneTotal += sceneCount;
|
|
236
|
+
subgroupTotal += group.subgroups.length;
|
|
237
|
+
return {
|
|
238
|
+
id: group.id,
|
|
239
|
+
icon: typeof group.icon === 'string' ? group.icon : '',
|
|
240
|
+
label: group.label,
|
|
241
|
+
subgroupCount: group.subgroups.length,
|
|
242
|
+
sceneCount,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
return { items, total: items.length, sceneTotal, subgroupTotal };
|
|
246
|
+
}
|
|
197
247
|
// 全键分发:读走 render/fetch 读,HELP 走触发词现找;未知键上游已拦,此处再拦一道。
|
|
198
248
|
/** #41 · 测试直调出口(纯 CLI 同逻辑,不经过 argv/spawn;CLI 唯一出口仍为 main)。 */
|
|
199
249
|
export function dispatch(key, params, db) {
|
|
@@ -207,7 +257,9 @@ export function dispatch(key, params, db) {
|
|
|
207
257
|
const items = rows.map((r) => ({ id: r.id, date: r.date, time: r.time, food_name: r.food_name, grams: r.grams, calories: r.calories, protein: r.protein, carbs: r.carbs, fat: r.fat }));
|
|
208
258
|
const o = buildDietOverview(db, date, date);
|
|
209
259
|
const dist = buildMealDistribution(db, date);
|
|
210
|
-
|
|
260
|
+
// #108 · 今日饮食全文档(餐次进度+营养配比+今日明细;配比无数据即 skip,不编数)。
|
|
261
|
+
const mt = dietMacroRatio(db, date, date);
|
|
262
|
+
return { data: { items, total: items.length }, html: buildTodayDietDoc({ overview: o, dist, meals: rows, macro: mt.status === 'ok' ? (mt.data ?? null) : null }) };
|
|
211
263
|
}
|
|
212
264
|
case 'calorie.view.home': {
|
|
213
265
|
const date = optStr(params, 'date') ?? optStr(params, 'today') ?? latestFoodDate(db) ?? todayISO();
|
|
@@ -241,6 +293,22 @@ export function dispatch(key, params, db) {
|
|
|
241
293
|
else
|
|
242
294
|
throw e;
|
|
243
295
|
}
|
|
296
|
+
// #108 · 窗口明细(逐日 listMeals 去水,上限 100 条并明示截断;单日失败跳过)。
|
|
297
|
+
const mealRows = [];
|
|
298
|
+
for (const d of o.series) {
|
|
299
|
+
try {
|
|
300
|
+
for (const r of listMeals(db, d.date)) {
|
|
301
|
+
if (r.food_name !== '💧水')
|
|
302
|
+
mealRows.push(r);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const MEAL_CAP = 100;
|
|
310
|
+
const mealTotal = mealRows.length;
|
|
311
|
+
const mealSlice = mealRows.slice(0, MEAL_CAP);
|
|
244
312
|
const metrics = nums({
|
|
245
313
|
totalCalories: o.totalCalories, avgCalories: o.avgCalories, calorieGoal: o.calorieGoal,
|
|
246
314
|
loggedDays: o.loggedDays, days: o.days, distTotal: dist.totalCalories,
|
|
@@ -249,7 +317,10 @@ export function dispatch(key, params, db) {
|
|
|
249
317
|
'meal.晚餐': dist.slices.find((s) => s.meal === '晚餐')?.calories,
|
|
250
318
|
'meal.加餐': dist.slices.find((s) => s.meal === '加餐')?.calories,
|
|
251
319
|
});
|
|
252
|
-
return { data: { metrics }, html:
|
|
320
|
+
return { data: { metrics }, html: buildViewDietDoc({
|
|
321
|
+
overview: o, dist, distDate: date, days: o.series,
|
|
322
|
+
meals: mealSlice, mealTotal, mealsTruncated: mealTotal > MEAL_CAP,
|
|
323
|
+
}) };
|
|
253
324
|
}
|
|
254
325
|
case 'calorie.view.exercise': {
|
|
255
326
|
const { start, end } = defaultRange(db, params);
|
|
@@ -259,7 +330,222 @@ export function dispatch(key, params, db) {
|
|
|
259
330
|
activeDays: v.review.activeDays, totalBurnedSeries: v.totalBurnedSeries,
|
|
260
331
|
avgBurnedPerLoggedDay: v.avgBurnedPerLoggedDay, seriesActiveDays: v.activeDays,
|
|
261
332
|
});
|
|
262
|
-
return { data: { metrics }, html:
|
|
333
|
+
return { data: { metrics }, html: buildExerciseDoc(v) };
|
|
334
|
+
}
|
|
335
|
+
// #111 · 运动移植 6 键(t71 需移植 exercise_*;envelope stat metrics 只收确定数字)。
|
|
336
|
+
case 'calorie.view.exercise-strength': {
|
|
337
|
+
const { start, end } = defaultRange(db, params);
|
|
338
|
+
const v = buildStrengthView(db, start, end);
|
|
339
|
+
const metrics = nums({
|
|
340
|
+
movementCount: v.movementCount, totalSets: v.totalSets,
|
|
341
|
+
totalVolumeKg: v.totalVolumeKg, totalReps: v.totalReps,
|
|
342
|
+
});
|
|
343
|
+
return { data: { metrics }, html: buildStrengthDoc(v) };
|
|
344
|
+
}
|
|
345
|
+
case 'calorie.view.exercise-cardio': {
|
|
346
|
+
const { start, end } = defaultRange(db, params);
|
|
347
|
+
const v = buildCardioView(db, start, end);
|
|
348
|
+
const metrics = nums({
|
|
349
|
+
sessions: v.sessions, totalMinutes: v.totalMinutes,
|
|
350
|
+
totalDistanceKm: v.totalDistanceKm, avgPaceMinPerKm: v.avgPaceMinPerKm,
|
|
351
|
+
});
|
|
352
|
+
return { data: { metrics }, html: buildCardioDoc(v) };
|
|
353
|
+
}
|
|
354
|
+
case 'calorie.view.exercise-distribution': {
|
|
355
|
+
const { start, end } = defaultRange(db, params);
|
|
356
|
+
const v = buildDistributionView(db, start, end);
|
|
357
|
+
const metrics = nums({
|
|
358
|
+
sessions: v.sessions, activeDays: v.activeDays, days: v.days, totalBurned: v.totalBurned,
|
|
359
|
+
intakeCal: v.intakeCal, tdeeTotal: v.tdeeTotal, deficit: v.deficit,
|
|
360
|
+
});
|
|
361
|
+
return { data: { metrics }, html: buildDistributionDoc(v) };
|
|
362
|
+
}
|
|
363
|
+
case 'calorie.view.exercise-recap': {
|
|
364
|
+
const { start, end } = defaultRange(db, params);
|
|
365
|
+
const v = buildRecapView(db, start, end);
|
|
366
|
+
const metrics = nums({
|
|
367
|
+
sessions: v.sessions, totalMinutes: v.totalMinutes, totalBurned: v.totalBurned,
|
|
368
|
+
activeDays: v.activeDays, days: v.days,
|
|
369
|
+
});
|
|
370
|
+
return { data: { metrics }, html: buildRecapDoc(v) };
|
|
371
|
+
}
|
|
372
|
+
case 'calorie.view.exercise-review': {
|
|
373
|
+
const { start, end } = defaultRange(db, params);
|
|
374
|
+
const v = buildReviewView(db, start, end);
|
|
375
|
+
const metrics = nums({
|
|
376
|
+
plannedSessions: v.plannedSessions, hitSessions: v.hitSessions,
|
|
377
|
+
completionPct: v.completionPct, plannedMovements: v.plannedMovements,
|
|
378
|
+
hitMovements: v.hitMovements, movementPct: v.movementPct,
|
|
379
|
+
});
|
|
380
|
+
return { data: { metrics }, html: buildReviewDoc(v) };
|
|
381
|
+
}
|
|
382
|
+
case 'calorie.view.exercise-trend': {
|
|
383
|
+
const { start, end } = defaultRange(db, params);
|
|
384
|
+
const v = buildTrendView(db, start, end);
|
|
385
|
+
const metrics = nums({
|
|
386
|
+
activeDays: v.activeDays, totalMinutes: v.totalMinutes,
|
|
387
|
+
totalBurned: v.totalBurned, peakBurned: v.peak?.burned,
|
|
388
|
+
});
|
|
389
|
+
return { data: { metrics }, html: buildTrendDoc(v) };
|
|
390
|
+
}
|
|
391
|
+
// #112 · 营养移植 4 键(t71 需移植 nutrition_*/source_stats/today_water;envelope stat metrics 只收确定数字)。
|
|
392
|
+
case 'calorie.view.nutrition-ratio': {
|
|
393
|
+
const { start, end } = defaultRange(db, params);
|
|
394
|
+
const v = buildNutritionRatioView(db, start, end);
|
|
395
|
+
const metrics = nums({
|
|
396
|
+
totalCalorie: v.totalCalorie, proteinG: v.proteinG, proteinPct: v.proteinPct,
|
|
397
|
+
carbG: v.carbG, carbPct: v.carbPct, fatG: v.fatG, fatPct: v.fatPct,
|
|
398
|
+
targetProteinG: v.targetProteinG, targetCarbG: v.targetCarbG, targetFatG: v.targetFatG,
|
|
399
|
+
});
|
|
400
|
+
return { data: { metrics }, html: buildNutritionRatioDoc(v) };
|
|
401
|
+
}
|
|
402
|
+
case 'calorie.view.nutrition-detail': {
|
|
403
|
+
const { start, end } = defaultRange(db, params);
|
|
404
|
+
const v = buildNutritionDetailView(db, start, end);
|
|
405
|
+
const metrics = nums({
|
|
406
|
+
days: v.days,
|
|
407
|
+
matchedMeals: v.matchedMeals,
|
|
408
|
+
missingFoods: v.missingFoods.length,
|
|
409
|
+
fiberAvg: v.items[0]?.avg,
|
|
410
|
+
sodiumAvg: v.items[1]?.avg,
|
|
411
|
+
sugarAvg: v.items[2]?.avg,
|
|
412
|
+
fiberPct: v.items[0]?.pct,
|
|
413
|
+
sodiumPct: v.items[1]?.pct,
|
|
414
|
+
sugarPct: v.items[2]?.pct,
|
|
415
|
+
});
|
|
416
|
+
return { data: { metrics }, html: buildNutritionDetailDoc(v) };
|
|
417
|
+
}
|
|
418
|
+
case 'calorie.view.source-stats': {
|
|
419
|
+
const v = buildSourceStatsView(db);
|
|
420
|
+
const metrics = nums({ total: v.total, sources: v.sources });
|
|
421
|
+
return { data: { metrics }, html: buildSourceStatsDoc(v) };
|
|
422
|
+
}
|
|
423
|
+
case 'calorie.view.today-water': {
|
|
424
|
+
const date = optStr(params, 'date') ?? latestFoodDate(db) ?? todayISO();
|
|
425
|
+
assertISO(date, 'date');
|
|
426
|
+
const v = buildTodayWaterView(db, date);
|
|
427
|
+
const metrics = nums({
|
|
428
|
+
todayMl: v.todayMl, targetMl: v.targetMl, pct: v.pct, remainMl: v.remainMl, cups: v.cups.length,
|
|
429
|
+
});
|
|
430
|
+
return { data: { metrics }, html: buildTodayWaterDoc(v) };
|
|
431
|
+
}
|
|
432
|
+
// #113 · 趋势 2+其他 6 移植 8 键(t71 需移植八模板;envelope stat metrics 只收确定数字)。
|
|
433
|
+
case 'calorie.view.calorie-trend': {
|
|
434
|
+
const { start, end } = defaultRange(db, params);
|
|
435
|
+
const v = buildCalorieTrendView(db, start, end);
|
|
436
|
+
const s = v.data.summary;
|
|
437
|
+
const metrics = nums({
|
|
438
|
+
avg: s.avg, target: s.target, trendValue: s.trendValue,
|
|
439
|
+
startAvg: s.startAvg, endAvg: s.endAvg, weekdayAvg: s.weekdayAvg,
|
|
440
|
+
weekendAvg: s.weekendAvg, weekendDiff: s.weekendDiff,
|
|
441
|
+
compliantDays: s.compliantDays, complianceRate: s.complianceRate,
|
|
442
|
+
});
|
|
443
|
+
return { data: { metrics }, html: buildCalorieTrendDoc(v) };
|
|
444
|
+
}
|
|
445
|
+
case 'calorie.view.long-trend': {
|
|
446
|
+
const end = optStr(params, 'end') ?? latestFoodDate(db) ?? todayISO();
|
|
447
|
+
assertISO(end, 'end');
|
|
448
|
+
const v = buildLongTrendView(db, optStr(params, 'group'), optStr(params, 'window'), end);
|
|
449
|
+
const metrics = nums({
|
|
450
|
+
windowDays: v.windowDays, avgCalorie: v.avgCalorie, weightChange: v.weightChange,
|
|
451
|
+
});
|
|
452
|
+
return { data: { metrics }, html: buildLongTrendDoc(v) };
|
|
453
|
+
}
|
|
454
|
+
case 'calorie.view.nutrition-analysis': {
|
|
455
|
+
const { start, end } = defaultRange(db, params);
|
|
456
|
+
const v = buildNutritionAnalysisView(db, start, end);
|
|
457
|
+
const metrics = nums({
|
|
458
|
+
days: v.days, totalCalorie: v.totalCalorie,
|
|
459
|
+
proteinG: v.proteinG, proteinPct: v.proteinPct,
|
|
460
|
+
carbG: v.carbG, carbPct: v.carbPct, fatG: v.fatG, fatPct: v.fatPct,
|
|
461
|
+
fiberAvg: v.fiberAvg, sodiumAvg: v.sodiumAvg, sugarAvg: v.sugarAvg,
|
|
462
|
+
adviceCount: v.advice.length,
|
|
463
|
+
});
|
|
464
|
+
return { data: { metrics }, html: buildNutritionAnalysisDoc(v) };
|
|
465
|
+
}
|
|
466
|
+
case 'calorie.view.six-factors': {
|
|
467
|
+
const date = optStr(params, 'date') ?? latestFoodDate(db) ?? todayISO();
|
|
468
|
+
assertISO(date, 'date');
|
|
469
|
+
const v = buildSixFactorsView(db, date);
|
|
470
|
+
const metrics = nums({
|
|
471
|
+
score: v.score,
|
|
472
|
+
calorie: v.factors[0]?.ok ? 1 : 0,
|
|
473
|
+
protein: v.factors[1]?.ok ? 1 : 0,
|
|
474
|
+
water: v.factors[2]?.ok ? 1 : 0,
|
|
475
|
+
exercise: v.factors[3]?.ok ? 1 : 0,
|
|
476
|
+
weigh: v.factors[4]?.ok ? 1 : 0,
|
|
477
|
+
meals: v.factors[5]?.ok ? 1 : 0,
|
|
478
|
+
});
|
|
479
|
+
return { data: { metrics }, html: buildSixFactorsDoc(v) };
|
|
480
|
+
}
|
|
481
|
+
case 'calorie.view.measure-wizard': {
|
|
482
|
+
const v = buildMeasureWizardView(db, params);
|
|
483
|
+
const metrics = nums({
|
|
484
|
+
filledCount: v.filledCount, hasRecent: v.recent ? 1 : 0,
|
|
485
|
+
});
|
|
486
|
+
return { data: { metrics }, html: buildMeasureWizardDoc(v) };
|
|
487
|
+
}
|
|
488
|
+
case 'calorie.view.composition-wizard': {
|
|
489
|
+
const v = buildCompositionWizardView(db, params);
|
|
490
|
+
const metrics = nums({
|
|
491
|
+
filledCount: (v.source ? 1 : 0) + (v.bodyFatPct === null ? 0 : 1) + v.calipers.length,
|
|
492
|
+
caliperCount: v.calipers.length, sum7: v.sum7, hasRecent: v.recent ? 1 : 0,
|
|
493
|
+
});
|
|
494
|
+
return { data: { metrics }, html: buildCompositionWizardDoc(v) };
|
|
495
|
+
}
|
|
496
|
+
case 'calorie.view.photo-log-wizard': {
|
|
497
|
+
const v = buildPhotoLogWizardView(params);
|
|
498
|
+
const metrics = nums({
|
|
499
|
+
fileCount: v.srcPaths.length, hasTag: v.tag ? 1 : 0,
|
|
500
|
+
});
|
|
501
|
+
return { data: { metrics }, html: buildPhotoLogWizardDoc(v) };
|
|
502
|
+
}
|
|
503
|
+
case 'calorie.view.gif-planner': {
|
|
504
|
+
const dir = photosDirOf(params);
|
|
505
|
+
const v = buildGifPlannerView(db, params, dir ?? null);
|
|
506
|
+
const metrics = nums({
|
|
507
|
+
photoCount: v.photos.length, selectedCount: v.selectedIds.length,
|
|
508
|
+
missingCount: v.missingIds.length, cropCount: v.photos.filter((p) => p.crop).length,
|
|
509
|
+
});
|
|
510
|
+
return { data: { metrics }, html: buildGifPlannerDoc(v) };
|
|
511
|
+
}
|
|
512
|
+
case 'calorie.view.lint-health': {
|
|
513
|
+
const v = buildLintHealthView(db);
|
|
514
|
+
const metrics = nums({
|
|
515
|
+
issueCount: v.issueCount,
|
|
516
|
+
unmatched: v.checks[0]?.count,
|
|
517
|
+
badCalorie: v.checks[1]?.count,
|
|
518
|
+
future: v.checks[2]?.count,
|
|
519
|
+
duplicate: v.checks[3]?.count,
|
|
520
|
+
});
|
|
521
|
+
return { data: { metrics }, html: buildLintHealthDoc(v) };
|
|
522
|
+
}
|
|
523
|
+
case 'calorie.view.batch-import-preview': {
|
|
524
|
+
const v = buildBatchImportPreviewView(db, params['items']);
|
|
525
|
+
const metrics = nums({
|
|
526
|
+
total: v.total, matched: v.matched, missing: v.missing, totalCalorie: v.totalCalorie,
|
|
527
|
+
});
|
|
528
|
+
return { data: { metrics }, html: buildBatchImportPreviewDoc(v) };
|
|
529
|
+
}
|
|
530
|
+
case 'calorie.view.process-progress': {
|
|
531
|
+
const end = optStr(params, 'end') ?? latestFoodDate(db) ?? todayISO();
|
|
532
|
+
assertISO(end, 'end');
|
|
533
|
+
const v = buildProcessProgressView(db, end);
|
|
534
|
+
const metrics = nums({
|
|
535
|
+
hasPlan: v.hasPlan ? 1 : 0, plannedDays: v.plannedDays,
|
|
536
|
+
sessions7d: v.sessions7d, minutes7d: v.minutes7d,
|
|
537
|
+
});
|
|
538
|
+
return { data: { metrics }, html: buildProcessProgressDoc(v) };
|
|
539
|
+
}
|
|
540
|
+
case 'calorie.view.review-template': {
|
|
541
|
+
const { start, end } = defaultRange(db, params);
|
|
542
|
+
const v = buildReviewTemplateView(db, start, end);
|
|
543
|
+
const metrics = nums({
|
|
544
|
+
days: v.days, meals: v.meals, avgCalorie: v.avgCalorie,
|
|
545
|
+
sessions: v.sessions, minutes: v.minutes, weightChange: v.weightChange,
|
|
546
|
+
points: v.points.length,
|
|
547
|
+
});
|
|
548
|
+
return { data: { metrics }, html: buildReviewTemplateDoc(v) };
|
|
263
549
|
}
|
|
264
550
|
case 'calorie.view.goal': {
|
|
265
551
|
const { start, end } = defaultRange(db, params);
|
|
@@ -334,7 +620,7 @@ export function dispatch(key, params, db) {
|
|
|
334
620
|
correlationR: c.analysis.correlation.r, correlationN: c.analysis.correlation.n, days: c.analysis.days,
|
|
335
621
|
seriesDays: c.series.length,
|
|
336
622
|
});
|
|
337
|
-
return { data: { metrics }, html:
|
|
623
|
+
return { data: { metrics }, html: buildCombinedDoc(c) };
|
|
338
624
|
}
|
|
339
625
|
case 'calorie.view.deficit': {
|
|
340
626
|
const { start, end } = defaultRange(db, params);
|
|
@@ -345,7 +631,7 @@ export function dispatch(key, params, db) {
|
|
|
345
631
|
days: d.meta.days, weekdayCount: d.meta.weekdayCount, weekendCount: d.meta.weekendCount,
|
|
346
632
|
targetIntake: d.target.intake, targetTdee: d.target.tdee,
|
|
347
633
|
});
|
|
348
|
-
return { data: { metrics }, html:
|
|
634
|
+
return { data: { metrics }, html: buildDeficitDoc(d) };
|
|
349
635
|
}
|
|
350
636
|
case 'calorie.view.diet-review': {
|
|
351
637
|
const { start, end } = defaultRange(db, params);
|
|
@@ -357,13 +643,15 @@ export function dispatch(key, params, db) {
|
|
|
357
643
|
'meal.晚餐': r.byMeal.find((s) => s.meal === '晚餐')?.totalCalories,
|
|
358
644
|
'meal.加餐': r.byMeal.find((s) => s.meal === '加餐')?.totalCalories,
|
|
359
645
|
});
|
|
360
|
-
|
|
646
|
+
// #108 · 复盘全文档(趋势折线+配比环+高频 TOP5+按餐汇总;TOP5 取数失败即空态,不编数)。
|
|
647
|
+
const fr = dietFoodRanking(db, start, end, 'frequent', 5);
|
|
648
|
+
return { data: { metrics }, html: buildDietReviewDoc(r, fr.status === 'ok' ? (fr.data ?? null) : null) };
|
|
361
649
|
}
|
|
362
650
|
case 'calorie.view.health': {
|
|
363
651
|
const { start, end } = defaultRange(db, params);
|
|
364
652
|
const h = buildHealthPlate(db, start, end);
|
|
365
653
|
const metrics = nums({ loggedDays: h.loggedDays, avgIntake: h.avgIntake, avgDeficit: h.avgDeficit });
|
|
366
|
-
return { data: { metrics }, html:
|
|
654
|
+
return { data: { metrics }, html: buildHealthDoc(h) };
|
|
367
655
|
}
|
|
368
656
|
case 'calorie.view.ranking': {
|
|
369
657
|
const { start, end } = defaultRange(db, params);
|
|
@@ -375,11 +663,11 @@ export function dispatch(key, params, db) {
|
|
|
375
663
|
const one = buildFoodRankingPlate(db, start, end, category, topN);
|
|
376
664
|
const top = one.items[0];
|
|
377
665
|
const metrics = nums({ total: one.items.length, topN: one.topN, topCal: top?.totalCal, topCnt: top?.cnt, topRank: top?.rank });
|
|
378
|
-
return { data: { metrics }, html:
|
|
666
|
+
return { data: { metrics }, html: buildRankingDoc(one) };
|
|
379
667
|
}
|
|
380
668
|
const all = buildAllRankings(db, start, end, topN);
|
|
381
669
|
const metrics = nums({ okCount: all.okCount, topN: all.topN });
|
|
382
|
-
return { data: { metrics }, html:
|
|
670
|
+
return { data: { metrics }, html: buildAllRankingsDoc(all) };
|
|
383
671
|
}
|
|
384
672
|
case 'calorie.view.library': {
|
|
385
673
|
const category = optStr(params, 'category') ?? null;
|
|
@@ -389,7 +677,7 @@ export function dispatch(key, params, db) {
|
|
|
389
677
|
const lib = buildProductLibrary(db, category, limit);
|
|
390
678
|
const stats = buildProductStats(db);
|
|
391
679
|
const metrics = nums({ total: lib.total, statsTotal: stats.total });
|
|
392
|
-
return { data: { metrics }, html:
|
|
680
|
+
return { data: { metrics }, html: buildLibraryDoc(lib, stats.total) };
|
|
393
681
|
}
|
|
394
682
|
case 'calorie.view.search': {
|
|
395
683
|
const keyword = needStr(params, 'keyword');
|
|
@@ -398,7 +686,7 @@ export function dispatch(key, params, db) {
|
|
|
398
686
|
fail(2, 'limit 须为 1..100 整数');
|
|
399
687
|
const s = buildProductSearch(db, keyword, limit);
|
|
400
688
|
const metrics = nums({ total: s.total, limit: limit });
|
|
401
|
-
return { data: { metrics }, html:
|
|
689
|
+
return { data: { metrics }, html: buildSearchDoc(s) };
|
|
402
690
|
}
|
|
403
691
|
case 'calorie.photo.list': {
|
|
404
692
|
const dir = photosDirOf(params);
|
|
@@ -441,12 +729,39 @@ export function dispatch(key, params, db) {
|
|
|
441
729
|
return { data: { summary: 'GIF 任务:标签 ' + gif.tag + ' 共 ' + gif.photoCount + ' 张(' + (gif.firstDate ?? '—') + ' ~ ' + (gif.lastDate ?? '—') + ')· ' + gif.note }, html: renderGifHtml(gif) };
|
|
442
730
|
}
|
|
443
731
|
case 'calorie.help.center': {
|
|
732
|
+
// #91 · Q9:本键承载**全量速查台**(#88 壳,三态同源);**照片 10 键兼容走 `q`**(既有语义逐字不变)。
|
|
733
|
+
// D6:交付形态**显式** `mode`(`file`/`inline`/`text`),不靠猜、不从别的参数推;非法值即 exit 2。
|
|
444
734
|
const q = optStr(params, 'q') ?? optStr(params, 'keyword') ?? undefined;
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
735
|
+
const modeRaw = optStr(params, 'mode');
|
|
736
|
+
const photoLookup = q !== undefined && q !== '';
|
|
737
|
+
if (photoLookup && modeRaw !== undefined) {
|
|
738
|
+
fail(2, '参数 q 与 mode 互斥:q=照片 HELP 现找(10 键),mode=全量速查台交付形态('
|
|
739
|
+
+ HELP_CENTER_MODES.join('/') + ')');
|
|
740
|
+
}
|
|
741
|
+
if (q !== undefined && modeRaw === undefined) {
|
|
742
|
+
// 照片路径(原样保留):非空 q = 现找(无命中 exit 4);`q:""` = 全量 10 键。
|
|
743
|
+
const hits = photoLookup ? lookupPhotoHelp(q) : buildPhotoHelp();
|
|
744
|
+
if (photoLookup && hits.length === 0)
|
|
745
|
+
throw new CalorieRenderError('missing-data', 'HELP 无命中:' + q);
|
|
746
|
+
const items = hits.map((h) => ({ wakeWord: h.wakeWord, key: h.key, desc: h.desc, exec: h.exec }));
|
|
747
|
+
return { data: { items, total: items.length }, html: renderPhotoHelpHtml(hits, q) };
|
|
748
|
+
}
|
|
749
|
+
// 全量速查台:缺省 `file`(#88 的缺省同值);`text` 态把文本一并回传(file/inline 只回落点,不塞 1 MB)。
|
|
750
|
+
const mode = (modeRaw ?? 'file');
|
|
751
|
+
if (!HELP_CENTER_MODES.includes(mode)) {
|
|
752
|
+
fail(2, '参数 mode 非法(' + String(mode) + '):须为 ' + HELP_CENTER_MODES.join('/'));
|
|
753
|
+
}
|
|
754
|
+
const sceneData = buildHelpSceneData();
|
|
755
|
+
const rendered = renderHelpCenterHtml({ mode, sceneData });
|
|
756
|
+
const data = {
|
|
757
|
+
...helpCenterIndex(sceneData),
|
|
758
|
+
mode,
|
|
759
|
+
bytes: Buffer.byteLength(rendered.html, 'utf8'),
|
|
760
|
+
};
|
|
761
|
+
if (mode === 'text')
|
|
762
|
+
data['text'] = rendered.html;
|
|
763
|
+
// #83 · 渲染层已定文本交付:产物即文本(③ 文本态之一),交付装配层据此走文本通道。
|
|
764
|
+
return { data, html: rendered.html, deliveryKind: mode === 'text' ? 'text' : 'html' };
|
|
450
765
|
}
|
|
451
766
|
case 'calorie.help.lookup': {
|
|
452
767
|
const q = needStr(params, 'q');
|
|
@@ -460,8 +775,8 @@ export function dispatch(key, params, db) {
|
|
|
460
775
|
const category = src ? src.category : (sceneToCategory[h.scene] ?? h.scene);
|
|
461
776
|
return { wake_word: h.wake_word, category, key: String(h.key ?? ''), cli: h.cli, desc: h.desc };
|
|
462
777
|
});
|
|
463
|
-
|
|
464
|
-
|
|
778
|
+
// #90 · 渲染收敛到 render/html.ts 的 renderHelpLookupHtml(每行复制按钮 + 页尾注入双通道运行时)。
|
|
779
|
+
const html = renderHelpLookupHtml(hits, q);
|
|
465
780
|
return { data: { items: hits, total: hits.length }, html };
|
|
466
781
|
}
|
|
467
782
|
case 'calorie.history': {
|
|
@@ -486,7 +801,7 @@ export function dispatch(key, params, db) {
|
|
|
486
801
|
changeKg: w.trend.changeKg, dailyChangeG: w.trend.dailyChangeG,
|
|
487
802
|
weightGoal: w.weightGoal, gapKg: w.gapKg,
|
|
488
803
|
});
|
|
489
|
-
return { data: { metrics }, html:
|
|
804
|
+
return { data: { metrics }, html: buildWeightDoc(w) };
|
|
490
805
|
}
|
|
491
806
|
case 'calorie.view.weight-history': {
|
|
492
807
|
const startDate = optStr(params, 'startDate') ?? optStr(params, 'start');
|
|
@@ -506,7 +821,7 @@ export function dispatch(key, params, db) {
|
|
|
506
821
|
spanDays: h.change?.spanDays, first: h.change?.first, last: h.change?.last,
|
|
507
822
|
delta: h.change?.delta, dailyAvg: h.change?.dailyAvg,
|
|
508
823
|
});
|
|
509
|
-
return { data: { metrics }, html:
|
|
824
|
+
return { data: { metrics }, html: buildWeightHistoryDoc(h) };
|
|
510
825
|
}
|
|
511
826
|
case 'calorie.view.weight-compare': {
|
|
512
827
|
const start = needStr(params, 'start');
|
|
@@ -519,7 +834,7 @@ export function dispatch(key, params, db) {
|
|
|
519
834
|
currentAvg: v.compare.currentPeriod.avgWeight, compareAvg: v.compare.comparePeriod.avgWeight,
|
|
520
835
|
currentChange: v.compare.currentPeriod.changeKg, compareChange: v.compare.comparePeriod.changeKg,
|
|
521
836
|
});
|
|
522
|
-
return { data: { metrics }, html:
|
|
837
|
+
return { data: { metrics }, html: buildWeightCompareDoc(v) };
|
|
523
838
|
}
|
|
524
839
|
case 'calorie.view.weight-review': {
|
|
525
840
|
const today = optStr(params, 'today') ?? optStr(params, 'date');
|
|
@@ -529,7 +844,7 @@ export function dispatch(key, params, db) {
|
|
|
529
844
|
gapKg: v.milestone.gapKg, actualDailyChangeKg: v.milestone.actualDailyChangeKg,
|
|
530
845
|
estDays: v.milestone.estDays, calorieAdjustment: v.milestone.calorieAdjustment,
|
|
531
846
|
});
|
|
532
|
-
return { data: { metrics }, html:
|
|
847
|
+
return { data: { metrics }, html: buildWeightReviewDoc(v) };
|
|
533
848
|
}
|
|
534
849
|
case 'calorie.view.volatility': {
|
|
535
850
|
const { start, end } = defaultRange(db, params);
|
|
@@ -541,7 +856,7 @@ export function dispatch(key, params, db) {
|
|
|
541
856
|
points: v.volatility.points.length, anomalies: v.volatility.recentAnomalies.length,
|
|
542
857
|
deviationKg: v.volatility.earlyWarning.deviationKg,
|
|
543
858
|
});
|
|
544
|
-
return { data: { metrics }, html:
|
|
859
|
+
return { data: { metrics }, html: buildVolatilityDoc(v) };
|
|
545
860
|
}
|
|
546
861
|
case 'calorie.view.body-composition': {
|
|
547
862
|
const days = optNum(params, 'days') ?? 90;
|
|
@@ -549,7 +864,7 @@ export function dispatch(key, params, db) {
|
|
|
549
864
|
const limit = optNum(params, 'limit') ?? 20;
|
|
550
865
|
const v = buildBodyCompositionView(db, { days: days, source: source ?? undefined, limit: limit });
|
|
551
866
|
const metrics = nums({ total: v.total, latestPct: v.latestPct, trendDays: v.trend.length });
|
|
552
|
-
return { data: { metrics }, html:
|
|
867
|
+
return { data: { metrics }, html: buildBodyCompositionDoc(v) };
|
|
553
868
|
}
|
|
554
869
|
case 'calorie.view.body-measure': {
|
|
555
870
|
const metric = optStr(params, 'metric');
|
|
@@ -559,7 +874,7 @@ export function dispatch(key, params, db) {
|
|
|
559
874
|
const dateTo = optStr(params, 'dateTo');
|
|
560
875
|
const v = buildBodyMeasureView(db, { metric: metric ?? undefined, days: days, limit: limit, dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined });
|
|
561
876
|
const metrics = nums({ total: v.total, latestVal: v.latestVal, trendDays: v.trend.length });
|
|
562
|
-
return { data: { metrics }, html:
|
|
877
|
+
return { data: { metrics }, html: buildBodyMeasureDoc(v) };
|
|
563
878
|
}
|
|
564
879
|
case 'calorie.view.plan': {
|
|
565
880
|
const v = buildPlanView(db);
|
|
@@ -572,14 +887,14 @@ export function dispatch(key, params, db) {
|
|
|
572
887
|
fail(2, '缺参数 plan(PlanInput 对象)');
|
|
573
888
|
const catalog = params['catalog'];
|
|
574
889
|
const v = buildPlanWizardView(plan, catalog ?? undefined);
|
|
575
|
-
const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount,
|
|
890
|
+
const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount, checkedSessions: v.checkedSessions });
|
|
576
891
|
return { data: { metrics }, html: renderPlanWizardHtml(v) };
|
|
577
892
|
}
|
|
578
893
|
case 'calorie.view.exercise-goal': {
|
|
579
894
|
const { start, end } = defaultRange(db, params);
|
|
580
895
|
const v = buildExerciseGoalView(db, start, end);
|
|
581
896
|
const metrics = nums({ dailyGoal: v.dailyGoal, goalTotal: v.goalTotal, actual: v.actual, pct: v.pct, gap: v.gap, achieved: v.achieved ? 1 : 0, days: v.days });
|
|
582
|
-
return { data: { metrics }, html:
|
|
897
|
+
return { data: { metrics }, html: buildExerciseGoalDoc(v) };
|
|
583
898
|
}
|
|
584
899
|
case 'calorie.view.goal-expiring': {
|
|
585
900
|
const withinDays = optNum(params, 'withinDays') ?? optNum(params, 'days') ?? 14;
|
|
@@ -589,10 +904,11 @@ export function dispatch(key, params, db) {
|
|
|
589
904
|
return { data: { metrics }, html: renderGoalExpiringHtml(v) };
|
|
590
905
|
}
|
|
591
906
|
case 'calorie.view.goal-predict': {
|
|
592
|
-
|
|
907
|
+
// #103 G2 · 目标预测需 ≥14 条体重记录(simulate SIM_MIN_DAYS=14),7 天默认窗结构性不可达 → 默认 14 天。
|
|
908
|
+
const { start, end } = defaultRange(db, params, 14);
|
|
593
909
|
const v = buildGoalPredictView(db, start, end);
|
|
594
910
|
const metrics = nums({ targetKg: v.targetKg, current: v.current, daysLeft: v.daysLeft, ratePerWeek: v.ratePerWeek, feasible: v.feasible ? 1 : 0 });
|
|
595
|
-
return { data: { metrics }, html:
|
|
911
|
+
return { data: { metrics }, html: buildGoalPredictDoc(v) };
|
|
596
912
|
}
|
|
597
913
|
case 'calorie.view.goal-vs-actual': {
|
|
598
914
|
const { start, end } = defaultRange(db, params);
|
|
@@ -605,29 +921,30 @@ export function dispatch(key, params, db) {
|
|
|
605
921
|
return { data: { metrics }, html: renderGoalVsActualHtml(v) };
|
|
606
922
|
}
|
|
607
923
|
case 'calorie.view.predict': {
|
|
608
|
-
|
|
924
|
+
// #103 G2 · 同 goal-predict:默认 14 天,否则缺省调用恒走 missing-data。
|
|
925
|
+
const { start, end } = defaultRange(db, params, 14);
|
|
609
926
|
const horizonDays = optNum(params, 'horizonDays') ?? optNum(params, 'days') ?? 30;
|
|
610
927
|
const v = buildPredictView(db, start, end, horizonDays);
|
|
611
928
|
const metrics = nums({ current: v.current, ratePerWeek: v.ratePerWeek, forecastValue: v.forecastValue, forecastLo: v.forecastLo, forecastHi: v.forecastHi, horizonDays: v.horizonDays });
|
|
612
|
-
return { data: { metrics }, html:
|
|
929
|
+
return { data: { metrics }, html: buildPredictDoc(v) };
|
|
613
930
|
}
|
|
614
931
|
case 'calorie.view.anomaly': {
|
|
615
932
|
const kind = needStr(params, 'kind');
|
|
616
933
|
const { start, end } = defaultRange(db, params);
|
|
617
934
|
const v = buildAnomalyView(db, kind, start, end);
|
|
618
935
|
const metrics = nums({ findingCount: v.findingCount, days: v.diagnosis.days, degraded: v.diagnosis.degraded ? 1 : 0 });
|
|
619
|
-
return { data: { metrics }, html:
|
|
936
|
+
return { data: { metrics }, html: buildAnomalyDoc(v) };
|
|
620
937
|
}
|
|
621
938
|
case 'calorie.view.contraindication': {
|
|
622
939
|
const part = optStr(params, 'part') ?? 'all';
|
|
623
940
|
const v = buildContraView(db, part);
|
|
624
941
|
const metrics = nums({ scannedSessions: v.scannedSessions, scannedMovements: v.scannedMovements, errorCount: v.errorCount, warnCount: v.warnCount, infoCount: v.infoCount });
|
|
625
|
-
return { data: { metrics }, html:
|
|
942
|
+
return { data: { metrics }, html: buildContraDoc(v) };
|
|
626
943
|
}
|
|
627
944
|
case 'calorie.view.dedupe': {
|
|
628
945
|
const v = buildDedupeView(db);
|
|
629
946
|
const metrics = nums({ groupCount: v.groupCount, rowCount: v.rowCount, totalProducts: v.totalProducts });
|
|
630
|
-
return { data: { metrics }, html:
|
|
947
|
+
return { data: { metrics }, html: buildDedupeDoc(v) };
|
|
631
948
|
}
|
|
632
949
|
case 'calorie.view.profile': {
|
|
633
950
|
const v = buildProfileView(db);
|
|
@@ -643,13 +960,113 @@ export function dispatch(key, params, db) {
|
|
|
643
960
|
throw new Error('unreachable');
|
|
644
961
|
}
|
|
645
962
|
}
|
|
963
|
+
/* ── #83 · 三态交付装配(M4 HTML-First + 渲染失败回执) ───────────────────────────────── */
|
|
964
|
+
/** 交付落点的可读描述(回执文案用;默认目录名必须出现在文案里,便于用户定位)。 */
|
|
965
|
+
function describeDeliveryTarget(explicit) {
|
|
966
|
+
return explicit !== undefined ? '显式落点 ' + explicit : '默认目录 <SKILLS_DB_PATH>/' + HTML_DIR_NAME;
|
|
967
|
+
}
|
|
968
|
+
/** ③ 文本态的结构化文本:**同源**取 `buildDataText`(#77 契约,五 shape 投影)。
|
|
969
|
+
* `fallback` 形不在 `SERIALIZABLE_SHAPES` 内(#93 登记:无 CLI 出口)——此时退化为缩进 JSON,
|
|
970
|
+
* 仍是「结构化文本」且零编造;本退化分支由 `delivery-83.test.mjs` 直接钉住。 */
|
|
971
|
+
function dataTextOf(shape, key, data) {
|
|
972
|
+
try {
|
|
973
|
+
return buildDataText({
|
|
974
|
+
envelope: { version: ENVELOPE_VERSION, skill: CALORIE_SKILL, shape, key, data },
|
|
975
|
+
format: 'text',
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
return JSON.stringify(data, null, 2);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/** 三态判定 + envelope 装配(**唯一交付落点**:同一 key、同一份 `data`,绝不各自取数):
|
|
983
|
+
* ③ 文本态:用户**明确**要文本(通用 `--params '{"delivery":"text"}'`)/渲染层已产出文本
|
|
984
|
+
* (#91 `help.center` 的 `mode:'text'`)/**无 HTML 产物**(结构缝:`html` 为空 ⇒ 无对应模板,允许文字答);
|
|
985
|
+
* ② 内联态:有 HTML 产物但写不进去(只读/沙箱 `EACCES|EPERM|EROFS|EBUSY`)⇒ 产物随 envelope 回传;
|
|
986
|
+
* ① 文件态(默认):落盘 `calorie_html/*.html`,`data.output` 与 `delivery.path` 同值同源。
|
|
987
|
+
* `delivery` 为 envelope 的**顶层追加字段**(既有五字段一字不改);P9「stdout 一行 JSON」不变。 */
|
|
988
|
+
export function buildDeliveredEnvelope(input) {
|
|
989
|
+
const { key, shape, params, out } = input;
|
|
990
|
+
const html = typeof out.html === 'string' ? out.html : '';
|
|
991
|
+
const askedText = params['delivery'] === 'text';
|
|
992
|
+
const kind = askedText ? 'text' : (out.deliveryKind ?? (html.trim() === '' ? 'text' : 'html'));
|
|
993
|
+
if (kind === 'text') {
|
|
994
|
+
// 三态同源:文本由**同一份** envelope data 经 #77 `buildDataText` 投影(技能侧不自产第二套序列化)。
|
|
995
|
+
const text = typeof out.data['text'] === 'string' ? out.data['text'] : dataTextOf(shape, key, out.data);
|
|
996
|
+
const data = { ...out.data, text };
|
|
997
|
+
// 渲染层已定文本交付的键(#91 `help.center` text 态)**保留既有落盘**(`data.output` 指向该文本文件);
|
|
998
|
+
// 其余键的文本态只走 envelope(③ 产物=结构化文本,不落 HTML 文件)。
|
|
999
|
+
if (out.deliveryKind === 'text') {
|
|
1000
|
+
const d = deliverHtml({ key, params, explicit: input.explicit, html: text });
|
|
1001
|
+
if (d.mode === 'file')
|
|
1002
|
+
data['output'] = d.path;
|
|
1003
|
+
return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
|
|
1004
|
+
mode: 'text', path: d.mode === 'file' ? d.path : undefined, shape, html: text, bytes: d.bytes, template: 'text',
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
|
|
1008
|
+
mode: 'text', shape, html: text, bytes: Buffer.byteLength(text, 'utf8'), template: 'text',
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
const d = deliverHtml({ key, params, explicit: input.explicit, html });
|
|
1012
|
+
const data = d.mode === 'file'
|
|
1013
|
+
? { ...out.data, output: d.path }
|
|
1014
|
+
: { ...out.data, html };
|
|
1015
|
+
return withDelivery(buildEnvelope(key, shape, data), buildDelivery({
|
|
1016
|
+
mode: d.mode, path: d.mode === 'file' ? d.path : undefined, shape, html, bytes: d.bytes,
|
|
1017
|
+
}));
|
|
1018
|
+
}
|
|
1019
|
+
/** #83 · M4「渲染失败回执」:**模板化**回执(`buildErrorReceipt` + `renderErrorHtml`,旧
|
|
1020
|
+
* `render_error_receipt.py` 的等价物)——**严禁手写 HTML 兜底**。回执自身也走三态:默认目录可写即落盘,
|
|
1021
|
+
* 否则内联随 stderr 回传。机器可读回执以一行 `RECEIPT {…}` 落 **stderr**(P9:stdout 保持纯净,
|
|
1022
|
+
* 不吐半截 envelope),exit 5 与既有「渲染/落盘失败」口径一致。 */
|
|
1023
|
+
function failWithReceipt(reason, key) {
|
|
1024
|
+
console.error('ERR 5: ' + reason);
|
|
1025
|
+
try {
|
|
1026
|
+
const receipt = buildErrorReceipt({
|
|
1027
|
+
sceneName: '渲染',
|
|
1028
|
+
wakeWord: key ?? '渲染失败',
|
|
1029
|
+
op: '渲染/落盘未完成',
|
|
1030
|
+
reason,
|
|
1031
|
+
suggestions: [
|
|
1032
|
+
'检查 SKILLS_DB_PATH 与 ' + HTML_DIR_NAME + ' 目录权限(只读/沙箱会自动转内联交付)',
|
|
1033
|
+
'用 --output <可写绝对路径> 显式指定落点后重试',
|
|
1034
|
+
'确认 ' + HTML_DIR_NAME + ' 未被同名文件占位(占位会挡住落点解析)',
|
|
1035
|
+
],
|
|
1036
|
+
fixPrompt: 'calorie-cmd-read ' + (key ?? '<key>') + " --params '{…}' --output <可写绝对路径>",
|
|
1037
|
+
});
|
|
1038
|
+
const receiptHtml = renderErrorHtml(receipt);
|
|
1039
|
+
let delivery;
|
|
1040
|
+
try {
|
|
1041
|
+
const d = deliverHtml({
|
|
1042
|
+
key: key ?? 'calorie.help.center', params: {}, target: resolveReceiptHtmlPath(), html: receiptHtml,
|
|
1043
|
+
});
|
|
1044
|
+
delivery = buildDelivery({
|
|
1045
|
+
mode: d.mode, path: d.mode === 'file' ? d.path : undefined, shape: 'receipt', html: receiptHtml, bytes: d.bytes,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
delivery = buildDelivery({
|
|
1050
|
+
mode: 'inline', shape: 'receipt', html: receiptHtml, bytes: Buffer.byteLength(receiptHtml, 'utf8'),
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
// 内联回执把模板化回执页面一并回传(否则调用方拿不到回执正文);落盘态只回路径。
|
|
1054
|
+
console.error('RECEIPT ' + JSON.stringify({
|
|
1055
|
+
ok: false, ...receipt, delivery, html: delivery.mode === 'inline' ? receiptHtml : undefined,
|
|
1056
|
+
}));
|
|
1057
|
+
}
|
|
1058
|
+
catch (e) {
|
|
1059
|
+
console.error('TOAST: 回执生成失败(' + (e.message || String(e)) + ')');
|
|
1060
|
+
}
|
|
1061
|
+
process.exit(5);
|
|
1062
|
+
}
|
|
646
1063
|
function parseReadArgs(a) {
|
|
647
1064
|
return parseArgs(a);
|
|
648
1065
|
}
|
|
649
1066
|
async function main() {
|
|
650
1067
|
const o = parseReadArgs(process.argv.slice(2));
|
|
651
1068
|
if (!o.key)
|
|
652
|
-
fail(2,
|
|
1069
|
+
fail(2, USAGE);
|
|
653
1070
|
const dbPath = preflight();
|
|
654
1071
|
let params = {};
|
|
655
1072
|
if (o.params !== undefined) {
|
|
@@ -678,20 +1095,28 @@ async function main() {
|
|
|
678
1095
|
timer.unref();
|
|
679
1096
|
let env = null;
|
|
680
1097
|
try {
|
|
681
|
-
const
|
|
1098
|
+
const dbFile = join(dbPath, DB_FILENAME);
|
|
1099
|
+
// #93 · 读键走只读打开(不建表、不迁移、写入被拒);写键或库文件尚不存在时仍走 openDb。
|
|
1100
|
+
const db = isCalorieWriteKey(o.key) || !existsSync(dbFile) ? openDb(dbFile) : openDbReadOnly(dbFile);
|
|
682
1101
|
try {
|
|
683
1102
|
// #40 · 唯一出口:写键走 write.ts 分发(memo.create/update/remove 范式),读键走既有 dispatch。
|
|
684
1103
|
const out = isCalorieWriteKey(o.key)
|
|
685
1104
|
? dispatchWrite(o.key, params, db)
|
|
686
1105
|
: dispatch(o.key, params, db);
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
1106
|
+
// #83 · 三态交付(M4 HTML-First):① 文件态(默认)/② 内联态(只读·沙箱回退)/③ 文本态。
|
|
1107
|
+
// 落点:--output(显式覆盖)> --html(legacy 别名)> 默认 calorie_html/<中文command>_<TS>[_N].html(#87/#119)。
|
|
1108
|
+
// 只读类写失败 → 内联交付(产物随 envelope 回传,绝不因写不进去而文字答);
|
|
1109
|
+
// 结构错/渲染错 → 渲染失败回执(模板化回执,exit 5;严禁手写 HTML 兜底)。
|
|
1110
|
+
try {
|
|
1111
|
+
env = buildDeliveredEnvelope({
|
|
1112
|
+
key: o.key, shape: shape, out, params, explicit: o.output ?? o.html,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
catch (e) {
|
|
1116
|
+
if (e instanceof CalorieRenderError)
|
|
1117
|
+
throw e;
|
|
1118
|
+
failWithReceipt('渲染失败:' + describeDeliveryTarget(o.output ?? o.html) + ':'
|
|
1119
|
+
+ (e.message || String(e)), o.key);
|
|
695
1120
|
}
|
|
696
1121
|
}
|
|
697
1122
|
finally {
|
|
@@ -702,7 +1127,9 @@ async function main() {
|
|
|
702
1127
|
if (e instanceof CalorieRenderError) {
|
|
703
1128
|
if (e.code === 'missing-data')
|
|
704
1129
|
fail(4, '取数失败(缺失阻断):' + e.message);
|
|
705
|
-
|
|
1130
|
+
if (e.code === 'bad-input')
|
|
1131
|
+
fail(2, '参数失败:' + e.message);
|
|
1132
|
+
failWithReceipt('渲染失败:' + e.message, o.key);
|
|
706
1133
|
}
|
|
707
1134
|
if (e instanceof FetchError)
|
|
708
1135
|
fail(4, '取数失败:' + e.message);
|