feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl committed 2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 files changed
+15505

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import { randomUUID } from 'crypto';
import { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { addActivities, isActivityUnit } from "./activities";
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
const storage = getStorage();
/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */
const MAX_ITEMS = 50;
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
function getKey(login: string): string {
return `workouts:${login}`;
}
/** Spočítá odhad spálených kcal za celou šablonu. */
function estimateCalories(items: WorkoutTemplateItem[]): number {
return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0);
}
/** Doplní šabloně dopočítaný odhad kalorií. */
function withEstimate(template: Omit<WorkoutTemplate, 'estimatedCalories'>): WorkoutTemplate {
return { ...template, estimatedCalories: estimateCalories(template.items) };
}
/** Uložená podoba šablony — odhad kalorií se dopočítává, neukládá se. */
type StoredTemplate = Omit<WorkoutTemplate, 'estimatedCalories'>;
/**
* Vrátí šablony tréninků uživatele seřazené podle názvu.
*
* @param login přihlašovací jméno uživatele
*/
export async function getWorkoutTemplates(login: string): Promise<WorkoutTemplate[]> {
const templates = await storage.getData<StoredTemplate[]>(getKey(login)) ?? [];
return templates
.map(withEstimate)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
/** Ověří a znormalizuje jednu položku šablony. */
function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
const value = (raw ?? {}) as Record<string, unknown>;
const name = typeof value.name === 'string' ? value.name.trim() : '';
if (!name.length) {
throw new BadRequestError(`Položka ${index + 1} nemá název`);
}
if (!isActivityUnit(value.unit)) {
throw new BadRequestError(`Položka '${name}' nemá platnou jednotku`);
}
if (typeof value.quantity !== 'number' || !Number.isFinite(value.quantity) || value.quantity < 0) {
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
}
const rate = value.caloriesPer100Units;
return {
name,
unit: value.unit,
quantity: Math.round(value.quantity),
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
? { caloriesPer100Units: Math.round(rate) }
: {}),
};
}
/**
* Založí novou šablonu, nebo přepíše existující podle předaného id.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravované šablony, nebo nic pro založení nové
* @param name název šablony
* @param items položky šablony
*/
export async function saveWorkoutTemplate(
login: string,
id: unknown,
name: unknown,
items: unknown,
): Promise<WorkoutTemplate[]> {
if (typeof name !== 'string' || !name.trim().length) {
throw new BadRequestError("Nebyl předán název šablony");
}
if (!Array.isArray(items) || !items.length) {
throw new BadRequestError("Šablona musí mít alespoň jednu položku");
}
if (items.length > MAX_ITEMS) {
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
}
const normalized: StoredTemplate = {
id: typeof id === 'string' && id.length ? id : randomUUID(),
name: name.trim(),
items: items.map(normalizeItem),
};
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
const others = (current ?? []).filter(template => template.id !== normalized.id);
return [...others, normalized];
});
return getWorkoutTemplates(login);
}
/**
* Smaže šablonu tréninku.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
*/
export async function deleteWorkoutTemplate(login: string, id: unknown): Promise<WorkoutTemplate[]> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
let found = false;
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
found = (current ?? []).some(template => template.id === id);
return (current ?? []).filter(template => template.id !== id);
});
if (!found) {
throw new NotFoundError("Šablona nebyla nalezena");
}
return getWorkoutTemplates(login);
}
/**
* Založí do zvoleného dne všechny položky šablony.
*
* Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat,
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
* odkud přišly.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
* @param date den, do kterého se položky založí
*/
export async function applyWorkoutTemplate(login: string, id: unknown, date: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
const template = (await getWorkoutTemplates(login)).find(item => item.id === id);
if (!template) {
throw new NotFoundError("Šablona nebyla nalezena");
}
const now = new Date().toISOString();
const entries: ActivityEntry[] = template.items.map(item => ({
id: randomUUID(),
date,
name: item.name,
unit: item.unit,
quantity: item.quantity,
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)),
templateId: template.id,
createdAt: now,
}));
await addActivities(login, date, entries);
return date;
}