304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|