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