feat: add kcal app

This commit is contained in:
2026-08-07 13:33:17 +07:00
parent 1ee76faf55
commit c02448f7d9
39 changed files with 6966 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
import { computed, shallowRef } from 'vue';
import { useEntity, useMutation, useQuery } from 'vue-sync-engine';
import {
ProfileEntity,
WeightEntity,
daySummariesQuery,
logWeightMutation,
removeWeightMutation,
weightsQuery,
} from '../data/defs';
import type { DaySummary } from '../data/defs';
import { useEntities } from '../data/composables';
import { dayShort, dayTitle, lastDays, todayISO } from '../domain/dates';
import { fmtG, fmtKcal } from '../domain/format';
import { PROFILE_ID } from '../domain/types';
import type { WeightLog } from '../domain/types';
import { activeTab, selectedDate } from '../ui/state';
import { IconScale, IconTrash } from '../ui/icons';
const PERIODS = [7, 14, 30] as const;
export default function StatsScreen() {
const profile = useEntity(ProfileEntity, () => PROFILE_ID);
const summariesQ = useQuery(daySummariesQuery, () => undefined);
const weightsQ = useQuery(weightsQuery, () => undefined);
const weights = useEntities(WeightEntity, () => weightsQ.data.value?.ids);
const logWeight = useMutation(logWeightMutation);
const removeWeight = useMutation(removeWeightMutation);
const period = shallowRef<7 | 14 | 30>(14);
const selectedBar = shallowRef(todayISO());
const target = computed(() => profile.value?.targetKcal ?? 2000);
const days = computed(() => {
const byDate = new Map((summariesQ.data.value ?? []).map(day => [day.date, day]));
return lastDays(period.value).map(date =>
byDate.get(date) ?? { date, kcal: 0, protein: 0, fat: 0, carbs: 0, entries: 0 },
);
});
const chartMax = computed(() => {
const peak = Math.max(target.value, ...days.value.map(day => day.kcal));
return peak * 1.08;
});
const selectedDay = computed(() =>
days.value.find(day => day.date === selectedBar.value) ?? null,
);
const tracked = computed(() => days.value.filter(day => day.entries > 0));
const averages = computed(() => {
const list = tracked.value;
if (list.length === 0) return null;
const total = list.reduce(
(acc, day) => ({ kcal: acc.kcal + day.kcal, protein: acc.protein + day.protein }),
{ kcal: 0, protein: 0 },
);
const onTarget = list.filter(day => day.kcal <= target.value).length;
return {
kcal: total.kcal / list.length,
protein: total.protein / list.length,
onTargetShare: Math.round((onTarget / list.length) * 100),
};
});
// ── вес ───────────────────────────────────────────────────────────────────
const weightInput = shallowRef(0);
const latestWeight = computed(() => weights.value.at(-1) ?? null);
const weightDelta = computed(() => {
const list = weights.value;
const latest = list.at(-1);
if (!latest || list.length < 2) return null;
const weekAgoDate = lastDays(8)[0] ?? latest.date;
const reference = [...list].reverse().find(item => item.date <= weekAgoDate) ?? list[0];
if (!reference || reference.id === latest.id) return null;
return latest.kg - reference.kg;
});
const sparkPoints = computed(() => {
const list = weights.value.slice(-30);
if (list.length < 2) return '';
const min = Math.min(...list.map(item => item.kg));
const max = Math.max(...list.map(item => item.kg));
const span = Math.max(max - min, 0.5);
return list
.map((item, index) => {
const x = (index / (list.length - 1)) * 100;
const y = 26 - ((item.kg - min) / span) * 22 + 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
const submitWeight = () => {
if (weightInput.value <= 0) return;
const date = todayISO();
const weight: WeightLog = { id: date, date, kg: weightInput.value, createdAt: Date.now() };
logWeight.mutate({ weight });
weightInput.value = 0;
};
const openInDiary = (day: DaySummary) => {
selectedDate.value = day.date;
activeTab.value = 'diary';
};
const numeric = (raw: string): number => {
const value = Number(raw.replace(',', '.'));
return Number.isFinite(value) && value >= 0 ? value : 0;
};
return (
<div class="flex flex-col gap-5 pb-6">
<div class="animate-rise flex items-center justify-between">
<h1 class="text-display text-xl font-medium">Статистика</h1>
<div class="flex gap-1 rounded-full border hairline bg-surface/60 p-1">
{PERIODS.map(value => (
<button
type="button"
class={`rounded-full px-3 py-1 text-[12px] transition ${period.value === value ? 'bg-ember/20 text-ember-bright' : 'text-ink-faint hover:text-ink'}`}
onClick={() => (period.value = value)}
>
{value}
{' '}
дн
</button>
))}
</div>
</div>
{/* График калорий по дням */}
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '40ms' }}>
<div class="mb-3 flex items-baseline justify-between">
<h2 class="text-[13px] text-ink-soft">Калории по дням</h2>
<span class="text-[12px] text-ink-faint tnum">
{`цель ${fmtKcal(target.value)}`}
</span>
</div>
{selectedDay.value && (
<button
type="button"
class="mb-3 flex w-full items-baseline justify-between rounded-xl bg-raised/50 px-3.5 py-2 text-left transition hover:bg-raised"
onClick={() => selectedDay.value && openInDiary(selectedDay.value)}
>
<span class="text-[13px] text-ink-soft">{dayTitle(selectedDay.value.date)}</span>
<span class="text-[13px] tnum">
{selectedDay.value.entries > 0
? (
<span>
{fmtKcal(selectedDay.value.kcal)}
{' '}
ккал · Б
{' '}
{fmtG(selectedDay.value.protein)}
</span>
)
: <span class="text-ink-faint">нет записей</span>}
</span>
</button>
)}
<div class="relative h-36">
{/* Линия цели */}
<div
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
/>
<div class="flex h-full items-end gap-[3px]">
{days.value.map(day => (
<button
type="button"
aria-label={`${day.date}: ${Math.round(day.kcal)} ккал`}
class="group flex h-full flex-1 flex-col items-center justify-end"
onClick={() => (selectedBar.value = day.date)}
>
<div
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date
? 'bg-ember-bright'
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
/>
</button>
))}
</div>
</div>
<div class="mt-1.5 flex justify-between text-[10px] text-ink-faint tnum">
<span>{dayShort(days.value[0]?.date ?? todayISO())}</span>
<span>{dayShort(days.value.at(-1)?.date ?? todayISO())}</span>
</div>
</section>
{/* Средние за период */}
{averages.value && (
<section class="animate-rise grid grid-cols-3 gap-2" style={{ animationDelay: '80ms' }}>
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
<div class="text-display text-[22px] font-light tnum">{fmtKcal(averages.value.kcal)}</div>
<div class="mt-0.5 text-[11px] text-ink-faint">ккал в среднем</div>
</div>
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
<div class="text-display text-[22px] font-light tnum">{fmtG(averages.value.protein)}</div>
<div class="mt-0.5 text-[11px] text-ink-faint">белка в день, г</div>
</div>
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
<div class="text-display text-[22px] font-light tnum">
{averages.value.onTargetShare}
%
</div>
<div class="mt-0.5 text-[11px] text-ink-faint">дней в цели</div>
</div>
</section>
)}
{/* Вес */}
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '120ms' }}>
<div class="mb-3 flex items-center gap-2">
<IconScale class="size-4.5 text-ink-soft" />
<h2 class="flex-1 text-[13px] text-ink-soft">Вес</h2>
{weightDelta.value !== null && (
<span class={`text-[12px] tnum ${weightDelta.value <= 0 ? 'text-protein' : 'text-ink-faint'}`}>
{weightDelta.value > 0 ? '+' : ''}
{fmtG(weightDelta.value)}
{' '}
кг за неделю
</span>
)}
</div>
{latestWeight.value && (
<div class="mb-3 flex items-end justify-between">
<div>
<span class="text-display text-[36px] leading-none font-light tnum">{fmtG(latestWeight.value.kg)}</span>
<span class="ml-1 text-[13px] text-ink-faint">кг</span>
</div>
{sparkPoints.value && (
<svg class="h-8 w-36" viewBox="0 0 100 30" preserveAspectRatio="none">
<polyline
points={sparkPoints.value}
fill="none"
stroke="var(--color-ember-bright)"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)}
</div>
)}
<div class="flex gap-2">
<input
type="number"
inputmode="decimal"
min="1"
step="0.1"
placeholder="Вес сегодня, кг"
value={weightInput.value || ''}
onInput={event => (weightInput.value = numeric(event.currentTarget.value))}
class="w-full flex-1 rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint tnum"
/>
<button
type="button"
class="rounded-xl bg-ember px-4 text-[14px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
disabled={weightInput.value <= 0}
onClick={submitWeight}
>
Записать
</button>
</div>
{weights.value.length > 0 && (
<div class="mt-3 flex flex-col">
{[...weights.value].reverse().slice(0, 5).map(item => (
<div class="flex items-center gap-2 border-t hairline py-2 text-[13px]">
<span class="flex-1 text-ink-faint">{dayTitle(item.date)}</span>
<span class="tnum">
{fmtG(item.kg)}
{' '}
кг
</span>
<button
type="button"
aria-label="Удалить замер"
class="grid size-7 place-items-center rounded-full text-ink-faint transition hover:bg-over/15 hover:text-over-bright"
onClick={() => removeWeight.mutate({ id: item.id })}
>
<IconTrash class="size-3.5" />
</button>
</div>
))}
</div>
)}
<p class="mt-3 text-[12px] leading-relaxed text-ink-faint">
Взвешивайтесь утром натощак. Смотрите на тренд за неделю, а не на
ежедневные колебания вода и еда в желудке шумят на ±1 кг.
</p>
</section>
</div>
);
}