fixed reminder

This commit is contained in:
2026-06-11 12:22:37 +03:00
parent 4108d737e3
commit 41cbef61a9
12 changed files with 410 additions and 59 deletions
+10
View File
@@ -67,6 +67,16 @@ def get_summary(
return FitnessService(db).get_daily_summary(d) return FitnessService(db).get_daily_summary(d)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db).get_history(days=days, end_day=end_day)
@router.get("/fitness/profile") @router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
profile = FitnessService(db).get_profile() profile = FitnessService(db).get_profile()
+2 -1
View File
@@ -12,7 +12,8 @@ TOOLS_INSTRUCTIONS = """
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary, set_fitness_profile, log_meal, log_water, log_weight, log_workout, - Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight, log_workout,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder. calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. - Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
+2
View File
@@ -57,6 +57,7 @@ MEMORY_TOOL_NAMES = frozenset({
FITNESS_TOOL_NAMES = frozenset({ FITNESS_TOOL_NAMES = frozenset({
"get_fitness_summary", "get_fitness_summary",
"get_fitness_history",
"set_fitness_profile", "set_fitness_profile",
"calc_fitness_targets", "calc_fitness_targets",
"log_meal", "log_meal",
@@ -90,6 +91,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status", "get_pomodoro_status",
"recall_memories", "recall_memories",
"get_fitness_summary", "get_fitness_summary",
"get_fitness_history",
"lookup_food", "lookup_food",
"lookup_exercise", "lookup_exercise",
"calc_fitness_targets", "calc_fitness_targets",
+1 -1
View File
@@ -48,7 +48,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines.append("") lines.append("")
lines.append( lines.append(
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary, " "Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить." "Еда — оценка LLM (≈), пользователь может уточнить."
) )
+38 -2
View File
@@ -1,5 +1,5 @@
import json import json
from datetime import date, datetime, time, timezone from datetime import date, datetime, time, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -346,10 +346,46 @@ class FitnessService:
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def get_history(
self,
*,
days: int = 7,
end_day: date | None = None,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
summaries: list[dict[str, Any]] = []
for offset in range(days):
d = start + timedelta(days=offset)
full = self.get_daily_summary(d)
totals = full["totals"]
has_data = bool(full["meals"] or full["water"] or full["workouts"])
summaries.append(
{
"date": full["date"],
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"days": days,
"summaries": summaries,
}
def snapshot(self) -> dict[str, Any]: def snapshot(self) -> dict[str, Any]:
today = datetime.now(timezone.utc).date()
return { return {
"profile": self.get_profile(), "profile": self.get_profile(),
"today": self.get_daily_summary(), "today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"body_metrics": self.list_body_metrics(limit=10), "body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(), "reminders": self.list_reminders(),
} }
+73
View File
@@ -0,0 +1,73 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session):
self.db = db
self.llm = LLMClient()
self.character = CharacterService()
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
rec_part = ""
if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание.
Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
async def process(self, row: Reminder) -> None:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
post_notice_to_latest_chat(format_reminder_notice(row))
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
+12 -27
View File
@@ -4,10 +4,9 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.db.models import Reminder from app.db.models import Reminder
from app.reminders.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq from app.reminders.notify import bump_notify_seq
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,7 +15,7 @@ def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def check_due_reminders(db: Session) -> int: def get_due_reminders(db: Session) -> list[Reminder]:
now = _utcnow() now = _utcnow()
stmt = ( stmt = (
select(Reminder) select(Reminder)
@@ -28,33 +27,19 @@ def check_due_reminders(db: Session) -> int:
.order_by(Reminder.due_at.asc()) .order_by(Reminder.due_at.asc())
) )
rows = list(db.scalars(stmt).all()) rows = list(db.scalars(stmt).all())
fired = 0 return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
for row in rows:
if row.last_fired_at and row.last_fired_at >= row.due_at:
continue
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) async def process_due_reminders(db: Session) -> int:
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" due = get_due_reminders(db)
if row.notes: if not due:
notice += f"\n{row.notes}" return 0
post_notice_to_latest_chat(notice) handler = ReminderCompletionHandler(db)
row.last_fired_at = now for row in due:
await handler.process(row)
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
fired += 1
if fired:
db.commit() db.commit()
bump_notify_seq(db) bump_notify_seq(db)
logger.info("Reminders fired: %d", fired) logger.info("Reminders fired: %d", len(due))
return len(due)
return fired
+2 -2
View File
@@ -3,7 +3,7 @@ import logging
from app.config import get_settings from app.config import get_settings
from app.db.base import SessionLocal from app.db.base import SessionLocal
from app.reminders.fire import check_due_reminders from app.reminders.fire import process_due_reminders
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,6 +26,6 @@ async def reminders_watcher_loop() -> None:
async def _tick() -> None: async def _tick() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
check_due_reminders(db) await process_due_reminders(db)
finally: finally:
db.close() db.close()
+50 -3
View File
@@ -1,4 +1,5 @@
import json import json
from datetime import date, datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -281,8 +282,39 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_fitness_summary", "name": "get_fitness_summary",
"description": "Сводка фитнеса за сегодня: ккал, БЖУ, вода, тренировки.", "description": (
"parameters": {"type": "object", "properties": {}, "required": []}, "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. "
"Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)."
),
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "Дата YYYY-MM-DD"},
"days_ago": {
"type": "integer",
"description": "0 сегодня, 1 вчера, 2 позавчера…",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_fitness_history",
"description": (
"Краткая история за несколько дней (ккал, вода, тренировки по дням). "
"«На прошлой неделе», «за 7 дней»."
),
"parameters": {
"type": "object",
"properties": {
"days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"},
"end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"},
},
"required": [],
},
}, },
}, },
{ {
@@ -778,7 +810,22 @@ async def execute_tool(
arguments.get("summary", ""), arguments.get("summary", ""),
) )
elif name == "get_fitness_summary": elif name == "get_fitness_summary":
result = fitness.get_daily_summary() day: date | None = None
if arguments.get("date"):
day = date.fromisoformat(str(arguments["date"]))
elif arguments.get("days_ago") is not None:
day = datetime.now(timezone.utc).date() - timedelta(
days=int(arguments["days_ago"])
)
result = fitness.get_daily_summary(day)
elif name == "get_fitness_history":
end_day = None
if arguments.get("end_date"):
end_day = date.fromisoformat(str(arguments["end_date"]))
result = fitness.get_history(
days=int(arguments.get("days") or 7),
end_day=end_day,
)
elif name == "set_fitness_profile": elif name == "set_fitness_profile":
updates = { updates = {
k: arguments[k] k: arguments[k]
+28
View File
@@ -168,9 +168,26 @@ export interface FitnessReminder {
enabled: boolean; enabled: boolean;
} }
export interface FitnessDayOverview {
date: string;
has_data: boolean;
totals: FitnessDailySummary["totals"];
targets: FitnessDailySummary["targets"];
meal_count: number;
workout_count: number;
}
export interface FitnessHistory {
start_date: string;
end_date: string;
days: number;
summaries: FitnessDayOverview[];
}
export interface FitnessSnapshot { export interface FitnessSnapshot {
profile: FitnessProfile | null; profile: FitnessProfile | null;
today: FitnessDailySummary; today: FitnessDailySummary;
history?: FitnessHistory;
body_metrics: BodyMetric[]; body_metrics: BodyMetric[];
reminders: FitnessReminder[]; reminders: FitnessReminder[];
} }
@@ -355,6 +372,17 @@ export const api = {
getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"), getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"),
getFitnessSummary: (day?: string) =>
request<FitnessDailySummary>(
`/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}`
),
getFitnessHistory: (days = 7, end?: string) => {
const params = new URLSearchParams({ days: String(days) });
if (end) params.set("end", end);
return request<FitnessHistory>(`/api/v1/fitness/history?${params}`);
},
updateFitnessProfile: (updates: Partial<FitnessProfile>) => updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", { request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
method: "PUT", method: "PUT",
+83
View File
@@ -37,6 +37,89 @@
border-radius: 8px; border-radius: 8px;
} }
.fitness-day-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.fitness-day-nav button {
width: 2rem;
height: 2rem;
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.fitness-day-nav button:disabled {
opacity: 0.4;
cursor: default;
}
.fitness-day-title {
text-align: center;
flex: 1;
}
.fitness-day-title h3 {
margin: 0 0 0.25rem;
}
.fitness-day-title input[type="date"] {
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
border-radius: 6px;
padding: 0.2rem 0.4rem;
font-size: 0.85rem;
}
.fitness-week-strip {
display: flex;
gap: 0.35rem;
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
}
.fitness-week-day {
flex: 0 0 auto;
min-width: 3.2rem;
border: 1px solid #2f3748;
border-radius: 8px;
background: #1a1f2b;
color: inherit;
padding: 0.35rem 0.4rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.fitness-week-day.active {
border-color: #4a7cff;
background: #1c2740;
}
.fitness-week-day.empty .fitness-week-kcal {
color: #6b7280;
}
.fitness-week-date {
font-size: 0.75rem;
color: #8b95a8;
}
.fitness-week-kcal {
font-size: 0.8rem;
font-weight: 600;
}
.fitness-section { .fitness-section {
background: #151922; background: #151922;
border: 1px solid #2a2f3a; border: 1px solid #2a2f3a;
+101 -15
View File
@@ -1,12 +1,35 @@
import { FormEvent, useCallback, useEffect, useState } from "react"; import { FormEvent, useCallback, useEffect, useState } from "react";
import { import {
api, api,
FitnessDailySummary,
FitnessHistory,
FitnessProfile, FitnessProfile,
FitnessReminder, FitnessReminder,
FitnessSnapshot, FitnessSnapshot,
} from "../api/client"; } from "../api/client";
import "./Fitness.css"; import "./Fitness.css";
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function shiftDate(iso: string, deltaDays: number) {
const d = new Date(`${iso}T12:00:00`);
d.setDate(d.getDate() + deltaDays);
return d.toISOString().slice(0, 10);
}
function formatDayLabel(iso: string) {
const today = todayIso();
if (iso === today) return "Сегодня";
if (iso === shiftDate(today, -1)) return "Вчера";
return new Date(`${iso}T12:00:00`).toLocaleDateString("ru-RU", {
weekday: "short",
day: "numeric",
month: "short",
});
}
function ProgressBar({ label, current, target, unit }: { function ProgressBar({ label, current, target, unit }: {
label: string; label: string;
current: number; current: number;
@@ -31,27 +54,41 @@ function ProgressBar({ label, current, target, unit }: {
export default function Fitness() { export default function Fitness() {
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null); const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
const [selectedDate, setSelectedDate] = useState(todayIso);
const [daySummary, setDaySummary] = useState<FitnessDailySummary | null>(null);
const [history, setHistory] = useState<FitnessHistory | null>(null);
const [profile, setProfile] = useState<Partial<FitnessProfile>>({}); const [profile, setProfile] = useState<Partial<FitnessProfile>>({});
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [showRaw, setShowRaw] = useState(false); const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { const load = useCallback(async (day: string = selectedDate) => {
setLoading(true); setLoading(true);
try { try {
const data = await api.getFitnessSnapshot(); const [data, summary, hist] = await Promise.all([
api.getFitnessSnapshot(),
api.getFitnessSummary(day),
api.getFitnessHistory(7, day),
]);
setSnapshot(data); setSnapshot(data);
setDaySummary(summary);
setHistory(hist);
if (data.profile) setProfile(data.profile); if (data.profile) setProfile(data.profile);
setMessage("");
} catch (err) { } catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [selectedDate]);
useEffect(() => { useEffect(() => {
load().catch(console.error); load(selectedDate).catch(console.error);
}, [load]); }, [selectedDate, load]);
const pickDay = (iso: string) => {
setSelectedDate(iso);
};
const handleProfileSave = async (e: FormEvent) => { const handleProfileSave = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -83,9 +120,9 @@ export default function Fitness() {
await load(); await load();
}; };
const today = snapshot?.today; const totals = daySummary?.totals;
const totals = today?.totals; const targets = daySummary?.targets;
const targets = today?.targets; const isToday = selectedDate === todayIso();
return ( return (
<div className="fitness-page"> <div className="fitness-page">
@@ -95,7 +132,7 @@ export default function Fitness() {
<p>Дневник, цели, напоминания</p> <p>Дневник, цели, напоминания</p>
</div> </div>
<div className="fitness-header-actions"> <div className="fitness-header-actions">
<button type="button" onClick={() => load()} disabled={loading}> <button type="button" onClick={() => load(selectedDate)} disabled={loading}>
{loading ? "…" : "Обновить"} {loading ? "…" : "Обновить"}
</button> </button>
<button type="button" onClick={() => setShowRaw((v) => !v)}> <button type="button" onClick={() => setShowRaw((v) => !v)}>
@@ -111,7 +148,52 @@ export default function Fitness() {
) : ( ) : (
<> <>
<section className="fitness-section"> <section className="fitness-section">
<h3>Сегодня</h3> <div className="fitness-day-nav">
<button type="button" onClick={() => pickDay(shiftDate(selectedDate, -1))}>
</button>
<div className="fitness-day-title">
<h3>{formatDayLabel(selectedDate)}</h3>
<input
type="date"
value={selectedDate}
onChange={(e) => pickDay(e.target.value)}
aria-label="Выбор дня"
/>
</div>
<button
type="button"
onClick={() => pickDay(shiftDate(selectedDate, 1))}
disabled={selectedDate >= todayIso()}
>
</button>
</div>
{history && history.summaries.length > 0 && (
<div className="fitness-week-strip">
{history.summaries.map((row) => (
<button
key={row.date}
type="button"
className={`fitness-week-day${row.date === selectedDate ? " active" : ""}${row.has_data ? "" : " empty"}`}
onClick={() => pickDay(row.date)}
title={`${row.date}: ${row.totals.calories.toFixed(0)} ккал`}
>
<span className="fitness-week-date">
{new Date(`${row.date}T12:00:00`).toLocaleDateString("ru-RU", {
day: "numeric",
month: "numeric",
})}
</span>
<span className="fitness-week-kcal">
{row.has_data ? `${row.totals.calories.toFixed(0)}` : "—"}
</span>
</button>
))}
</div>
)}
{totals && targets ? ( {totals && targets ? (
<div className="fitness-progress-grid"> <div className="fitness-progress-grid">
<ProgressBar <ProgressBar
@@ -146,7 +228,7 @@ export default function Fitness() {
/> />
</div> </div>
) : ( ) : (
<p className="fitness-empty">Нет данных за сегодня</p> <p className="fitness-empty">Нет записей за этот день</p>
)} )}
</section> </section>
@@ -230,33 +312,37 @@ export default function Fitness() {
</section> </section>
<section className="fitness-section"> <section className="fitness-section">
<h3>Логи за сегодня</h3> <h3>Логи {isToday ? "за сегодня" : `за ${selectedDate}`}</h3>
<h4>Еда</h4> <h4>Еда</h4>
<ul className="fitness-log-list"> <ul className="fitness-log-list">
{(today?.meals ?? []).map((m) => ( {(daySummary?.meals ?? []).map((m) => (
<li key={m.id}> <li key={m.id}>
{m.estimated ? "≈" : ""} {m.estimated ? "≈" : ""}
{m.description} {m.calories} ккал {m.description} {m.calories} ккал
{isToday && (
<button type="button" onClick={() => handleDeleteMeal(m.id)}> <button type="button" onClick={() => handleDeleteMeal(m.id)}>
× ×
</button> </button>
)}
</li> </li>
))} ))}
</ul> </ul>
<h4>Вода</h4> <h4>Вода</h4>
<ul className="fitness-log-list"> <ul className="fitness-log-list">
{(today?.water ?? []).map((w) => ( {(daySummary?.water ?? []).map((w) => (
<li key={w.id}> <li key={w.id}>
+{w.amount_ml} мл +{w.amount_ml} мл
{isToday && (
<button type="button" onClick={() => handleDeleteWater(w.id)}> <button type="button" onClick={() => handleDeleteWater(w.id)}>
× ×
</button> </button>
)}
</li> </li>
))} ))}
</ul> </ul>
<h4>Тренировки</h4> <h4>Тренировки</h4>
<ul className="fitness-log-list"> <ul className="fitness-log-list">
{(today?.workouts ?? []).map((w) => ( {(daySummary?.workouts ?? []).map((w) => (
<li key={w.id}>{w.title}</li> <li key={w.id}>{w.title}</li>
))} ))}
</ul> </ul>