skill-calorie 0.2.0 → 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 +54 -26
- 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 +460 -54
- 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 +12 -9
- package/dist/cli/write.d.ts.map +1 -1
- package/dist/cli/write.js +133 -48
- package/dist/cli/write.js.map +1 -1
- package/dist/output.d.ts +55 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +203 -5
- package/dist/output.js.map +1 -1
- 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.map +1 -1
- package/dist/render/html.js +2 -1
- package/dist/render/html.js.map +1 -1
- package/dist/render/index.d.ts +3 -0
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +2 -0
- 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.map +1 -1
- package/dist/render/photo.js +5 -1
- 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/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 +21 -21
- package/dist/triggers/routing.d.ts.map +1 -1
- package/dist/triggers/routing.js +60 -30
- package/dist/triggers/routing.js.map +1 -1
- package/package.json +1 -1
- package/templates/diet.html +2 -2
- package/templates/exercise.html +2 -2
- package/templates/goal.html +2 -2
- package/templates/help.html +2 -2
- package/templates/home.html +2 -2
- package/templates/photo-gallery.html +2 -2
package/dist/cli/cmd_read.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* <SKILLS_DB_PATH>/calorie_html/<中文command>_<YYYYMMDD>_<HHMMSS>[_N].html(同秒冲突自动加后缀),
|
|
13
13
|
* 中文 command 取 CALORIE_COMBOS[key].title;显式 --output(--html 为 legacy 别名)覆盖任意路径。
|
|
14
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 产物。
|
|
15
18
|
*/
|
|
16
|
-
import { existsSync
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
17
20
|
import { join } from 'node:path';
|
|
18
21
|
import { openDb } from '../schema.js';
|
|
19
22
|
import { DB_FILENAME } from '../paths.js';
|
|
@@ -32,18 +35,34 @@ import { buildGoalExpiringView, buildGoalPredictView, buildGoalVsActualView } fr
|
|
|
32
35
|
import { buildPredictView, buildAnomalyView, buildContraView, buildDedupeView } from '../render/insightPlate.js';
|
|
33
36
|
import { buildProfileView } from '../render/profilePlate.js';
|
|
34
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';
|
|
35
50
|
import { buildHealthPlate } from '../render/health.js';
|
|
36
51
|
import { buildAllRankings, buildFoodRankingPlate } from '../render/ranking.js';
|
|
37
52
|
import { buildProductLibrary, buildProductSearch, buildProductStats } from '../render/library.js';
|
|
38
53
|
import { buildCompareData, buildGalleryData, buildGifTask, buildViewerData } from '../render/photo.js';
|
|
39
54
|
import { buildPhotoHelp, lookupPhotoHelp } from '../render/help.js';
|
|
40
|
-
|
|
41
|
-
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';
|
|
42
61
|
import { CalorieRenderError } from '../render/errors.js';
|
|
43
62
|
import { TRIGGERS, searchHelp } from '../triggers/index.js';
|
|
44
63
|
import { shiftISODate, todayISO } from '../analysis/utils.js';
|
|
45
64
|
import { CALORIE_COMBOS, ENVELOPE_VERSION, CALORIE_SKILL, calorieShapeFor, isCalorieWriteKey } from './keys.js';
|
|
46
|
-
import { HTML_DIR_NAME,
|
|
65
|
+
import { HTML_DIR_NAME, deliverHtml, resolveReceiptHtmlPath, } from '../output.js';
|
|
47
66
|
import { openDbReadOnly } from '../db/readonly.js';
|
|
48
67
|
import { dispatchWrite } from './write.js';
|
|
49
68
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
@@ -122,7 +141,7 @@ function latestFoodDate(db) {
|
|
|
122
141
|
return null;
|
|
123
142
|
}
|
|
124
143
|
}
|
|
125
|
-
function defaultRange(db, params) {
|
|
144
|
+
function defaultRange(db, params, defDays = 7) {
|
|
126
145
|
let end = optStr(params, 'end') ?? optStr(params, 'date') ?? optStr(params, 'today') ?? undefined;
|
|
127
146
|
let start = optStr(params, 'start') ?? undefined;
|
|
128
147
|
if (end)
|
|
@@ -142,7 +161,7 @@ function defaultRange(db, params) {
|
|
|
142
161
|
fail(2, 'start 不得晚于 end');
|
|
143
162
|
return { start, end: end };
|
|
144
163
|
}
|
|
145
|
-
return { start: shiftISODate(end, -
|
|
164
|
+
return { start: shiftISODate(end, -(defDays - 1)), end: end };
|
|
146
165
|
}
|
|
147
166
|
/** 本地 envelope 形状校验(镜像 link-core assertShapeData,不运行时 import)。 */
|
|
148
167
|
function assertEnvelopeData(shape, data) {
|
|
@@ -203,6 +222,28 @@ function photosDirOf(params) {
|
|
|
203
222
|
const e = process.env.CALORIE_PHOTOS_DIR;
|
|
204
223
|
return e ? e : undefined;
|
|
205
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
|
+
}
|
|
206
247
|
// 全键分发:读走 render/fetch 读,HELP 走触发词现找;未知键上游已拦,此处再拦一道。
|
|
207
248
|
/** #41 · 测试直调出口(纯 CLI 同逻辑,不经过 argv/spawn;CLI 唯一出口仍为 main)。 */
|
|
208
249
|
export function dispatch(key, params, db) {
|
|
@@ -216,7 +257,9 @@ export function dispatch(key, params, db) {
|
|
|
216
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 }));
|
|
217
258
|
const o = buildDietOverview(db, date, date);
|
|
218
259
|
const dist = buildMealDistribution(db, date);
|
|
219
|
-
|
|
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 }) };
|
|
220
263
|
}
|
|
221
264
|
case 'calorie.view.home': {
|
|
222
265
|
const date = optStr(params, 'date') ?? optStr(params, 'today') ?? latestFoodDate(db) ?? todayISO();
|
|
@@ -250,6 +293,22 @@ export function dispatch(key, params, db) {
|
|
|
250
293
|
else
|
|
251
294
|
throw e;
|
|
252
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);
|
|
253
312
|
const metrics = nums({
|
|
254
313
|
totalCalories: o.totalCalories, avgCalories: o.avgCalories, calorieGoal: o.calorieGoal,
|
|
255
314
|
loggedDays: o.loggedDays, days: o.days, distTotal: dist.totalCalories,
|
|
@@ -258,7 +317,10 @@ export function dispatch(key, params, db) {
|
|
|
258
317
|
'meal.晚餐': dist.slices.find((s) => s.meal === '晚餐')?.calories,
|
|
259
318
|
'meal.加餐': dist.slices.find((s) => s.meal === '加餐')?.calories,
|
|
260
319
|
});
|
|
261
|
-
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
|
+
}) };
|
|
262
324
|
}
|
|
263
325
|
case 'calorie.view.exercise': {
|
|
264
326
|
const { start, end } = defaultRange(db, params);
|
|
@@ -268,7 +330,222 @@ export function dispatch(key, params, db) {
|
|
|
268
330
|
activeDays: v.review.activeDays, totalBurnedSeries: v.totalBurnedSeries,
|
|
269
331
|
avgBurnedPerLoggedDay: v.avgBurnedPerLoggedDay, seriesActiveDays: v.activeDays,
|
|
270
332
|
});
|
|
271
|
-
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) };
|
|
272
549
|
}
|
|
273
550
|
case 'calorie.view.goal': {
|
|
274
551
|
const { start, end } = defaultRange(db, params);
|
|
@@ -343,7 +620,7 @@ export function dispatch(key, params, db) {
|
|
|
343
620
|
correlationR: c.analysis.correlation.r, correlationN: c.analysis.correlation.n, days: c.analysis.days,
|
|
344
621
|
seriesDays: c.series.length,
|
|
345
622
|
});
|
|
346
|
-
return { data: { metrics }, html:
|
|
623
|
+
return { data: { metrics }, html: buildCombinedDoc(c) };
|
|
347
624
|
}
|
|
348
625
|
case 'calorie.view.deficit': {
|
|
349
626
|
const { start, end } = defaultRange(db, params);
|
|
@@ -354,7 +631,7 @@ export function dispatch(key, params, db) {
|
|
|
354
631
|
days: d.meta.days, weekdayCount: d.meta.weekdayCount, weekendCount: d.meta.weekendCount,
|
|
355
632
|
targetIntake: d.target.intake, targetTdee: d.target.tdee,
|
|
356
633
|
});
|
|
357
|
-
return { data: { metrics }, html:
|
|
634
|
+
return { data: { metrics }, html: buildDeficitDoc(d) };
|
|
358
635
|
}
|
|
359
636
|
case 'calorie.view.diet-review': {
|
|
360
637
|
const { start, end } = defaultRange(db, params);
|
|
@@ -366,13 +643,15 @@ export function dispatch(key, params, db) {
|
|
|
366
643
|
'meal.晚餐': r.byMeal.find((s) => s.meal === '晚餐')?.totalCalories,
|
|
367
644
|
'meal.加餐': r.byMeal.find((s) => s.meal === '加餐')?.totalCalories,
|
|
368
645
|
});
|
|
369
|
-
|
|
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) };
|
|
370
649
|
}
|
|
371
650
|
case 'calorie.view.health': {
|
|
372
651
|
const { start, end } = defaultRange(db, params);
|
|
373
652
|
const h = buildHealthPlate(db, start, end);
|
|
374
653
|
const metrics = nums({ loggedDays: h.loggedDays, avgIntake: h.avgIntake, avgDeficit: h.avgDeficit });
|
|
375
|
-
return { data: { metrics }, html:
|
|
654
|
+
return { data: { metrics }, html: buildHealthDoc(h) };
|
|
376
655
|
}
|
|
377
656
|
case 'calorie.view.ranking': {
|
|
378
657
|
const { start, end } = defaultRange(db, params);
|
|
@@ -384,11 +663,11 @@ export function dispatch(key, params, db) {
|
|
|
384
663
|
const one = buildFoodRankingPlate(db, start, end, category, topN);
|
|
385
664
|
const top = one.items[0];
|
|
386
665
|
const metrics = nums({ total: one.items.length, topN: one.topN, topCal: top?.totalCal, topCnt: top?.cnt, topRank: top?.rank });
|
|
387
|
-
return { data: { metrics }, html:
|
|
666
|
+
return { data: { metrics }, html: buildRankingDoc(one) };
|
|
388
667
|
}
|
|
389
668
|
const all = buildAllRankings(db, start, end, topN);
|
|
390
669
|
const metrics = nums({ okCount: all.okCount, topN: all.topN });
|
|
391
|
-
return { data: { metrics }, html:
|
|
670
|
+
return { data: { metrics }, html: buildAllRankingsDoc(all) };
|
|
392
671
|
}
|
|
393
672
|
case 'calorie.view.library': {
|
|
394
673
|
const category = optStr(params, 'category') ?? null;
|
|
@@ -398,7 +677,7 @@ export function dispatch(key, params, db) {
|
|
|
398
677
|
const lib = buildProductLibrary(db, category, limit);
|
|
399
678
|
const stats = buildProductStats(db);
|
|
400
679
|
const metrics = nums({ total: lib.total, statsTotal: stats.total });
|
|
401
|
-
return { data: { metrics }, html:
|
|
680
|
+
return { data: { metrics }, html: buildLibraryDoc(lib, stats.total) };
|
|
402
681
|
}
|
|
403
682
|
case 'calorie.view.search': {
|
|
404
683
|
const keyword = needStr(params, 'keyword');
|
|
@@ -407,7 +686,7 @@ export function dispatch(key, params, db) {
|
|
|
407
686
|
fail(2, 'limit 须为 1..100 整数');
|
|
408
687
|
const s = buildProductSearch(db, keyword, limit);
|
|
409
688
|
const metrics = nums({ total: s.total, limit: limit });
|
|
410
|
-
return { data: { metrics }, html:
|
|
689
|
+
return { data: { metrics }, html: buildSearchDoc(s) };
|
|
411
690
|
}
|
|
412
691
|
case 'calorie.photo.list': {
|
|
413
692
|
const dir = photosDirOf(params);
|
|
@@ -450,12 +729,39 @@ export function dispatch(key, params, db) {
|
|
|
450
729
|
return { data: { summary: 'GIF 任务:标签 ' + gif.tag + ' 共 ' + gif.photoCount + ' 张(' + (gif.firstDate ?? '—') + ' ~ ' + (gif.lastDate ?? '—') + ')· ' + gif.note }, html: renderGifHtml(gif) };
|
|
451
730
|
}
|
|
452
731
|
case 'calorie.help.center': {
|
|
732
|
+
// #91 · Q9:本键承载**全量速查台**(#88 壳,三态同源);**照片 10 键兼容走 `q`**(既有语义逐字不变)。
|
|
733
|
+
// D6:交付形态**显式** `mode`(`file`/`inline`/`text`),不靠猜、不从别的参数推;非法值即 exit 2。
|
|
453
734
|
const q = optStr(params, 'q') ?? optStr(params, 'keyword') ?? undefined;
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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' };
|
|
459
765
|
}
|
|
460
766
|
case 'calorie.help.lookup': {
|
|
461
767
|
const q = needStr(params, 'q');
|
|
@@ -495,7 +801,7 @@ export function dispatch(key, params, db) {
|
|
|
495
801
|
changeKg: w.trend.changeKg, dailyChangeG: w.trend.dailyChangeG,
|
|
496
802
|
weightGoal: w.weightGoal, gapKg: w.gapKg,
|
|
497
803
|
});
|
|
498
|
-
return { data: { metrics }, html:
|
|
804
|
+
return { data: { metrics }, html: buildWeightDoc(w) };
|
|
499
805
|
}
|
|
500
806
|
case 'calorie.view.weight-history': {
|
|
501
807
|
const startDate = optStr(params, 'startDate') ?? optStr(params, 'start');
|
|
@@ -515,7 +821,7 @@ export function dispatch(key, params, db) {
|
|
|
515
821
|
spanDays: h.change?.spanDays, first: h.change?.first, last: h.change?.last,
|
|
516
822
|
delta: h.change?.delta, dailyAvg: h.change?.dailyAvg,
|
|
517
823
|
});
|
|
518
|
-
return { data: { metrics }, html:
|
|
824
|
+
return { data: { metrics }, html: buildWeightHistoryDoc(h) };
|
|
519
825
|
}
|
|
520
826
|
case 'calorie.view.weight-compare': {
|
|
521
827
|
const start = needStr(params, 'start');
|
|
@@ -528,7 +834,7 @@ export function dispatch(key, params, db) {
|
|
|
528
834
|
currentAvg: v.compare.currentPeriod.avgWeight, compareAvg: v.compare.comparePeriod.avgWeight,
|
|
529
835
|
currentChange: v.compare.currentPeriod.changeKg, compareChange: v.compare.comparePeriod.changeKg,
|
|
530
836
|
});
|
|
531
|
-
return { data: { metrics }, html:
|
|
837
|
+
return { data: { metrics }, html: buildWeightCompareDoc(v) };
|
|
532
838
|
}
|
|
533
839
|
case 'calorie.view.weight-review': {
|
|
534
840
|
const today = optStr(params, 'today') ?? optStr(params, 'date');
|
|
@@ -538,7 +844,7 @@ export function dispatch(key, params, db) {
|
|
|
538
844
|
gapKg: v.milestone.gapKg, actualDailyChangeKg: v.milestone.actualDailyChangeKg,
|
|
539
845
|
estDays: v.milestone.estDays, calorieAdjustment: v.milestone.calorieAdjustment,
|
|
540
846
|
});
|
|
541
|
-
return { data: { metrics }, html:
|
|
847
|
+
return { data: { metrics }, html: buildWeightReviewDoc(v) };
|
|
542
848
|
}
|
|
543
849
|
case 'calorie.view.volatility': {
|
|
544
850
|
const { start, end } = defaultRange(db, params);
|
|
@@ -550,7 +856,7 @@ export function dispatch(key, params, db) {
|
|
|
550
856
|
points: v.volatility.points.length, anomalies: v.volatility.recentAnomalies.length,
|
|
551
857
|
deviationKg: v.volatility.earlyWarning.deviationKg,
|
|
552
858
|
});
|
|
553
|
-
return { data: { metrics }, html:
|
|
859
|
+
return { data: { metrics }, html: buildVolatilityDoc(v) };
|
|
554
860
|
}
|
|
555
861
|
case 'calorie.view.body-composition': {
|
|
556
862
|
const days = optNum(params, 'days') ?? 90;
|
|
@@ -558,7 +864,7 @@ export function dispatch(key, params, db) {
|
|
|
558
864
|
const limit = optNum(params, 'limit') ?? 20;
|
|
559
865
|
const v = buildBodyCompositionView(db, { days: days, source: source ?? undefined, limit: limit });
|
|
560
866
|
const metrics = nums({ total: v.total, latestPct: v.latestPct, trendDays: v.trend.length });
|
|
561
|
-
return { data: { metrics }, html:
|
|
867
|
+
return { data: { metrics }, html: buildBodyCompositionDoc(v) };
|
|
562
868
|
}
|
|
563
869
|
case 'calorie.view.body-measure': {
|
|
564
870
|
const metric = optStr(params, 'metric');
|
|
@@ -568,7 +874,7 @@ export function dispatch(key, params, db) {
|
|
|
568
874
|
const dateTo = optStr(params, 'dateTo');
|
|
569
875
|
const v = buildBodyMeasureView(db, { metric: metric ?? undefined, days: days, limit: limit, dateFrom: dateFrom ?? undefined, dateTo: dateTo ?? undefined });
|
|
570
876
|
const metrics = nums({ total: v.total, latestVal: v.latestVal, trendDays: v.trend.length });
|
|
571
|
-
return { data: { metrics }, html:
|
|
877
|
+
return { data: { metrics }, html: buildBodyMeasureDoc(v) };
|
|
572
878
|
}
|
|
573
879
|
case 'calorie.view.plan': {
|
|
574
880
|
const v = buildPlanView(db);
|
|
@@ -581,14 +887,14 @@ export function dispatch(key, params, db) {
|
|
|
581
887
|
fail(2, '缺参数 plan(PlanInput 对象)');
|
|
582
888
|
const catalog = params['catalog'];
|
|
583
889
|
const v = buildPlanWizardView(plan, catalog ?? undefined);
|
|
584
|
-
const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount,
|
|
890
|
+
const metrics = nums({ errorCount: v.errorCount, warningCount: v.warningCount, checkedSessions: v.checkedSessions });
|
|
585
891
|
return { data: { metrics }, html: renderPlanWizardHtml(v) };
|
|
586
892
|
}
|
|
587
893
|
case 'calorie.view.exercise-goal': {
|
|
588
894
|
const { start, end } = defaultRange(db, params);
|
|
589
895
|
const v = buildExerciseGoalView(db, start, end);
|
|
590
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 });
|
|
591
|
-
return { data: { metrics }, html:
|
|
897
|
+
return { data: { metrics }, html: buildExerciseGoalDoc(v) };
|
|
592
898
|
}
|
|
593
899
|
case 'calorie.view.goal-expiring': {
|
|
594
900
|
const withinDays = optNum(params, 'withinDays') ?? optNum(params, 'days') ?? 14;
|
|
@@ -598,10 +904,11 @@ export function dispatch(key, params, db) {
|
|
|
598
904
|
return { data: { metrics }, html: renderGoalExpiringHtml(v) };
|
|
599
905
|
}
|
|
600
906
|
case 'calorie.view.goal-predict': {
|
|
601
|
-
|
|
907
|
+
// #103 G2 · 目标预测需 ≥14 条体重记录(simulate SIM_MIN_DAYS=14),7 天默认窗结构性不可达 → 默认 14 天。
|
|
908
|
+
const { start, end } = defaultRange(db, params, 14);
|
|
602
909
|
const v = buildGoalPredictView(db, start, end);
|
|
603
910
|
const metrics = nums({ targetKg: v.targetKg, current: v.current, daysLeft: v.daysLeft, ratePerWeek: v.ratePerWeek, feasible: v.feasible ? 1 : 0 });
|
|
604
|
-
return { data: { metrics }, html:
|
|
911
|
+
return { data: { metrics }, html: buildGoalPredictDoc(v) };
|
|
605
912
|
}
|
|
606
913
|
case 'calorie.view.goal-vs-actual': {
|
|
607
914
|
const { start, end } = defaultRange(db, params);
|
|
@@ -614,29 +921,30 @@ export function dispatch(key, params, db) {
|
|
|
614
921
|
return { data: { metrics }, html: renderGoalVsActualHtml(v) };
|
|
615
922
|
}
|
|
616
923
|
case 'calorie.view.predict': {
|
|
617
|
-
|
|
924
|
+
// #103 G2 · 同 goal-predict:默认 14 天,否则缺省调用恒走 missing-data。
|
|
925
|
+
const { start, end } = defaultRange(db, params, 14);
|
|
618
926
|
const horizonDays = optNum(params, 'horizonDays') ?? optNum(params, 'days') ?? 30;
|
|
619
927
|
const v = buildPredictView(db, start, end, horizonDays);
|
|
620
928
|
const metrics = nums({ current: v.current, ratePerWeek: v.ratePerWeek, forecastValue: v.forecastValue, forecastLo: v.forecastLo, forecastHi: v.forecastHi, horizonDays: v.horizonDays });
|
|
621
|
-
return { data: { metrics }, html:
|
|
929
|
+
return { data: { metrics }, html: buildPredictDoc(v) };
|
|
622
930
|
}
|
|
623
931
|
case 'calorie.view.anomaly': {
|
|
624
932
|
const kind = needStr(params, 'kind');
|
|
625
933
|
const { start, end } = defaultRange(db, params);
|
|
626
934
|
const v = buildAnomalyView(db, kind, start, end);
|
|
627
935
|
const metrics = nums({ findingCount: v.findingCount, days: v.diagnosis.days, degraded: v.diagnosis.degraded ? 1 : 0 });
|
|
628
|
-
return { data: { metrics }, html:
|
|
936
|
+
return { data: { metrics }, html: buildAnomalyDoc(v) };
|
|
629
937
|
}
|
|
630
938
|
case 'calorie.view.contraindication': {
|
|
631
939
|
const part = optStr(params, 'part') ?? 'all';
|
|
632
940
|
const v = buildContraView(db, part);
|
|
633
941
|
const metrics = nums({ scannedSessions: v.scannedSessions, scannedMovements: v.scannedMovements, errorCount: v.errorCount, warnCount: v.warnCount, infoCount: v.infoCount });
|
|
634
|
-
return { data: { metrics }, html:
|
|
942
|
+
return { data: { metrics }, html: buildContraDoc(v) };
|
|
635
943
|
}
|
|
636
944
|
case 'calorie.view.dedupe': {
|
|
637
945
|
const v = buildDedupeView(db);
|
|
638
946
|
const metrics = nums({ groupCount: v.groupCount, rowCount: v.rowCount, totalProducts: v.totalProducts });
|
|
639
|
-
return { data: { metrics }, html:
|
|
947
|
+
return { data: { metrics }, html: buildDedupeDoc(v) };
|
|
640
948
|
}
|
|
641
949
|
case 'calorie.view.profile': {
|
|
642
950
|
const v = buildProfileView(db);
|
|
@@ -652,6 +960,106 @@ export function dispatch(key, params, db) {
|
|
|
652
960
|
throw new Error('unreachable');
|
|
653
961
|
}
|
|
654
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
|
+
}
|
|
655
1063
|
function parseReadArgs(a) {
|
|
656
1064
|
return parseArgs(a);
|
|
657
1065
|
}
|
|
@@ -695,25 +1103,21 @@ async function main() {
|
|
|
695
1103
|
const out = isCalorieWriteKey(o.key)
|
|
696
1104
|
? dispatchWrite(o.key, params, db)
|
|
697
1105
|
: dispatch(o.key, params, db);
|
|
698
|
-
// #
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
// exit 5
|
|
702
|
-
let htmlTarget;
|
|
1106
|
+
// #83 · 三态交付(M4 HTML-First):① 文件态(默认)/② 内联态(只读·沙箱回退)/③ 文本态。
|
|
1107
|
+
// 落点:--output(显式覆盖)> --html(legacy 别名)> 默认 calorie_html/<中文command>_<TS>[_N].html(#87/#119)。
|
|
1108
|
+
// 只读类写失败 → 内联交付(产物随 envelope 回传,绝不因写不进去而文字答);
|
|
1109
|
+
// 结构错/渲染错 → 渲染失败回执(模板化回执,exit 5;严禁手写 HTML 兜底)。
|
|
703
1110
|
try {
|
|
704
|
-
|
|
1111
|
+
env = buildDeliveredEnvelope({
|
|
1112
|
+
key: o.key, shape: shape, out, params, explicit: o.output ?? o.html,
|
|
1113
|
+
});
|
|
705
1114
|
}
|
|
706
1115
|
catch (e) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
writeFileSync(resolveExplicitHtmlPath(htmlTarget), out.html, 'utf8');
|
|
712
|
-
}
|
|
713
|
-
catch (e) {
|
|
714
|
-
fail(5, 'HTML 写盘失败:' + htmlTarget + '(' + e.message + ')');
|
|
1116
|
+
if (e instanceof CalorieRenderError)
|
|
1117
|
+
throw e;
|
|
1118
|
+
failWithReceipt('渲染失败:' + describeDeliveryTarget(o.output ?? o.html) + ':'
|
|
1119
|
+
+ (e.message || String(e)), o.key);
|
|
715
1120
|
}
|
|
716
|
-
env = buildEnvelope(o.key, shape, { ...out.data, output: htmlTarget });
|
|
717
1121
|
}
|
|
718
1122
|
finally {
|
|
719
1123
|
db.close();
|
|
@@ -723,7 +1127,9 @@ async function main() {
|
|
|
723
1127
|
if (e instanceof CalorieRenderError) {
|
|
724
1128
|
if (e.code === 'missing-data')
|
|
725
1129
|
fail(4, '取数失败(缺失阻断):' + e.message);
|
|
726
|
-
|
|
1130
|
+
if (e.code === 'bad-input')
|
|
1131
|
+
fail(2, '参数失败:' + e.message);
|
|
1132
|
+
failWithReceipt('渲染失败:' + e.message, o.key);
|
|
727
1133
|
}
|
|
728
1134
|
if (e instanceof FetchError)
|
|
729
1135
|
fail(4, '取数失败:' + e.message);
|