313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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;
|
||
}
|