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

+248
View File
@@ -0,0 +1,248 @@
import { randomUUID } from 'crypto';
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
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.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 + (entry.calories ?? 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;
quantity: number;
caloriesPer100Units?: number;
calories?: number;
note?: string;
};
/**
* 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(input.quantity / 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");
}
return deriveActivityCalories({
date: value.date,
name,
unit: value.unit,
quantity,
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
calories: normalizeNumber(value.calories, 'calories'),
note: normalizeText(value.note, 'note'),
});
}
/**
* 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;
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,
caloriesPer100Units: normalized.caloriesPer100Units,
calories: normalized.calories,
note: normalized.note,
updatedAt: new Date().toISOString(),
};
await storage.updateData<ActivityEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), updated]
);
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;
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");
}
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;
}