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.
Files changed (144) hide show
  1. package/SKILL.md +207 -0
  2. package/dist/analysis/anomaly/common.d.ts.map +1 -1
  3. package/dist/analysis/anomaly/common.js +2 -1
  4. package/dist/analysis/anomaly/common.js.map +1 -1
  5. package/dist/analysis/cross.d.ts.map +1 -1
  6. package/dist/analysis/cross.js +3 -2
  7. package/dist/analysis/cross.js.map +1 -1
  8. package/dist/analysis/deficit.d.ts.map +1 -1
  9. package/dist/analysis/deficit.js +4 -0
  10. package/dist/analysis/deficit.js.map +1 -1
  11. package/dist/analysis/diet.js +2 -2
  12. package/dist/analysis/diet.js.map +1 -1
  13. package/dist/analysis/exercise.js +5 -5
  14. package/dist/analysis/exercise.js.map +1 -1
  15. package/dist/analysis/review.js +2 -2
  16. package/dist/analysis/review.js.map +1 -1
  17. package/dist/analysis/series.d.ts +7 -1
  18. package/dist/analysis/series.d.ts.map +1 -1
  19. package/dist/analysis/series.js +22 -6
  20. package/dist/analysis/series.js.map +1 -1
  21. package/dist/analysis/utils.d.ts +7 -0
  22. package/dist/analysis/utils.d.ts.map +1 -1
  23. package/dist/analysis/utils.js +7 -0
  24. package/dist/analysis/utils.js.map +1 -1
  25. package/dist/analysis/weightCompare3.js +2 -2
  26. package/dist/analysis/weightCompare3.js.map +1 -1
  27. package/dist/cli/cmd_read.d.ts +21 -3
  28. package/dist/cli/cmd_read.d.ts.map +1 -1
  29. package/dist/cli/cmd_read.js +480 -53
  30. package/dist/cli/cmd_read.js.map +1 -1
  31. package/dist/cli/keys.d.ts +89 -1
  32. package/dist/cli/keys.d.ts.map +1 -1
  33. package/dist/cli/keys.js +30 -0
  34. package/dist/cli/keys.js.map +1 -1
  35. package/dist/cli/write.d.ts +19 -1
  36. package/dist/cli/write.d.ts.map +1 -1
  37. package/dist/cli/write.js +148 -52
  38. package/dist/cli/write.js.map +1 -1
  39. package/dist/db/readonly.d.ts +3 -0
  40. package/dist/db/readonly.d.ts.map +1 -0
  41. package/dist/db/readonly.js +22 -0
  42. package/dist/db/readonly.js.map +1 -0
  43. package/dist/output.d.ts +82 -0
  44. package/dist/output.d.ts.map +1 -0
  45. package/dist/output.js +305 -0
  46. package/dist/output.js.map +1 -0
  47. package/dist/render/copy.d.ts +20 -0
  48. package/dist/render/copy.d.ts.map +1 -0
  49. package/dist/render/copy.js +36 -0
  50. package/dist/render/copy.js.map +1 -0
  51. package/dist/render/dietDocs.d.ts +45 -0
  52. package/dist/render/dietDocs.d.ts.map +1 -0
  53. package/dist/render/dietDocs.js +561 -0
  54. package/dist/render/dietDocs.js.map +1 -0
  55. package/dist/render/envelope.d.ts +32 -6
  56. package/dist/render/envelope.d.ts.map +1 -1
  57. package/dist/render/envelope.js +71 -0
  58. package/dist/render/envelope.js.map +1 -1
  59. package/dist/render/exercisePort.d.ts +161 -0
  60. package/dist/render/exercisePort.d.ts.map +1 -0
  61. package/dist/render/exercisePort.js +395 -0
  62. package/dist/render/exercisePort.js.map +1 -0
  63. package/dist/render/goal.d.ts.map +1 -1
  64. package/dist/render/goal.js +13 -2
  65. package/dist/render/goal.js.map +1 -1
  66. package/dist/render/goalPlate.d.ts +1 -1
  67. package/dist/render/goalPlate.d.ts.map +1 -1
  68. package/dist/render/goalPlate.js +5 -1
  69. package/dist/render/goalPlate.js.map +1 -1
  70. package/dist/render/helpCenter.d.ts +104 -0
  71. package/dist/render/helpCenter.d.ts.map +1 -0
  72. package/dist/render/helpCenter.js +386 -0
  73. package/dist/render/helpCenter.js.map +1 -0
  74. package/dist/render/html.d.ts +12 -0
  75. package/dist/render/html.d.ts.map +1 -1
  76. package/dist/render/html.js +21 -5
  77. package/dist/render/html.js.map +1 -1
  78. package/dist/render/index.d.ts +8 -1
  79. package/dist/render/index.d.ts.map +1 -1
  80. package/dist/render/index.js +5 -1
  81. package/dist/render/index.js.map +1 -1
  82. package/dist/render/nutritionPort.d.ts +89 -0
  83. package/dist/render/nutritionPort.d.ts.map +1 -0
  84. package/dist/render/nutritionPort.js +183 -0
  85. package/dist/render/nutritionPort.js.map +1 -0
  86. package/dist/render/nutritionPortDocs.d.ts +6 -0
  87. package/dist/render/nutritionPortDocs.d.ts.map +1 -0
  88. package/dist/render/nutritionPortDocs.js +317 -0
  89. package/dist/render/nutritionPortDocs.js.map +1 -0
  90. package/dist/render/photo.d.ts +6 -1
  91. package/dist/render/photo.d.ts.map +1 -1
  92. package/dist/render/photo.js +13 -4
  93. package/dist/render/photo.js.map +1 -1
  94. package/dist/render/planPlate.d.ts +2 -1
  95. package/dist/render/planPlate.d.ts.map +1 -1
  96. package/dist/render/planPlate.js +6 -1
  97. package/dist/render/planPlate.js.map +1 -1
  98. package/dist/render/receipt.d.ts +47 -1
  99. package/dist/render/receipt.d.ts.map +1 -1
  100. package/dist/render/receipt.js +67 -3
  101. package/dist/render/receipt.js.map +1 -1
  102. package/dist/render/sportDocs.d.ts +14 -0
  103. package/dist/render/sportDocs.d.ts.map +1 -0
  104. package/dist/render/sportDocs.js +540 -0
  105. package/dist/render/sportDocs.js.map +1 -0
  106. package/dist/render/sportPortDocs.d.ts +8 -0
  107. package/dist/render/sportPortDocs.d.ts.map +1 -0
  108. package/dist/render/sportPortDocs.js +468 -0
  109. package/dist/render/sportPortDocs.js.map +1 -0
  110. package/dist/render/templates.d.ts +4 -0
  111. package/dist/render/templates.d.ts.map +1 -0
  112. package/dist/render/templates.js +29 -0
  113. package/dist/render/templates.js.map +1 -0
  114. package/dist/render/trendDocs.d.ts +11 -0
  115. package/dist/render/trendDocs.d.ts.map +1 -0
  116. package/dist/render/trendDocs.js +445 -0
  117. package/dist/render/trendDocs.js.map +1 -0
  118. package/dist/render/trendMiscPort.d.ts +127 -0
  119. package/dist/render/trendMiscPort.d.ts.map +1 -0
  120. package/dist/render/trendMiscPort.js +277 -0
  121. package/dist/render/trendMiscPort.js.map +1 -0
  122. package/dist/render/trendMiscPortDocs.d.ts +10 -0
  123. package/dist/render/trendMiscPortDocs.d.ts.map +1 -0
  124. package/dist/render/trendMiscPortDocs.js +397 -0
  125. package/dist/render/trendMiscPortDocs.js.map +1 -0
  126. package/dist/render/wizardPort.d.ts +125 -0
  127. package/dist/render/wizardPort.d.ts.map +1 -0
  128. package/dist/render/wizardPort.js +393 -0
  129. package/dist/render/wizardPort.js.map +1 -0
  130. package/dist/render/wizardPortDocs.d.ts +6 -0
  131. package/dist/render/wizardPortDocs.d.ts.map +1 -0
  132. package/dist/render/wizardPortDocs.js +274 -0
  133. package/dist/render/wizardPortDocs.js.map +1 -0
  134. package/dist/triggers/routing.d.ts +141 -0
  135. package/dist/triggers/routing.d.ts.map +1 -0
  136. package/dist/triggers/routing.js +678 -0
  137. package/dist/triggers/routing.js.map +1 -0
  138. package/package.json +5 -3
  139. package/templates/diet.html +40 -0
  140. package/templates/exercise.html +40 -0
  141. package/templates/goal.html +40 -0
  142. package/templates/help.html +40 -0
  143. package/templates/home.html +40 -0
  144. package/templates/photo-gallery.html +40 -0
@@ -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 用 --html 显式落盘(utf8):视图键走 render/html.ts 专属模板(与 T8/T9/T10 快照同源),其余走通用 section。
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 { writeFileSync } from 'node:fs';
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
- import { renderCombinedHtml, renderDeficitHtml, renderDietHtml, renderDietReviewHtml, renderExerciseHtml, renderGalleryHtml, renderCompareHtml, renderViewerHtml, renderGifHtml, renderPhotoHelpHtml, renderGoalConfigHtml, renderGoalRecommendHtml, renderGoalWeightHtml, renderGoalProgressHtml, renderGoalStatusHtml, renderGoalHtml, renderHealthHtml, renderHomeHtml, renderProductLibraryHtml, renderProductSearchHtml, renderRankingHtml, renderAllRankingsHtml, renderWeightHtml, renderWeightHistoryHtml, renderWeightCompareHtml, renderWeightReviewHtml, renderVolatilityHtml, renderBodyCompositionHtml, renderBodyMeasureHtml, renderPlanHtml, renderPlanWizardHtml, renderExerciseGoalHtml, renderGoalExpiringHtml, renderGoalPredictHtml, renderGoalVsActualHtml, renderPredictHtml, renderAnomalyHtml, renderContraHtml, renderDedupeHtml, renderProfileHtml, } from '../render/html.js';
37
- import { assertStatMetrics } from '../render/envelope.js';
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] + '(用法:cmd_read <calorie.key> [--params JSON对象] [--html 输出路径] [--timeout 毫秒])');
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, -6), end: 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
- return { data: { items, total: items.length }, html: renderDietHtml(o, dist) };
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: renderDietHtml(o, dist) };
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: renderExerciseHtml(v) };
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: renderCombinedHtml(c) };
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: renderDeficitHtml(d) };
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
- return { data: { metrics }, html: renderDietReviewHtml(r) };
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: renderHealthHtml(h) };
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: renderRankingHtml(one) };
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: renderAllRankingsHtml(all) };
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: renderProductLibraryHtml(lib) };
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: renderProductSearchHtml(s) };
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 hits = q ? lookupPhotoHelp(q) : buildPhotoHelp();
446
- if (q && hits.length === 0)
447
- throw new CalorieRenderError('missing-data', 'HELP 无命中:' + q);
448
- const items = hits.map((h) => ({ wakeWord: h.wakeWord, key: h.key, desc: h.desc, exec: h.exec }));
449
- return { data: { items, total: items.length }, html: renderPhotoHelpHtml(hits, q) };
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
- const html = '<section class="ilife-page" data-skill="calorie" data-slot="ilife:calorie:help"><h1>唤醒词 HELP 速查 · ' + q.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '(' + hits.length + ' 条)</h1>' +
464
- hits.map((h) => '<div class="ilife-item"><b>' + String(h.wake_word).replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</b> <span>' + String(h.key).replace(/&/g, '&amp;') + '</span><div>' + String(h.desc).replace(/&/g, '&amp;') + '</div><pre>' + String(h.cli).replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</pre></div>').join('') + '</section>';
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: renderWeightHtml(w) };
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: renderWeightHistoryHtml(h) };
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: renderWeightCompareHtml(v) };
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: renderWeightReviewHtml(v) };
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: renderVolatilityHtml(v) };
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: renderBodyCompositionHtml(v) };
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: renderBodyMeasureHtml(v) };
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, insertedCount: v.insertedCount });
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: renderExerciseGoalHtml(v) };
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
- const { start, end } = defaultRange(db, params);
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: renderGoalPredictHtml(v) };
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
- const { start, end } = defaultRange(db, params);
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: renderPredictHtml(v) };
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: renderAnomalyHtml(v) };
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: renderContraHtml(v) };
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: renderDedupeHtml(v) };
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, '用法:calorie-cmd-read <calorie.key> [--params JSON对象] [--html 输出路径] [--timeout 毫秒]');
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 db = openDb(join(dbPath, DB_FILENAME));
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
- env = buildEnvelope(o.key, shape, out.data);
688
- if (o.html) {
689
- try {
690
- writeFileSync(o.html, out.html, 'utf8');
691
- }
692
- catch (e) {
693
- fail(5, 'HTML 写盘失败:' + String(o.html) + '(' + e.message + ')');
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
- fail(e.code === 'bad-input' ? 2 : 5, (e.code === 'bad-input' ? '参数失败:' : '渲染失败:') + e.message);
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);