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,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;
|
||||
}
|
||||
Reference in new issue
Block a user