feat: přidána midnight snack

feat: přidány statistiky pohybu
This commit is contained in:
Ondřej Anděl committed 2026-10-02 19:33:08 +02:00
1 parent 8dac67bdd1
commit f1ce3ff297
13 files changed
+833 -163

No files matched your search

+7 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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),
+11 -1
View File
@@ -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
View File
@@ -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,
};
}
+14
View File
@@ -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;
};