feat: split jídel

feat: graf příjmů
This commit is contained in:
Ondřej Anděl committed 2026-10-09 15:15:04 +02:00
1 parent bc85a37fc0
commit b880b36edd
32 files changed
+2048 -262

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types";
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared";
/**
* Barva chodu v grafech příjmu. Pořadí sleduje průběh dne, takže sousední
* chody mají od sebe dobře odlišitelné barvy palety.
*/
export const MEAL_TYPE_COLORS: Record<MealType, string> = {
SNIDANE: 'var(--ft-series-4)',
DOPOLEDNI_SVACINA: 'var(--ft-series-5)',
OBED: 'var(--ft-series-1)',
ODPOLEDNI_SVACINA: 'var(--ft-series-3)',
VECERE: 'var(--ft-series-7)',
PULNOCNI_SVACINA: 'var(--ft-series-2)',
JINE: 'var(--ft-series-other)',
};
/** Jeden sloupec grafu příjmu — kcal po chodech pod klíčem chodu (`OBED`, ...). */
export type IntakeSeriesRow = {
label: string;
calories: number;
/** Výdej dní s kaloriemi; v období bez nich chybí, čára se tam přeruší místo pádu na nulu. */
burn: number | null;
/** Výdej posunutý o cílovou bilanci — kolik se mělo sníst; jen s nastaveným cílem. */
target: number | null;
/** Cílová bilance za dny s kaloriemi. */
targetBalance: number | null;
dayCount: number;
caloriesDayCount: number;
} & Partial<Record<MealType, number>>;
/**
* Sestaví sloupce grafu příjmu: každý chod vlastní díl sloupce, nad nimi čára
* výdeje a případně cílového příjmu.
*
* Období bez zapsaného jídla zůstávají v grafu jako prázdná, ale výdej v nich
* chybí — nezapsaný den (nebo den s jídly bez kalorií) není den, kdy se nejedlo.
*
* @param stats statistiky příjmu ze serveru
* @param range zobrazené období
* @param mode rozsah statistik
*/
export const buildIntakeSeries = (stats: IntakeStats, range: StatsBounds, mode: StatsRange): IntakeSeriesRow[] => {
const totalMap = new Map(pickPeriods(stats, mode).map(total => [total.period, total]));
return listPeriods(range, mode, stats.byDay[0]?.period).map(period => {
const total = totalMap.get(period);
const row: IntakeSeriesRow = {
label: formatPeriodLabel(period, mode),
calories: total?.calories ?? 0,
burn: null,
target: null,
targetBalance: null,
dayCount: total?.dayCount ?? 0,
caloriesDayCount: total?.caloriesDayCount ?? 0,
};
if (!total?.caloriesDayCount) return row;
row.burn = total.burnCalories;
if (stats.targetDailyBalance != null) {
row.targetBalance = stats.targetDailyBalance * total.caloriesDayCount;
row.target = total.burnCalories + row.targetBalance;
}
for (const group of total.byMealType) {
row[group.key as MealType] = group.calories;
}
return row;
});
};
/** Kolik zdrojů má v koláči vlastní výseč; zbytek se slije do "Ostatní". */
const SOURCE_SLICE_COUNT = 7;
/** Výseč koláče zdrojů. */
export type SourceSlice = { name: string, value: number, color: string };
/**
* Sestaví výseče koláče příjmu dle zdroje. Barva jde podle pořadí ve výběru,
* zdrojů bývá hodně a pevná barva pro každý by paletu nestačila.
*
* @param bySource příjem dle zdroje, sestupně dle kcal
*/
export const buildSourceSlices = (bySource: IntakeGroupTotal[]): SourceSlice[] => {
const slices = bySource
.filter(group => group.calories > 0)
.slice(0, SOURCE_SLICE_COUNT)
.map((group, index) => ({ name: group.key, value: group.calories, color: `var(--ft-series-${index + 1})` }));
const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, group) => sum + group.calories, 0);
if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' });
return slices;
};
/** Chody, které v období něco dodaly, v pořadí průběhu dne — pořadí dílů sloupce i legendy. */
export const getUsedMealTypes = (stats: IntakeStats): MealType[] => {
const used = new Set(stats.byMealType.map(group => group.key));
return MEAL_TYPES_IN_ORDER.filter(mealType => used.has(mealType));
};