From 6f969dbd1a67f56401d7025ab21f6af88a5599bc Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Fri, 26 Sep 2025 16:01:49 +0900 Subject: [PATCH] calendar events features --- app/src/main/AndroidManifest.xml | 3 +- .../womansafe/data/api/WomanSafeApi.kt | 10 +- .../example/womansafe/data/model/ApiModels.kt | 2 +- .../womansafe/data/model/CalendarModels.kt | 88 ++++-- .../womansafe/data/network/NetworkClient.kt | 2 +- .../data/repository/ApiRepository.kt | 18 +- .../womansafe/ui/screens/CalendarScreen.kt | 69 ++++- .../ui/viewmodel/CalendarViewModel.kt | 259 +++++++++++++++--- docs/CALENDAR_API_RESPONSE.json | 56 ++++ 9 files changed, 421 insertions(+), 86 deletions(-) create mode 100644 docs/CALENDAR_API_RESPONSE.json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6849cf..f6c3718 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,7 +23,8 @@ android:supportsRtl="true" android:theme="@style/Theme.WomanSafe" android:usesCleartextTraffic="true" - android:networkSecurityConfig="@xml/network_security_config"> + android:networkSecurityConfig="@xml/network_security_config" + android:enableOnBackInvokedCallback="true"> + suspend fun getCalendarEntries(): Response - @POST("api/v1/calendar/entries") - suspend fun createCalendarEntry(): Response + @POST("api/v1/calendar/entry") + suspend fun createCalendarEntry(@Body entry: CalendarEntryCreate): Response @GET("api/v1/calendar/entries/{entry_id}") - suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response + suspend fun getCalendarEntry(@Path("entry_id") entryId: String): Response @PUT("api/v1/calendar/entries/{entry_id}") - suspend fun updateCalendarEntry(@Path("entry_id") entryId: String): Response + suspend fun updateCalendarEntry(@Path("entry_id") entryId: String, @Body entry: CalendarEntryUpdate): Response @DELETE("api/v1/calendar/entries/{entry_id}") suspend fun deleteCalendarEntry(@Path("entry_id") entryId: String): Response diff --git a/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt b/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt index c187c44..09addd7 100644 --- a/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt +++ b/app/src/main/java/com/example/womansafe/data/model/ApiModels.kt @@ -260,7 +260,7 @@ data class CalendarEntry( val notes: String? = null ) -data class CalendarEntryResponse( +data class LegacyCalendarEntryResponse( // Переименовано, чтобы избежать конфликта с CalendarModels.kt val id: Int, val title: String, val description: String? = null, diff --git a/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt b/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt index c11883c..2ff3275 100644 --- a/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt +++ b/app/src/main/java/com/example/womansafe/data/model/CalendarModels.kt @@ -36,7 +36,7 @@ enum class SymptomType { // Событие календаря data class CalendarEvent( - val id: Int? = null, + val id: String? = null, // Изменено с Int? на String?, так как в API используются UUID val date: LocalDate, val type: CalendarEventType, val isActual: Boolean = true, // true - фактическое, false - прогноз @@ -45,35 +45,91 @@ data class CalendarEvent( val notes: String = "", val flowIntensity: Int? = null, // Интенсивность выделений 1-5 val createdAt: LocalDate = LocalDate.now(), - val updatedAt: LocalDate = LocalDate.now() + val updatedAt: LocalDate = LocalDate.now(), + val isPredicted: Boolean = false // Добавлено поле для совместимости с API ) // Настройки цикла data class CycleSettings( - val averageCycleLength: Int = 28, // Средняя длина цикла - val averagePeriodLength: Int = 5, // Средняя длина менструации - val lastPeriodStart: LocalDate? = null, // Последняя менструация - val reminderDaysBefore: Int = 2, // За сколько дней напоминать - val enablePredictions: Boolean = true, - val enableReminders: Boolean = true + val averageCycleLength: Int = 28, + val averagePeriodLength: Int = 5, + val lastPeriodStart: LocalDate? = null, + val irregularCycles: Boolean = false, + val trackSymptoms: Boolean = true, + val trackMood: Boolean = true, + val showPredictions: Boolean = true, + val reminderDaysBefore: Int = 3 // За сколько дней напоминать о приближающемся цикле ) -// Прогнозы цикла +// Прогноз цикла data class CyclePrediction( val nextPeriodStart: LocalDate, val nextPeriodEnd: LocalDate, val nextOvulation: LocalDate, val fertileWindowStart: LocalDate, val fertileWindowEnd: LocalDate, - val confidence: Float = 0.8f // Уверенность в прогнозе 0-1 + val confidence: Float = 0.85f // Добавлено значение достоверности прогноза в % ) // Статистика цикла data class CycleStatistics( - val averageCycleLength: Float, - val cycleVariation: Float, // Отклонение в днях - val lastCycles: List, // Длины последних циклов - val periodLengthAverage: Float, - val commonSymptoms: List, - val moodPatterns: Map + val averageCycleLength: Float = 0f, + val cycleVariation: Float = 0f, + val lastCycles: List = emptyList(), + val periodLengthAverage: Float = 0f, + val commonSymptoms: List = emptyList(), + val moodPatterns: Map = emptyMap() +) + +// Модели для API + +// Запрос на создание события в календаре +data class CalendarEntryCreate( + val date: String, + val type: String, + val mood: String? = null, + val symptoms: List = emptyList(), + val notes: String? = null, + val flow_intensity: Int? = null +) + +// Запрос на обновление события в календаре +data class CalendarEntryUpdate( + val date: String? = null, + val type: String? = null, + val mood: String? = null, + val symptoms: List? = null, + val notes: String? = null, + val flow_intensity: Int? = null +) + +// API ответ для отдельной записи календаря +data class CalendarEntryResponse( + val id: String, + val date: String, + val type: String, + val mood: String? = null, + val symptoms: List = emptyList(), + val notes: String? = null, + val flow_intensity: Int? = null, + val is_predicted: Boolean = false, + val created_at: String? = null, + val updated_at: String? = null +) + +// API ответ для информации о цикле +data class CycleInfoResponse( + val average_cycle_length: Int, + val average_period_length: Int, + val last_period_start: String?, + val next_period_predicted: String?, + val next_ovulation_predicted: String?, + val fertile_window_start: String?, + val fertile_window_end: String? +) + +// Полный API ответ для записей календаря +data class CalendarEntriesResponse( + val entries: List, + val cycle_info: CycleInfoResponse ) diff --git a/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt b/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt index 4c1bef3..6247777 100644 --- a/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt +++ b/app/src/main/java/com/example/womansafe/data/network/NetworkClient.kt @@ -9,7 +9,7 @@ import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit object NetworkClient { - private var BASE_URL = "http://192.168.0.103:8000/" + private var BASE_URL = "http://192.168.0.112:8000/" private var authToken: String? = null private val authInterceptor = Interceptor { chain -> diff --git a/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt b/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt index e7b7f9f..5425b0a 100644 --- a/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt +++ b/app/src/main/java/com/example/womansafe/data/repository/ApiRepository.kt @@ -191,24 +191,20 @@ class ApiRepository { } // Calendar methods - suspend fun getCalendarEntries(): Response { + suspend fun getCalendarEntries(): Response { return apiService.getCalendarEntries() } - suspend fun createCalendarEntry(): Response { - return apiService.createCalendarEntry() + suspend fun createCalendarEntry(entry: CalendarEntryCreate): Response { + return apiService.createCalendarEntry(entry) } - suspend fun getCalendarEntry(entryId: Int): Response { - return apiService.getCalendarEntry(entryId.toString()) + suspend fun updateCalendarEntry(id: String, entry: CalendarEntryUpdate): Response { + return apiService.updateCalendarEntry(id, entry) } - suspend fun updateCalendarEntry(entryId: Int): Response { - return apiService.updateCalendarEntry(entryId.toString()) - } - - suspend fun deleteCalendarEntry(entryId: Int): Response { - return apiService.deleteCalendarEntry(entryId.toString()) + suspend fun deleteCalendarEntry(id: String): Response { + return apiService.deleteCalendarEntry(id) } suspend fun getCycleOverview(): Response { diff --git a/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt b/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt index beed86b..9e0eacb 100644 --- a/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt +++ b/app/src/main/java/com/example/womansafe/ui/screens/CalendarScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -20,16 +21,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.womansafe.data.model.* import com.example.womansafe.ui.viewmodel.CalendarViewModel import com.example.womansafe.util.DateUtils -import java.text.SimpleDateFormat import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit -import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -623,12 +623,12 @@ private fun AddEventDialog( Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { - // Создаем строки по 2 симптома вручную, избегая chunked + // Создаем строки по 2 симптома, безопасно обрабатывая границы массива val symptoms = SymptomType.values().toList() - var i = 0 - while (i < symptoms.size) { + // Обходим список с шагом 2, чтобы обрабатывать симптомы парами + for (i in symptoms.indices step 2) { Row { - // Первый симптом в строке + // Первый симптом в строке (всегда существует) FilterChip( onClick = { val symptom = symptoms[i] @@ -645,7 +645,7 @@ private fun AddEventDialog( .padding(end = 4.dp) ) - // Второй симптом в строке (если есть) + // Второй симптом в строке (проверяем, что он существует) if (i + 1 < symptoms.size) { FilterChip( onClick = { @@ -667,7 +667,6 @@ private fun AddEventDialog( Spacer(modifier = Modifier.weight(1f)) } } - i += 2 } } @@ -715,6 +714,9 @@ private fun CycleSettingsDialog( var periodLength by remember { mutableStateOf(settings.averagePeriodLength.toString()) } var lastPeriodDate by remember { mutableStateOf(settings.lastPeriodStart) } var reminderDays by remember { mutableStateOf(settings.reminderDaysBefore.toString()) } + var trackSymptoms by remember { mutableStateOf(settings.trackSymptoms) } + var trackMood by remember { mutableStateOf(settings.trackMood) } + var showPredictions by remember { mutableStateOf(settings.showPredictions) } AlertDialog( onDismissRequest = onDismiss, @@ -728,7 +730,8 @@ private fun CycleSettingsDialog( onValueChange = { cycleLength = it }, label = { Text("Средняя длина цикла (дни)") }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) OutlinedTextField( @@ -736,7 +739,8 @@ private fun CycleSettingsDialog( onValueChange = { periodLength = it }, label = { Text("Длина менструации (дни)") }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) OutlinedTextField( @@ -744,9 +748,47 @@ private fun CycleSettingsDialog( onValueChange = { reminderDays = it }, label = { Text("Напоминать за (дни)") }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) + // Переключатели для отслеживания симптомов и настроения + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Отслеживать симптомы") + Switch( + checked = trackSymptoms, + onCheckedChange = { trackSymptoms = it } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Отслеживать настроение") + Switch( + checked = trackMood, + onCheckedChange = { trackMood = it } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Показывать прогнозы") + Switch( + checked = showPredictions, + onCheckedChange = { showPredictions = it } + ) + } + // TODO: Добавить выбор даты последних месячных lastPeriodDate?.let { date -> Text( @@ -763,7 +805,10 @@ private fun CycleSettingsDialog( averageCycleLength = cycleLength.toIntOrNull() ?: settings.averageCycleLength, averagePeriodLength = periodLength.toIntOrNull() ?: settings.averagePeriodLength, reminderDaysBefore = reminderDays.toIntOrNull() ?: settings.reminderDaysBefore, - lastPeriodStart = lastPeriodDate + lastPeriodStart = lastPeriodDate, + trackSymptoms = trackSymptoms, + trackMood = trackMood, + showPredictions = showPredictions ) onSave(newSettings) } diff --git a/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt b/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt index 4b932e6..5caf4b7 100644 --- a/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt +++ b/app/src/main/java/com/example/womansafe/ui/viewmodel/CalendarViewModel.kt @@ -38,62 +38,207 @@ class CalendarViewModel : ViewModel() { private var loadJob: Job? = null private var retryCount = 0 private val maxRetryCount = 5 - private val cacheValidityDuration = 10 * 60 * 1000 // 10 минут в миллисекундах + private val cacheValidityDuration = 30 * 60 * 1000 // 30 минут private var debounceJob: Job? = null + // Для определения, инициализирован ли ViewModel уже + private var initialized = false + + companion object { + // Статические переменные для предотвращения одновременных запросов из разных экземпляров + @Volatile + private var isRequestInProgress = false + private var lastRefreshTimestamp = 0L + private const val GLOBAL_COOLDOWN = 2000L // Минимальный интервал между запросами (2 секунды) + // Максимальное количество неудачных запросов перед временным отключением + private const val MAX_ERROR_COUNT = 3 + // Счетчик неудачных запросов + private var errorCount = 0 + // Время последней ошибки + private var lastErrorTimestamp = 0L + // Интервал охлаждения при ошибках (увеличивается экспоненциально) + private var errorCooldownInterval = 5000L // Начинаем с 5 секунд + } + init { - loadCalendarData() - loadCycleSettings() + // Добавляем небольшую задержку для предотвращения одновременных запросов + viewModelScope.launch { + delay(100L * (0..5).random()) // Случайная задержка до 500 мс + if (!initialized) { + initialized = true + loadInitialData() + } + } + } + + private fun loadInitialData() { + debounceJob?.cancel() + debounceJob = viewModelScope.launch { + delay(300) // Добавляем задержку для дебаунсинга + loadCalendarData() + loadCycleSettings() + } } fun loadCalendarData() { - // Если данные были обновлены недавно и это не первая загрузка, не делаем запрос - if (uiState.events.isNotEmpty() && - System.currentTimeMillis() - uiState.lastRefreshed < cacheValidityDuration) { + // Если запрос уже выполняется глобально, не начинаем новый + if (isRequestInProgress) { return } - // Отменяем предыдущий запрос, если он еще выполняется + // Глобальная проверка интервала между запросами + val now = System.currentTimeMillis() + if (now - lastRefreshTimestamp < GLOBAL_COOLDOWN) { + return + } + + // Проверка интервала охлаждения при ошибках + if (errorCount >= MAX_ERROR_COUNT && now - lastErrorTimestamp < errorCooldownInterval) { + uiState = uiState.copy( + error = "Временное ограничение запросов из-за ошибок. Повторите через ${(errorCooldownInterval / 1000).toInt()} секунд." + ) + return + } + + // Если данные были обновлены недавно и это не первая загрузка, не делаем запрос + if (uiState.events.isNotEmpty() && + now - uiState.lastRefreshed < cacheValidityDuration) { + return + } + + // Отменяем предыдущий запрос и дебаунс, если они выполняются loadJob?.cancel() + debounceJob?.cancel() - loadJob = viewModelScope.launch { - uiState = uiState.copy(isLoading = true, error = null) - try { - // Загружаем события календаря - val response = repository.getCalendarEntries() - if (response.isSuccessful) { - // Сбрасываем счетчик повторных попыток при успехе - retryCount = 0 + // Используем дебаунсинг для предотвращения частых запросов + debounceJob = viewModelScope.launch { + delay(300) // Задержка в 300 мс для дебаунсинга - // TODO: Преобразовать ответ API в события календаря - generatePredictions() - calculateStatistics() + loadJob = launch { + try { + isRequestInProgress = true // Устанавливаем глобальный флаг запроса + lastRefreshTimestamp = System.currentTimeMillis() - // Обновляем время последнего успешного запроса - uiState = uiState.copy( - isLoading = false, - lastRefreshed = System.currentTimeMillis() - ) - } else if (response.code() == 429) { - // Обработка Too Many Requests с экспоненциальным откатом - handleRateLimitExceeded() - } else { - uiState = uiState.copy( - isLoading = false, - error = "Ошибка загрузки данных календаря: ${response.code()}" - ) + uiState = uiState.copy(isLoading = true, error = null) + + // Загружаем события календаря + val response = repository.getCalendarEntries() + if (response.isSuccessful) { + // Сбрасываем счетчики ошибок при успехе + errorCount = 0 + errorCooldownInterval = 5000L // Сбрасываем до начального значения + retryCount = 0 + + // Обрабатываем ответ API + response.body()?.let { calendarResponse -> + // Преобразуем API ответ в объекты домена + val events = processCalendarEntries(calendarResponse) + + // Обновляем настройки и прогнозы из данных API + updateCycleInfoFromResponse(calendarResponse.cycle_info) + + // Генерируем прогнозы на основе данных + generatePredictions() + + // Рассчитываем статистику + calculateStatistics() + + // Обновляем состояние UI + uiState = uiState.copy( + isLoading = false, + events = events, + lastRefreshed = System.currentTimeMillis(), + error = null + ) + } + } else if (response.code() == 429) { + // Обработка Too Many Requests с экспоненциальным откатом + handleRateLimitExceeded() + } else { + // Увеличиваем счетчик ошибок + handleApiError(response.code().toString()) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + // Увеличиваем счетчик ошибок + handleApiError(e.message ?: "Неизвестная ошибка") + } finally { + isRequestInProgress = false // Сбрасываем флаг в любом случае } - } catch (e: Exception) { - if (e is CancellationException) throw e - - uiState = uiState.copy( - isLoading = false, - error = "Ошибка сети: ${e.message}" - ) } } } + private fun handleApiError(errorMsg: String) { + errorCount++ + lastErrorTimestamp = System.currentTimeMillis() + + // Экспоненциально увеличиваем время ожидания при повторных ошибках + if (errorCount >= MAX_ERROR_COUNT) { + errorCooldownInterval = (errorCooldownInterval * 2).coerceAtMost(60000L) // максимум 1 минута + uiState = uiState.copy( + isLoading = false, + error = "Ошибка API: $errorMsg. Повторные запросы ограничены на ${errorCooldownInterval/1000} сек." + ) + } else { + uiState = uiState.copy( + isLoading = false, + error = "Ошибка API: $errorMsg" + ) + } + } + + // Преобразует API-ответ в события календаря + private fun processCalendarEntries(response: CalendarEntriesResponse): Map> { + val result = mutableMapOf>() + + response.entries.forEach { entry -> + try { + val date = LocalDate.parse(entry.date) + val event = CalendarEvent( + id = entry.id, + date = date, + type = CalendarEventType.valueOf(entry.type), + isActual = !entry.is_predicted, + isPredicted = entry.is_predicted, + mood = entry.mood?.let { MoodType.valueOf(it) }, + symptoms = entry.symptoms.mapNotNull { + try { SymptomType.valueOf(it) } catch (e: Exception) { null } + }, + notes = entry.notes ?: "", + flowIntensity = entry.flow_intensity + ) + + if (result.containsKey(date)) { + result[date]?.add(event) + } else { + result[date] = mutableListOf(event) + } + } catch (e: Exception) { + // Пропускаем некорректные записи + println("Ошибка обработки записи календаря: ${e.message}") + } + } + + return result + } + + // Обновляет информацию о цикле из API-ответа + private fun updateCycleInfoFromResponse(cycleInfo: CycleInfoResponse) { + val lastPeriodStart = try { + cycleInfo.last_period_start?.let { LocalDate.parse(it) } + } catch (e: Exception) { null } + + val newSettings = uiState.settings.copy( + averageCycleLength = cycleInfo.average_cycle_length, + averagePeriodLength = cycleInfo.average_period_length, + lastPeriodStart = lastPeriodStart + ) + + uiState = uiState.copy(settings = newSettings) + } + private suspend fun handleRateLimitExceeded() { if (retryCount < maxRetryCount) { retryCount++ @@ -357,7 +502,43 @@ class CalendarViewModel : ViewModel() { private suspend fun saveEventToServer(event: CalendarEvent) { try { - // TODO: Реализовать сохранение на сервер через API + // Преобразуем CalendarEvent в CalendarEntryCreate для API + val entryCreate = CalendarEntryCreate( + date = event.date.toString(), + type = event.type.name, + mood = event.mood?.name, + symptoms = event.symptoms.map { it.name }, + notes = event.notes.ifEmpty { null }, + flow_intensity = event.flowIntensity + ) + + // Отправляем данные на сервер + val response = repository.createCalendarEntry(entryCreate) + + if (response.isSuccessful) { + // Обновляем локальное событие с ID с сервера, если такой вернулся + response.body()?.let { serverEvent -> + val currentEvents = uiState.events.toMutableMap() + val dateEvents = currentEvents[event.date]?.toMutableList() ?: return@let + + // Находим и заменяем событие, добавляя ему ID с сервера + val index = dateEvents.indexOfFirst { + it.type == event.type && it.date == event.date + } + + if (index != -1) { + dateEvents[index] = serverEvent + currentEvents[event.date] = dateEvents + uiState = uiState.copy( + events = currentEvents, + error = null + ) + } + } + } else { + // Обработка ошибки API + uiState = uiState.copy(error = "Ошибка сохранения на сервере: ${response.code()}") + } } catch (e: Exception) { uiState = uiState.copy(error = "Ошибка сохранения: ${e.message}") } diff --git a/docs/CALENDAR_API_RESPONSE.json b/docs/CALENDAR_API_RESPONSE.json new file mode 100644 index 0000000..c0bcfd4 --- /dev/null +++ b/docs/CALENDAR_API_RESPONSE.json @@ -0,0 +1,56 @@ +{ + "entries": [ + { + "id": "uuid-1", + "date": "2025-09-10", + "type": "MENSTRUATION", + "mood": "GOOD", + "symptoms": ["CRAMPS", "FATIGUE"], + "notes": "День начала цикла", + "flow_intensity": 3, + "is_predicted": false + }, + { + "id": "uuid-2", + "date": "2025-09-11", + "type": "MENSTRUATION", + "mood": "NEUTRAL", + "symptoms": ["CRAMPS"], + "notes": "", + "flow_intensity": 2, + "is_predicted": false + }, + { + "id": "uuid-3", + "date": "2025-09-12", + "type": "MENSTRUATION", + "mood": "GOOD", + "symptoms": [], + "notes": "", + "flow_intensity": 1, + "is_predicted": false + }, + { + "id": "uuid-4", + "date": "2025-10-08", + "type": "MENSTRUATION", + "is_predicted": true + }, + { + "id": "uuid-5", + "date": "2025-09-24", + "type": "OVULATION", + "is_predicted": true + } + ], + "cycle_info": { + "average_cycle_length": 28, + "average_period_length": 5, + "last_period_start": "2025-09-10", + "next_period_predicted": "2025-10-08", + "next_ovulation_predicted": "2025-09-24", + "fertile_window_start": "2025-09-21", + "fertile_window_end": "2025-09-25" + } +} +