feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
commit
a5711f3e1e
98 files changed
+15505
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user