feat: přidána midnight snack
feat: přidány statistiky pohybu
This commit is contained in:
1 parent
8dac67bdd1
commit
f1ce3ff297
13 files changed
+833
-163
No files matched your search
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
import { dropOrphanedPlans } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -203,7 +204,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
}
|
||||
|
||||
let original: ActivityEntry | undefined;
|
||||
await storage.updateData<ActivityEntry[]>(getDayKey(login, originalDate), current => {
|
||||
const remaining = await storage.updateData<ActivityEntry[]>(getDayKey(login, originalDate), current => {
|
||||
original = (current ?? []).find(entry => entry.id === id);
|
||||
return (current ?? []).filter(entry => entry.id !== id);
|
||||
});
|
||||
@@ -227,6 +228,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
getDayKey(login, normalized.date),
|
||||
current => [...(current ?? []), updated]
|
||||
);
|
||||
if (originalDate !== normalized.date) {
|
||||
await dropOrphanedPlans(login, originalDate, remaining);
|
||||
}
|
||||
return normalized.date;
|
||||
}
|
||||
|
||||
@@ -245,13 +249,14 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => {
|
||||
const remaining = await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => {
|
||||
found = (current ?? []).some(entry => entry.id === id);
|
||||
return (current ?? []).filter(entry => entry.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||
}
|
||||
await dropOrphanedPlans(login, date, remaining);
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ export const applyMealTemplate = async (
|
||||
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
||||
multiplier: normalizeMultiplier(multiplier),
|
||||
});
|
||||
}, template.id);
|
||||
return date;
|
||||
};
|
||||
|
||||
|
||||
+4
-1
@@ -21,6 +21,7 @@ const MEAL_TYPE_ORDER: MealType[] = [
|
||||
MealType.OBED,
|
||||
MealType.ODPOLEDNI_SVACINA,
|
||||
MealType.VECERE,
|
||||
MealType.PULNOCNI_SVACINA,
|
||||
MealType.JINE,
|
||||
];
|
||||
|
||||
@@ -180,13 +181,15 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param input data jídla
|
||||
* @param templateId šablona, ze které jídlo vzniká — klient ji poslat nemůže, nastavuje ji jen server
|
||||
*/
|
||||
export async function addMeal(login: string, input: unknown): Promise<DayRecord> {
|
||||
export async function addMeal(login: string, input: unknown, templateId?: string): Promise<DayRecord> {
|
||||
const normalized = normalizeInput(input);
|
||||
const entry: MealEntry = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...normalized,
|
||||
...(templateId ? { templateId } : {}),
|
||||
};
|
||||
const entries = await storage.updateData<MealEntry[]>(
|
||||
getDayKey(login, normalized.date),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getFoodStats } from "../statsService";
|
||||
import { getActivityStats, getFoodStats } from "../statsService";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -15,4 +15,14 @@ router.get("/summary", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */
|
||||
router.get("/activity", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const from = requireIsoDate(req.query.from, 'from');
|
||||
const to = requireIsoDate(req.query.to, 'to');
|
||||
res.status(200).json(await getActivityStats(login, from, to));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
+222
-2
@@ -1,6 +1,13 @@
|
||||
import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen";
|
||||
import {
|
||||
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
|
||||
ActivityUnit, FoodStats, GroupTotal, MealEntry, PeriodTotal, PlanDeviationKind, TemplateDayUsage,
|
||||
TemplateUsageTotal,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { applyMultiplier } from "./utils";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getWorkoutTemplates } from "./workouts";
|
||||
import { WorkoutPlan, WorkoutPlanItem, getWorkoutPlans } from "./workoutPlans";
|
||||
import { BadRequestError, applyMultiplier, listDatesInRange } from "./utils";
|
||||
|
||||
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
|
||||
const UNKNOWN_SOURCE = 'Neuvedeno';
|
||||
@@ -81,3 +88,216 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
||||
bySource: toGroupTotals(bySource),
|
||||
};
|
||||
}
|
||||
|
||||
/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */
|
||||
const DELETED_TEMPLATE = 'Smazaná šablona';
|
||||
|
||||
/** Přičte výdej dne do období pod daným klíčem. */
|
||||
function accumulateActivity(
|
||||
buckets: Map<string, ActivityPeriodTotal>, period: string, template: number, planned: number, extra: number,
|
||||
): void {
|
||||
const bucket = buckets.get(period) ?? { period, templateCalories: 0, plannedCalories: 0, extraCalories: 0 };
|
||||
bucket.templateCalories += template;
|
||||
bucket.plannedCalories += planned;
|
||||
bucket.extraCalories += extra;
|
||||
buckets.set(period, bucket);
|
||||
}
|
||||
|
||||
/** Seřadí součty období vzestupně dle označení období. */
|
||||
const sortPeriods = (buckets: Map<string, ActivityPeriodTotal>): ActivityPeriodTotal[] =>
|
||||
Array.from(buckets.values()).sort((a, b) => a.period.localeCompare(b.period));
|
||||
|
||||
/** Stručný popis aktivity nebo cviku plánu; jedna série a násobek 1 se vynechávají. */
|
||||
const summarize = (
|
||||
item: { name: string, unit: ActivityUnit, quantity: number, sets?: number, multiplier?: number },
|
||||
calories: number,
|
||||
): ActivitySummaryItem => ({
|
||||
name: item.name,
|
||||
unit: item.unit,
|
||||
quantity: item.quantity,
|
||||
...(item.sets != null && item.sets > 1 ? { sets: item.sets } : {}),
|
||||
...(item.multiplier != null && item.multiplier !== 1 ? { multiplier: item.multiplier } : {}),
|
||||
calories,
|
||||
});
|
||||
|
||||
/** Vrátí true, pokud se aktivita provedla jinak, než plán cviku sliboval. */
|
||||
const differsFromPlan = (entry: ActivityEntry, item: WorkoutPlanItem, calories: number): boolean =>
|
||||
entry.quantity !== item.quantity
|
||||
|| (entry.sets ?? 1) !== (item.sets ?? 1)
|
||||
|| (entry.multiplier ?? 1) !== 1
|
||||
|| calories !== item.calories;
|
||||
|
||||
/** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */
|
||||
type UsageCounts = { planned: number, removed: number };
|
||||
|
||||
/**
|
||||
* Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu
|
||||
* a aktivity mimo šablony vrátí zvlášť.
|
||||
*
|
||||
* Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se
|
||||
* berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde.
|
||||
*/
|
||||
function analyzeDay(
|
||||
entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map<string, string>, date: string,
|
||||
): { day: ActivityDayStats, counts: Map<string, UsageCounts> } {
|
||||
const byId = new Map(entries.map(entry => [entry.id, entry]));
|
||||
const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId)));
|
||||
const usages = new Map<string, TemplateDayUsage>();
|
||||
const counts = new Map<string, UsageCounts>();
|
||||
|
||||
const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => {
|
||||
const usage = usages.get(templateId) ?? {
|
||||
templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [],
|
||||
};
|
||||
usages.set(templateId, usage);
|
||||
if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 });
|
||||
return usage;
|
||||
};
|
||||
|
||||
for (const plan of plans) {
|
||||
// Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se
|
||||
const usage = usageFor(plan.templateId, plan.templateName, true);
|
||||
const count = counts.get(plan.templateId)!;
|
||||
for (const item of plan.items) {
|
||||
usage.plannedCalories += item.calories;
|
||||
count.planned++;
|
||||
const entry = item.entryId ? byId.get(item.entryId) : undefined;
|
||||
if (!entry) {
|
||||
usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
|
||||
count.removed++;
|
||||
continue;
|
||||
}
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
usage.actualCalories += calories;
|
||||
if (differsFromPlan(entry, item, calories)) {
|
||||
usage.deviations.push({
|
||||
kind: PlanDeviationKind.UPRAVENO,
|
||||
planned: summarize(item, item.calories),
|
||||
actual: summarize(entry, calories),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extras: ActivitySummaryItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (planned.has(entry.id)) continue;
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
if (!entry.templateId) {
|
||||
extras.push(summarize(entry, calories));
|
||||
continue;
|
||||
}
|
||||
const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false);
|
||||
usage.plannedCalories += calories;
|
||||
usage.actualCalories += calories;
|
||||
counts.get(entry.templateId)!.planned++;
|
||||
}
|
||||
|
||||
const templates = Array.from(usages.values());
|
||||
const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0);
|
||||
return {
|
||||
day: {
|
||||
date,
|
||||
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories,
|
||||
templates,
|
||||
extraCalories,
|
||||
extras,
|
||||
},
|
||||
counts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sestaví statistiky výdeje pohybem za zvolené období.
|
||||
*
|
||||
* Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při
|
||||
* použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla
|
||||
* mezitím změnit.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param from první den období (včetně), YYYY-MM-DD
|
||||
* @param to poslední den období (včetně), YYYY-MM-DD
|
||||
*/
|
||||
export async function getActivityStats(login: string, from: string, to: string): Promise<ActivityStats> {
|
||||
if (from > to) {
|
||||
throw new BadRequestError("Začátek rozsahu je až za jeho koncem");
|
||||
}
|
||||
const dates = listDatesInRange(from, to);
|
||||
const [activityDays, plansByDay, templates] = await Promise.all([
|
||||
Promise.all(dates.map(date => getActivityDay(login, date))),
|
||||
Promise.all(dates.map(date => getWorkoutPlans(login, date))),
|
||||
getWorkoutTemplates(login),
|
||||
]);
|
||||
const templateNames = new Map(templates.map(template => [template.id, template.name]));
|
||||
|
||||
const byDay = new Map<string, ActivityPeriodTotal>();
|
||||
const byMonth = new Map<string, ActivityPeriodTotal>();
|
||||
const byYear = new Map<string, ActivityPeriodTotal>();
|
||||
const byTemplate = new Map<string, TemplateUsageTotal>();
|
||||
const extras = new Map<string, ActivityGroupTotal>();
|
||||
const days: ActivityDayStats[] = [];
|
||||
|
||||
let templateCalories = 0;
|
||||
let plannedCalories = 0;
|
||||
let extraCalories = 0;
|
||||
let entryCount = 0;
|
||||
|
||||
activityDays.forEach((activityDay, index) => {
|
||||
if (!activityDay.entries.length) return;
|
||||
entryCount += activityDay.entries.length;
|
||||
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date);
|
||||
days.push(day);
|
||||
|
||||
let dayTemplate = 0;
|
||||
let dayPlanned = 0;
|
||||
for (const usage of day.templates) {
|
||||
const count = counts.get(usage.templateId)!;
|
||||
const total = byTemplate.get(usage.templateId) ?? {
|
||||
templateId: usage.templateId, name: usage.name, dayCount: 0,
|
||||
plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
|
||||
};
|
||||
// Dny jdou vzestupně, takže zůstane název z posledního použití
|
||||
total.name = usage.name;
|
||||
total.dayCount++;
|
||||
total.plannedCalories += usage.plannedCalories;
|
||||
total.actualCalories += usage.actualCalories;
|
||||
total.plannedItemCount += count.planned;
|
||||
total.doneItemCount += count.planned - count.removed;
|
||||
byTemplate.set(usage.templateId, total);
|
||||
dayTemplate += usage.actualCalories;
|
||||
dayPlanned += usage.plannedCalories;
|
||||
}
|
||||
for (const item of day.extras) {
|
||||
const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 };
|
||||
group.calories += item.calories;
|
||||
group.count++;
|
||||
extras.set(item.name, group);
|
||||
}
|
||||
|
||||
accumulateActivity(byDay, day.date, dayTemplate, dayPlanned, day.extraCalories);
|
||||
accumulateActivity(byMonth, day.date.substring(0, 7), dayTemplate, dayPlanned, day.extraCalories);
|
||||
accumulateActivity(byYear, day.date.substring(0, 4), dayTemplate, dayPlanned, day.extraCalories);
|
||||
templateCalories += dayTemplate;
|
||||
plannedCalories += dayPlanned;
|
||||
extraCalories += day.extraCalories;
|
||||
});
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
totalCalories: templateCalories + extraCalories,
|
||||
templateCalories,
|
||||
plannedCalories,
|
||||
extraCalories,
|
||||
entryCount,
|
||||
dayCount: days.length,
|
||||
byDay: sortPeriods(byDay),
|
||||
byMonth: sortPeriods(byMonth),
|
||||
byYear: sortPeriods(byYear),
|
||||
byTemplate: Array.from(byTemplate.values())
|
||||
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')),
|
||||
extras: Array.from(extras.values())
|
||||
.sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')),
|
||||
days,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import getStorage from "./storage";
|
||||
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
import { recordWorkoutPlan } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -280,6 +281,19 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
||||
}));
|
||||
|
||||
await addActivities(login, date, entries);
|
||||
await recordWorkoutPlan(login, date, {
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
appliedAt: now,
|
||||
items: entries.map(entry => ({
|
||||
entryId: entry.id,
|
||||
name: entry.name,
|
||||
unit: entry.unit,
|
||||
quantity: entry.quantity,
|
||||
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||
calories: entry.calories ?? 0,
|
||||
})),
|
||||
});
|
||||
return date;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user