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

+337
View File
@@ -0,0 +1,337 @@
import { randomUUID } from 'crypto';
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError, NotFoundError, isValidIsoDate, listDatesInRange } from "./utils";
import { rememberCalories } from "./calories";
const storage = getStorage();
/** Maximální délka textových polí, aby se do úložiště nedostaly nesmyslně velké hodnoty. */
const MAX_TEXT_LENGTH = 500;
/**
* Pořadí typů jídel v rámci dne. Podle něj se řadí záznamy dne,
* aby přehled odpovídal skutečnému průběhu dne, ne pořadí zadání.
*/
const MEAL_TYPE_ORDER: MealType[] = [
MealType.SNIDANE,
MealType.DOPOLEDNI_SVACINA,
MealType.OBED,
MealType.ODPOLEDNI_SVACINA,
MealType.VECERE,
MealType.JINE,
];
/** Vrátí true, pokud je hodnota podporovaným typem jídla. */
export function isMealType(value: unknown): value is MealType {
return typeof value === 'string' && (MEAL_TYPE_ORDER as string[]).includes(value);
}
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
function getDayKey(login: string, date: string): string {
return `meals:${login}:${date}`;
}
/** Seřadí jídla dne dle typu (snídaně -> večeře) a při shodě dle času vzniku. */
function sortEntries(entries: MealEntry[]): MealEntry[] {
return [...entries].sort((a, b) => {
const typeDiff = MEAL_TYPE_ORDER.indexOf(a.mealType) - MEAL_TYPE_ORDER.indexOf(b.mealType);
return typeDiff !== 0 ? typeDiff : a.createdAt.localeCompare(b.createdAt);
});
}
/** Sestaví přehled dne včetně součtů z předaných záznamů. */
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
const sorted = sortEntries(entries);
return {
date,
entries: sorted,
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
};
}
/**
* Vrátí jídla 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 getDay(login: string, date: string): Promise<DayRecord> {
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
return buildDayRecord(date, entries ?? []);
}
/**
* Vrátí dny uživatele v rozsahu, které obsahují alespoň jeden záznam.
*
* @param login přihlašovací jméno uživatele
* @param from první den rozsahu (včetně), YYYY-MM-DD
* @param to poslední den rozsahu (včetně), YYYY-MM-DD
*/
export async function getRange(login: string, from: string, to: string): Promise<DayRecord[]> {
if (from > to) {
throw new BadRequestError("Začátek rozsahu je až za jeho koncem");
}
const days = await Promise.all(
listDatesInRange(from, to).map(date => getDay(login, date))
);
return days.filter(day => day.entries.length > 0);
}
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
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 (cena v haléřích, kalorie). */
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 jídla. Oproti {@link MealInput} jsou datum, typ i název po
* validaci vždy vyplněné a nevyplněná volitelná pole jsou `undefined` místo `null`,
* takže je lze rovnou rozprostřít do {@link MealEntry}.
*/
export type NormalizedMealInput = {
date: string;
mealType: MealType;
name: string;
source?: string;
note?: string;
price?: number;
calories?: number;
weight?: number;
pricePer100g?: number;
caloriesPer100g?: number;
};
/**
* Dopočte gramáž a kalorie z jednotkových hodnot.
*
* Podniky jako TechTower účtují podle váhy, takže z ceny a sazby za 100 g jde
* gramáž odvodit. Ručně zadaná gramáž má přednost — porce se dá zvážit přesněji,
* než kolik řekne cena, a u položek s jinou sazbou (salát) uživatel sazbu přepíše.
* Kalorie se stejně tak dopočtou z gramáže, pokud je známá energie na 100 g.
*/
export function deriveAmounts(input: NormalizedMealInput): NormalizedMealInput {
const weight = input.weight ?? (
input.price != null && input.pricePer100g
? Math.round(input.price / input.pricePer100g * 100)
: undefined
);
const calories = weight != null && input.caloriesPer100g
? Math.round(weight / 100 * input.caloriesPer100g)
: input.calories;
return { ...input, weight, calories };
}
/** Ověří a znormalizuje vstupní data jídla. */
export function normalizeInput(input: unknown): NormalizedMealInput {
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 (!isMealType(value.mealType)) {
throw new BadRequestError("Nebyl předán platný typ jídla");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new BadRequestError("Nebyl předán název jídla");
}
return deriveAmounts({
date: value.date,
mealType: value.mealType,
name,
source: normalizeText(value.source, 'source'),
note: normalizeText(value.note, 'note'),
price: normalizeNumber(value.price, 'price'),
calories: normalizeNumber(value.calories, 'calories'),
weight: normalizeNumber(value.weight, 'weight'),
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
});
}
/**
* Přidá jídlo do zvoleného dne a vrátí aktualizovaný přehled dne.
*
* @param login přihlašovací jméno uživatele
* @param input data jídla
*/
export async function addMeal(login: string, input: unknown): Promise<DayRecord> {
const normalized = normalizeInput(input);
const entry: MealEntry = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...normalized,
};
const entries = await storage.updateData<MealEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), entry]
);
if (normalized.caloriesPer100g) {
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
}
return buildDayRecord(normalized.date, entries);
}
/**
* Uloží najednou více záznamů (používá import). Záznamy se rozdělí dle data.
* Vrátí dny, kterých se zápis dotkl.
*
* @param login přihlašovací jméno uživatele
* @param entries záznamy k uložení
*/
export async function addEntries(login: string, entries: MealEntry[]): Promise<string[]> {
const byDate = new Map<string, MealEntry[]>();
for (const entry of entries) {
const forDate = byDate.get(entry.date) ?? [];
forDate.push(entry);
byDate.set(entry.date, forDate);
}
for (const [date, forDate] of byDate) {
await storage.updateData<MealEntry[]>(
getDayKey(login, date),
current => [...(current ?? []), ...forDate]
);
}
return Array.from(byDate.keys()).sort();
}
/**
* Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne.
*
* Změna data znamená přesun mezi dny — záznam se ze starého dne odebere
* a do nového přidá. Vrací se vždy přehled cílového (nového) dne.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravovaného záznamu
* @param input nová data jídla
*/
export async function updateMeal(login: string, id: unknown, input: unknown): Promise<DayRecord> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
const normalized = normalizeInput(input);
const originalDate = await findEntryDate(login, id, normalized.date);
if (!originalDate) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
let original: MealEntry | undefined;
await storage.updateData<MealEntry[]>(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
// Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
// proto se berou z normalizovaného vstupu a ne z původního záznamu.
const updated: MealEntry = {
...original,
...normalized,
price: normalized.price,
calories: normalized.calories,
source: normalized.source,
note: normalized.note,
weight: normalized.weight,
pricePer100g: normalized.pricePer100g,
caloriesPer100g: normalized.caloriesPer100g,
updatedAt: new Date().toISOString(),
};
const entries = await storage.updateData<MealEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), updated]
);
if (normalized.caloriesPer100g) {
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
}
return buildDayRecord(normalized.date, entries);
}
/**
* Smaže záznam o jídle a vrátí aktualizovaný přehled dne.
*
* @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 deleteMeal(login: string, id: unknown, date: unknown): Promise<DayRecord> {
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 entries = await storage.updateData<MealEntry[]>(getDayKey(login, date), current => {
found = (current ?? []).some(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!found) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
return buildDayRecord(date, entries);
}
/**
* Najde datum, pod kterým je záznam uložený.
* Nejdřív zkusí očekávaný den, teprve pak prohledá ostatní klíče uživatele —
* úprava záznamu totiž může měnit i datum.
*/
async function findEntryDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
const expected = await storage.getData<MealEntry[]>(getDayKey(login, expectedDate));
if (expected?.some(entry => entry.id === id)) {
return expectedDate;
}
const prefix = `meals:${login}:`;
for (const key of await storage.listKeys(prefix)) {
if (!key.startsWith(prefix)) continue;
const entries = await storage.getData<MealEntry[]>(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}
/**
* Vrátí klíče importovaných záznamů uživatele v předaných dnech.
* Slouží k rozpoznání duplicit při opakovaném importu stejného měsíce.
*
* @param login přihlašovací jméno uživatele
* @param dates dny, ve kterých se má hledat
*/
export async function getExistingImportKeys(login: string, dates: string[]): Promise<Set<string>> {
const keys = new Set<string>();
for (const date of dates) {
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
for (const entry of entries ?? []) {
if (entry.importKey) {
keys.add(entry.importKey);
}
}
}
return keys;
}