115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
import { Request } from 'express';
|
|
|
|
/** Regulární výraz pro datum ve formátu YYYY-MM-DD. */
|
|
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/** Chyba způsobená neplatným vstupem uživatele — mapuje se na HTTP 400. */
|
|
export class BadRequestError extends Error { }
|
|
|
|
/** Chyba hledaného, ale neexistujícího záznamu — mapuje se na HTTP 404. */
|
|
export class NotFoundError extends Error { }
|
|
|
|
/** Vrátí datum v ISO formátu (YYYY-MM-DD) v lokální časové zóně. */
|
|
export function formatDate(date: Date): string {
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const year = String(date.getFullYear());
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
/** Vrátí dnešní datum s vynulovaným časem. */
|
|
export function getToday(): Date {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
return today;
|
|
}
|
|
|
|
/**
|
|
* Ověří, že řetězec je datum ve formátu YYYY-MM-DD a odpovídá reálnému dni.
|
|
* Zachytí i přetečení typu 2024-02-31, které by `new Date` tiše posunul na březen.
|
|
*/
|
|
export function isValidIsoDate(value: unknown): value is string {
|
|
if (typeof value !== 'string' || !ISO_DATE_PATTERN.test(value)) {
|
|
return false;
|
|
}
|
|
const [year, month, day] = value.split('-').map(Number);
|
|
const date = new Date(year, month - 1, day);
|
|
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
|
}
|
|
|
|
/**
|
|
* Vrátí datum z query parametru, nebo vyhodí {@link BadRequestError}.
|
|
*
|
|
* @param value hodnota query parametru
|
|
* @param name název parametru pro chybovou hlášku
|
|
*/
|
|
export function requireIsoDate(value: unknown, name: string): string {
|
|
if (!isValidIsoDate(value)) {
|
|
throw new BadRequestError(`Parametr '${name}' není platné datum ve formátu YYYY-MM-DD`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Vrátí JWT token z hlavičky Authorization, pokud tam je. */
|
|
export function parseToken(req: Request): string | undefined {
|
|
return req.headers?.authorization?.split(' ')[1];
|
|
}
|
|
|
|
/**
|
|
* Vrátí všechna data v rozsahu (včetně obou krajů) jako ISO řetězce.
|
|
*
|
|
* @param from první den rozsahu (YYYY-MM-DD)
|
|
* @param to poslední den rozsahu (YYYY-MM-DD)
|
|
*/
|
|
export function listDatesInRange(from: string, to: string): string[] {
|
|
const dates: string[] = [];
|
|
const [fromYear, fromMonth, fromDay] = from.split('-').map(Number);
|
|
const [toYear, toMonth, toDay] = to.split('-').map(Number);
|
|
const cursor = new Date(fromYear, fromMonth - 1, fromDay);
|
|
const end = new Date(toYear, toMonth - 1, toDay);
|
|
while (cursor <= end) {
|
|
dates.push(formatDate(cursor));
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
}
|
|
return dates;
|
|
}
|
|
|
|
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
|
|
const MAX_MULTIPLIER = 100;
|
|
|
|
/**
|
|
* Ověří násobek porce nebo aktivity a zaokrouhlí ho na setiny.
|
|
*
|
|
* Násobek 1 je výchozí stav, proto se vrací jako undefined a neukládá se —
|
|
* stejně jako jedna série u aktivit.
|
|
*
|
|
* @param value vstupní hodnota
|
|
*/
|
|
export const normalizeMultiplier = (value: unknown): number | undefined => {
|
|
if (value == null) return undefined;
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
throw new BadRequestError("Násobek musí být číslo");
|
|
}
|
|
const rounded = Math.round(value * 100) / 100;
|
|
if (rounded <= 0) {
|
|
throw new BadRequestError("Násobek musí být kladné číslo");
|
|
}
|
|
if (rounded > MAX_MULTIPLIER) {
|
|
throw new BadRequestError(`Násobek může být nejvýše ${MAX_MULTIPLIER}`);
|
|
}
|
|
return rounded === 1 ? undefined : rounded;
|
|
};
|
|
|
|
/**
|
|
* Vrátí kalorie záznamu po započtení násobku.
|
|
*
|
|
* Uložené kalorie jsou vždy za jednu porci (jedno provedení), aby šlo násobek
|
|
* měnit tam a zpět bez ztráty přesnosti zaokrouhlováním. Klient má stejnou
|
|
* funkci v `Utils.tsx` — musí dávat stejné výsledky.
|
|
*
|
|
* @param calories kcal jedné porce
|
|
* @param multiplier násobek, nevyplněný znamená 1
|
|
*/
|
|
export const applyMultiplier = (calories: number | undefined, multiplier?: number): number | undefined =>
|
|
calories == null ? undefined : Math.round(calories * (multiplier ?? 1));
|