Анализ сайта babymommy.ru
Основное Готовность: 100%
Домен
babymommy.ru
Состояние доменного имени
?
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
Домен второго уровня идеален для продвижения.
Отличный запоминающийся домен.
Регистратор домена - REGRU-RU
Дата регистрации домена 02.08.2026 (более 2 недель назад)
Ответ сервера
200 Успешный ответ
HTTP-код ответа и цепочка редиректов
?
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
Сервер настроен корректно.
Цепочка редиректов:
http://babymommy.ru
308 PermanentRedirect
https://babymommy.ru/
200 OK
Безопасность
Сайт безопасен
Использование HTTPS и SSL-сертификат
?
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
Не настроен HSTS (Strict-Transport-Security) — рекомендуется включить.
На сайте работает защищенный протокол ssl и сайт открывается по https.
Ssl-сертификат действителен до 01.11.2026 3:42:15.
Сервер поддерживает HTTP/3 (QUIC) — новейший протокол.
HTTP автоматически перенаправляется на HTTPS.
Поздравляем! Сайт не содержится в реестре РКН.
Кодировка
UTF-8
Кодировка символов страницы
?
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
Указана кодировка на странице UTF-8.
Язык
ru
Атрибут lang в HTML-теге
?
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
Указан язык документа ru.
Скорость загрузки
~0,23сек
Время отклика сервера (TTFB)
?
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
Скорость загрузки сайта 0,23сек оптимальна.
Объем документа
174Кб
Размер HTML-кода страницы
?
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
Объем html-документа 174Кб оптимален.
Структура html-документа корректна.
Ресурсы
Ресурсы: 2
Внешние ресурсы страницы (CSS, JS, изображения)
?
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
Кол-во файлов ресурсов 2 достаточно.
Показать полный список ресурсов
| Тип | Название | Значение |
|---|---|---|
| stylesheet | https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap | |
| js | https://telegram.org/js/telegram-web-app.js |
Серверные заголовки
Кол-во: 6
HTTP-заголовки ответа сервера
?
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
Найдены серверные заголовки 6шт. Подробнее про серверные заголовки.
Показать полный список серверных заголовков
| Ключ | Значение |
|---|---|
| Accept-Ranges | bytes |
| Alt-Svc | h3=":443"; ma=2592000 |
| Date | Wed, 19 Aug 2026 13:41:40 GMT |
| ETag | "6a7c3573-2dbb5" |
| Server | nginx/1.27.5 |
| Via | 1.1 Caddy |
CMS
Не определена
Система управления сайтом (движок)
?
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
CMS не определена. Вероятно, сайт самописный либо движок надёжно скрыт. Это не ошибка.
Веб-сервер
Программное обеспечение сервера
?
Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
Сайт работает на веб-сервере nginx.
Мета-теги Готовность: 32%
Title
Малыш рядом
Заголовок страницы в браузере и поисковой выдаче
?
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
Необходимо увеличить число символов в title (текущее значение мало: 11, минимум: 25, оптимально: от 40 до 45)
Дублей словоформ в title не найдено.
Description
Описание страницы в поисковой выдаче (сниппет)
?
Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
Установите мета-тег description!
Keywords
Список ключевых слов страницы (устаревший тег)
?
Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
Установите мета-тег keywords!
Канонический Url
Указывает поисковику основную версию страницы
?
Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен точно совпадать с URL проверяемой страницы. Неправильный canonical может передать ссылочный вес на другую страницу.
Рекомендуем прописать канонический Url.
Robots
Ошибок нет
Директивы для поисковых роботов на уровне страницы
?
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
Meta-тег robots не указан. Страница свободна для индексации.
Адаптивность
width=device-width, initial-scale=1, viewport-fit=cover
Настройка масштабирования на мобильных устройствах
?
Тег viewport (<meta name="viewport">) сообщает браузеру, как масштабировать страницу на мобильных. Стандарт: width=device-width, initial-scale=1. Отсутствие — признак отсутствия мобильной версии.
Meta-тег viewport со значением-константой width=device-width задаёт ширину страницы в соответствии с размером экрана.
Meta-тег viewport со значением initial-scale=1.0 определяет масштаб 1:1, т.е. «не масштабировать».
Разметка OpenGraph
Не найдено
Мета-теги для красивых превью в соцсетях
?
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
Разметка OpenGraph не задана. Страница не оптимизирована под социальные сети. Мета-теги с разметкой Og помогают социальным роботам лучше структурировать Ваш сайт.
Все мета-теги
Кол-во: 2
Полный список мета-тегов страницы
?
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
Найдены мета-теги 2шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
Показать полный список мета-тегов
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1, viewport-fit=cover |
| name | theme-color | #f7f7f9 |
Оптимизация Готовность: 60%
Структура
Ошибок нет
Семантические HTML-элементы страницы
?
Проверяет наличие основных структурных элементов: nav, header, footer, main. Корректная семантическая структура помогает поисковику понять архитектуру страницы.
Структура документа корректна (теги <html> и <body> присутствуют по одному на документ).
Контент
Есть ошибки
Объём и качество текстового содержимого
?
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
Слова из title 1 встречаются в тексте редко. Добавьте в контент страницы слова из тега <title>!
Среднее число слов в абзаце 7 слишком мало. Сделайте контент более читаемым!
Кол-во слов 376 слишком мало. Добавьте больше текста (минимум 400 слов)!
Абзацев с текстом 13 достаточно.
Кол-во знаков контента 2303 на странице оптимально.
Заголовки
Ошибок нет
Иерархия заголовков H1–H6
?
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
На странице присутствуют заголовки <h1> 1. Это прекрасно.
На странице присутствуют заголовки <h2> 19. Это хорошо.
Тошнота
2,45
Насколько одно слово доминирует в тексте
?
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
Тошнота страницы в пределах нормы 3.
Академич. тошнота
3,72%
Насколько текст перенасыщен ключевыми словами
?
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
Академическая тошнота не дотягивает до нормы 5-15%. Измените текст страницы!
Семантическое ядро
20
Наиболее часто встречающиеся слова на странице
?
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
Контент страницы содержит осмысленный текст и слова.
Показать список слов
| Слово | Кол-во | Частота |
|---|---|---|
| сегодня | 6 | 1,60% |
| малыша | 5 | 1,33% |
| кормления | 4 | 1,06% |
| сейчас | 4 | 1,06% |
| профиль | 3 | 0,80% |
| настроение | 3 | 0,80% |
| быстрый | 3 | 0,80% |
| кормление | 3 | 0,80% |
| история | 3 | 0,80% |
| бодрствование | 3 | 0,80% |
| детский | 3 | 0,80% |
| укажите | 2 | 0,53% |
| рождения | 2 | 0,53% |
| подгузники | 2 | 0,53% |
| бодрствует | 2 | 0,53% |
| болеет | 2 | 0,53% |
| добавить | 2 | 0,53% |
| данные | 2 | 0,53% |
| таймер | 2 | 0,53% |
| внести | 2 | 0,53% |
Индексация Готовность: 20%
Индексирование
Есть ошибки
Разрешено ли индексирование страницы
?
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
Анкоров на странице не обнаружено. Велика вероятность, что сайт - одностраничник, такой сайт продвинуть крайне затруднительно!
Robots.txt
Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Robots.txt настроен корректно. Размер файла: 178430 байт. Загружен за: 0сек.
Проверяемая страница не запрещена в robots.txt.
Robots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
http://babymommy.ru/robots.txt
308 PermanentRedirect
https://babymommy.ru/robots.txt
200 OK
Показать содержимое robots.txt
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
>
<meta name="theme-color" content="#f7f7f9">
<title>Малыш рядом</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
>
<style>
:root {
--bg: #f6f6f8;
--bg-soft: #eeeeF2;
--surface: rgba(255, 255, 255, 0.9);
--surface-solid: #ffffff;
--surface-strong: #fbfbfc;
--text: #242329;
--text-soft: #5d5a64;
--muted: #95919c;
--line: rgba(64, 58, 72, 0.09);
--accent: #b79ecb;
--accent-strong: #9479aa;
--accent-soft: #eee7f4;
--accent-rgb: 183, 158, 203;
--sage: #9daf9f;
--sage-soft: #e9f0ea;
--peach: #d8aa8f;
--peach-soft: #f7ebe4;
--blue: #8caec8;
--blue-soft: #e6f0f6;
--pink: #d99cab;
--pink-soft: #f8e9ed;
--yellow: #d5ae62;
--yellow-soft: #fbf2df;
--danger: #c87575;
--danger-soft: #f8e8e8;
--shadow:
0 18px 50px rgba(45, 38, 53, 0.07),
0 4px 14px rgba(45, 38, 53, 0.035);
--shadow-large:
0 24px 70px rgba(30, 24, 37, 0.16);
--radius-xl: 30px;
--radius-lg: 24px;
--radius-md: 18px;
--radius-sm: 14px;
}
body.gender-boy {
--accent: #8eb6d3;
--accent-strong: #678eaa;
--accent-soft: #e5f0f7;
--accent-rgb: 142, 182, 211;
}
body.gender-girl {
--accent: #d99dad;
--accent-strong: #b87589;
--accent-soft: #f8e7ec;
--accent-rgb: 217, 157, 173;
}
body.dark {
--bg: #151519;
--bg-soft: #1e1d23;
--surface: rgba(36, 34, 40, 0.9);
--surface-solid: #25232a;
--surface-strong: #2a282f;
--text: #f5f2f6;
--text-soft: #ccc6cf;
--muted: #9e98a3;
--line: rgba(255, 255, 255, 0.08);
--accent-soft: rgba(var(--accent-rgb), 0.16);
--sage-soft: #27332b;
--peach-soft: #382c27;
--blue-soft: #25323b;
--pink-soft: #392b31;
--yellow-soft: #383122;
--danger-soft: #3a282b;
--shadow:
0 18px 50px rgba(0, 0, 0, 0.22),
0 4px 14px rgba(0, 0, 0, 0.12);
--shadow-large:
0 24px 70px rgba(0, 0, 0, 0.36);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
min-height: 100%;
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100%;
display: flex;
justify-content: center;
color: var(--text);
background:
radial-gradient(
circle at 8% 0%,
rgba(var(--accent-rgb), 0.14),
transparent 32%
),
radial-gradient(
circle at 100% 12%,
rgba(157, 175, 159, 0.11),
transparent 32%
),
var(--bg);
font-family:
"Manrope",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
transition:
background 0.25s ease,
color 0.25s ease;
}
body.dark {
background:
radial-gradient(
circle at 8% 0%,
rgba(var(--accent-rgb), 0.08),
transparent 32%
),
radial-gradient(
circle at 100% 12%,
rgba(157, 175, 159, 0.06),
transparent 32%
),
var(--bg);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app {
width: min(100%, 500px);
min-height: 100vh;
position: relative;
overflow-x: hidden;
padding-bottom: 110px;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
padding:
calc(13px + env(safe-area-inset-top))
16px
11px;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--bg) 96%, transparent),
color-mix(in srgb, var(--bg) 88%, transparent),
transparent
);
backdrop-filter: blur(18px);
}
.topbar-inner {
display: flex;
align-items: center;
gap: 12px;
}
.baby-avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 17px;
background:
linear-gradient(
145deg,
var(--accent-soft),
color-mix(in srgb, var(--accent-soft) 42%, var(--surface-solid))
);
box-shadow: var(--shadow);
font-size: 23px;
}
.topbar-copy {
flex: 1;
min-width: 0;
}
.topbar-copy small {
display: block;
margin-bottom: 3px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
}
.topbar-copy strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.35px;
}
.topbar-actions {
display: flex;
gap: 8px;
}
.icon-button {
width: 43px;
height: 43px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 15px;
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow);
font-size: 17px;
}
.content {
padding: 2px 16px 28px;
}
.tab {
display: none;
}
.tab.active {
display: block;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 19px 2px 10px;
}
.section-heading h2 {
margin: 0;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.35px;
}
.section-heading p {
margin: 3px 0 0;
color: var(--muted);
font-size: 10px;
}
.text-button {
padding: 6px 2px;
border: 0;
background: transparent;
color: var(--accent-strong);
font-size: 12px;
font-weight: 800;
}
.hero-card {
position: relative;
overflow: hidden;
padding: 20px;
background:
linear-gradient(
145deg,
color-mix(in srgb, var(--accent-soft) 88%, var(--surface-solid)),
color-mix(in srgb, var(--sage-soft) 52%, var(--surface-solid))
);
}
.hero-card::after {
content: "";
position: absolute;
right: -65px;
bottom: -95px;
width: 190px;
height: 190px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.24);
}
.hero-eyebrow {
position: relative;
z-index: 2;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.hero-card h1 {
position: relative;
z-index: 2;
max-width: 87%;
margin: 7px 0 0;
font-size: 25px;
line-height: 1.14;
font-weight: 800;
letter-spacing: -0.8px;
}
.hero-card p {
position: relative;
z-index: 2;
margin: 9px 0 0;
color: var(--text-soft);
font-size: 12px;
line-height: 1.5;
}
.birthday-box {
position: relative;
z-index: 2;
display: none;
margin-top: 13px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 15px;
background:
color-mix(in srgb, var(--surface-solid) 66%, transparent);
font-size: 12px;
font-weight: 800;
}
.birthday-box.show {
display: block;
}
.status-grid {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 10px;
}
.main-status {
min-height: 148px;
padding: 17px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 0;
color: white;
background:
linear-gradient(
145deg,
color-mix(in srgb, var(--accent) 92%, #ffffff),
color-mix(in srgb, var(--accent-strong) 92%, #ffffff)
);
}
.main-status small {
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
font-weight: 700;
}
.main-status strong {
display: block;
margin-top: 7px;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.8px;
}
.main-status span {
color: rgba(255, 255, 255, 0.82);
font-size: 10px;
line-height: 1.4;
}
.side-statuses {
display: grid;
gap: 10px;
}
.small-status {
min-height: 69px;
padding: 13px;
}
.small-status small {
display: block;
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.small-status strong {
display: block;
margin-top: 5px;
font-size: 20px;
font-weight: 800;
}
.illness-banner {
display: none;
padding: 15px;
border-color:
color-mix(in srgb, var(--danger) 25%, transparent);
background:
linear-gradient(
145deg,
var(--danger-soft),
color-mix(in srgb, var(--danger-soft) 48%, var(--surface-solid))
);
}
.illness-banner.show {
display: block;
}
.illness-top {
display: flex;
align-items: flex-start;
gap: 12px;
}
.illness-icon {
width: 44px;
height: 44px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 15px;
background: rgba(255, 255, 255, 0.52);
font-size: 21px;
}
.illness-copy {
flex: 1;
min-width: 0;
}
.illness-copy strong {
display: block;
font-size: 14px;
font-weight: 800;
}
.illness-copy small {
display: block;
margin-top: 4px;
color: var(--text-soft);
font-size: 11px;
line-height: 1.4;
}
.illness-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.compact-button {
flex: 1;
padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 13px;
color: var(--text);
background: var(--surface-solid);
font-size: 10px;
font-weight: 800;
}
.compact-button.danger {
border: 0;
color: white;
background: var(--danger);
}
.mood-card {
padding: 16px;
}
.mood-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.mood-head strong {
font-size: 13px;
font-weight: 800;
}
.mood-head span {
color: var(--muted);
font-size: 10px;
}
.moods {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.mood-button {
min-width: 0;
padding: 9px 2px;
border: 1px solid transparent;
border-radius: 16px;
color: var(--muted);
background: transparent;
}
.mood-button b {
display: block;
font-size: 23px;
font-weight: 400;
}
.mood-button span {
display: block;
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 8px;
font-weight: 700;
}
.mood-button.active {
border-color:
color-mix(in srgb, var(--accent) 28%, transparent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.sleep-card {
padding: 17px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
background:
linear-gradient(
145deg,
var(--blue-soft),
color-mix(in srgb, var(--accent-soft) 48%, var(--surface-solid))
);
}
.sleep-copy small {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.sleep-copy strong {
font-size: 22px;
font-weight: 800;
letter-spacing: 0.5px;
}
.sleep-button {
min-width: 112px;
padding: 12px 13px;
border: 0;
border-radius: 15px;
color: white;
background: var(--blue);
font-size: 11px;
font-weight: 800;
}
.sleep-button.running {
background: var(--danger);
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
}
.quick-button {
min-height: 94px;
padding: 10px 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 21px;
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow);
}
.quick-icon {
width: 43px;
height: 43px;
display: grid;
place-items: center;
border-radius: 15px;
background: var(--accent-soft);
font-size: 20px;
}
.quick-button:nth-child(2) .quick-icon {
background: var(--peach-soft);
}
.quick-button:nth-child(3) .quick-icon {
background: var(--sage-soft);
}
.quick-button:nth-child(4) .quick-icon {
background: var(--danger-soft);
}
.quick-button:nth-child(5) .quick-icon {
background: var(--blue-soft);
}
.quick-button:nth-child(6) .quick-icon {
background: var(--bg-soft);
}
.quick-button span {
font-size: 10px;
font-weight: 800;
}
.weather-card {
padding: 17px;
background:
linear-gradient(
145deg,
var(--blue-soft),
color-mix(in srgb, var(--sage-soft) 58%, var(--surface-solid))
);
}
.weather-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.weather-location {
color: var(--muted);
font-size: 10px;
font-weight: 700;
}
.weather-temperature {
margin-top: 5px;
font-size: 30px;
font-weight: 800;
letter-spacing: -1px;
}
.weather-icon {
font-size: 39px;
}
.weather-clothes {
margin-top: 13px;
padding: 12px 13px;
border: 1px solid var(--line);
border-radius: 16px;
background:
color-mix(in srgb, var(--surface-solid) 68%, transparent);
font-size: 11px;
line-height: 1.55;
}
.weather-note {
margin-top: 9px;
color: var(--muted);
font-size: 9px;
line-height: 1.45;
}
.list {
overflow: hidden;
}
.list-row {
display: flex;
align-items: center;
gap: 11px;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
}
.list-row:last-child {
border-bottom: 0;
}
.list-icon {
width: 40px;
height: 40px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 14px;
background: var(--accent-soft);
font-size: 18px;
}
.list-copy {
flex: 1;
min-width: 0;
}
.list-copy strong {
display: block;
font-size: 12px;
font-weight: 800;
}
.list-copy small {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 9px;
line-height: 1.4;
}
.pill {
flex: 0 0 auto;
padding: 6px 8px;
border-radius: 999px;
color: var(--sage);
background: var(--sage-soft);
font-size: 8px;
font-weight: 800;
}
.pill.warning {
color: var(--yellow);
background: var(--yellow-soft);
}
.pill.danger {
color: var(--danger);
background: var(--danger-soft);
}
.events-list {
display: grid;
gap: 8px;
}
.event-card {
padding: 12px 13px;
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 10px;
}
.event-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 14px;
background: var(--bg-soft);
font-size: 18px;
}
.event-card strong {
display: block;
font-size: 12px;
font-weight: 800;
}
.event-card small {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 9px;
line-height: 1.4;
}
.event-time {
color: var(--muted);
font-size: 9px;
}
.empty {
padding: 24px 17px;
text-align: center;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
.segmented {
display: flex;
gap: 3px;
margin-bottom: 11px;
padding: 4px;
border-radius: 16px;
background: var(--bg-soft);
}
.segmented button {
flex: 1;
min-width: 0;
padding: 8px 3px;
border: 0;
border-radius: 12px;
color: var(--muted);
background: transparent;
font-size: 9px;
font-weight: 700;
}
.segmented button.active {
color: var(--text);
background: var(--surface-solid);
box-shadow: var(--shadow);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 9px;
}
.metric-card {
padding: 14px;
}
.metric-card small {
display: block;
color: var(--muted);
font-size: 9px;
font-weight: 600;
}
.metric-card strong {
display: block;
margin-top: 6px;
font-size: 21px;
font-weight: 800;
letter-spacing: -0.5px;
}
.metric-card span {
display: block;
margin-top: 4px;
color: var(--sage);
font-size: 8px;
font-weight: 700;
}
.chart-card {
padding: 15px;
}
.chart-title {
font-size: 11px;
font-weight: 800;
}
.bars {
height: 145px;
margin-top: 16px;
display: flex;
align-items: end;
gap: 7px;
}
.bar-column {
flex: 1;
text-align: center;
}
.bar {
min-height: 7px;
border-radius: 9px 9px 4px 4px;
background:
linear-gradient(
180deg,
var(--accent),
var(--accent-strong)
);
}
.bar-label {
margin-top: 6px;
color: var(--muted);
font-size: 8px;
font-weight: 700;
}
.mood-chart {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-top: 14px;
}
.mood-day {
text-align: center;
}
.mood-emoji {
width: 37px;
height: 37px;
margin: 0 auto;
display: grid;
place-items: center;
border-radius: 13px;
background: var(--accent-soft);
font-size: 17px;
}
.mood-day small {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 8px;
font-weight: 700;
}
.insight {
margin-bottom: 8px;
padding: 13px;
border: 1px solid
color-mix(in srgb, var(--sage) 20%, transparent);
border-radius: 17px;
color: var(--text-soft);
background: var(--sage-soft);
font-size: 10px;
line-height: 1.55;
}
.profile-card {
padding: 20px;
text-align: center;
}
.profile-avatar {
width: 86px;
height: 86px;
margin: 0 auto 11px;
display: grid;
place-items: center;
border-radius: 29px;
background:
linear-gradient(
145deg,
var(--accent-soft),
color-mix(in srgb, var(--accent-soft) 48%, var(--surface-solid))
);
font-size: 40px;
}
.profile-card h2 {
margin: 0;
font-size: 21px;
font-weight: 800;
letter-spacing: -0.5px;
}
.profile-card p {
margin: 6px 0 0;
color: var(--muted);
font-size: 11px;
}
.settings-list {
overflow: hidden;
}
.settings-row {
width: 100%;
padding: 14px;
display: flex;
align-items: center;
gap: 11px;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--text);
background: transparent;
text-align: left;
}
.settings-row:last-child {
border-bottom: 0;
}
.settings-row .list-icon {
background: var(--bg-soft);
}
.settings-arrow {
color: var(--muted);
font-size: 18px;
}
.bottom-nav {
position: fixed;
left: 50%;
bottom: 0;
z-index: 40;
width: min(100%, 500px);
display: grid;
grid-template-columns: repeat(5, 1fr);
transform: translateX(-50%);
padding:
8px
10px
calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background:
color-mix(in srgb, var(--surface-solid) 90%, transparent);
backdrop-filter: blur(20px);
}
.nav-button {
padding: 7px 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: 0;
color: var(--muted);
background: transparent;
font-size: 9px;
font-weight: 700;
}
.nav-button b {
font-size: 20px;
font-weight: 500;
line-height: 1;
}
.nav-button.active {
color: var(--accent-strong);
}
.fab {
position: fixed;
right: max(
17px,
calc((100vw - 500px) / 2 + 17px)
);
bottom: 94px;
z-index: 38;
width: 57px;
height: 57px;
border: 0;
border-radius: 19px;
color: white;
background:
linear-gradient(
145deg,
var(--accent),
var(--accent-strong)
);
box-shadow:
0 17px 34px rgba(var(--accent-rgb), 0.34);
font-size: 27px;
font-weight: 400;
}
.modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: flex-end;
justify-content: center;
background: rgba(28, 24, 31, 0.44);
}
.modal.open {
display: flex;
}
.sheet {
width: min(100%, 500px);
max-height: 91vh;
overflow-y: auto;
padding:
18px
16px
calc(24px + env(safe-area-inset-bottom));
border-radius: 29px 29px 0 0;
color: var(--text);
background: var(--surface-solid);
box-shadow: var(--shadow-large);
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.sheet-header h3 {
margin: 0;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.4px;
}
.close-button {
width: 38px;
height: 38px;
flex: 0 0 auto;
border: 0;
border-radius: 13px;
color: var(--text);
background: var(--bg-soft);
font-size: 18px;
}
.form {
display: grid;
gap: 11px;
}
.field label {
display: block;
margin: 0 0 6px 2px;
color: var(--muted);
font-size: 9px;
font-weight: 700;
}
.field input,
.field select,
.field textarea {
width: 100%;
padding: 12px 13px;
border: 1px solid var(--line);
border-radius: 15px;
outline: none;
color: var(--text);
background: var(--bg);
}
.field textarea {
min-height: 85px;
resize: vertical;
}
.primary-button,
.secondary-button,
.danger-button {
width: 100%;
padding: 13px;
border-radius: 16px;
font-size: 11px;
font-weight: 800;
}
.primary-button {
border: 0;
color: white;
background:
linear-gradient(
145deg,
var(--accent),
var(--accent-strong)
);
}
.secondary-button {
border: 1px solid var(--line);
color: var(--text);
background: var(--surface-solid);
}
.danger-button {
border: 0;
color: white;
background: var(--danger);
}
.event-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
}
.event-button {
min-height: 84px;
padding: 9px 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--line);
border-radius: 18px;
color: var(--text);
background: var(--bg);
}
.event-button b {
font-size: 22px;
font-weight: 400;
}
.event-button span {
font-size: 9px;
font-weight: 800;
}
.gender-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.gender-option {
padding: 12px 6px;
border: 1px solid var(--line);
border-radius: 16px;
color: var(--text);
background: var(--bg);
font-size: 10px;
font-weight: 800;
}
.gender-option.active {
border-color:
color-mix(in srgb, var(--accent) 50%, transparent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.symptoms-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.symptom-option {
padding: 11px 9px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--text-soft);
background: var(--bg);
text-align: left;
font-size: 9px;
font-weight: 700;
}
.symptom-option.selected {
border-color:
color-mix(in srgb, var(--danger) 38%, transparent);
color: var(--danger);
background: var(--danger-soft);
}
.illness-history-card {
margin-bottom: 9px;
padding: 14px;
}
.illness-history-card strong {
display: block;
font-size: 12px;
font-weight: 800;
}
.illness-history-card small {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 9px;
line-height: 1.5;
}
.toast {
position: fixed;
left: 50%;
bottom: 106px;
z-index: 200;
padding: 10px 14px;
border-radius: 13px;
color: white;
background: #332f35;
opacity: 0;
pointer-events: none;
transform:
translateX(-50%)
translateY(15px);
transition: 0.22s ease;
font-size: 10px;
font-weight: 700;
}
.toast.show {
opacity: 1;
transform:
translateX(-50%)
translateY(0);
}
.garden-summary { padding: 17px; background: linear-gradient(145deg,var(--sage-soft),color-mix(in srgb,var(--accent-soft) 52%,var(--surface-solid))); }
.garden-summary-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
.garden-stat { padding:11px 7px; border:1px solid var(--line); border-radius:15px; background:color-mix(in srgb,var(--surface-solid) 72%,transparent); text-align:center; }
.garden-stat strong { display:block; font-size:18px; font-weight:800; }
.garden-stat small { display:block; margin-top:3px; color:var(--muted); font-size:8px; }
.garden-filters { display:flex; gap:6px; overflow-x:auto; padding-bottom:3px; scrollbar-width:none; }
.garden-filters::-webkit-scrollbar { display:none; }
.garden-filter { flex:0 0 auto; padding:8px 11px; border:1px solid var(--line); border-radius:999px; background:var(--surface); color:var(--muted); font-size:9px; font-weight:800; }
.garden-filter.active { color:var(--accent-strong); background:var(--accent-soft); border-color:color-mix(in srgb,var(--accent) 35%,transparent); }
.garden-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:9px; }
.garden-child { padding:14px; position:relative; overflow:hidden; }
.garden-child.mine { border-color:color-mix(in srgb,var(--accent) 45%,transparent); }
.garden-child-top { display:flex; align-items:center; gap:10px; }
.garden-child-avatar { width:43px; height:43px; display:grid; place-items:center; border-radius:15px; background:var(--accent-soft); font-size:21px; }
.garden-child strong { display:block; font-size:12px; font-weight:800; }
.garden-child small { display:block; margin-top:3px; color:var(--muted); font-size:8px; }
.garden-mood { margin-top:11px; padding:9px 10px; border-radius:13px; background:var(--bg-soft); font-size:9px; font-weight:700; }
.garden-seen { margin-top:8px; color:var(--muted); font-size:8px; }
.garden-badge { position:absolute; top:10px; right:10px; padding:4px 7px; border-radius:999px; background:var(--accent-soft); color:var(--accent-strong); font-size:7px; font-weight:800; }
.privacy-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 13px; border:1px solid var(--line); border-radius:15px; background:var(--bg); }
.privacy-row strong { display:block; font-size:10px; }
.privacy-row small { display:block; margin-top:3px; color:var(--muted); font-size:8px; line-height:1.35; }
.privacy-row input { width:20px; height:20px; accent-color:var(--accent-strong); }
/* v4: плавность, редактирование и диаграмма сна */
.tab.active { animation: tabIn .32s ease both; }
.card, .quick-button, .event-card, .settings-row, .nav-button, .sleep-button, .primary-button { transition: transform .18s ease, box-shadow .18s ease, background .2s ease, opacity .2s ease; }
.quick-button:active, .event-card:active, .settings-row:active { transform: scale(.985); }
.card { animation: cardIn .34s ease both; }
@keyframes tabIn { from { opacity:0; transform:translateY(8px) } to { opacity:1; transform:none } }
@keyframes cardIn { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:none } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation:none!important; transition:none!important; scroll-behavior:auto!important; } }
.event-actions { display:flex; gap:5px; align-items:center; }
.event-action { width:30px; height:30px; display:grid; place-items:center; border:0; border-radius:10px; background:var(--bg-soft); color:var(--muted); font-size:13px; }
.event-action.delete { color:var(--danger); background:var(--danger-soft); }
.event-time-wrap { display:grid; justify-items:end; gap:6px; }
.analytics-switch { margin: 2px 0 12px; }
.analytics-pane { display:none; }
.analytics-pane.active { display:block; animation:tabIn .28s ease both; }
.sleep-day-card { padding:16px; overflow:hidden; }
.sleep-date-row { display:flex; align-items:center; gap:9px; margin-bottom:14px; }
.sleep-date-row input { flex:1; padding:10px 12px; border:1px solid var(--line); border-radius:13px; background:var(--bg); color:var(--text); }
.sleep-summary-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-bottom:15px; }
.sleep-summary-item { padding:11px 7px; border-radius:15px; background:var(--bg-soft); text-align:center; }
.sleep-summary-item strong { display:block; font-size:15px; font-weight:800; }
.sleep-summary-item small { display:block; margin-top:3px; color:var(--muted); font-size:8px; }
.sleep-timeline { position:relative; height:48px; border-radius:16px; overflow:hidden; background:var(--bg-soft); display:flex; box-shadow:inset 0 0 0 1px var(--line); }
.sleep-segment { height:100%; min-width:2px; position:relative; display:flex; align-items:center; justify-content:center; overflow:hidden; transition:width .35s ease; }
.sleep-segment.awake { background:color-mix(in srgb,var(--yellow-soft) 80%,var(--surface-solid)); }
.sleep-segment.day { background:linear-gradient(145deg,var(--blue-soft),var(--blue)); color:#fff; }
.sleep-segment.night { background:linear-gradient(145deg,#4f536f,#26283a); color:#fff; }
.sleep-segment span { font-size:7px; font-weight:800; white-space:nowrap; text-shadow:0 1px 2px rgba(0,0,0,.12); }
.sleep-axis { display:grid; grid-template-columns:repeat(5,1fr); margin-top:7px; color:var(--muted); font-size:8px; }
.sleep-axis span:last-child { text-align:right; }
.sleep-legend { display:flex; flex-wrap:wrap; gap:10px; margin-top:13px; color:var(--muted); font-size:9px; }
.sleep-legend i { width:9px; height:9px; border-radius:3px; display:inline-block; margin-right:4px; vertical-align:-1px; }
.sleep-periods { display:grid; gap:8px; margin-top:14px; }
.sleep-period { padding:11px 12px; border-radius:15px; background:var(--bg-soft); display:flex; justify-content:space-between; gap:10px; font-size:9px; }
.sleep-period strong { font-size:10px; }
.avatar-photo { width:100%; height:100%; object-fit:cover; border-radius:inherit; display:block; }
.avatar-picker { display:grid; grid-template-columns:74px 1fr; gap:12px; align-items:center; }
.avatar-preview { width:74px; height:74px; border-radius:24px; overflow:hidden; display:grid; place-items:center; background:var(--accent-soft); font-size:34px; border:1px solid var(--line); }
.avatar-actions { display:grid; gap:8px; }
.feeding-card { padding:17px; display:flex; align-items:center; justify-content:space-between; gap:14px; background:linear-gradient(145deg,var(--peach-soft),color-mix(in srgb,var(--accent-soft) 38%,var(--surface-solid))); }
.feeding-copy small { display:block; margin-bottom:4px; color:var(--muted); font-size:10px; font-weight:600; }
.feeding-copy strong { font-size:22px; font-weight:800; letter-spacing:.5px; }
.feeding-button { min-width:112px; padding:12px 13px; border:0; border-radius:15px; color:white; background:var(--peach); font-size:11px; font-weight:800; }
.feeding-button.running { background:var(--danger); }
.chat-card { padding:15px; }
.chat-list { display:grid; gap:9px; max-height:390px; overflow-y:auto; padding:2px; }
.chat-message { max-width:88%; padding:10px 12px; border-radius:16px 16px 16px 5px; background:var(--bg-soft); }
.chat-message.mine { margin-left:auto; border-radius:16px 16px 5px 16px; background:var(--accent-soft); }
.chat-message strong { display:block; font-size:9px; font-weight:800; margin-bottom:4px; }
.chat-message p { margin:0; font-size:10px; line-height:1.45; white-space:pre-wrap; overflow-wrap:anywhere; }
.chat-message small { display:block; margin-top:5px; color:var(--muted); font-size:7px; }
.chat-compose { display:grid; grid-template-columns:1fr auto; gap:8px; margin-top:12px; }
.chat-compose textarea { min-height:44px; max-height:100px; resize:vertical; padding:11px 12px; border:1px solid var(--line); border-radius:15px; background:var(--bg); color:var(--text); }
.chat-send { padding:0 15px; border:0; border-radius:15px; color:#fff; background:var(--accent-strong); font-size:10px; font-weight:800; }
.chat-meta { margin-top:8px; color:var(--muted); font-size:8px; line-height:1.4; }
.wake-period { border-left:3px solid var(--yellow); }
.notification-button { position: relative; }
.notification-badge {
position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px;
padding: 0 5px; display: grid; place-items: center; border: 2px solid var(--bg);
border-radius: 999px; color: #fff; background: var(--danger); font-size: 9px; font-weight: 800;
}
.notification-list { display: grid; gap: 10px; }
.notification-card {
padding: 15px; display: flex; gap: 12px; align-items: flex-start;
border: 1px solid var(--line); border-radius: 18px; color: var(--text);
background: var(--bg); text-decoration: none; transition: transform .18s ease, background .2s ease;
}
.notification-card:active { transform: scale(.985); }
.notification-card-icon {
width: 42px; height: 42px; flex: 0 0 auto; display: grid; place-items: center;
border-radius: 14px; background: var(--accent-soft); font-size: 20px;
}
.notification-card strong { display:block; font-size:12px; font-weight:800; }
.notification-card small { display:block; margin-top:5px; color:var(--muted); font-size:9px; line-height:1.5; }
.notification-card .notification-link { display:block; margin-top:8px; color:var(--accent-strong); font-size:10px; font-weight:800; }
.weather-updated { margin-top: 8px; color: var(--muted); font-size: 8px; }
.weather-refreshing { opacity: .62; }
@media (max-width: 370px) {
.status-grid {
grid-template-columns: 1fr;
}
.side-statuses {
grid-template-columns: repeat(2, 1fr);
}
.moods {
gap: 2px;
}
.mood-button span {
font-size: 7px;
}
}
/* v7: feeding interval + period report */
.feeding-gap-card {
margin-top: 10px;
padding: 13px 15px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feeding-gap-card small { display:block; color:var(--muted); font-size:9px; font-weight:700; }
.feeding-gap-card strong { display:block; margin-top:4px; font-size:17px; font-weight:800; }
.feeding-gap-note { color:var(--muted); font-size:9px; text-align:right; line-height:1.35; }
.report-presets { display:grid; grid-template-columns:repeat(3,1fr); gap:7px; margin-bottom:12px; }
.report-preset { padding:9px 6px; border:1px solid var(--line); border-radius:13px; background:var(--bg); color:var(--text-soft); font-size:9px; font-weight:800; }
.report-preset.active { border-color:color-mix(in srgb,var(--accent) 45%,transparent); background:var(--accent-soft); color:var(--accent-strong); }
.report-range { display:grid; grid-template-columns:1fr 1fr; gap:9px; margin-bottom:11px; }
.report-output { margin-top:12px; padding:14px; border:1px solid var(--line); border-radius:17px; background:var(--bg); color:var(--text-soft); font-size:10px; line-height:1.55; white-space:pre-wrap; max-height:42vh; overflow:auto; }
.report-actions { display:grid; grid-template-columns:1fr 1fr; gap:9px; margin-top:11px; }
.report-chip-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-top:11px; }
.report-chip { padding:11px; border-radius:15px; background:var(--bg-soft); }
.report-chip small { display:block; color:var(--muted); font-size:8px; }
.report-chip strong { display:block; margin-top:4px; font-size:14px; font-weight:800; }
</style>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="topbar-inner">
<div class="baby-avatar" id="headerAvatar">
👶
</div>
<div class="topbar-copy">
<small id="greeting">
Доброе утро
</small>
<strong id="headerTitle">
Настройте профиль малыша
</strong>
</div>
<div class="topbar-actions">
<button
class="icon-button notification-button"
onclick="openNotifications()"
aria-label="События"
>
🔔
<span class="notification-badge" id="notificationBadge">2</span>
</button>
<button
class="icon-button"
onclick="toggleTheme()"
aria-label="Сменить тему"
>
<span id="themeIcon">☾</span>
</button>
<button
class="icon-button"
onclick="openProfileEditor()"
aria-label="Настройки"
>
⚙
</button>
</div>
</div>
</header>
<main class="content">
<section class="tab active" id="today">
<div class="card hero-card">
<div class="hero-eyebrow">
Ваш день вместе
</div>
<h1 id="heroTitle">
Давайте познакомимся
</h1>
<p id="heroSubtitle">
Укажите имя, дату рождения и пол малыша.
</p>
<div
class="birthday-box"
id="birthdayBox"
></div>
</div>
<div class="section-heading">
<h2>Сегодня</h2>
<button
class="text-button"
onclick="openReport()"
>
Отчёт
</button>
</div>
<div class="status-grid">
<div class="card main-status">
<div>
<small>Сон за сутки</small>
<strong id="sleepTotal">
0 ч 00 мин
</strong>
</div>
<span id="lastSleepText">
Сон ещё не добавлен
</span>
</div>
<div class="side-statuses">
<div class="card small-status">
<small>Кормления</small>
<strong id="feedingCount">0</strong>
</div>
<div class="card small-status">
<small>Подгузники</small>
<strong id="diaperCount">0</strong>
</div>
<div class="card small-status">
<small>Бодрствует</small>
<strong id="awakeCurrent">—</strong>
</div>
</div>
</div>
<div class="card feeding-gap-card">
<div>
<small>Между кормлениями</small>
<strong id="feedingGapCurrent">—</strong>
</div>
<div class="feeding-gap-note" id="feedingGapNote">Кормления ещё не добавлены</div>
</div>
<div
class="card illness-banner"
id="illnessBanner"
>
<div class="illness-top">
<div class="illness-icon">
🤒
</div>
<div class="illness-copy">
<strong id="illnessTitle">
Малыш болеет
</strong>
<small id="illnessDescription">
Симптомы не указаны
</small>
</div>
</div>
<div class="illness-actions">
<button
class="compact-button"
onclick="openCurrentIllness()"
>
Добавить данные
</button>
<button
class="compact-button danger"
onclick="finishIllness()"
>
Выздоровел
</button>
</div>
</div>
<div class="section-heading">
<h2>Настроение</h2>
</div>
<div class="card mood-card">
<div class="mood-head">
<strong>Как малыш себя чувствует?</strong>
<span id="currentMoodText">
Не отмечено
</span>
</div>
<div class="moods">
<button
class="mood-button"
data-mood="great"
onclick="setMood('great')"
>
<b>😊</b>
<span>Отлично</span>
</button>
<button
class="mood-button"
data-mood="calm"
onclick="setMood('calm')"
>
<b>🙂</b>
<span>Спокойно</span>
</button>
<button
class="mood-button"
data-mood="fussy"
onclick="setMood('fussy')"
>
<b>😣</b>
<span>Капризно</span>
</button>
<button
class="mood-button"
data-mood="sad"
onclick="setMood('sad')"
>
<b>😢</b>
<span>Грустно</span>
</button>
<button
class="mood-button"
data-mood="tired"
onclick="setMood('tired')"
>
<b>😴</b>
<span>Вялость</span>
</button>
</div>
</div>
<div class="section-heading">
<div>
<h2>Сон</h2>
<p>Быстрый таймер сна</p>
</div>
<button
class="text-button"
onclick="openEventForm('sleep')"
>
Внести вручную
</button>
</div>
<div class="card sleep-card">
<div class="sleep-copy">
<small id="sleepStatus">
Сейчас не спит
</small>
<strong id="sleepTimer">
00:00:00
</strong>
</div>
<button
class="sleep-button"
id="sleepButton"
onclick="toggleSleep()"
>
Начать сон
</button>
</div>
<div class="section-heading">
<div><h2>Кормление</h2><p>Быстрый таймер грудного кормления</p></div>
<button class="text-button" onclick="openEventForm('feeding')">Внести вручную</button>
</div>
<div class="card feeding-card">
<div class="feeding-copy">
<small id="feedingStatus">Сейчас не кормит</small>
<strong id="feedingTimer">00:00:00</strong>
</div>
<button class="feeding-button" id="feedingButton" onclick="toggleFeeding()">Начать кормление</button>
</div>
<div class="section-heading">
<div>
<h2>Быстрый ввод</h2>
<p>Самые частые действия</p>
</div>
<button
class="text-button"
onclick="openAddMenu()"
>
Все
</button>
</div>
<div class="quick-grid">
<button
class="quick-button"
onclick="openEventForm('feeding')"
>
<div class="quick-icon">🍼</div>
<span>Кормление</span>
</button>
<button
class="quick-button"
onclick="openEventForm('diaper')"
>
<div class="quick-icon">🧷</div>
<span>Подгузник</span>
</button>
<button
class="quick-button"
onclick="openEventForm('food')"
>
<div class="quick-icon">🥣</div>
<span>Прикорм</span>
</button>
<button
class="quick-button"
onclick="openIllnessForm()"
>
<div class="quick-icon">🤒</div>
<span>Болеет</span>
</button>
<button
class="quick-button"
onclick="openDoctorForm()"
>
<div class="quick-icon">🩺</div>
<span>Врач</span>
</button>
<button
class="quick-button"
onclick="openAddMenu()"
>
<div class="quick-icon">+</div>
<span>Другие</span>
</button>
</div>
<div class="section-heading">
<div>
<h2>Baby Weather</h2>
<p>Что надеть на прогулку</p>
</div>
<button
class="text-button"
onclick="openWeatherSettings()"
>
Настроить
</button>
</div>
<div class="card weather-card">
<div class="weather-top">
<div>
<div
class="weather-location"
id="weatherLocation"
>
Погода не настроена
</div>
<div
class="weather-temperature"
id="weatherTemperature"
>
—°
</div>
</div>
<div
class="weather-icon"
id="weatherIcon"
>
🌤️
</div>
</div>
<div
class="weather-clothes"
id="weatherClothes"
>
Укажите температуру и тип прогулки.
</div>
<div class="weather-note">
Рекомендация ориентировочная. Проверяйте шею
и спину малыша через 10–15 минут прогулки.
<div class="weather-updated" id="weatherUpdated">Погода загрузится автоматически по городу из профиля.</div>
</div>
</div>
<div class="section-heading">
<div>
<h2>Ближайшее</h2>
<p>Врачи и напоминания</p>
</div>
<button
class="text-button"
onclick="openDoctorForm()"
>
Добавить
</button>
</div>
<div
class="card list"
id="upcomingList"
></div>
<div class="section-heading">
<div>
<h2>Последние события</h2>
<p>Всё, что происходило сегодня</p>
</div>
<button
class="text-button"
onclick="switchTab('journal')"
>
Все
</button>
</div>
<div
class="events-list"
id="recentEvents"
></div>
</section>
<section class="tab" id="journal">
<div class="section-heading">
<div>
<h2>Дневник</h2>
<p>История всех событий</p>
</div>
<button
class="text-button"
onclick="clearEvents()"
>
Очистить
</button>
</div>
<div class="segmented">
<button
class="active"
onclick="setJournalFilter('all', this)"
>
Все
</button>
<button
onclick="setJournalFilter('sleep', this)"
>
Сон
</button>
<button
onclick="setJournalFilter('food', this)"
>
Еда
</button>
<button
onclick="setJournalFilter('health', this)"
>
Здоровье
</button>
<button
onclick="setJournalFilter('mood', this)"
>
Настроение
</button>
</div>
<div
class="events-list"
id="journalEvents"
></div>
</section>
<section class="tab" id="analytics">
<div class="segmented analytics-switch">
<button class="active" onclick="switchAnalyticsPane('overview', this)">Обзор</button>
<button onclick="switchAnalyticsPane('sleepday', this)">Сон за день</button>
</div>
<div class="analytics-pane active" id="analyticsOverview">
<div class="section-heading">
<div>
<h2>Аналитика</h2>
<p>Тренды и наблюдения</p>
</div>
<button
class="text-button"
onclick="openReport()"
>
Отчёт
</button>
</div>
<div class="metric-grid">
<div class="card metric-card">
<small>Средний сон</small>
<strong id="averageSleep">0 ч</strong>
<span>за 7 дней</span>
</div>
<div class="card metric-card">
<small>Кормления</small>
<strong id="weekFeedings">0</strong>
<span>за 7 дней</span>
</div>
<div class="card metric-card">
<small>Болезней</small>
<strong id="monthIllnesses">0</strong>
<span>за 30 дней</span>
</div>
<div class="card metric-card">
<small>Текущий вес</small>
<strong id="latestWeight">—</strong>
<span>последняя запись</span>
</div>
</div>
<div class="section-heading">
<h2>Сон по дням</h2>
</div>
<div class="card chart-card">
<div class="chart-title">
Последние 7 дней
</div>
<div
class="bars"
id="sleepChart"
></div>
</div>
<div class="section-heading">
<h2>Настроение</h2>
</div>
<div class="card chart-card">
<div class="chart-title">
Последняя отметка каждого дня
</div>
<div
class="mood-chart"
id="moodChart"
></div>
</div>
<div class="section-heading">
<h2>Наблюдения</h2>
</div>
<div id="insights"></div>
</div>
<div class="analytics-pane" id="analyticsSleepday">
<div class="section-heading"><div><h2>Сон и бодрствование</h2><p>Дневной ритм по часам</p></div></div>
<div class="card sleep-day-card">
<div class="sleep-date-row">
<input type="date" id="sleepDiagramDate" onchange="renderSleepDayDiagram()">
<button class="text-button" onclick="setSleepDiagramToday()">Сегодня</button>
</div>
<div class="sleep-summary-grid">
<div class="sleep-summary-item"><strong id="diagramSleepTotal">0 ч</strong><small>всего сна</small></div>
<div class="sleep-summary-item"><strong id="diagramAwakeTotal">0 ч</strong><small>бодрствование</small></div>
<div class="sleep-summary-item"><strong id="diagramCurrentAwake">—</strong><small>сейчас бодрствует</small></div>
</div>
<div class="sleep-timeline" id="sleepTimeline"></div>
<div class="sleep-axis"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
<div class="sleep-legend"><span><i style="background:var(--blue)"></i>Дневной сон</span><span><i style="background:#35384f"></i>Ночной сон</span><span><i style="background:var(--yellow-soft)"></i>Бодрствование</span></div>
<div class="sleep-periods" id="sleepPeriods"></div>
</div>
</div>
</section>
<section class="tab" id="garden">
<div class="section-heading">
<div><h2>Детский сад</h2><p>Общее пространство малышей OnlyMoms</p></div>
<button class="text-button" onclick="loadGarden(true)">Обновить</button>
</div>
<div class="card garden-summary">
<strong style="font-size:14px">Сегодня в садике</strong>
<div class="garden-summary-grid">
<div class="garden-stat"><strong id="gardenTotal">0</strong><small>всего детей</small></div>
<div class="garden-stat"><strong id="gardenOnline">0</strong><small>сейчас онлайн</small></div>
<div class="garden-stat"><strong id="gardenToday">0</strong><small>заходили сегодня</small></div>
</div>
</div>
<div class="section-heading"><h2>Малыши</h2></div>
<div class="garden-filters">
<button class="garden-filter active" data-garden-filter="all" onclick="setGardenFilter('all',this)">Все</button>
<button class="garden-filter" data-garden-filter="online" onclick="setGardenFilter('online',this)">Онлайн</button>
<button class="garden-filter" data-garden-filter="boy" onclick="setGardenFilter('boy',this)">Мальчики</button>
<button class="garden-filter" data-garden-filter="girl" onclick="setGardenFilter('girl',this)">Девочки</button>
</div>
<div class="garden-grid" id="gardenGrid" style="margin-top:10px">
<div class="card empty" style="grid-column:1/-1">Откройте приложение через Telegram, чтобы увидеть детский сад.</div>
</div>
<div class="section-heading">
<div><h2>Чат мам</h2><p>Сообщения автоматически удаляются через сутки</p></div>
<button class="text-button" onclick="loadGardenChat(true)">Обновить</button>
</div>
<div class="card chat-card">
<div class="chat-list" id="gardenChatList"><div class="empty">Откройте приложение через Telegram, чтобы читать чат.</div></div>
<div class="chat-compose">
<textarea id="gardenChatInput" maxlength="500" placeholder="Напишите сообщение…"></textarea>
<button class="chat-send" onclick="sendGardenMessage()">Отправить</button>
</div>
<div class="chat-meta" id="gardenChatMeta">Не более 2 сообщений в день от одного пользователя.</div>
</div>
</section>
<section class="tab" id="profile">
<div class="card profile-card">
<div class="profile-avatar" id="profileAvatar">
👶
</div>
<h2 id="profileName">
Малыш
</h2>
<p id="profileAge">
Профиль пока не заполнен
</p>
</div>
<div class="section-heading">
<h2>Профиль малыша</h2>
</div>
<div class="card settings-list">
<button
class="settings-row"
onclick="openProfileEditor()"
>
<span class="list-icon">👶</span>
<span class="list-copy">
<strong>Данные малыша</strong>
<small>Имя, дата рождения, пол и город</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openIllnessHistory()"
>
<span class="list-icon">🤒</span>
<span class="list-copy">
<strong>История болезней</strong>
<small id="illnessSummary">Нет записей</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openEventForm('weight')"
>
<span class="list-icon">⚖️</span>
<span class="list-copy">
<strong>Рост и вес</strong>
<small id="growthSummary">Нет данных</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openDoctorList()"
>
<span class="list-icon">🩺</span>
<span class="list-copy">
<strong>Записи к врачам</strong>
<small>Визиты и напоминания</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openEventForm('vaccine')"
>
<span class="list-icon">💉</span>
<span class="list-copy">
<strong>Прививки</strong>
<small>История и реакции</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openSizes()"
>
<span class="list-icon">👕</span>
<span class="list-copy">
<strong>Размеры</strong>
<small id="sizesSummary">
Одежда и подгузники
</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="openShopping()"
>
<span class="list-icon">🛍️</span>
<span class="list-copy">
<strong>Покупки</strong>
<small id="shoppingSummary">
Список покупок пуст
</small>
</span>
<span class="settings-arrow">›</span>
</button>
<button
class="settings-row"
onclick="toggleTheme()"
>
<span class="list-icon">☾</span>
<span class="list-copy">
<strong>Тема приложения</strong>
<small id="themeSummary">Дневная</small>
</span>
<span class="settings-arrow">›</span>
</button>
</div>
</section>
</main>
<button
class="fab"
onclick="openAddMenu()"
aria-label="Добавить событие"
>
+
</button>
<nav class="bottom-nav">
<button
class="nav-button active"
data-tab="today"
onclick="switchTab('today')"
>
<b>⌂</b>
Сегодня
</button>
<button
class="nav-button"
data-tab="journal"
onclick="switchTab('journal')"
>
<b>☷</b>
Дневник
</button>
<button
class="nav-button"
data-tab="garden"
onclick="switchTab('garden')"
>
<b>♧</b>
Детский сад
</button>
<button
class="nav-button"
data-tab="analytics"
onclick="switchTab('analytics')"
>
<b>⌁</b>
Аналитика
</button>
<button
class="nav-button"
data-tab="profile"
onclick="switchTab('profile')"
>
<b>○</b>
Малыш
</button>
</nav>
</div>
<div
class="modal"
id="modal"
onclick="if (event.target === this) closeModal()"
>
<div class="sheet" id="sheet"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const EVENT_ICONS = {
sleep: "😴",
feeding: "🍼",
diaper: "🧷",
food: "🥣",
temperature: "🌡️",
medicine: "💊",
tooth: "🪥",
weight: "⚖️",
height: "📏",
vaccine: "💉",
note: "📝",
mood: "🙂",
illness: "🤒"
};
const EVENT_NAMES = {
sleep: "Сон",
feeding: "Кормление",
diaper: "Подгузник",
food: "Прикорм",
temperature: "Температура",
medicine: "Лекарство",
tooth: "Чистка зубов",
weight: "Вес",
height: "Рост",
vaccine: "Прививка",
note: "Заметка",
mood: "Настроение",
illness: "Болезнь"
};
const MOODS = {
great: {
emoji: "😊",
name: "Отличное"
},
calm: {
emoji: "🙂",
name: "Спокойное"
},
fussy: {
emoji: "😣",
name: "Капризное"
},
sad: {
emoji: "😢",
name: "Грустное"
},
tired: {
emoji: "😴",
name: "Вялое"
}
};
const SYMPTOMS = [
"Температура",
"Насморк",
"Кашель",
"Боль в горле",
"Диарея",
"Рвота",
"Сыпь",
"Плохой аппетит",
"Вялость",
"Капризность",
"Боль в животе",
"Затруднённое дыхание"
];
let events = readStorage("baby-events", []);
let profile = readStorage("baby-profile", {
name: "",
birthDate: "",
city: "",
gender: "neutral",
gardenVisible: true
});
let doctors = readStorage("baby-doctors", []);
let illnesses = readStorage("baby-illnesses", []);
let weather = readStorage("baby-weather", {
city: "",
temperature: "",
feelsLike: "",
wind: "normal",
rain: "no",
activity: "stroller",
updatedAt: 0
});
let sizes = readStorage("baby-sizes", {
clothes: "",
diaper: "",
hat: "",
shoes: ""
});
let shopping = readStorage("baby-shopping", []);
let sleepStart =
localStorage.getItem("baby-sleep-start");
let sleepKind =
localStorage.getItem("baby-sleep-kind") || "day";
let journalFilter = "all";
let sleepTimerInterval = null;
let selectedSymptoms = [];
let apiToken = localStorage.getItem("onlymoms-api-token") || "";
let apiReady = false;
let gardenChildren = [];
let gardenFilter = "all";
let gardenMessages = [];
let gardenMessagesLeft = 2;
let syncedEventIds = readStorage("onlymoms-synced-events", []);
let localAvatar = localStorage.getItem("baby-avatar-local") || "";
let feedingStart = localStorage.getItem("baby-feeding-start");
let feedingSide = localStorage.getItem("baby-feeding-side") || "left";
let feedingTimerInterval = null;
function readStorage(key, fallback) {
try {
const value = JSON.parse(
localStorage.getItem(key)
);
return value ?? fallback;
} catch {
return fallback;
}
}
function saveStorage(key, value) {
localStorage.setItem(
key,
JSON.stringify(value)
);
}
function escapeHtml(value = "") {
return String(value).replace(
/[&<>"']/g,
character => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
})[character]
);
}
function getLocalDateTimeValue(
date = new Date()
) {
const localDate = new Date(
date.getTime() -
date.getTimezoneOffset() * 60000
);
return localDate
.toISOString()
.slice(0, 16);
}
function formatTime(timestamp) {
return new Date(timestamp)
.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit"
});
}
function formatDate(timestamp) {
return new Date(timestamp)
.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short"
});
}
function isSameDay(
timestamp,
comparisonDate = new Date()
) {
return (
new Date(timestamp).toDateString() ===
comparisonDate.toDateString()
);
}
function durationText(minutes) {
const hours = Math.floor(minutes / 60);
const remainingMinutes =
Math.round(minutes % 60);
return `${hours} ч ${String(
remainingMinutes
).padStart(2, "0")} мин`;
}
function plural(number, forms) {
const absolute = Math.abs(number) % 100;
const last = absolute % 10;
if (absolute > 10 && absolute < 20) {
return forms[2];
}
if (last > 1 && last < 5) {
return forms[1];
}
if (last === 1) {
return forms[0];
}
return forms[2];
}
function calculateAge(dateValue) {
if (!dateValue) {
return null;
}
const birthDate =
new Date(`${dateValue}T00:00:00`);
const today = new Date();
if (
Number.isNaN(birthDate.getTime()) ||
birthDate > today
) {
return null;
}
let years =
today.getFullYear() -
birthDate.getFullYear();
let months =
today.getMonth() -
birthDate.getMonth();
let days =
today.getDate() -
birthDate.getDate();
if (days < 0) {
months--;
const previousMonth =
new Date(
today.getFullYear(),
today.getMonth(),
0
);
days += previousMonth.getDate();
}
if (months < 0) {
years--;
months += 12;
}
const startToday = new Date(today);
const startBirth = new Date(birthDate);
startToday.setHours(0, 0, 0, 0);
startBirth.setHours(0, 0, 0, 0);
const totalDays = Math.floor(
(startToday - startBirth) / 86400000
);
return {
years,
months,
days,
totalDays
};
}
function getAgeText(age) {
if (!age) {
return "Возраст не указан";
}
const parts = [];
if (age.years > 0) {
parts.push(
`${age.years} ${plural(
age.years,
["год", "года", "лет"]
)}`
);
}
if (
age.months > 0 ||
age.years === 0
) {
parts.push(
`${age.months} ${plural(
age.months,
["месяц", "месяца", "месяцев"]
)}`
);
}
if (
age.days > 0 &&
age.years === 0
) {
parts.push(
`${age.days} ${plural(
age.days,
["день", "дня", "дней"]
)}`
);
}
return parts.join(" ");
}
function updateGreeting() {
const hour = new Date().getHours();
let greeting = "Добрый вечер";
if (hour < 6) {
greeting = "Доброй ночи";
} else if (hour < 12) {
greeting = "Доброе утро";
} else if (hour < 18) {
greeting = "Добрый день";
}
document.getElementById(
"greeting"
).textContent = greeting;
}
function getBabyAvatar() {
if (profile.gender === "boy") {
return "👦";
}
if (profile.gender === "girl") {
return "👧";
}
return "👶";
}
function applyGenderTheme() {
document.body.classList.remove(
"gender-boy",
"gender-girl"
);
if (profile.gender === "boy") {
document.body.classList.add(
"gender-boy"
);
}
if (profile.gender === "girl") {
document.body.classList.add(
"gender-girl"
);
}
}
function isBirthdayToday() {
if (!profile.birthDate) {
return false;
}
const birth =
new Date(`${profile.birthDate}T00:00:00`);
const today = new Date();
return (
birth.getDate() === today.getDate() &&
birth.getMonth() === today.getMonth()
);
}
function renderProfile() {
applyGenderTheme();
updateGreeting();
const age =
calculateAge(profile.birthDate);
const ageText =
getAgeText(age);
const name =
profile.name.trim();
const avatar =
getBabyAvatar();
const avatarMarkup = localAvatar
? `<img class="avatar-photo" src="${localAvatar}" alt="Фото малыша">`
: avatar;
document.getElementById("headerAvatar").innerHTML = avatarMarkup;
document.getElementById("profileAvatar").innerHTML = avatarMarkup;
document.getElementById(
"headerTitle"
).textContent = name
? `${name} · ${ageText}`
: "Настройте профиль малыша";
document.getElementById(
"heroTitle"
).textContent = name && age
? `${name} с вами уже ${age.totalDays} ${plural(
age.totalDays,
["день", "дня", "дней"]
)}`
: "Давайте познакомимся";
document.getElementById(
"heroSubtitle"
).textContent = name
? `${name}: ${ageText}`
: "Укажите имя, дату рождения и пол малыша.";
document.getElementById(
"profileName"
).textContent = name || "Малыш";
document.getElementById(
"profileAge"
).textContent = name
? ageText
: "Профиль пока не заполнен";
const birthdayBox =
document.getElementById("birthdayBox");
if (isBirthdayToday()) {
birthdayBox.classList.add("show");
birthdayBox.textContent =
age && age.years > 0
? `Сегодня ${name} исполняется ${age.years} ${plural(
age.years,
["год", "года", "лет"]
)} 🎈`
: `Сегодня день рождения ${name} 🎈`;
} else {
birthdayBox.classList.remove("show");
}
}
function openProfileEditor() {
openSheet(`
<div class="sheet-header">
<h3>Профиль малыша</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
<div class="field">
<label>Фото малыша</label>
<div class="avatar-picker">
<div class="avatar-preview" id="avatarPreview">${localAvatar ? `<img class="avatar-photo" src="${localAvatar}" alt="Фото малыша">` : getBabyAvatar()}</div>
<div class="avatar-actions">
<label class="secondary-button" style="text-align:center;cursor:pointer">Выбрать фото<input type="file" accept="image/*" onchange="handleAvatarUpload(event)" hidden></label>
<button class="secondary-button" type="button" onclick="removeLocalAvatar()">Удалить фото</button>
</div>
</div>
<small style="display:block;margin-top:7px;color:var(--muted);font-size:8px">Фото хранится только на этом устройстве и не показывается в «Детском саду».</small>
</div>
<div class="field">
<label>Имя малыша</label>
<input
id="profileNameInput"
value="${escapeHtml(profile.name)}"
placeholder="Например, Артём"
>
</div>
<div class="field">
<label>Дата рождения</label>
<input
id="profileBirthInput"
type="date"
value="${escapeHtml(profile.birthDate)}"
>
</div>
<div class="field">
<label>Пол малыша</label>
<div class="gender-grid">
<button
class="gender-option ${
profile.gender === "boy"
? "active"
: ""
}"
data-gender="boy"
onclick="selectGender('boy')"
>
👦 Мальчик
</button>
<button
class="gender-option ${
profile.gender === "girl"
? "active"
: ""
}"
data-gender="girl"
onclick="selectGender('girl')"
>
👧 Девочка
</button>
<button
class="gender-option ${
profile.gender === "neutral"
? "active"
: ""
}"
data-gender="neutral"
onclick="selectGender('neutral')"
>
👶 Не указывать
</button>
</div>
<input
type="hidden"
id="profileGenderInput"
value="${escapeHtml(profile.gender)}"
>
</div>
<div class="field">
<label>Город</label>
<input
id="profileCityInput"
value="${escapeHtml(profile.city)}"
placeholder="Например, Пермь"
>
</div>
<label class="privacy-row">
<span><strong>Показывать малыша в «Детском саду»</strong><small>Только имя, примерный возраст, пол, настроение и время активности. Данные родителя не показываются.</small></span>
<input id="profileGardenVisible" type="checkbox" ${profile.gardenVisible !== false ? "checked" : ""}>
</label>
<button
class="primary-button"
onclick="saveProfile()"
>
Сохранить профиль
</button>
</div>
`);
}
function handleAvatarUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) { showToast("Выберите изображение"); return; }
if (file.size > 12 * 1024 * 1024) { showToast("Фото слишком большое"); return; }
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const size = 512;
const canvas = document.createElement("canvas");
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext("2d");
const scale = Math.max(size / img.width, size / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (size-w)/2, (size-h)/2, w, h);
localAvatar = canvas.toDataURL("image/jpeg", .82);
localStorage.setItem("baby-avatar-local", localAvatar);
const preview = document.getElementById("avatarPreview");
if (preview) preview.innerHTML = `<img class="avatar-photo" src="${localAvatar}" alt="Фото малыша">`;
renderProfile();
showToast("Фото сохранено на устройстве");
};
img.src = reader.result;
};
reader.readAsDataURL(file);
}
function removeLocalAvatar() {
localAvatar = "";
localStorage.removeItem("baby-avatar-local");
const preview = document.getElementById("avatarPreview");
if (preview) preview.textContent = getBabyAvatar();
renderProfile();
showToast("Фото удалено");
}
function selectGender(gender) {
document.getElementById(
"profileGenderInput"
).value = gender;
document
.querySelectorAll(".gender-option")
.forEach(button => {
button.classList.toggle(
"active",
button.dataset.gender === gender
);
});
}
function saveProfile() {
profile = {
name:
document
.getElementById("profileNameInput")
.value
.trim(),
birthDate:
document
.getElementById("profileBirthInput")
.value,
gender:
document
.getElementById("profileGenderInput")
.value,
city:
document
.getElementById("profileCityInput")
.value
.trim(),
gardenVisible:
document.getElementById("profileGardenVisible")?.checked !== false
};
saveStorage(
"baby-profile",
profile
);
renderAll();
syncProfileToServer();
if (profile.city) fetchWeatherForCity(profile.city, true);
closeModal();
showToast("Профиль сохранён");
}
function getActiveIllness() {
return illnesses.find(
illness => !illness.endTime
);
}
function openIllnessForm() {
selectedSymptoms = [];
openSheet(`
<div class="sheet-header">
<h3>Малыш заболел</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
<div class="field">
<label>Когда появились симптомы</label>
<input
id="illnessStart"
type="datetime-local"
value="${getLocalDateTimeValue()}"
>
</div>
<div class="field">
<label>Выберите симптомы</label>
<div class="symptoms-grid">
${SYMPTOMS.map(symptom => `
<button
type="button"
class="symptom-option"
data-symptom="${escapeHtml(symptom)}"
onclick="toggleSymptom(this)"
>
${escapeHtml(symptom)}
</button>
`).join("")}
</div>
</div>
<div class="field">
<label>Температура, если измеряли</label>
<input
id="illnessTemperature"
type="number"
step="0.1"
placeholder="Например, 38.2"
>
</div>
<div class="field">
<label>Предполагаемый или известный диагноз</label>
<input
id="illnessDiagnosis"
placeholder="Например, ОРВИ"
>
</div>
<div class="field">
<label>Комментарий</label>
<textarea
id="illnessComment"
placeholder="Когда началось, что назначил врач, особенности состояния"
></textarea>
</div>
<button
class="danger-button"
onclick="startIllness()"
>
Начать отслеживание болезни
</button>
</div>
`);
}
function toggleSymptom(button) {
const symptom =
button.dataset.symptom;
button.classList.toggle("selected");
if (
selectedSymptoms.includes(symptom)
) {
selectedSymptoms =
selectedSymptoms.filter(
item => item !== symptom
);
} else {
selectedSymptoms.push(symptom);
}
}
function startIllness() {
const active =
getActiveIllness();
if (active) {
showToast(
"Уже есть активная болезнь"
);
return;
}
const startValue =
document
.getElementById("illnessStart")
.value;
illnesses.unshift({
id: Date.now(),
startTime:
startValue
? new Date(startValue).getTime()
: Date.now(),
endTime: null,
symptoms: [
...selectedSymptoms
],
temperature:
document
.getElementById("illnessTemperature")
.value,
diagnosis:
document
.getElementById("illnessDiagnosis")
.value
.trim(),
comment:
document
.getElementById("illnessComment")
.value
.trim(),
updates: []
});
events.unshift({
id: Date.now() + 1,
type: "illness",
time:
illnesses[0].startTime,
details:
selectedSymptoms.length
? `Начало болезни · ${selectedSymptoms.join(", ")}`
: "Начало болезни"
});
saveStorage(
"baby-illnesses",
illnesses
);
saveEvents();
closeModal();
showToast("Болезнь отмечена");
}
function openCurrentIllness() {
const illness =
getActiveIllness();
if (!illness) {
openIllnessForm();
return;
}
selectedSymptoms = [
...(illness.symptoms || [])
];
openSheet(`
<div class="sheet-header">
<h3>Текущее состояние</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
<div class="field">
<label>Симптомы</label>
<div class="symptoms-grid">
${SYMPTOMS.map(symptom => `
<button
type="button"
class="symptom-option ${
selectedSymptoms.includes(symptom)
? "selected"
: ""
}"
data-symptom="${escapeHtml(symptom)}"
onclick="toggleSymptom(this)"
>
${escapeHtml(symptom)}
</button>
`).join("")}
</div>
</div>
<div class="field">
<label>Текущая температура</label>
<input
id="illnessUpdateTemperature"
type="number"
step="0.1"
value="${escapeHtml(illness.temperature || "")}"
placeholder="36.6"
>
</div>
<div class="field">
<label>Диагноз</label>
<input
id="illnessUpdateDiagnosis"
value="${escapeHtml(illness.diagnosis || "")}"
placeholder="Если известен"
>
</div>
<div class="field">
<label>Комментарий о состоянии</label>
<textarea
id="illnessUpdateComment"
placeholder="Что изменилось сегодня"
></textarea>
</div>
<button
class="primary-button"
onclick="updateIllness()"
>
Сохранить изменения
</button>
<button
class="danger-button"
onclick="finishIllness()"
>
Отметить выздоровление
</button>
</div>
`);
}
function updateIllness() {
const illness =
getActiveIllness();
if (!illness) {
return;
}
const temperature =
document
.getElementById(
"illnessUpdateTemperature"
)
.value;
const diagnosis =
document
.getElementById(
"illnessUpdateDiagnosis"
)
.value
.trim();
const comment =
document
.getElementById(
"illnessUpdateComment"
)
.value
.trim();
illness.symptoms = [
...selectedSymptoms
];
illness.temperature =
temperature;
illness.diagnosis =
diagnosis;
if (
temperature ||
comment ||
diagnosis
) {
illness.updates.push({
time: Date.now(),
temperature,
diagnosis,
comment
});
}
events.unshift({
id: Date.now(),
type: "illness",
time: Date.now(),
details:
comment ||
`Состояние обновлено${
temperature
? ` · ${temperature} °C`
: ""
}`
});
saveStorage(
"baby-illnesses",
illnesses
);
saveEvents();
closeModal();
showToast("Состояние обновлено");
}
function finishIllness() {
const illness =
getActiveIllness();
if (!illness) {
return;
}
const confirmed =
confirm(
"Отметить, что ребёнок выздоровел?"
);
if (!confirmed) {
return;
}
illness.endTime =
Date.now();
events.unshift({
id: Date.now(),
type: "illness",
time: Date.now(),
details: "Выздоровление"
});
saveStorage(
"baby-illnesses",
illnesses
);
saveEvents();
closeModal();
showToast("Выздоровление отмечено");
}
function renderIllness() {
const active =
getActiveIllness();
const banner =
document.getElementById(
"illnessBanner"
);
if (!active) {
banner.classList.remove("show");
} else {
banner.classList.add("show");
const durationDays =
Math.max(
1,
Math.ceil(
(
Date.now() -
active.startTime
) /
86400000
)
);
document.getElementById(
"illnessTitle"
).textContent =
`${profile.name || "Малыш"} болеет ${durationDays} ${plural(
durationDays,
["день", "дня", "дней"]
)}`;
const parts = [];
if (
active.symptoms &&
active.symptoms.length
) {
parts.push(
active.symptoms
.slice(0, 4)
.join(", ")
);
}
if (active.diagnosis) {
parts.push(
`Диагноз: ${active.diagnosis}`
);
}
if (active.temperature) {
parts.push(
`${active.temperature} °C`
);
}
document.getElementById(
"illnessDescription"
).textContent =
parts.join(" · ") ||
"Симптомы не указаны";
}
const completed =
illnesses.filter(
illness => illness.endTime
).length;
document.getElementById(
"illnessSummary"
).textContent =
illnesses.length
? `${illnesses.length} ${plural(
illnesses.length,
["эпизод", "эпизода", "эпизодов"]
)}, завершено ${completed}`
: "Нет записей";
}
function openIllnessHistory() {
const content =
illnesses.length
? illnesses.map(illness => {
const end =
illness.endTime ||
Date.now();
const days =
Math.max(
1,
Math.ceil(
(
end -
illness.startTime
) /
86400000
)
);
return `
<div class="card illness-history-card">
<strong>
${
illness.diagnosis
? escapeHtml(illness.diagnosis)
: "Болезнь без диагноза"
}
</strong>
<small>
${new Date(
illness.startTime
).toLocaleDateString("ru-RU")}
—
${
illness.endTime
? new Date(
illness.endTime
).toLocaleDateString("ru-RU")
: "по настоящее время"
}
· ${days} ${plural(
days,
["день", "дня", "дней"]
)}
</small>
<small>
Симптомы:
${
illness.symptoms?.length
? escapeHtml(
illness.symptoms.join(", ")
)
: "не указаны"
}
</small>
${
illness.comment
? `
<small>
${escapeHtml(illness.comment)}
</small>
`
: ""
}
</div>
`;
}).join("")
: `
<div class="card empty">
История болезней пока пуста.
</div>
`;
openSheet(`
<div class="sheet-header">
<h3>История болезней</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
${content}
<button
class="danger-button"
onclick="openIllnessForm()"
>
Отметить новую болезнь
</button>
`);
}
function setMood(moodKey) {
const mood =
MOODS[moodKey];
events.unshift({
id: Date.now(),
type: "mood",
mood: moodKey,
details: mood.name,
time: Date.now()
});
saveEvents();
showToast(
`Настроение: ${mood.name}`
);
}
function toggleFeeding() {
if (!feedingStart) {
openSheet(`
<div class="sheet-header"><h3>С какой груди начать?</h3><button class="close-button" onclick="closeModal()">×</button></div>
<div class="event-grid" style="grid-template-columns:repeat(2,1fr)">
<button class="event-button" onclick="startFeeding('left')"><b>Л</b><span>Левая грудь</span></button>
<button class="event-button" onclick="startFeeding('right')"><b>П</b><span>Правая грудь</span></button>
</div>`);
return;
}
stopFeeding();
}
function startFeeding(side) {
feedingSide = side === "right" ? "right" : "left";
feedingStart = Date.now();
localStorage.setItem("baby-feeding-start", feedingStart);
localStorage.setItem("baby-feeding-side", feedingSide);
closeModal();
startFeedingTimer();
showToast(`Кормление начато · ${feedingSide === "right" ? "правая" : "левая"} грудь`);
}
function stopFeeding() {
const end = Date.now();
const start = Number(feedingStart);
const minutes = Math.max(1, Math.round((end-start)/60000));
const sideText = feedingSide === "right" ? "Правая грудь" : "Левая грудь";
events.unshift({id:Date.now(), type:"feeding", time:start, end, duration:minutes, feedingMinutes:minutes, feedingKind:"breast", breast:feedingSide, details:`Грудное кормление · ${sideText} · ${minutes} мин`});
feedingStart = null;
localStorage.removeItem("baby-feeding-start");
localStorage.removeItem("baby-feeding-side");
clearInterval(feedingTimerInterval);
saveEvents();
renderAll();
syncUnsyncedEvents();
updateFeedingTimer();
showToast("Кормление сохранено");
}
function startFeedingTimer() {
clearInterval(feedingTimerInterval);
updateFeedingTimer();
feedingTimerInterval = setInterval(updateFeedingTimer, 1000);
}
function updateFeedingTimer() {
const timer = document.getElementById("feedingTimer");
const status = document.getElementById("feedingStatus");
const button = document.getElementById("feedingButton");
if (!timer || !status || !button) return;
if (!feedingStart) {
timer.textContent = "00:00:00";
status.textContent = "Сейчас не кормит";
button.textContent = "Начать кормление";
button.classList.remove("running");
return;
}
const seconds = Math.max(0, Math.floor((Date.now()-Number(feedingStart))/1000));
const h=String(Math.floor(seconds/3600)).padStart(2,"0");
const m=String(Math.floor(seconds%3600/60)).padStart(2,"0");
const sec=String(seconds%60).padStart(2,"0");
timer.textContent=`${h}:${m}:${sec}`;
status.textContent=`Идёт кормление · ${feedingSide === "right" ? "правая" : "левая"} грудь`;
button.textContent="Завершить";
button.classList.add("running");
}
function getTodayMood() {
return events.find(
event =>
event.type === "mood" &&
isSameDay(event.time)
);
}
function renderMood() {
const todayMood =
getTodayMood();
document
.querySelectorAll(
".mood-button"
)
.forEach(button => {
button.classList.toggle(
"active",
todayMood &&
button.dataset.mood ===
todayMood.mood
);
});
document.getElementById(
"currentMoodText"
).textContent =
todayMood
? `${
MOODS[todayMood.mood]
?.emoji || "🙂"
} ${todayMood.details}`
: "Не отмечено";
}
function toggleSleep() {
if (!sleepStart) {
openSheet(`
<div class="sheet-header">
<h3>Какой сон начинается?</h3>
<button class="close-button" onclick="closeModal()">×</button>
</div>
<div class="event-grid" style="grid-template-columns: repeat(2, 1fr)">
<button class="event-button" onclick="startSleep('day')">
<b>☀️</b>
<span>Дневной сон</span>
</button>
<button class="event-button" onclick="startSleep('night')">
<b>🌙</b>
<span>Ночной сон</span>
</button>
</div>
`);
return;
}
const end = Date.now();
const duration = Math.max(
1,
Math.round((end - Number(sleepStart)) / 60000)
);
events.unshift({
id: Date.now(),
type: "sleep",
time: Number(sleepStart),
end,
duration,
sleepKind
});
localStorage.removeItem("baby-sleep-start");
localStorage.removeItem("baby-sleep-kind");
sleepStart = null;
sleepKind = "day";
clearInterval(sleepTimerInterval);
updateSleepTimer();
saveEvents();
showToast("Сон завершён");
}
function startSleep(kind) {
sleepKind = kind === "night" ? "night" : "day";
sleepStart = Date.now();
localStorage.setItem("baby-sleep-start", sleepStart);
localStorage.setItem("baby-sleep-kind", sleepKind);
closeModal();
startSleepTimer();
showToast(sleepKind === "night" ? "Ночной сон начат" : "Дневной сон начат");
}
function startSleepTimer() {
clearInterval(
sleepTimerInterval
);
sleepTimerInterval =
setInterval(
updateSleepTimer,
1000
);
updateSleepTimer();
}
function updateSleepTimer() {
const status =
document.getElementById(
"sleepStatus"
);
const timer =
document.getElementById(
"sleepTimer"
);
const button =
document.getElementById(
"sleepButton"
);
if (!sleepStart) {
status.textContent =
"Сейчас не спит";
timer.textContent =
"00:00:00";
button.textContent =
"Начать сон";
button.classList.remove(
"running"
);
return;
}
const seconds =
Math.floor(
(
Date.now() -
Number(sleepStart)
) /
1000
);
const hours =
Math.floor(seconds / 3600);
const minutes =
Math.floor(
(seconds % 3600) / 60
);
const remainingSeconds =
seconds % 60;
timer.textContent = [
hours,
minutes,
remainingSeconds
]
.map(value =>
String(value).padStart(
2,
"0"
)
)
.join(":");
status.textContent =
`${profile.name || "Малыш"} спит · ${
sleepKind === "night" ? "ночной сон" : "дневной сон"
}`;
button.textContent =
"Завершить";
button.classList.add(
"running"
);
}
function addEvent(type, data = {}) {
events.unshift({
id: Date.now(),
type,
time:
data.time ||
Date.now(),
...data
});
saveEvents();
closeModal();
showToast("Событие добавлено");
}
function saveEvents() {
saveStorage(
"baby-events",
events
);
renderAll();
syncUnsyncedEvents();
}
function openAddMenu() {
const types = [
"sleep",
"feeding",
"diaper",
"food",
"temperature",
"medicine",
"tooth",
"weight",
"height",
"vaccine",
"note"
];
openSheet(`
<div class="sheet-header">
<h3>Добавить событие</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="event-grid">
<button
class="event-button"
onclick="openIllnessForm()"
>
<b>🤒</b>
<span>Болеет</span>
</button>
${types.map(type => `
<button
class="event-button"
onclick="openEventForm('${type}')"
>
<b>${EVENT_ICONS[type]}</b>
<span>${EVENT_NAMES[type]}</span>
</button>
`).join("")}
</div>
`);
}
function openEventForm(type) {
const now =
getLocalDateTimeValue();
let fields = "";
if (type === "sleep") {
fields = `
<div class="field">
<label>Тип сна</label>
<select id="eventSleepKind">
<option value="day">Дневной сон</option>
<option value="night">Ночной сон</option>
</select>
</div>
<div class="field">
<label>Начало сна</label>
<input
id="eventStart"
type="datetime-local"
value="${now}"
>
</div>
<div class="field">
<label>Окончание сна</label>
<input
id="eventEnd"
type="datetime-local"
value="${now}"
>
</div>
`;
}
if (type === "feeding") {
fields = `
<div class="field">
<label>Тип кормления</label>
<select id="eventKind">
<option value="breast">Грудное молоко</option>
<option value="formula">Смесь</option>
<option value="other">Другое</option>
</select>
</div>
<div class="field">
<label>Грудь</label>
<select id="eventBreastSide">
<option value="left">Левая</option>
<option value="right">Правая</option>
<option value="both">Обе</option>
<option value="none">Не указывать</option>
</select>
</div>
<div class="field">
<label>Время кормления, минут</label>
<input id="eventFeedingMinutes" type="number" min="1" step="1" placeholder="Например, 15">
</div>
<div class="field">
<label>Количество или комментарий</label>
<input id="eventDetails" placeholder="Например, 180 мл">
</div>
`;
}
if (type === "diaper") {
fields = `
<div class="field">
<label>Состояние подгузника</label>
<select id="eventDetails">
<option>Мокрый</option>
<option>Стул</option>
<option>Мокрый и стул</option>
</select>
</div>
<div class="field">
<label>Комментарий</label>
<input
id="eventExtra"
placeholder="Необязательно"
>
</div>
`;
}
if (type === "food") {
fields = `
<div class="field">
<label>Продукт и количество</label>
<input
id="eventDetails"
placeholder="Брокколи, 2 ложки"
>
</div>
<div class="field">
<label>Реакция</label>
<select id="eventReaction">
<option>Без реакции</option>
<option>Понравилось</option>
<option>Отказался</option>
<option>Сыпь</option>
<option>Болит живот</option>
<option>Изменился стул</option>
</select>
</div>
`;
}
if (type === "temperature") {
fields = `
<div class="field">
<label>Температура, °C</label>
<input
id="eventValue"
type="number"
step="0.1"
placeholder="36.6"
>
</div>
`;
}
if (type === "weight") {
fields = `
<div class="field">
<label>Вес, кг</label>
<input
id="eventValue"
type="number"
step="0.01"
placeholder="8.35"
>
</div>
<div class="field">
<label>Рост, см</label>
<input
id="eventExtraValue"
type="number"
step="0.1"
placeholder="72"
>
</div>
`;
}
if (type === "height") {
fields = `
<div class="field">
<label>Рост, см</label>
<input
id="eventValue"
type="number"
step="0.1"
placeholder="72"
>
</div>
`;
}
if (type === "medicine") {
fields = `
<div class="field">
<label>Лекарство и дозировка</label>
<input
id="eventDetails"
placeholder="Витамин D, 1 капля"
>
</div>
`;
}
if (type === "tooth") {
fields = `
<div class="field">
<label>Что отметить</label>
<select id="eventDetails">
<option>Утренняя чистка</option>
<option>Вечерняя чистка</option>
<option>Прорезался новый зуб</option>
</select>
</div>
<div class="field">
<label>Комментарий</label>
<input
id="eventExtra"
placeholder="Необязательно"
>
</div>
`;
}
if (type === "vaccine") {
fields = `
<div class="field">
<label>Название прививки</label>
<input
id="eventDetails"
placeholder="Например, Пентаксим"
>
</div>
<div class="field">
<label>Реакция</label>
<input
id="eventExtra"
placeholder="Без реакции"
>
</div>
`;
}
if (type === "note") {
fields = `
<div class="field">
<label>Заметка</label>
<textarea
id="eventDetails"
placeholder="Что важно запомнить"
></textarea>
</div>
`;
}
const timeField =
type === "sleep"
? ""
: `
<div class="field">
<label>Дата и время</label>
<input
id="eventTime"
type="datetime-local"
value="${now}"
>
</div>
`;
openSheet(`
<div class="sheet-header">
<h3>
${EVENT_ICONS[type]}
${EVENT_NAMES[type]}
</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
${fields}
${timeField}
<button
class="primary-button"
onclick="submitEvent('${type}')"
>
Сохранить
</button>
</div>
`);
}
function submitEvent(type) {
if (type === "sleep") {
const start =
new Date(
document
.getElementById("eventStart")
.value
);
const end =
new Date(
document
.getElementById("eventEnd")
.value
);
if (
Number.isNaN(start.getTime()) ||
Number.isNaN(end.getTime())
) {
showToast(
"Укажите время сна"
);
return;
}
if (end <= start) {
showToast(
"Окончание должно быть позже"
);
return;
}
addEvent("sleep", {
time: start.getTime(),
end: end.getTime(),
duration: Math.round((end - start) / 60000),
sleepKind: document.getElementById("eventSleepKind")?.value || "day"
});
return;
}
const timeValue =
document
.getElementById("eventTime")
?.value;
const value =
document
.getElementById("eventValue")
?.value || "";
const extraValue =
document
.getElementById(
"eventExtraValue"
)
?.value || "";
const kind =
document
.getElementById("eventKind")
?.value || "";
const breastSide =
document
.getElementById("eventBreastSide")
?.value || "";
const feedingMinutes =
document
.getElementById("eventFeedingMinutes")
?.value || "";
const baseDetails =
document
.getElementById(
"eventDetails"
)
?.value || "";
const extra =
document
.getElementById("eventExtra")
?.value || "";
const reaction =
document
.getElementById(
"eventReaction"
)
?.value || "";
let details = baseDetails;
if (type === "feeding") {
const kindNames = {
breast: "Грудное молоко",
formula: "Смесь",
other: "Другое"
};
const sideNames = {
left: "левая грудь",
right: "правая грудь",
both: "обе груди"
};
const feedingParts = [kindNames[kind] || kind];
if (sideNames[breastSide]) feedingParts.push(sideNames[breastSide]);
if (feedingMinutes) feedingParts.push(`${feedingMinutes} мин`);
if (baseDetails) feedingParts.push(baseDetails);
details = feedingParts.filter(Boolean).join(" · ");
} else if (kind) {
details = `${kind}${details ? ` · ${details}` : ""}`;
}
if (reaction) {
details +=
`${details ? " · " : ""}${reaction}`;
}
if (extra) {
details +=
`${details ? " · " : ""}${extra}`;
}
addEvent(type, {
time:
timeValue
? new Date(timeValue).getTime()
: Date.now(),
value,
extraValue,
details,
feedingKind: type === "feeding" ? kind : "",
breast: type === "feeding" ? breastSide : "",
feedingMinutes: type === "feeding" ? Number(feedingMinutes || 0) : 0
});
}
function getEventDescription(event) {
if (event.type === "sleep") {
const kindText = event.sleepKind === "night" ? "Ночной сон" : "Дневной сон";
return `${kindText} · ${durationText(event.duration || 0)}`;
}
if (event.type === "temperature") {
return `${event.value || "—"} °C`;
}
if (event.type === "weight") {
const values = [];
if (event.value) {
values.push(
`${event.value} кг`
);
}
if (event.extraValue) {
values.push(
`${event.extraValue} см`
);
}
return values.join(" · ");
}
if (event.type === "height") {
return `${event.value || "—"} см`;
}
if (event.type === "mood") {
return `${
MOODS[event.mood]?.emoji || "🙂"
} ${event.details || ""}`;
}
return (
event.details ||
"Событие добавлено"
);
}
function eventHtml(event) {
return `
<div class="card event-card">
<div class="event-icon">
${EVENT_ICONS[event.type] || "•"}
</div>
<div>
<strong>
${EVENT_NAMES[event.type] || "Событие"}
</strong>
<small>
${escapeHtml(
getEventDescription(event)
)}
</small>
</div>
<div class="event-time-wrap">
<div class="event-time">
${isSameDay(event.time) ? formatTime(event.time) : formatDate(event.time)}
</div>
<div class="event-actions">
<button class="event-action" onclick="event.stopPropagation(); editEvent(${event.id})" aria-label="Редактировать">✎</button>
<button class="event-action delete" onclick="event.stopPropagation(); deleteEvent(${event.id})" aria-label="Удалить">×</button>
</div>
</div>
</div>
`;
}
function renderEvents() {
document.getElementById(
"recentEvents"
).innerHTML =
events.length
? events
.slice(0, 5)
.map(eventHtml)
.join("")
: `
<div class="card empty">
Пока нет событий. Добавьте сон,
кормление или настроение малыша.
</div>
`;
let filtered =
events;
if (journalFilter === "sleep") {
filtered =
events.filter(
event =>
event.type === "sleep"
);
}
if (journalFilter === "food") {
filtered =
events.filter(
event =>
event.type === "feeding" ||
event.type === "food"
);
}
if (journalFilter === "health") {
filtered =
events.filter(
event =>
[
"temperature",
"medicine",
"weight",
"height",
"vaccine",
"tooth",
"illness"
].includes(event.type)
);
}
if (journalFilter === "mood") {
filtered =
events.filter(
event =>
event.type === "mood"
);
}
document.getElementById(
"journalEvents"
).innerHTML =
filtered.length
? filtered
.map(eventHtml)
.join("")
: `
<div class="card empty">
В этой категории пока нет событий.
</div>
`;
}
function setJournalFilter(
filter,
button
) {
journalFilter = filter;
document
.querySelectorAll(
".segmented button"
)
.forEach(item => {
item.classList.remove(
"active"
);
});
button.classList.add(
"active"
);
renderEvents();
}
function renderTodaySummary() {
const todayEvents =
events.filter(
event =>
isSameDay(event.time)
);
const sleepMinutes =
todayEvents
.filter(
event =>
event.type === "sleep"
)
.reduce(
(sum, event) =>
sum +
(event.duration || 0),
0
);
document.getElementById(
"sleepTotal"
).textContent =
durationText(sleepMinutes);
document.getElementById(
"feedingCount"
).textContent =
todayEvents.filter(
event =>
event.type === "feeding" ||
event.type === "food"
).length;
const feedingEvents = events
.filter(event => event.type === "feeding")
.sort((a, b) => Number(b.time) - Number(a.time));
const lastFeeding = feedingEvents[0] || null;
const previousFeeding = feedingEvents[1] || null;
const feedingGapCurrent = document.getElementById("feedingGapCurrent");
const feedingGapNote = document.getElementById("feedingGapNote");
if (feedingGapCurrent && feedingGapNote) {
if (feedingStart) {
feedingGapCurrent.textContent = "Кормление идёт";
feedingGapNote.textContent = lastFeeding
? `До начала: ${durationText((Number(feedingStart) - Number(lastFeeding.time)) / 60000)}`
: "Первое кормление";
} else if (lastFeeding) {
const lastEnd = Number(lastFeeding.end || (Number(lastFeeding.time) + (Number(lastFeeding.duration || lastFeeding.feedingMinutes) || 0) * 60000));
feedingGapCurrent.textContent = durationText(Math.max(0, (Date.now() - lastEnd) / 60000));
feedingGapNote.textContent = previousFeeding
? `Предыдущий интервал: ${durationText(Math.max(0, (Number(lastFeeding.time) - Number(previousFeeding.end || (Number(previousFeeding.time) + (Number(previousFeeding.duration || previousFeeding.feedingMinutes) || 0) * 60000))) / 60000))}`
: `Последнее кормление: ${formatTime(lastFeeding.time)}`;
} else {
feedingGapCurrent.textContent = "—";
feedingGapNote.textContent = "Кормления ещё не добавлены";
}
}
document.getElementById(
"diaperCount"
).textContent =
todayEvents.filter(
event =>
event.type === "diaper"
).length;
const completedSleeps = events.filter(event => event.type === "sleep")
.map(event => Number(event.end || (Number(event.time)+(Number(event.duration)||0)*60000)))
.filter(Number.isFinite);
const lastSleepEnd = completedSleeps.length ? Math.max(...completedSleeps) : null;
const awakeNode = document.getElementById("awakeCurrent");
if (awakeNode) {
awakeNode.textContent = sleepStart ? "Спит" : lastSleepEnd ? durationText((Date.now()-lastSleepEnd)/60000) : "—";
}
const lastSleep =
events.find(
event =>
event.type === "sleep"
);
document.getElementById(
"lastSleepText"
).textContent =
lastSleep
? `Последний сон: ${durationText(
lastSleep.duration || 0
)}`
: "Сон ещё не добавлен";
}
function openDoctorForm() {
openSheet(`
<div class="sheet-header">
<h3>Запись к врачу</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
<div class="field">
<label>Специалист</label>
<input
id="doctorSpecialty"
placeholder="Педиатр"
>
</div>
<div class="field">
<label>Имя врача</label>
<input
id="doctorName"
placeholder="Необязательно"
>
</div>
<div class="field">
<label>Дата и время</label>
<input
id="doctorDate"
type="datetime-local"
value="${getLocalDateTimeValue(
new Date(
Date.now() + 86400000
)
)}"
>
</div>
<div class="field">
<label>Клиника или адрес</label>
<input
id="doctorPlace"
placeholder="Название клиники"
>
</div>
<div class="field">
<label>Причина визита и вопросы</label>
<textarea
id="doctorReason"
placeholder="Что обсудить с врачом"
></textarea>
</div>
<button
class="primary-button"
onclick="saveDoctor()"
>
Сохранить запись
</button>
</div>
`);
}
function saveDoctor() {
const specialty =
document
.getElementById(
"doctorSpecialty"
)
.value
.trim();
const date =
document
.getElementById(
"doctorDate"
)
.value;
if (!specialty || !date) {
showToast(
"Укажите врача и дату"
);
return;
}
doctors.push({
id: Date.now(),
specialty,
doctorName:
document
.getElementById(
"doctorName"
)
.value
.trim(),
time:
new Date(date).getTime(),
place:
document
.getElementById(
"doctorPlace"
)
.value
.trim(),
reason:
document
.getElementById(
"doctorReason"
)
.value
.trim()
});
doctors.sort(
(a, b) =>
a.time - b.time
);
saveStorage(
"baby-doctors",
doctors
);
renderAll();
closeModal();
showToast("Запись сохранена");
}
function renderUpcoming() {
const upcoming =
doctors
.filter(
doctor =>
doctor.time >
Date.now() - 3600000
)
.slice(0, 3);
const container =
document.getElementById(
"upcomingList"
);
if (!upcoming.length) {
container.innerHTML = `
<div class="empty">
Ближайших записей пока нет.
</div>
`;
return;
}
container.innerHTML =
upcoming.map(doctor => {
const difference =
doctor.time -
Date.now();
const days =
Math.ceil(
difference /
86400000
);
let status =
"Сегодня";
if (days === 1) {
status = "Завтра";
} else if (days > 1) {
status =
`Через ${days} ${plural(
days,
["день", "дня", "дней"]
)}`;
}
return `
<div class="list-row">
<div class="list-icon">
🩺
</div>
<div class="list-copy">
<strong>
${escapeHtml(doctor.specialty)}
</strong>
<small>
${new Date(
doctor.time
).toLocaleString("ru-RU", {
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit"
})}
${
doctor.place
? ` · ${escapeHtml(doctor.place)}`
: ""
}
</small>
</div>
<span class="pill warning">
${status}
</span>
</div>
`;
}).join("");
}
function openDoctorList() {
const content =
doctors.length
? doctors
.slice()
.sort(
(a, b) =>
a.time - b.time
)
.map(doctor => `
<div class="list-row">
<div class="list-icon">
🩺
</div>
<div class="list-copy">
<strong>
${escapeHtml(doctor.specialty)}
</strong>
<small>
${new Date(
doctor.time
).toLocaleString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})}
</small>
</div>
<button
class="close-button"
onclick="removeDoctor(${doctor.id})"
>
×
</button>
</div>
`).join("")
: `
<div class="empty">
Записей к врачам пока нет.
</div>
`;
openSheet(`
<div class="sheet-header">
<h3>Записи к врачам</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="card list">
${content}
</div>
<button
class="primary-button"
style="margin-top: 11px"
onclick="openDoctorForm()"
>
Добавить запись
</button>
`);
}
function removeDoctor(id) {
doctors =
doctors.filter(
doctor =>
doctor.id !== id
);
saveStorage(
"baby-doctors",
doctors
);
renderAll();
openDoctorList();
}
function openNotifications() {
localStorage.setItem("onlymoms-notifications-seen", "1");
updateNotificationBadge();
openSheet(`
<div class="sheet-header">
<h3>События</h3>
<button class="close-button" onclick="closeModal()">×</button>
</div>
<div class="notification-list">
<a class="notification-card" href="https://t.me/onlymommms" target="_blank" rel="noopener" onclick="openExternalLink(event, 'https://t.me/onlymommms', true)">
<span class="notification-card-icon">↗</span>
<span><strong>Подпишитесь на группу OnlyMoms</strong><small>Новости сервиса, обновления и общение с мамами.</small><span class="notification-link">Перейти в Telegram</span></span>
</a>
<a class="notification-card" href="https://tbank.ru/cf/1b0ljObhZsf" target="_blank" rel="noopener" onclick="openExternalLink(event, 'https://tbank.ru/cf/1b0ljObhZsf', false)">
<span class="notification-card-icon">♡</span>
<span><strong>Поддержать развитие проекта</strong><small>Добровольная поддержка помогает развивать OnlyMoms и добавлять новые функции.</small><span class="notification-link">Поддержать проект</span></span>
</a>
</div>
`);
}
function openExternalLink(event, url, telegramLink) {
event?.preventDefault();
try {
if (window.Telegram?.WebApp) {
if (telegramLink && window.Telegram.WebApp.openTelegramLink) window.Telegram.WebApp.openTelegramLink(url);
else if (window.Telegram.WebApp.openLink) window.Telegram.WebApp.openLink(url);
else window.open(url, "_blank", "noopener");
} else window.open(url, "_blank", "noopener");
} catch (_) { window.location.href = url; }
}
function updateNotificationBadge() {
const badge = document.getElementById("notificationBadge");
if (badge) badge.style.display = localStorage.getItem("onlymoms-notifications-seen") ? "none" : "grid";
}
function openWeatherSettings() {
openSheet(`
<div class="sheet-header">
<h3>Baby Weather</h3>
<button class="close-button" onclick="closeModal()">×</button>
</div>
<div class="form">
<div class="field">
<label>Город из профиля малыша</label>
<input value="${escapeHtml(profile.city || "Не указан")}" disabled>
</div>
<div class="field">
<label>Тип прогулки</label>
<select id="weatherActivity">
<option value="stroller" ${weather.activity === "stroller" ? "selected" : ""}>В коляске</option>
<option value="carrier" ${weather.activity === "carrier" ? "selected" : ""}>В переноске</option>
<option value="walking" ${weather.activity === "walking" ? "selected" : ""}>Активно ходит</option>
<option value="car" ${weather.activity === "car" ? "selected" : ""}>Поездка в машине</option>
</select>
</div>
<button class="primary-button" onclick="refreshWeatherFromSettings()">Обновить погоду</button>
<small style="color:var(--muted);font-size:9px;line-height:1.5">Температура, ощущаемая температура, ветер и осадки загружаются автоматически по городу из профиля.</small>
</div>
`);
}
async function refreshWeatherFromSettings() {
weather.activity = document.getElementById("weatherActivity")?.value || weather.activity || "stroller";
saveStorage("baby-weather", weather);
if (!profile.city) { showToast("Сначала укажите город в профиле"); return; }
closeModal();
await fetchWeatherForCity(profile.city, true);
}
function weatherCodeToRain(code, precipitation, snowfall) {
if (Number(snowfall) > 0 || [71,73,75,77,85,86].includes(Number(code))) return "snow";
if (Number(precipitation) > 0 || [51,53,55,56,57,61,63,65,66,67,80,81,82,95,96,99].includes(Number(code))) return "rain";
return "no";
}
async function fetchWeatherForCity(city, showMessage = false) {
const cleanCity = String(city || "").trim();
if (!cleanCity) { renderWeather(); return; }
const card = document.querySelector(".weather-card");
card?.classList.add("weather-refreshing");
const updated = document.getElementById("weatherUpdated");
if (updated) updated.textContent = "Обновляем погоду…";
try {
const geoResponse = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cleanCity)}&count=1&language=ru&format=json`);
if (!geoResponse.ok) throw new Error("geocoding");
const geo = await geoResponse.json();
const place = geo.results?.[0];
if (!place) throw new Error("city_not_found");
const forecastResponse = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}¤t=temperature_2m,apparent_temperature,precipitation,rain,snowfall,weather_code,wind_speed_10m&timezone=auto`);
if (!forecastResponse.ok) throw new Error("forecast");
const forecast = await forecastResponse.json();
const current = forecast.current || {};
const speed = Number(current.wind_speed_10m || 0);
weather = {
...weather,
city: [place.name, place.admin1].filter(Boolean).join(", "),
temperature: Math.round(Number(current.temperature_2m)),
feelsLike: Math.round(Number(current.apparent_temperature)),
wind: speed < 10 ? "calm" : speed < 25 ? "normal" : "strong",
rain: weatherCodeToRain(current.weather_code, current.precipitation || current.rain, current.snowfall),
activity: weather.activity || "stroller",
updatedAt: Date.now()
};
saveStorage("baby-weather", weather);
renderWeather();
if (showMessage) showToast("Погода обновлена автоматически");
} catch (error) {
if (updated) updated.textContent = error.message === "city_not_found" ? "Город не найден. Проверьте название в профиле." : "Не удалось обновить погоду. Попробуйте позже.";
if (showMessage) showToast(error.message === "city_not_found" ? "Город не найден" : "Не удалось загрузить погоду");
} finally { card?.classList.remove("weather-refreshing"); }
}
function ensureAutomaticWeather() {
if (!profile.city) { renderWeather(); return; }
const cityChanged = !weather.city || !weather.city.toLowerCase().includes(profile.city.toLowerCase());
const stale = !weather.updatedAt || Date.now() - Number(weather.updatedAt) > 30 * 60 * 1000;
if (cityChanged || stale) fetchWeatherForCity(profile.city, false); else renderWeather();
}
function getClothes() {
const temperature =
Number(
weather.feelsLike ||
weather.temperature
);
if (
weather.temperature === "" ||
Number.isNaN(temperature)
) {
return {
icon: "🌤️",
items: [
"Укажите температуру и тип прогулки."
]
};
}
let icon = "🌤️";
let items = [];
if (temperature >= 25) {
icon = "☀️";
items = [
"лёгкий хлопковый боди",
"панама или тонкая шапочка",
"защита от прямого солнца"
];
} else if (temperature >= 20) {
icon = "🌤️";
items = [
"боди",
"лёгкие брюки или слип",
"тонкая шапочка при ветре"
];
} else if (temperature >= 15) {
icon = "⛅";
items = [
"хлопковый боди",
"лёгкий слип",
"ветровка или тонкий комбинезон",
"тонкая шапка"
];
} else if (temperature >= 8) {
icon = "🌥️";
items = [
"хлопковый боди",
"тёплый слип",
"демисезонный комбинезон",
"шапка и носки"
];
} else if (temperature >= 0) {
icon = "☁️";
items = [
"хлопковый первый слой",
"флисовый второй слой",
"тёплый комбинезон",
"шапка и варежки"
];
} else if (temperature >= -10) {
icon = "❄️";
items = [
"хлопковый первый слой",
"флисовый или шерстяной слой",
"зимний комбинезон",
"тёплая шапка и пинетки"
];
} else {
icon = "🥶";
items = [
"тёплый первый слой",
"флисовый или шерстяной слой",
"зимний комбинезон",
"утеплённый конверт"
];
}
if (weather.activity === "carrier") {
items.push(
"учтите тепло от тела взрослого"
);
}
if (weather.activity === "walking") {
items.push(
"для активного малыша можно убрать один слой"
);
}
if (weather.activity === "car") {
items.push(
"в автокресле снимите объёмную верхнюю одежду"
);
}
if (weather.wind === "strong") {
items.push(
"защитите малыша от прямого ветра"
);
}
if (weather.rain === "rain") {
items.push(
"возьмите дождевик"
);
}
if (weather.rain === "snow") {
items.push(
"следите, чтобы одежда оставалась сухой"
);
}
return {
icon,
items
};
}
function renderWeather() {
const result =
getClothes();
document.getElementById(
"weatherLocation"
).textContent =
weather.city ||
profile.city ||
"Погода не настроена";
document.getElementById(
"weatherTemperature"
).textContent =
weather.temperature !== ""
? `${weather.temperature}°`
: "—°";
document.getElementById(
"weatherIcon"
).textContent =
result.icon;
document.getElementById(
"weatherClothes"
).innerHTML =
result.items
.map(item => `• ${escapeHtml(item)}`)
.join("<br>");
const updated = document.getElementById("weatherUpdated");
if (updated) {
updated.textContent = weather.updatedAt
? `Обновлено автоматически: ${new Date(Number(weather.updatedAt)).toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit"})}`
: (profile.city ? "Погода загрузится автоматически." : "Укажите город в профиле малыша.");
}
}
function renderAnalytics() {
const days =
Array.from(
{ length: 7 },
(_, index) => {
const date =
new Date();
date.setHours(
0,
0,
0,
0
);
date.setDate(
date.getDate() -
(6 - index)
);
return date;
}
);
const sleepTotals =
days.map(day =>
events
.filter(
event =>
event.type === "sleep" &&
isSameDay(
event.time,
day
)
)
.reduce(
(sum, event) =>
sum +
(event.duration || 0),
0
)
);
const maximum =
Math.max(
...sleepTotals,
1
);
document.getElementById(
"sleepChart"
).innerHTML =
days.map((day, index) => {
const height =
Math.max(
7,
sleepTotals[index] /
maximum *
125
);
return `
<div class="bar-column">
<div
class="bar"
style="height: ${height}px"
></div>
<div class="bar-label">
${
[
"вс",
"пн",
"вт",
"ср",
"чт",
"пт",
"сб"
][day.getDay()]
}
</div>
</div>
`;
}).join("");
const average =
sleepTotals.reduce(
(sum, value) =>
sum + value,
0
) / 7;
document.getElementById(
"averageSleep"
).textContent =
average
? durationText(average)
: "0 ч";
const weekStart =
days[0].getTime();
document.getElementById(
"weekFeedings"
).textContent =
events.filter(
event =>
event.time >= weekStart &&
(
event.type === "feeding" ||
event.type === "food"
)
).length;
document.getElementById(
"monthIllnesses"
).textContent =
illnesses.filter(
illness =>
Date.now() -
illness.startTime <
30 * 86400000
).length;
const weight =
events.find(
event =>
event.type === "weight"
);
document.getElementById(
"latestWeight"
).textContent =
weight
? `${weight.value} кг`
: "—";
renderMoodChart(days);
renderInsights(average);
}
function renderMoodChart(days) {
document.getElementById(
"moodChart"
).innerHTML =
days.map(day => {
const moodEvent =
events.find(
event =>
event.type === "mood" &&
isSameDay(
event.time,
day
)
);
return `
<div class="mood-day">
<div class="mood-emoji">
${
moodEvent
? MOODS[moodEvent.mood]
?.emoji || "🙂"
: "·"
}
</div>
<small>
${
[
"вс",
"пн",
"вт",
"ср",
"чт",
"пт",
"сб"
][day.getDay()]
}
</small>
</div>
`;
}).join("");
}
function renderInsights(
averageSleep
) {
const insights = [];
if (averageSleep > 0) {
insights.push(
`Средний сон за последние 7 дней — ${durationText(
averageSleep
)}.`
);
} else {
insights.push(
"Добавьте несколько дней сна, чтобы увидеть изменения."
);
}
const recentMoods =
events.filter(
event =>
event.type === "mood" &&
Date.now() -
event.time <
7 * 86400000
);
if (recentMoods.length >= 3) {
const difficult =
recentMoods.filter(
event =>
[
"fussy",
"sad",
"tired"
].includes(event.mood)
).length;
insights.push(
`За неделю настроение отмечалось ${recentMoods.length} раз. Неспокойное состояние — ${difficult} раз.`
);
} else {
insights.push(
"Отмечайте настроение каждый день, чтобы увидеть тренд."
);
}
const recentIllnesses =
illnesses.filter(
illness =>
Date.now() -
illness.startTime <
90 * 86400000
);
if (recentIllnesses.length) {
const totalDays =
recentIllnesses.reduce(
(sum, illness) => {
const end =
illness.endTime ||
Date.now();
return (
sum +
Math.max(
1,
Math.ceil(
(
end -
illness.startTime
) /
86400000
)
)
);
},
0
);
insights.push(
`За последние 90 дней отмечено ${recentIllnesses.length} ${plural(
recentIllnesses.length,
["эпизод болезни", "эпизода болезни", "эпизодов болезни"]
)}, общей длительностью около ${totalDays} ${plural(
totalDays,
["дня", "дней", "дней"]
)}.`
);
}
document.getElementById(
"insights"
).innerHTML =
insights.map(text => `
<div class="insight">
${escapeHtml(text)}
</div>
`).join("");
}
function openSizes() {
openSheet(`
<div class="sheet-header">
<h3>Актуальные размеры</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="form">
<div class="field">
<label>Одежда</label>
<input
id="sizeClothes"
value="${escapeHtml(sizes.clothes)}"
placeholder="74"
>
</div>
<div class="field">
<label>Подгузники</label>
<input
id="sizeDiaper"
value="${escapeHtml(sizes.diaper)}"
placeholder="M или 4"
>
</div>
<div class="field">
<label>Шапка</label>
<input
id="sizeHat"
value="${escapeHtml(sizes.hat)}"
placeholder="46"
>
</div>
<div class="field">
<label>Обувь</label>
<input
id="sizeShoes"
value="${escapeHtml(sizes.shoes)}"
placeholder="18"
>
</div>
<button
class="primary-button"
onclick="saveSizes()"
>
Сохранить
</button>
</div>
`);
}
function saveSizes() {
sizes = {
clothes:
document
.getElementById(
"sizeClothes"
)
.value
.trim(),
diaper:
document
.getElementById(
"sizeDiaper"
)
.value
.trim(),
hat:
document
.getElementById(
"sizeHat"
)
.value
.trim(),
shoes:
document
.getElementById(
"sizeShoes"
)
.value
.trim()
};
saveStorage(
"baby-sizes",
sizes
);
renderProfileSummaries();
closeModal();
showToast("Размеры сохранены");
}
function openShopping() {
const content =
shopping.length
? shopping.map(
(item, index) => `
<div class="list-row">
<div class="list-icon">
🛍️
</div>
<div class="list-copy">
<strong>
${escapeHtml(item)}
</strong>
</div>
<button
class="close-button"
onclick="removeShoppingItem(${index})"
>
×
</button>
</div>
`
).join("")
: `
<div class="empty">
Список покупок пока пуст.
</div>
`;
openSheet(`
<div class="sheet-header">
<h3>Покупки</h3>
<button
class="close-button"
onclick="closeModal()"
>
×
</button>
</div>
<div class="card list">
${content}
</div>
<div
class="form"
style="margin-top: 11px"
>
<div class="field">
<input
id="shoppingInput"
placeholder="Подгузники, смесь..."
>
</div>
<button
class="primary-button"
onclick="addShoppingItem()"
>
Добавить
</button>
</div>
`);
}
function addShoppingItem() {
const input =
document.getElementById(
"shoppingInput"
);
const value =
input.value.trim();
if (!value) {
showToast(
"Введите название"
);
return;
}
shopping.push(value);
saveStorage(
"baby-shopping",
shopping
);
renderProfileSummaries();
openShopping();
}
function removeShoppingItem(index) {
shopping.splice(index, 1);
saveStorage(
"baby-shopping",
shopping
);
renderProfileSummaries();
openShopping();
}
function renderProfileSummaries() {
const weight =
events.find(
event =>
event.type === "weight"
);
const height =
events.find(
event =>
event.type === "height"
);
const values = [];
if (weight?.value) {
values.push(
`${weight.value} кг`
);
}
if (weight?.extraValue) {
values.push(
`${weight.extraValue} см`
);
} else if (height?.value) {
values.push(
`${height.value} см`
);
}
document.getElementById(
"growthSummary"
).textContent =
values.length
? values.join(" · ")
: "Нет данных";
document.getElementById(
"sizesSummary"
).textContent =
sizes.clothes ||
sizes.diaper
? `Одежда ${sizes.clothes || "—"} · подгузники ${sizes.diaper || "—"}`
: "Одежда и подгузники";
document.getElementById(
"shoppingSummary"
).textContent =
shopping.length
? `${shopping.length} ${plural(
shopping.length,
["позиция", "позиции", "позиций"]
)}`
: "Список покупок пуст";
}
function reportDateInputValue(date) {
const d = new Date(date);
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 10);
}
function reportDayStart(value) {
const d = new Date(`${value}T00:00:00`);
return d.getTime();
}
function reportDayEnd(value) {
const d = new Date(`${value}T23:59:59.999`);
return d.getTime();
}
function setReportPreset(days, button = null) {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - Math.max(0, days - 1));
const from = document.getElementById("reportFrom");
const to = document.getElementById("reportTo");
if (from) from.value = reportDateInputValue(startDate);
if (to) to.value = reportDateInputValue(endDate);
document.querySelectorAll(".report-preset").forEach(item => item.classList.remove("active"));
if (button) button.classList.add("active");
generatePeriodReport();
}
function reportPeriodStats(list) {
const sleepEvents = list.filter(event => event.type === "sleep");
const feedingEvents = list.filter(event => event.type === "feeding");
return {
sleepMinutes: sleepEvents.reduce((sum, event) => sum + Number(event.duration || 0), 0),
daySleepMinutes: sleepEvents.filter(event => event.sleepKind !== "night").reduce((sum, event) => sum + Number(event.duration || 0), 0),
nightSleepMinutes: sleepEvents.filter(event => event.sleepKind === "night").reduce((sum, event) => sum + Number(event.duration || 0), 0),
feedings: feedingEvents.length,
feedingMinutes: feedingEvents.reduce((sum, event) => sum + Number(event.feedingMinutes || event.duration || 0), 0),
diapers: list.filter(event => event.type === "diaper").length,
foods: list.filter(event => event.type === "food").length,
moods: list.filter(event => event.type === "mood").length,
illnesses: list.filter(event => event.type === "illness").length
};
}
function reportTrend(current, previous, label, unit = "") {
if (!previous && !current) return `${label}: данных для сравнения пока недостаточно.`;
if (!previous) return `${label}: появились данные в этом периоде, сравнить с предыдущим пока нельзя.`;
const delta = ((current - previous) / previous) * 100;
if (Math.abs(delta) < 7) return `${label}: примерно на прежнем уровне.`;
const direction = delta > 0 ? "стало больше" : "стало меньше";
return `${label}: ${direction} примерно на ${Math.round(Math.abs(delta))}%${unit}.`;
}
function reportEventLine(event) {
const stamp = new Date(Number(event.time));
const date = stamp.toLocaleDateString("ru-RU", {day:"2-digit", month:"2-digit", year:"numeric"});
const time = formatTime(event.time);
return `${date} ${time} — ${EVENT_NAMES[event.type] || "Событие"}: ${getEventDescription(event)}`;
}
function buildPeriodReportText(fromTs, toTs) {
const selected = events
.filter(event => Number(event.time) >= fromTs && Number(event.time) <= toTs)
.sort((a, b) => Number(a.time) - Number(b.time));
const span = Math.max(1, toTs - fromTs + 1);
const previousFrom = fromTs - span;
const previousTo = fromTs - 1;
const previous = events.filter(event => Number(event.time) >= previousFrom && Number(event.time) <= previousTo);
const nowStats = reportPeriodStats(selected);
const prevStats = reportPeriodStats(previous);
const fromLabel = new Date(fromTs).toLocaleDateString("ru-RU");
const toLabel = new Date(toTs).toLocaleDateString("ru-RU");
const days = Math.max(1, Math.round(span / 86400000));
const lines = [];
lines.push(`OnlyMoms — отчёт за ${fromLabel}–${toLabel}`);
if (profile.name) lines.push(`Малыш: ${profile.name}`);
lines.push("");
lines.push("ИТОГИ");
lines.push(`Сон: ${durationText(nowStats.sleepMinutes)} (дневной ${durationText(nowStats.daySleepMinutes)}, ночной ${durationText(nowStats.nightSleepMinutes)}).`);
lines.push(`Кормления грудью: ${nowStats.feedings}${nowStats.feedingMinutes ? `, суммарно ${durationText(nowStats.feedingMinutes)}` : ""}.`);
lines.push(`Прикорм: ${nowStats.foods}. Подгузники: ${nowStats.diapers}.`);
const periodIllnesses = illnesses.filter(item => {
const start = Number(item.startTime || 0);
const finish = Number(item.endTime || Date.now());
return start <= toTs && finish >= fromTs;
});
if (periodIllnesses.length) {
lines.push(`Болезни: ${periodIllnesses.length}.`);
periodIllnesses.forEach(item => {
const parts = [];
if (item.diagnosis) parts.push(`диагноз: ${item.diagnosis}`);
if (item.symptoms?.length) parts.push(`симптомы: ${item.symptoms.join(", ")}`);
lines.push(`• ${parts.length ? parts.join("; ") : "отмечен период болезни"}.`);
});
} else {
lines.push("Болезней за выбранный период не отмечено.");
}
const periodDoctors = doctors.filter(item => Number(item.time) >= fromTs && Number(item.time) <= toTs);
if (periodDoctors.length) {
lines.push(`Визиты к врачу: ${periodDoctors.length}.`);
periodDoctors.sort((a,b)=>Number(a.time)-Number(b.time)).forEach(item => {
lines.push(`• ${new Date(item.time).toLocaleDateString("ru-RU")} ${formatTime(item.time)} — ${item.specialty || "врач"}${item.doctorName ? `, ${item.doctorName}` : ""}.`);
});
}
lines.push("");
lines.push("НАБЛЮДЕНИЯ");
const normalizedSleepNow = nowStats.sleepMinutes / days;
const normalizedSleepPrev = prevStats.sleepMinutes / days;
const normalizedFeedNow = nowStats.feedings / days;
const normalizedFeedPrev = prevStats.feedings / days;
lines.push(reportTrend(normalizedSleepNow, normalizedSleepPrev, "Сон"));
lines.push(reportTrend(normalizedFeedNow, normalizedFeedPrev, "Кормления"));
const weights = selected.filter(event => event.type === "weight" && Number(event.value)).sort((a,b)=>Number(a.time)-Number(b.time));
if (weights.length >= 2) {
const change = Number(weights[weights.length - 1].value) - Number(weights[0].value);
lines.push(`Вес: ${change > 0 ? "увеличился" : change < 0 ? "уменьшился" : "не изменился"}${change ? ` на ${Math.abs(change).toFixed(2)} кг` : ""}.`);
} else if (weights.length === 1) {
lines.push(`Вес: ${weights[0].value} кг — одна отметка за период.`);
}
const moodEvents = selected.filter(event => event.type === "mood");
if (moodEvents.length) {
const counts = {};
moodEvents.forEach(event => counts[event.mood] = (counts[event.mood] || 0) + 1);
const topMood = Object.entries(counts).sort((a,b)=>b[1]-a[1])[0]?.[0];
if (topMood) lines.push(`Настроение чаще всего: ${MOODS[topMood]?.name || "отмечено"} ${MOODS[topMood]?.emoji || ""}.`);
}
lines.push("");
lines.push("ХРОНОЛОГИЯ");
if (selected.length) {
selected.forEach(event => lines.push(`• ${reportEventLine(event)}`));
} else {
lines.push("За выбранный период событий не добавлено.");
}
lines.push("");
lines.push("Наблюдения построены по внесённым в OnlyMoms данным и сравнивают выбранный период с предыдущим периодом такой же длительности.");
return { text: lines.join("\n"), stats: nowStats };
}
function generatePeriodReport() {
const from = document.getElementById("reportFrom")?.value;
const to = document.getElementById("reportTo")?.value;
if (!from || !to) return;
const fromTs = reportDayStart(from);
const toTs = reportDayEnd(to);
if (fromTs > toTs) {
showToast("Дата начала позже даты окончания");
return;
}
const result = buildPeriodReportText(fromTs, toTs);
const output = document.getElementById("periodReportText");
if (output) output.textContent = result.text;
const sleepNode = document.getElementById("reportSleepMetric");
const feedNode = document.getElementById("reportFeedMetric");
const eventsNode = document.getElementById("reportEventsMetric");
const illnessNode = document.getElementById("reportIllnessMetric");
if (sleepNode) sleepNode.textContent = durationText(result.stats.sleepMinutes);
if (feedNode) feedNode.textContent = result.stats.feedings;
if (eventsNode) eventsNode.textContent = events.filter(event => Number(event.time)>=fromTs && Number(event.time)<=toTs).length;
if (illnessNode) illnessNode.textContent = illnesses.filter(item => Number(item.startTime || 0)<=toTs && Number(item.endTime || Date.now())>=fromTs).length;
}
async function copyPeriodReport() {
const text = document.getElementById("periodReportText")?.textContent || "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
showToast("Отчёт скопирован");
}
function openReport() {
const today = new Date();
const weekAgo = new Date();
weekAgo.setDate(today.getDate() - 6);
openSheet(`
<div class="sheet-header">
<h3>Отчёт</h3>
<button class="close-button" onclick="closeModal()">×</button>
</div>
<div class="report-presets">
<button class="report-preset" onclick="setReportPreset(1, this)">Сегодня</button>
<button class="report-preset active" onclick="setReportPreset(7, this)">7 дней</button>
<button class="report-preset" onclick="setReportPreset(30, this)">30 дней</button>
</div>
<div class="report-range">
<div class="field"><label>С</label><input id="reportFrom" type="date" value="${reportDateInputValue(weekAgo)}" onchange="generatePeriodReport()"></div>
<div class="field"><label>По</label><input id="reportTo" type="date" value="${reportDateInputValue(today)}" onchange="generatePeriodReport()"></div>
</div>
<div class="report-chip-grid">
<div class="report-chip"><small>Сон</small><strong id="reportSleepMetric">—</strong></div>
<div class="report-chip"><small>Кормления</small><strong id="reportFeedMetric">—</strong></div>
<div class="report-chip"><small>События</small><strong id="reportEventsMetric">—</strong></div>
<div class="report-chip"><small>Болезни</small><strong id="reportIllnessMetric">—</strong></div>
</div>
<div class="report-output" id="periodReportText"></div>
<div class="report-actions">
<button class="secondary-button" onclick="generatePeriodReport()">Обновить</button>
<button class="primary-button" onclick="copyPeriodReport()">Скопировать отчёт</button>
</div>
`);
setTimeout(generatePeriodReport, 0);
}
function toggleTheme() {
const dark =
document.body.classList.toggle(
"dark"
);
localStorage.setItem(
"baby-theme",
dark
? "dark"
: "light"
);
renderTheme();
updateNotificationBadge();
}
function renderTheme() {
const dark =
document.body.classList.contains(
"dark"
);
document.getElementById(
"themeIcon"
).textContent =
dark ? "☀" : "☾";
document.getElementById(
"themeSummary"
).textContent =
dark
? "Ночная"
: "Дневная";
}
function switchTab(tabId) {
document
.querySelectorAll(".tab")
.forEach(tab => {
tab.classList.remove(
"active"
);
});
document
.getElementById(tabId)
.classList.add("active");
document
.querySelectorAll(
".nav-button"
)
.forEach(button => {
button.classList.toggle(
"active",
button.dataset.tab === tabId
);
});
window.scrollTo({
top: 0,
behavior: "smooth"
});
if (tabId === "analytics") {
renderAnalytics();
renderSleepDayDiagram();
}
if (tabId === "garden") {
loadGarden();
loadGardenChat();
}
}
function openSheet(html) {
document.getElementById(
"sheet"
).innerHTML = html;
document.getElementById(
"modal"
).classList.add("open");
}
function closeModal() {
document.getElementById(
"modal"
).classList.remove("open");
}
function showToast(text) {
const toast =
document.getElementById(
"toast"
);
toast.textContent = text;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove(
"show"
);
}, 1800);
}
function switchAnalyticsPane(pane, button) {
document.querySelectorAll(".analytics-pane").forEach(item => item.classList.remove("active"));
document.querySelectorAll(".analytics-switch button").forEach(item => item.classList.remove("active"));
document.getElementById(pane === "sleepday" ? "analyticsSleepday" : "analyticsOverview").classList.add("active");
button?.classList.add("active");
if (pane === "sleepday") renderSleepDayDiagram();
}
function setSleepDiagramToday() {
const input = document.getElementById("sleepDiagramDate");
input.value = new Date().toISOString().slice(0,10);
renderSleepDayDiagram();
}
function selectedDiagramDay() {
const input = document.getElementById("sleepDiagramDate");
if (!input.value) input.value = new Date().toISOString().slice(0,10);
const start = new Date(`${input.value}T00:00:00`);
return {start:start.getTime(), end:start.getTime()+86400000};
}
function renderSleepDayDiagram() {
const timeline = document.getElementById("sleepTimeline");
if (!timeline) return;
const {start,end} = selectedDiagramDay();
const now = Date.now();
const visibleEnd = Math.min(end, now > start ? now : end);
const sleeps = events.filter(e => e.type === "sleep").map(e => {
const a = Math.max(start, Number(e.time));
const rawEnd = Number(e.end || (Number(e.time)+(Number(e.duration)||0)*60000));
const b = Math.min(end, rawEnd);
return {...e, a, b};
}).filter(e => e.b > e.a).sort((a,b)=>a.a-b.a);
if (sleepStart) {
const a=Math.max(start,Number(sleepStart)), b=Math.min(end,now);
if (b>a) sleeps.push({id:"running",a,b,sleepKind, duration:(b-a)/60000, running:true});
}
sleeps.sort((a,b)=>a.a-b.a);
const segments=[]; let cursor=start;
for (const item of sleeps) {
if (item.a>cursor) segments.push({kind:"awake",a:cursor,b:item.a});
const a=Math.max(cursor,item.a); if(item.b>a) segments.push({kind:item.sleepKind==="night"?"night":"day",a,b:item.b,event:item});
cursor=Math.max(cursor,item.b);
}
if (cursor<visibleEnd) segments.push({kind:"awake",a:cursor,b:visibleEnd});
if (!segments.length) segments.push({kind:"awake",a:start,b:visibleEnd});
timeline.innerHTML=segments.map(seg=>{
const width=Math.max(0,(seg.b-seg.a)/86400000*100);
const mins=Math.round((seg.b-seg.a)/60000);
const label=width>8 ? (seg.kind==="awake"?"Бодр.":seg.kind==="night"?"Ночной":"Дневной") : "";
return `<div class="sleep-segment ${seg.kind}" style="width:${width}%" title="${formatTime(seg.a)}–${formatTime(seg.b)} · ${durationText(mins)}"><span>${label}</span></div>`;
}).join("");
const sleepMs=sleeps.reduce((sum,e)=>sum+(e.b-e.a),0);
const awakeMs=Math.max(0,visibleEnd-start-sleepMs);
document.getElementById("diagramSleepTotal").textContent=durationText(sleepMs/60000);
document.getElementById("diagramAwakeTotal").textContent=durationText(awakeMs/60000);
const lastSleepEnd=sleeps.length?Math.max(...sleeps.map(e=>e.b)):start;
const selectedToday=new Date(start).toDateString()===new Date().toDateString();
const activeSleep=sleepStart && Number(sleepStart)<=now;
document.getElementById("diagramCurrentAwake").textContent=selectedToday&&!activeSleep?durationText((now-lastSleepEnd)/60000):"—";
const periods=document.getElementById("sleepPeriods");
periods.innerHTML=segments.length?segments.map(seg=>{
const mins=(seg.b-seg.a)/60000;
const isAwake=seg.kind==="awake";
const title=isAwake?"Бодрствование":seg.kind==="night"?"Ночной сон":"Дневной сон";
return `<div class="sleep-period ${isAwake?"wake-period":""}"><div><strong>${title}</strong><br>${formatTime(seg.a)}–${formatTime(seg.b)}</div><strong>${durationText(mins)}</strong></div>`;
}).join(""):`<div class="empty">За выбранный день данных пока нет.</div>`;
}
function editEvent(id) {
const item=events.find(e=>Number(e.id)===Number(id));
if(!item) return;
const isSleep=item.type==="sleep", isFeeding=item.type==="feeding";
const endValue=getLocalDateTimeValue(new Date(item.end || (item.time+(item.duration||0)*60000)));
openSheet(`
<div class="sheet-header"><h3>Редактировать событие</h3><button class="close-button" onclick="closeModal()">×</button></div>
<div class="form">
<div class="field"><label>Дата и время</label><input id="editEventTime" type="datetime-local" value="${getLocalDateTimeValue(new Date(item.time))}"></div>
${isSleep?`<div class="field"><label>Тип сна</label><select id="editSleepKind"><option value="day" ${item.sleepKind!=="night"?"selected":""}>Дневной</option><option value="night" ${item.sleepKind==="night"?"selected":""}>Ночной</option></select></div><div class="field"><label>Окончание сна</label><input id="editEventEnd" type="datetime-local" value="${endValue}"></div>`:""}
${isFeeding?`<div class="field"><label>Тип кормления</label><select id="editFeedingKind"><option value="breast" ${(!item.feedingKind||item.feedingKind==="breast")?"selected":""}>Грудное молоко</option><option value="formula" ${item.feedingKind==="formula"?"selected":""}>Смесь</option><option value="other" ${item.feedingKind==="other"?"selected":""}>Другое</option></select></div><div class="field"><label>Грудь</label><select id="editBreast"><option value="left" ${item.breast==="left"?"selected":""}>Левая</option><option value="right" ${item.breast==="right"?"selected":""}>Правая</option><option value="both" ${item.breast==="both"?"selected":""}>Обе</option><option value="none" ${(!item.breast||item.breast==="none")?"selected":""}>Не указывать</option></select></div><div class="field"><label>Длительность, минут</label><input id="editFeedMinutes" type="number" min="1" value="${escapeHtml(item.feedingMinutes||item.durationMinutes||"")}"></div>`:""}
${!isSleep&&!isFeeding?`<div class="field"><label>Описание</label><textarea id="editEventDetails">${escapeHtml(item.details||"")}</textarea></div>`:""}
<button class="primary-button" onclick="saveEditedEvent(${item.id})">Сохранить изменения</button>
<button class="danger-button" onclick="deleteEvent(${item.id})">Удалить событие</button>
</div>`);
}
async function saveEditedEvent(id) {
const item=events.find(e=>Number(e.id)===Number(id)); if(!item) return;
const time=new Date(document.getElementById("editEventTime").value).getTime();
if(!Number.isFinite(time)){showToast("Укажите дату и время");return;}
item.time=time;
if(item.type==="sleep"){
const end=new Date(document.getElementById("editEventEnd").value).getTime();
if(!Number.isFinite(end)||end<=time){showToast("Проверьте окончание сна");return;}
item.end=end; item.duration=Math.max(1,Math.round((end-time)/60000)); item.sleepKind=document.getElementById("editSleepKind").value;
} else if(item.type==="feeding") {
item.feedingKind=document.getElementById("editFeedingKind").value;
item.breast=document.getElementById("editBreast").value;
item.feedingMinutes=Math.max(1,Number(document.getElementById("editFeedMinutes").value)||1);
const kindText={breast:"Грудное молоко",formula:"Смесь",other:"Другое"}[item.feedingKind];
const breastText={left:"левая грудь",right:"правая грудь",both:"обе груди"}[item.breast];
item.details=[kindText,breastText,`${item.feedingMinutes} мин`].filter(Boolean).join(" · ");
} else item.details=document.getElementById("editEventDetails")?.value.trim()||"";
saveStorage("baby-events",events); renderAll(); closeModal(); showToast("Событие обновлено");
if(apiToken&&item.serverId){try{await apiRequest(`/api/events/${item.serverId}`,{method:"PUT",body:JSON.stringify({type:item.type,occurred_at:Math.floor(item.time/1000),data:serverEventData(item)})});}catch(e){console.warn("Event update:",e)}}
}
async function deleteEvent(id) {
const item=events.find(e=>Number(e.id)===Number(id)); if(!item) return;
if(!confirm("Удалить это событие?")) return;
events=events.filter(e=>Number(e.id)!==Number(id));
syncedEventIds=syncedEventIds.filter(x=>Number(x)!==Number(id));
saveStorage("baby-events",events); saveStorage("onlymoms-synced-events",syncedEventIds);
renderAll(); closeModal(); showToast("Событие удалено");
if(apiToken&&item.serverId){try{await apiRequest(`/api/events/${item.serverId}`,{method:"DELETE"});}catch(e){console.warn("Event delete:",e)}}
}
function clearEvents() {
const confirmed =
confirm(
"Удалить все события дневника?"
);
if (!confirmed) {
return;
}
events = [];
saveEvents();
showToast("Дневник очищен");
}
async function apiRequest(path, options = {}) {
if (!apiToken) throw new Error("AUTH_REQUIRED");
const headers = {"Content-Type":"application/json", ...(options.headers || {})};
headers.Authorization = `Bearer ${apiToken}`;
const response = await fetch(path, {...options, headers});
if (response.status === 401) {
apiToken = "";
localStorage.removeItem("onlymoms-api-token");
apiReady = false;
throw new Error("AUTH_REQUIRED");
}
if (!response.ok) throw new Error(`API_${response.status}`);
return response.json();
}
async function initServerSync() {
try {
const tg = window.Telegram?.WebApp;
if (!tg?.initData) return;
tg.ready();
tg.expand();
const response = await fetch("/api/auth", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({init_data: tg.initData})
});
if (!response.ok) return;
const data = await response.json();
apiToken = data.token;
apiReady = true;
localStorage.setItem("onlymoms-api-token", apiToken);
if (data.profile && !profile.name) {
profile = {
name: data.profile.name || "",
birthDate: data.profile.birth_date || "",
gender: data.profile.gender || "neutral",
city: data.profile.city || "",
gardenVisible: data.profile.garden_visible !== 0
};
saveStorage("baby-profile", profile);
renderAll();
} else if (profile.name) {
await syncProfileToServer();
}
await syncUnsyncedEvents();
} catch (error) {
console.warn("OnlyMoms sync:", error);
}
}
async function syncProfileToServer() {
if (!apiToken || !profile.name || !profile.birthDate) return;
try {
await apiRequest("/api/profile", {
method: "PUT",
body: JSON.stringify({
name: profile.name,
birth_date: profile.birthDate,
gender: profile.gender || "neutral",
city: profile.city || "",
status: "",
garden_visible: profile.gardenVisible !== false
})
});
} catch (error) { console.warn("Profile sync:", error); }
}
function serverEventData(event) {
const data = {...event};
delete data.id; delete data.type; delete data.time; delete data.serverId;
if (event.type === "sleep") data.duration_minutes = event.duration || data.duration_minutes || 0;
if (event.type === "mood") data.mood = event.mood || "";
return data;
}
async function syncUnsyncedEvents() {
if (!apiToken) return;
const pending = events.filter(event => !syncedEventIds.includes(event.id)).slice().reverse();
for (const event of pending) {
try {
const saved = await apiRequest("/api/events", {
method: "POST",
body: JSON.stringify({type:event.type, occurred_at:Math.floor(event.time/1000), data:serverEventData(event)})
});
event.serverId = saved.id;
saveStorage("baby-events", events);
syncedEventIds.push(event.id);
if (syncedEventIds.length > 2000) syncedEventIds = syncedEventIds.slice(-1500);
saveStorage("onlymoms-synced-events", syncedEventIds);
} catch (error) { break; }
}
}
function setGardenFilter(filter, button) {
gardenFilter = filter;
document.querySelectorAll(".garden-filter").forEach(item => item.classList.remove("active"));
button?.classList.add("active");
renderGarden();
}
function gardenSeenText(timestamp) {
const seconds = Math.max(0, Math.floor(Date.now()/1000 - timestamp));
if (seconds < 120) return "Сейчас в садике";
if (seconds < 3600) return `Был(а) ${Math.floor(seconds/60)} мин назад`;
if (seconds < 86400) return `Был(а) ${Math.floor(seconds/3600)} ч назад`;
return "Заходил(а) ранее";
}
function renderGarden() {
const now = Math.floor(Date.now()/1000);
document.getElementById("gardenTotal").textContent = gardenChildren.length;
document.getElementById("gardenOnline").textContent = gardenChildren.filter(x => now-x.last_seen < 300).length;
document.getElementById("gardenToday").textContent = gardenChildren.filter(x => now-x.last_seen < 86400).length;
let list = gardenChildren;
if (gardenFilter === "online") list = list.filter(x => now-x.last_seen < 300);
if (gardenFilter === "boy" || gardenFilter === "girl") list = list.filter(x => x.gender === gardenFilter);
const grid = document.getElementById("gardenGrid");
if (!list.length) {
grid.innerHTML = `<div class="card empty" style="grid-column:1/-1">В этой категории пока никого нет.</div>`;
return;
}
grid.innerHTML = list.map(child => {
const avatar = child.gender === "boy" ? "👦" : child.gender === "girl" ? "👧" : "👶";
const mood = MOODS[child.mood];
return `<div class="card garden-child ${child.is_mine ? "mine" : ""}">
${child.is_mine ? '<span class="garden-badge">МОЙ МАЛЫШ</span>' : ''}
<div class="garden-child-top"><div class="garden-child-avatar">${avatar}</div><div><strong>${escapeHtml(child.name)}</strong><small>${escapeHtml(child.age)}</small></div></div>
<div class="garden-mood">${mood ? `${mood.emoji} ${mood.name} настроение` : "Настроение не отмечено"}</div>
<div class="garden-seen">${gardenSeenText(child.last_seen)}</div>
</div>`;
}).join("");
}
async function loadGarden(showMessage = false) {
const grid = document.getElementById("gardenGrid");
if (!apiToken) {
grid.innerHTML = `<div class="card empty" style="grid-column:1/-1">Откройте OnlyMoms через Telegram, чтобы увидеть общий детский сад.</div>`;
return;
}
try {
const data = await apiRequest("/api/garden");
gardenChildren = data.children || [];
renderGarden();
if (showMessage) showToast("Детский сад обновлён");
} catch (error) {
grid.innerHTML = `<div class="card empty" style="grid-column:1/-1">Не удалось загрузить детский сад. Попробуйте обновить.</div>`;
}
}
function renderGardenChat() {
const list = document.getElementById("gardenChatList");
const meta = document.getElementById("gardenChatMeta");
if (!list) return;
if (!gardenMessages.length) {
list.innerHTML = `<div class="empty">Сообщений за последние сутки пока нет.</div>`;
} else {
list.innerHTML = gardenMessages.map(message => `
<div class="chat-message ${message.is_mine ? "mine" : ""}">
<strong>${escapeHtml(message.child_name || "Мама")}</strong>
<p>${escapeHtml(message.text)}</p>
<small>${formatTime(Number(message.created_at)*1000)}</small>
</div>`).join("");
list.scrollTop = list.scrollHeight;
}
if (meta) meta.textContent = `Сегодня можно отправить ещё: ${gardenMessagesLeft} из 2. Сообщения хранятся 24 часа.`;
}
async function loadGardenChat(showMessage=false) {
const list = document.getElementById("gardenChatList");
if (!apiToken) {
if (list) list.innerHTML=`<div class="empty">Откройте OnlyMoms через Telegram, чтобы читать чат.</div>`;
return;
}
try {
const data=await apiRequest("/api/garden/chat");
gardenMessages=data.messages||[];
gardenMessagesLeft=Number(data.messages_left ?? 0);
renderGardenChat();
if(showMessage) showToast("Чат обновлён");
} catch(error) {
if(list) list.innerHTML=`<div class="empty">Не удалось загрузить чат.</div>`;
}
}
async function sendGardenMessage() {
const input=document.getElementById("gardenChatInput");
const text=input?.value.trim()||"";
if(!text) { showToast("Введите сообщение"); return; }
if(text.length>500) { showToast("Не более 500 символов"); return; }
if(!apiToken) { showToast("Откройте приложение через Telegram"); return; }
try {
await apiRequest("/api/garden/chat", {method:"POST", body:JSON.stringify({text})});
input.value="";
await loadGardenChat();
showToast("Сообщение отправлено");
} catch(error) {
showToast(error.message?.includes("429") ? "Лимит: 2 сообщения в день" : "Не удалось отправить сообщение");
}
}
function renderAll() {
renderProfile();
renderTodaySummary();
renderMood();
renderIllness();
renderEvents();
renderUpcoming();
renderWeather();
renderAnalytics();
renderSleepDayDiagram();
renderProfileSummaries();
renderTheme();
}
const savedTheme =
localStorage.getItem(
"baby-theme"
);
if (
savedTheme === "dark" ||
(
!savedTheme &&
window.matchMedia &&
window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
)
) {
document.body.classList.add(
"dark"
);
}
if (sleepStart) {
startSleepTimer();
} else {
updateSleepTimer();
}
if (feedingStart) startFeedingTimer(); else updateFeedingTimer();
renderAll();
ensureAutomaticWeather();
setInterval(renderTodaySummary, 60000);
setInterval(ensureAutomaticWeather, 30 * 60 * 1000);
initServerSync();
</script>
</body>
</html>
Sitemap
Кол-во: 0
XML-карта сайта для поисковиков
?
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
Robots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Не найдено
Ссылки на другие страницы своего сайта
?
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
Внутренних ссылок на странице 0 оптимально.
Внешние ссылки
Не найдено
Ссылки на сторонние сайты
?
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы.
Внешних ссылок на странице не найдено.
Конкуренты Готовность: 0%
Конкуренты в Яндексе
Кол-во: 0
Топ сайтов-конкурентов в Яндексе
?
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
Мы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
Конкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Кол-во: 0
Топ сайтов-конкурентов в Google
?
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
Конкуренты в Google тоже не найдены. Займитесь продвижением сайта!
Конкурентов в ТОП-10 Google не нашлось.
ФЗ-152: ПД Готовность: 40%
РКН
ОК
Сайт не заблокирован
Контакты
https://babymommy.ru/contacts/
Страница с контактами найдена
Политика ПД
https://babymommy.ru/policy/
Страница политики обработки ПД найдена
Согласие
https://babymommy.ru/personal/
Страница согласия на обработку ПД найдена
Чекбокс согласия
Не найден
Чекбокс согласия на обработку ПД в формах сайта не найден
Счётчики до согласия
ОК
Счётчики аналитики не собирают данные до согласия пользователя
ЗоЗПП: права потребителей Готовность: 100%
Нарушения
Не выявлены
Признаков дистанционной продажи товаров (интернет-магазина) не обнаружено — требования ЗоЗПП о раскрытии информации продавца к сайту не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии Готовность: 100%
Нарушения
Не выявлены
Рекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама Готовность: 0%
Противопоказания
Нарушение
Предупреждение о противопоказаниях в рекламе медуслуг
?
Ст. 24 ч. 7 ФЗ-38: реклама медицинских услуг и методов лечения должна сопровождаться предупреждением «Имеются противопоказания. Необходима консультация специалиста». Добавьте его на страницы с описанием услуг.
Реклама медуслуг/методов лечения без обязательного предупреждения о противопоказаниях (ст. 24 ч. 7)
ФЗ-436: защита детей Готовность: 100%
Нарушения
Не выявлены
Признаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
Вердикт
Анализ сайта babymommy.ru, слабо оптимизирован на 53%. Для хорошей оптимизации и выхода на первые места в поиске требуется:
Исправьте ошибки в мета-тегах.
Исправьте ошибки оптимизации.
Исправьте ошибки индексации.
Поделитесь с друзьями: