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 { const entries = await storage.getData(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; 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 { const normalized = normalizeActivityInput(input); const entry: ActivityEntry = { id: randomUUID(), createdAt: new Date().toISOString(), ...normalized, }; await storage.updateData( 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 { if (!entries.length) return; await storage.updateData( 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 { 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(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( 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 { 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(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 { 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(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 { const expected = await storage.getData(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(key); if (entries?.some(entry => entry.id === id)) { return key.substring(prefix.length); } } return undefined; }