Files
food-tracer/server/src/activities.ts
T
Ondřej Anděl f1ce3ff297 feat: přidána midnight snack
feat: přidány statistiky pohybu
2026-10-02 19:33:08 +02:00

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
/** Maximální délka textových polí. */
const MAX_TEXT_LENGTH = 500;
/** Podporované jednotky aktivit. */
const UNITS: ActivityUnit[] = [
ActivityUnit.KROKY,
ActivityUnit.MINUTY,
ActivityUnit.SEKUNDY,
ActivityUnit.KM,
ActivityUnit.OPAKOVANI,
];
/** Vrátí true, pokud je hodnota podporovanou jednotkou aktivity. */
export function isActivityUnit(value: unknown): value is ActivityUnit {
return typeof value === 'string' && (UNITS as string[]).includes(value);
}
/** Klíč úložiště s aktivitami jednoho dne jednoho uživatele. */
function getDayKey(login: string, date: string): string {
return `activities:${login}:${date}`;
}
/** Sestaví přehled pohybu dne včetně součtu spálených kcal. */
export function buildActivityDay(date: string, entries: ActivityEntry[]): ActivityDay {
const sorted = [...entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return {
date,
entries: sorted,
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
};
}
/**
* Vrátí pohyb uživatele za jeden den.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export async function getActivityDay(login: string, date: string): Promise<ActivityDay> {
const entries = await storage.getData<ActivityEntry[]>(getDayKey(login, date));
return buildActivityDay(date, entries ?? []);
}
/** Ořízne a znormalizuje volitelný textový vstup. */
function normalizeText(value: unknown, name: string): string | undefined {
if (value == null) return undefined;
if (typeof value !== 'string') {
throw new BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length) return undefined;
if (trimmed.length > MAX_TEXT_LENGTH) {
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`);
}
return trimmed;
}
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
function normalizeNumber(value: unknown, name: string): number | undefined {
if (value == null) return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
}
/** Znormalizovaný vstup aktivity. */
export type NormalizedActivityInput = {
date: string;
name: string;
unit: ActivityUnit;
/** Množství na jednu sérii. */
quantity: number;
/** Počet sérií; nevyplněno znamená jednu. */
sets?: number;
caloriesPer100Units?: number;
calories?: number;
note?: string;
/** Dodatečná zátěž v gramech — jen informace, kalorie nemění. */
extraWeightGrams?: number;
multiplier?: number;
};
/**
* Vrátí celkové množství aktivity, tedy počet sérií krát množství v jedné.
*
* Série se drží odděleně, protože 3 × 40 opakování s pauzami je jiný trénink
* než 120 opakování v kuse — spálené kalorie jsou ale stejné, takže do výpočtu
* jde součin.
*/
export const getTotalQuantity = (quantity: number, sets?: number): number =>
quantity * Math.max(sets ?? 1, 1);
/**
* Dopočte spálené kalorie z množství a sazby na 100 jednotek.
*
* Sto jednotek místo jedné proto, že u kroků by sazba na jeden krok byla
* zlomek (~0,04 kcal) a celá aplikace pracuje s celými čísly.
*/
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
const calories = input.caloriesPer100Units
? Math.round(getTotalQuantity(input.quantity, input.sets) / 100 * input.caloriesPer100Units)
: input.calories;
return { ...input, calories };
}
/** Ověří a znormalizuje vstupní data aktivity. */
export function normalizeActivityInput(input: unknown): NormalizedActivityInput {
const value = (input ?? {}) as Record<string, unknown>;
if (!isValidIsoDate(value.date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (!isActivityUnit(value.unit)) {
throw new BadRequestError("Nebyla předána platná jednotka aktivity");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new BadRequestError("Nebyl předán název aktivity");
}
const quantity = normalizeNumber(value.quantity, 'quantity');
if (quantity == null) {
throw new BadRequestError("Nebylo předáno množství aktivity");
}
const sets = normalizeNumber(value.sets, 'sets');
return deriveActivityCalories({
date: value.date,
name,
unit: value.unit,
quantity,
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
...(sets && sets > 1 ? { sets } : {}),
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
calories: normalizeNumber(value.calories, 'calories'),
note: normalizeText(value.note, 'note'),
extraWeightGrams: normalizeNumber(value.extraWeightGrams, 'extraWeightGrams') || undefined,
multiplier: normalizeMultiplier(value.multiplier),
});
}
/**
* Přidá aktivitu do zvoleného dne.
*
* @param login přihlašovací jméno uživatele
* @param input data aktivity
* @returns datum, do kterého se aktivita založila
*/
export async function addActivity(login: string, input: unknown): Promise<string> {
const normalized = normalizeActivityInput(input);
const entry: ActivityEntry = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...normalized,
};
await storage.updateData<ActivityEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), entry]
);
return normalized.date;
}
/**
* Uloží najednou více aktivit do jednoho dne (používá aplikace šablony).
*
* @param login přihlašovací jméno uživatele
* @param date den, do kterého se aktivity založí
* @param entries aktivity k uložení
*/
export async function addActivities(login: string, date: string, entries: ActivityEntry[]): Promise<void> {
if (!entries.length) return;
await storage.updateData<ActivityEntry[]>(
getDayKey(login, date),
current => [...(current ?? []), ...entries]
);
}
/**
* Upraví existující aktivitu. Změna data ji přesune do jiného dne.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravovaného záznamu
* @param input nová data aktivity
* @returns datum, ve kterém aktivita po úpravě je
*/
export async function updateActivity(login: string, id: unknown, input: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
const normalized = normalizeActivityInput(input);
const originalDate = await findActivityDate(login, id, normalized.date);
if (!originalDate) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
let original: ActivityEntry | undefined;
const remaining = await storage.updateData<ActivityEntry[]>(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
// Volitelná pole se přepisují včetně vymazání, proto se berou z normalizovaného vstupu
const updated: ActivityEntry = {
...original,
...normalized,
sets: normalized.sets,
caloriesPer100Units: normalized.caloriesPer100Units,
calories: normalized.calories,
note: normalized.note,
extraWeightGrams: normalized.extraWeightGrams,
multiplier: normalized.multiplier,
updatedAt: new Date().toISOString(),
};
await storage.updateData<ActivityEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), updated]
);
if (originalDate !== normalized.date) {
await dropOrphanedPlans(login, originalDate, remaining);
}
return normalized.date;
}
/**
* Smaže aktivitu.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor mazaného záznamu
* @param date datum záznamu (YYYY-MM-DD)
*/
export async function deleteActivity(login: string, id: unknown, date: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
let found = false;
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;
}
/**
* Nastaví násobek aktivity přímo z přehledu dne. Ostatní údaje zůstanou beze změny.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor záznamu
* @param date datum záznamu (YYYY-MM-DD)
* @param multiplier nový násobek; 1 násobek zruší
* @returns datum, ve kterém aktivita je
*/
export async function setActivityMultiplier(login: string, id: unknown, date: unknown, multiplier: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (multiplier == null) {
throw new BadRequestError("Nebyl předán násobek");
}
const normalized = normalizeMultiplier(multiplier);
let found = false;
await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
if (entry.id !== id) return entry;
found = true;
const updated: ActivityEntry = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
if (normalized == null) delete updated.multiplier;
return updated;
}));
if (!found) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
return date;
}
/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */
async function findActivityDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
const expected = await storage.getData<ActivityEntry[]>(getDayKey(login, expectedDate));
if (expected?.some(entry => entry.id === id)) {
return expectedDate;
}
const prefix = `activities:${login}:`;
for (const key of await storage.listKeys(prefix)) {
if (!key.startsWith(prefix)) continue;
const entries = await storage.getData<ActivityEntry[]>(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}