diff --git a/tui/include/trainlog/web_analysis.h b/tui/include/trainlog/web_analysis.h index f81cd0e..e00f482 100644 --- a/tui/include/trainlog/web_analysis.h +++ b/tui/include/trainlog/web_analysis.h @@ -14,6 +14,8 @@ typedef enum TrainlogWebAnalysisPeriod { TRAINLOG_WEB_ANALYSIS_7_DAYS = 7, + TRAINLOG_WEB_ANALYSIS_14_DAYS = 14, + TRAINLOG_WEB_ANALYSIS_21_DAYS = 21, TRAINLOG_WEB_ANALYSIS_30_DAYS = 30, TRAINLOG_WEB_ANALYSIS_90_DAYS = 90, TRAINLOG_WEB_ANALYSIS_ALL = 0 diff --git a/tui/include/trainlog/web_sleep.h b/tui/include/trainlog/web_sleep.h new file mode 100644 index 0000000..0102b05 --- /dev/null +++ b/tui/include/trainlog/web_sleep.h @@ -0,0 +1,40 @@ +#ifndef TRAINLOG_WEB_SLEEP_H +#define TRAINLOG_WEB_SLEEP_H + +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +#define TRAINLOG_WEB_SLEEP_BYTES_MAX (512U * 1024U) + +/* Successful calls transfer one free()-owned UTF-8 JSON allocation. */ +TrainlogStatus trainlog_web_sleep_list_json(TrainlogDatabase *database, + const char *start_date, + const char *end_date, + size_t limit, + char **output_json, + size_t *output_size); + +/* expected_revision is NULL only for creation. The complete snapshot is + * validated before the Core starts its transaction. */ +TrainlogStatus trainlog_web_sleep_save_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size); + +TrainlogStatus trainlog_web_sleep_delete_json(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *deleted_at, + char **output_json, + size_t *output_size); + +TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size); + +#endif diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c index c53a7cc..69b9502 100644 --- a/tui/src/web_analysis.c +++ b/tui/src/web_analysis.c @@ -544,6 +544,8 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database, if (database == NULL || query == NULL || output == NULL || capacity == 0U || output_size == NULL || query->reference_unix_second < 0 || (query->period != TRAINLOG_WEB_ANALYSIS_7_DAYS && + query->period != TRAINLOG_WEB_ANALYSIS_14_DAYS && + query->period != TRAINLOG_WEB_ANALYSIS_21_DAYS && query->period != TRAINLOG_WEB_ANALYSIS_30_DAYS && query->period != TRAINLOG_WEB_ANALYSIS_90_DAYS && query->period != TRAINLOG_WEB_ANALYSIS_ALL) || @@ -568,6 +570,8 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database, "period", query->period == TRAINLOG_WEB_ANALYSIS_ALL ? "all" : query->period == TRAINLOG_WEB_ANALYSIS_7_DAYS ? "7d" + : query->period == TRAINLOG_WEB_ANALYSIS_14_DAYS ? "14d" + : query->period == TRAINLOG_WEB_ANALYSIS_21_DAYS ? "21d" : query->period == TRAINLOG_WEB_ANALYSIS_30_DAYS ? "30d" : "90d") || !add_overview(database, query, document, root) || diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 63f76a8..5b37a11 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -27,6 +27,7 @@ #include "trainlog/web_programs.h" #include "trainlog/web_session_deletions.h" #include "trainlog/web_sessions.h" +#include "trainlog/web_sleep.h" #include "trainlog/dashboard_layout.h" #include "trainlog/web_preferences.h" #include "web_assets.h" @@ -115,6 +116,14 @@ static bool parse_analysis_period(const char *value, TrainlogWebAnalysisPeriod * *period = TRAINLOG_WEB_ANALYSIS_7_DAYS; return true; } + if (strcmp(value, "14d") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_14_DAYS; + return true; + } + if (strcmp(value, "21d") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_21_DAYS; + return true; + } if (strcmp(value, "90d") == 0) { *period = TRAINLOG_WEB_ANALYSIS_90_DAYS; return true; @@ -1398,6 +1407,70 @@ static enum MHD_Result handle_request(void *closure, } return queue_json(connection, MHD_HTTP_OK, json, NULL); } + if (strcmp(url, "/api/v1/sleep-diary") == 0) { + char *json = NULL; + size_t json_size = 0U; + TrainlogStatus status; + enum MHD_Result queued; + if (is_get) { + const char *start = + MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start_date"); + const char *end = + MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end_date"); + size_t limit; + if (!parse_page_number( + MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"), + 90U, + 3660U, + &limit) || + limit == 0U) { + return queue_json(connection, + MHD_HTTP_BAD_REQUEST, + "{\"error\":\"invalid_sleep_query\"}\n", + NULL); + } + status = trainlog_web_sleep_list_json( + context->database, start, end, limit, &json, &json_size); + } else if (strcmp(method, MHD_HTTP_METHOD_POST) == 0 || + strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) { + if (!program_mutation_allowed(context, connection)) { + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); + } + status = strcmp(method, MHD_HTTP_METHOD_POST) == 0 + ? trainlog_web_sleep_save_json( + context->database, state->body, state->body_size, &json, &json_size) + : trainlog_web_sleep_delete_request_json( + context->database, state->body, state->body_size, &json, &json_size); + } else { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET, POST, DELETE"); + } + if (status == TRAINLOG_STATUS_CONFLICT) { + free(json); + return queue_json( + connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL); + } + if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) { + free(json); + return queue_json(connection, + MHD_HTTP_UNPROCESSABLE_CONTENT, + "{\"error\":\"invalid_sleep_diary\"}\n", + NULL); + } + if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) { + free(json); + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"sleep_diary_unavailable\"}\n", + NULL); + } + queued = queue_json(connection, MHD_HTTP_OK, json, NULL); + free(json); + return queued; + } if (strcmp(url, "/api/v1/dashboard") == 0) { TrainlogWebDashboardQuery query; TrainlogWebDashboardSnapshot snapshot; diff --git a/tui/src/web_sleep.c b/tui/src/web_sleep.c new file mode 100644 index 0000000..13dbb48 --- /dev/null +++ b/tui/src/web_sleep.c @@ -0,0 +1,488 @@ +#include "trainlog/web_sleep.h" + +#include +#include +#include + +#include + +#include "trainlog/id.h" +#include "trainlog/sleep_diary.h" +#include "timestamp.h" + +typedef struct SleepListContext { + yyjson_mut_doc *document; + yyjson_mut_val *items; + size_t count; + int64_t sleep_seconds; + int64_t long_awake_seconds; + int64_t nap_seconds; + size_t long_awake_count; + size_t nap_count; + size_t sleepiness_count; + size_t quality[3][5]; + int64_t bed_minute_total; + int64_t get_up_minute_total; + size_t bed_count; + size_t get_up_count; +} SleepListContext; + +static bool event_duration(const TrainlogSleepEvent *event, int64_t *seconds) { + TrainlogTimestampKey start; + TrainlogTimestampKey end; + if (event->end_at[0] == '\0' || + !trainlog_timestamp_parse(event->start_at, strlen(event->start_at), &start) || + !trainlog_timestamp_parse(event->end_at, strlen(event->end_at), &end) || + end.utc_second <= start.utc_second) { + return false; + } + *seconds = end.utc_second - start.utc_second; + return true; +} + +/* WHY: ordinary HH:MM arithmetic maps 23:30 and 00:30 to noon. CONTRACT: + * analysis projects clock readings onto the diary's 18:00 -> 18:00 axis. + * INVARIANT: storage remains an absolute offset-bearing timestamp. */ +static bool diary_minute(const char *timestamp, int64_t *minute) { + const char *separator = strchr(timestamp, 'T'); + int hour; + int value; + if (separator == NULL || sscanf(separator + 1, "%2d:%2d", &hour, &value) != 2 || hour < 0 || + hour > 23 || value < 0 || value > 59) { + return false; + } + *minute = (int64_t)((hour < 18 ? hour + 24 : hour) * 60 + value) - 18 * 60; + return true; +} + +static const char *quality_text(TrainlogSleepQuality quality) { + static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"}; + return quality >= TRAINLOG_SLEEP_QUALITY_UNSET && quality <= TRAINLOG_SLEEP_QUALITY_TM + ? values[quality] + : NULL; +} + +static bool quality_value(yyjson_val *value, TrainlogSleepQuality *output) { + int index; + if (yyjson_is_null(value)) { + *output = TRAINLOG_SLEEP_QUALITY_UNSET; + return true; + } + if (!yyjson_is_str(value)) { + return false; + } + for (index = 1; index <= (int)TRAINLOG_SLEEP_QUALITY_TM; ++index) { + if (strcmp(yyjson_get_str(value), quality_text((TrainlogSleepQuality)index)) == 0) { + *output = (TrainlogSleepQuality)index; + return true; + } + } + return false; +} + +static const char *event_text(TrainlogSleepEventType type) { + static const char *const values[] = {"bed_time", + "final_get_up", + "night_get_up", + "sleep", + "nap", + "long_awake", + "half_sleep", + "daytime_sleepiness"}; + return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS + ? values[type] + : NULL; +} + +static bool event_value(yyjson_val *value, TrainlogSleepEventType *output) { + int index; + if (!yyjson_is_str(value)) { + return false; + } + for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) { + if (strcmp(yyjson_get_str(value), event_text((TrainlogSleepEventType)index)) == 0) { + *output = (TrainlogSleepEventType)index; + return true; + } + } + return false; +} + +static bool add_nullable_quality(yyjson_mut_doc *document, + yyjson_mut_val *object, + const char *key, + TrainlogSleepQuality quality) { + return quality == TRAINLOG_SLEEP_QUALITY_UNSET + ? yyjson_mut_obj_add_null(document, object, key) + : yyjson_mut_obj_add_strcpy(document, object, key, quality_text(quality)); +} + +static bool +add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDiaryEntry *entry) { + yyjson_mut_val *object = yyjson_mut_obj(document); + yyjson_mut_val *events = yyjson_mut_arr(document); + size_t index; + if (object == NULL || events == NULL || + !yyjson_mut_obj_add_strcpy(document, object, "entry_id", entry->entry_id) || + !yyjson_mut_obj_add_strcpy(document, object, "night_start_date", entry->night_start_date) || + !yyjson_mut_obj_add_strcpy(document, object, "night_end_date", entry->night_end_date) || + !yyjson_mut_obj_add_strcpy(document, object, "created_at", entry->created_at) || + !yyjson_mut_obj_add_strcpy(document, object, "updated_at", entry->updated_at) || + !yyjson_mut_obj_add_strcpy(document, object, "revision_id", entry->revision_id) || + !add_nullable_quality(document, object, "sleep_quality", entry->sleep_quality) || + !add_nullable_quality(document, object, "wake_quality", entry->wake_quality) || + !add_nullable_quality(document, object, "day_form", entry->day_form) || + !yyjson_mut_obj_add_strcpy( + document, object, "treatment_and_notes", entry->treatment_and_notes)) { + return false; + } + for (index = 0U; index < entry->event_count; ++index) { + const TrainlogSleepEvent *event = &entry->events[index]; + yyjson_mut_val *item = yyjson_mut_obj(document); + if (item == NULL || + !yyjson_mut_obj_add_strcpy(document, item, "event_id", event->event_id) || + !yyjson_mut_obj_add_strcpy(document, item, "type", event_text(event->type)) || + !yyjson_mut_obj_add_strcpy(document, item, "start_at", event->start_at) || + !(event->end_at[0] == '\0' + ? yyjson_mut_obj_add_null(document, item, "end_at") + : yyjson_mut_obj_add_strcpy(document, item, "end_at", event->end_at)) || + !yyjson_mut_arr_add_val(events, item)) { + return false; + } + } + return yyjson_mut_obj_add_val(document, object, "events", events) && + yyjson_mut_arr_add_val(items, object); +} + +static TrainlogStatus list_visitor(void *opaque, const TrainlogSleepDiaryEntry *entry) { + SleepListContext *context = opaque; + size_t index; + if (!add_entry(context->document, context->items, entry)) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + ++context->count; + if (entry->sleep_quality != TRAINLOG_SLEEP_QUALITY_UNSET) { + ++context->quality[0][entry->sleep_quality - 1]; + } + if (entry->wake_quality != TRAINLOG_SLEEP_QUALITY_UNSET) { + ++context->quality[1][entry->wake_quality - 1]; + } + if (entry->day_form != TRAINLOG_SLEEP_QUALITY_UNSET) { + ++context->quality[2][entry->day_form - 1]; + } + for (index = 0U; index < entry->event_count; ++index) { + const TrainlogSleepEvent *event = &entry->events[index]; + int64_t duration; + int64_t minute; + if (event->type == TRAINLOG_SLEEP_EVENT_LONG_AWAKE) { + ++context->long_awake_count; + } + if (event->type == TRAINLOG_SLEEP_EVENT_NAP) { + ++context->nap_count; + } + if (event->type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS) { + ++context->sleepiness_count; + } + if (event_duration(event, &duration)) { + if (event->type == TRAINLOG_SLEEP_EVENT_SLEEP) { + context->sleep_seconds += duration; + } + if (event->type == TRAINLOG_SLEEP_EVENT_LONG_AWAKE) { + context->long_awake_seconds += duration; + } + if (event->type == TRAINLOG_SLEEP_EVENT_NAP) { + context->nap_seconds += duration; + } + } + if (event->type == TRAINLOG_SLEEP_EVENT_BED_TIME && + diary_minute(event->start_at, &minute)) { + context->bed_minute_total += minute; + ++context->bed_count; + } + if (event->type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP && + diary_minute(event->start_at, &minute)) { + context->get_up_minute_total += minute; + ++context->get_up_count; + } + } + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus +write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) { + yyjson_write_err error; + char *json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, output_size, &error); + yyjson_mut_doc_free(document); + if (json == NULL || *output_size > TRAINLOG_WEB_SLEEP_BYTES_MAX) { + free(json); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + *output_json = json; + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_web_sleep_list_json(TrainlogDatabase *database, + const char *start_date, + const char *end_date, + size_t limit, + char **output_json, + size_t *output_size) { + static const char *const quality_names[] = {"TB", "B", "Moy", "M", "TM"}; + yyjson_mut_doc *document; + yyjson_mut_val *root; + yyjson_mut_val *summary; + yyjson_mut_val *distribution; + SleepListContext context = {0}; + TrainlogStatus status; + size_t category, quality; + if (database == NULL || output_json == NULL || output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_json = NULL; + *output_size = 0U; + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + context.items = document == NULL ? NULL : yyjson_mut_arr(document); + summary = document == NULL ? NULL : yyjson_mut_obj(document); + distribution = document == NULL ? NULL : yyjson_mut_obj(document); + if (document == NULL || root == NULL || context.items == NULL || summary == NULL || + distribution == NULL) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + context.document = document; + yyjson_mut_doc_set_root(document, root); + status = + trainlog_sleep_diary_list(database, start_date, end_date, limit, list_visitor, &context); + if (status != TRAINLOG_STATUS_OK || + !yyjson_mut_obj_add_uint(document, root, "api_version", 1U) || + !yyjson_mut_obj_add_uint(document, summary, "nights", context.count) || + !yyjson_mut_obj_add_uint(document, summary, "long_awake_count", context.long_awake_count) || + !yyjson_mut_obj_add_uint(document, summary, "nap_count", context.nap_count) || + !yyjson_mut_obj_add_uint(document, summary, "sleepiness_count", context.sleepiness_count) || + !yyjson_mut_obj_add_sint( + document, summary, "sleep_duration_seconds", context.sleep_seconds) || + !yyjson_mut_obj_add_sint( + document, summary, "long_awake_duration_seconds", context.long_awake_seconds) || + !yyjson_mut_obj_add_sint(document, summary, "nap_duration_seconds", context.nap_seconds) || + !(context.bed_count == 0U + ? yyjson_mut_obj_add_null(document, summary, "average_bed_minute") + : yyjson_mut_obj_add_sint(document, + summary, + "average_bed_minute", + context.bed_minute_total / (int64_t)context.bed_count)) || + !(context.get_up_count == 0U + ? yyjson_mut_obj_add_null(document, summary, "average_get_up_minute") + : yyjson_mut_obj_add_sint(document, + summary, + "average_get_up_minute", + context.get_up_minute_total / + (int64_t)context.get_up_count))) { + yyjson_mut_doc_free(document); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status; + } + for (category = 0U; category < 3U; ++category) { + yyjson_mut_val *values = yyjson_mut_obj(document); + const char *key = category == 0U ? "sleep_quality" + : category == 1U ? "wake_quality" + : "day_form"; + if (values == NULL) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + for (quality = 0U; quality < 5U; ++quality) { + if (!yyjson_mut_obj_add_uint( + document, values, quality_names[quality], context.quality[category][quality])) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + } + if (!yyjson_mut_obj_add_val(document, distribution, key, values)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + } + if (!yyjson_mut_obj_add_val(document, summary, "quality_distribution", distribution) || + !yyjson_mut_obj_add_val(document, root, "summary", summary) || + !yyjson_mut_obj_add_val(document, root, "entries", context.items)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + return write_document(document, output_json, output_size); +} + +static bool +copy_string(yyjson_val *object, const char *key, char *output, size_t capacity, bool nullable) { + yyjson_val *value = yyjson_obj_get(object, key); + size_t length; + if (nullable && yyjson_is_null(value)) { + output[0] = '\0'; + return true; + } + if (!yyjson_is_str(value)) { + return false; + } + length = yyjson_get_len(value); + if (length >= capacity) { + return false; + } + memcpy(output, yyjson_get_str(value), length + 1U); + return true; +} + +static bool parse_entry(const char *body, + size_t body_size, + TrainlogSleepDiaryEntry *entry, + char expected_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) { + yyjson_read_err error; + yyjson_doc *document = + yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error); + yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document); + yyjson_val *events; + size_t index, count; + bool valid = + root != NULL && yyjson_is_obj(root) && + copy_string(root, "entry_id", entry->entry_id, sizeof(entry->entry_id), true) && + copy_string(root, + "expected_revision", + expected_revision, + TRAINLOG_SLEEP_REVISION_ID_CAPACITY, + true) && + copy_string(root, + "night_start_date", + entry->night_start_date, + sizeof(entry->night_start_date), + false) && + copy_string( + root, "night_end_date", entry->night_end_date, sizeof(entry->night_end_date), false) && + copy_string(root, "created_at", entry->created_at, sizeof(entry->created_at), false) && + copy_string(root, "updated_at", entry->updated_at, sizeof(entry->updated_at), false) && + copy_string(root, + "treatment_and_notes", + entry->treatment_and_notes, + sizeof(entry->treatment_and_notes), + false) && + quality_value(yyjson_obj_get(root, "sleep_quality"), &entry->sleep_quality) && + quality_value(yyjson_obj_get(root, "wake_quality"), &entry->wake_quality) && + quality_value(yyjson_obj_get(root, "day_form"), &entry->day_form); + events = valid ? yyjson_obj_get(root, "events") : NULL; + count = yyjson_is_arr(events) ? yyjson_arr_size(events) : TRAINLOG_SLEEP_EVENTS_MAX + 1U; + if (count > TRAINLOG_SLEEP_EVENTS_MAX) { + valid = false; + } + for (index = 0U; valid && index < count; ++index) { + yyjson_val *value = yyjson_arr_get(events, index); + TrainlogSleepEvent *event = &entry->events[index]; + valid = yyjson_is_obj(value) && + copy_string(value, "event_id", event->event_id, sizeof(event->event_id), true) && + event_value(yyjson_obj_get(value, "type"), &event->type) && + copy_string(value, "start_at", event->start_at, sizeof(event->start_at), false) && + copy_string(value, "end_at", event->end_at, sizeof(event->end_at), true); + if (valid && event->event_id[0] == '\0') { + valid = trainlog_id_generate("sle", event->event_id, sizeof(event->event_id)) == + TRAINLOG_STATUS_OK; + } + } + entry->event_count = count; + yyjson_doc_free(document); + return valid; +} + +TrainlogStatus trainlog_web_sleep_save_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size) { + TrainlogSleepDiaryEntry entry = {0}; + char expected[TRAINLOG_SLEEP_REVISION_ID_CAPACITY] = ""; + TrainlogStatus status; + yyjson_mut_doc *document; + yyjson_mut_val *root; + if (database == NULL || body == NULL || body_size == 0U || + body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX || output_json == NULL || output_size == NULL || + !parse_entry(body, body_size, &entry, expected)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (expected[0] == '\0') { + status = trainlog_sleep_diary_create(database, &entry); + } else { + (void)snprintf(entry.revision_id, sizeof(entry.revision_id), "%s", expected); + status = trainlog_sleep_diary_update(database, expected, &entry); + } + if (status != TRAINLOG_STATUS_OK) { + return status; + } + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + if (document == NULL || root == NULL || + !yyjson_mut_obj_add_strcpy(document, root, "entry_id", entry.entry_id) || + !yyjson_mut_obj_add_strcpy(document, root, "revision_id", entry.revision_id)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + yyjson_mut_doc_set_root(document, root); + return write_document(document, output_json, output_size); +} + +TrainlogStatus trainlog_web_sleep_delete_json(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *deleted_at, + char **output_json, + size_t *output_size) { + char revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + TrainlogStatus status = + trainlog_sleep_diary_delete(database, entry_id, expected_revision, deleted_at, revision); + yyjson_mut_doc *document; + yyjson_mut_val *root; + if (status != TRAINLOG_STATUS_OK) { + return status; + } + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + if (document == NULL || root == NULL || + !yyjson_mut_obj_add_strcpy(document, root, "entry_id", entry_id) || + !yyjson_mut_obj_add_strcpy(document, root, "revision_id", revision) || + !yyjson_mut_obj_add_bool(document, root, "deleted", true)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + yyjson_mut_doc_set_root(document, root); + return write_document(document, output_json, output_size); +} + +TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database, + const char *body, + size_t body_size, + char **output_json, + size_t *output_size) { + yyjson_read_err error; + yyjson_doc *document; + yyjson_val *root; + const char *entry_id; + const char *revision; + const char *deleted_at; + TrainlogStatus status; + if (body == NULL || body_size == 0U || body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + document = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error); + root = document == NULL ? NULL : yyjson_doc_get_root(document); + entry_id = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "entry_id")) + ? yyjson_get_str(yyjson_obj_get(root, "entry_id")) + : NULL; + revision = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "expected_revision")) + ? yyjson_get_str(yyjson_obj_get(root, "expected_revision")) + : NULL; + deleted_at = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "deleted_at")) + ? yyjson_get_str(yyjson_obj_get(root, "deleted_at")) + : NULL; + if (entry_id == NULL || revision == NULL || deleted_at == NULL || yyjson_obj_size(root) != 3U) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + status = trainlog_web_sleep_delete_json( + database, entry_id, revision, deleted_at, output_json, output_size); + yyjson_doc_free(document); + return status; +} diff --git a/web/src/analysis/messages.ts b/web/src/analysis/messages.ts index 2a4d17a..397c8a8 100644 --- a/web/src/analysis/messages.ts +++ b/web/src/analysis/messages.ts @@ -3,8 +3,8 @@ export type AnalysisLanguage = 'fr' | 'en' export const analysisMessages = { fr: { eyebrow: 'DONNÉES FACTUELLES', title: 'Analyse', loading: 'Chargement de l’analyse…', failed: 'Analyse momentanément indisponible.', - overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations', - period: 'Période', periods: { '7d': '7 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' }, + overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations', sleep: 'Sommeil', + period: 'Période', periods: { '7d': '7 jours', '14d': '14 jours', '21d': '21 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' }, sessions: 'Séances', sets: 'Séries', duration: 'Durée', unavailable: 'Indisponible', noData: 'Aucune donnée sur cette période.', activeProgram: 'Programme actif', noProgram: 'Aucun programme actif', nextSession: 'Prochaine séance', exercise: 'Exercice', noExercise: 'Aucun exercice disponible', noExerciseData: 'Aucune donnée compatible pour cet exercice.', @@ -15,8 +15,8 @@ export const analysisMessages = { }, en: { eyebrow: 'FACTUAL DATA', title: 'Analysis', loading: 'Loading analysis…', failed: 'Analysis is temporarily unavailable.', - overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements', - period: 'Period', periods: { '7d': '7 days', '30d': '30 days', '90d': '90 days', all: 'All' }, + overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements', sleep: 'Sleep', + period: 'Period', periods: { '7d': '7 days', '14d': '14 days', '21d': '21 days', '30d': '30 days', '90d': '90 days', all: 'All' }, sessions: 'Sessions', sets: 'Sets', duration: 'Duration', unavailable: 'Unavailable', noData: 'No data in this period.', activeProgram: 'Active program', noProgram: 'No active program', nextSession: 'Next session', exercise: 'Exercise', noExercise: 'No exercise available', noExerciseData: 'No compatible data for this exercise.', diff --git a/web/src/api/analysis.ts b/web/src/api/analysis.ts index 0aaee24..b83898b 100644 --- a/web/src/api/analysis.ts +++ b/web/src/api/analysis.ts @@ -1,9 +1,9 @@ -export type AnalysisPeriod = '7d' | '30d' | '90d' | 'all' +export type AnalysisPeriod = '7d' | '14d' | '21d' | '30d' | '90d' | 'all' export type MeasurementMetric = 'weight' | 'neck' | 'shoulders' | 'chest' | 'waist' | 'hips' | 'left_arm' | 'right_arm' | 'left_forearm' | 'right_forearm' | 'left_thigh' | 'right_thigh' | 'left_calf' | 'right_calf' -export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '30d', '90d', 'all'] +export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '14d', '21d', '30d', '90d', 'all'] export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [ 'weight', 'neck', 'shoulders', 'chest', 'waist', 'hips', 'left_arm', 'right_arm', 'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf', diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts new file mode 100644 index 0000000..af7df1d --- /dev/null +++ b/web/src/api/sleepDiary.ts @@ -0,0 +1,69 @@ +import { mutationCsrfToken } from './sync' + +export type SleepQuality = 'TB' | 'B' | 'Moy' | 'M' | 'TM' +export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sleep' | + 'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness' + +export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null } +export interface SleepEntry { + entry_id: string; night_start_date: string; night_end_date: string + created_at: string; updated_at: string; revision_id: string + sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null + treatment_and_notes: string; events: SleepEvent[] +} +export interface SleepSnapshot { + api_version: 1; entries: SleepEntry[] + summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number + sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number + average_bed_minute: number | null; average_get_up_minute: number | null } +} +export type SleepEntryInput = Omit & { expected_revision: string | null } + +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) +const quality = (value: unknown): value is SleepQuality | null => + value === null || ['TB', 'B', 'Moy', 'M', 'TM'].includes(String(value)) +const eventType = (value: unknown): value is SleepEventType => + ['bed_time', 'final_get_up', 'night_get_up', 'sleep', 'nap', 'long_awake', 'half_sleep', 'daytime_sleepiness'].includes(String(value)) + +function validEntry(value: unknown): value is SleepEntry { + return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' && + typeof value.night_end_date === 'string' && typeof value.created_at === 'string' && + typeof value.updated_at === 'string' && typeof value.revision_id === 'string' && + quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) && + typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 && + value.events.every((event) => object(event) && typeof event.event_id === 'string' && + eventType(event.type) && typeof event.start_at === 'string' && + (event.end_at === null || typeof event.end_at === 'string')) +} + +export async function fetchSleepDiary(startDate?: string, endDate?: string, + signal?: AbortSignal): Promise { + const query = new URLSearchParams({ limit: '3660' }) + if (startDate) query.set('start_date', startDate) + if (endDate) query.set('end_date', endDate) + const response = await fetch(`/api/v1/sleep-diary?${query}`, { headers: { Accept: 'application/json' }, signal }) + const value: unknown = await response.json() + if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.entries) || + !value.entries.every(validEntry) || !object(value.summary)) throw new TypeError('sleep_diary_invalid') + return value as unknown as SleepSnapshot +} + +async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ entry_id: string; revision_id: string }> { + const csrf = await mutationCsrfToken() + const response = await fetch('/api/v1/sleep-diary', { + method, + headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf }, + body: JSON.stringify(body), + }) + const value: unknown = await response.json() + if (!response.ok || !object(value) || typeof value.entry_id !== 'string' || typeof value.revision_id !== 'string') { + throw new Error(object(value) && typeof value.error === 'string' ? value.error : 'sleep_diary_mutation_failed') + } + return value as { entry_id: string; revision_id: string } +} + +export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry) +export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', { + entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(), +}) diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts new file mode 100644 index 0000000..1233444 --- /dev/null +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { buildSleepDiaryPdf } from './sleepDiaryPdf' +import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary' + +const entry = (index: number): SleepEntry => ({ + entry_id: `sl_${index}`, night_start_date: `2026-09-${String(index + 1).padStart(2, '0')}`, + night_end_date: `2026-09-${String(index + 2).padStart(2, '0')}`, created_at: '2026-09-01T18:00:00+02:00', + updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B', + wake_quality: 'Moy', day_form: 'TB', treatment_and_notes: 'Synthetic observation', events: [ + { event_id: `sle_${index}`, type: 'sleep', start_at: '2026-09-01T23:30:00+02:00', end_at: '2026-09-02T06:30:00+02:00' }, + ], +}) +const snapshot = (count: number): SleepSnapshot => ({ api_version: 1, + entries: Array.from({ length: count }, (_, index) => entry(index)), summary: { nights: count, + long_awake_count: 1, nap_count: 1, sleepiness_count: 1, sleep_duration_seconds: 25_200, + long_awake_duration_seconds: 1_800, nap_duration_seconds: 1_200, average_bed_minute: 330, + average_get_up_minute: 750 } }) + +describe('sleep diary PDF', () => { + it('builds a vector PDF without screen capture', async () => { + const bytes = new Uint8Array(await buildSleepDiaryPdf(snapshot(7), 'fr').arrayBuffer()) + expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe('%PDF-1.4') + expect(new TextDecoder().decode(bytes)).toContain('AGENDA TRAINLOG') + }) + + it('paginates fourteen, twenty-one and thirty days', async () => { + for (const count of [14, 21, 30]) { + const text = await buildSleepDiaryPdf(snapshot(count), 'en').text() + expect((text.match(/\/Type \/Page /g) ?? []).length).toBe(Math.ceil(count / 9)) + expect(text).toContain('OBSERVATIONS') + } + }) +}) diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts new file mode 100644 index 0000000..f246c9f --- /dev/null +++ b/web/src/report/sleepDiaryPdf.ts @@ -0,0 +1,58 @@ +import type { SleepEntry, SleepEventType, SleepSnapshot } from '../api/sleepDiary' + +const ascii = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x20-\x7e]/g, '?') +const text = (x: number, y: number, size: number, value: string) => `0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${ascii(value).replace(/([\\()])/g, '\\$1')}) Tj ET\n` +const minute = (iso: string, night: string) => Math.max(0, Math.min(1440, + (new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) / 60000)) +const labels: Record = { bed_time: 'v', final_get_up: '^', night_get_up: '^', + sleep: 'SLEEP', nap: 'NAP', long_awake: 'AWAKE', half_sleep: 'HALF', daytime_sleepiness: 'S' } + +function row(stream: string[], entry: SleepEntry, y: number) { + const timelineX = 112; const width = 530 + stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`) + stream.push(text(33, y - 16, 7, `${entry.night_start_date} > ${entry.night_end_date}`)) + for (let hour = 0; hour <= 24; hour++) { const x = timelineX + hour / 24 * width; stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`) } + entry.events.forEach((event) => { + const x = timelineX + minute(event.start_at, entry.night_start_date) / 1440 * width + if (event.end_at) { + const eventWidth = Math.max(2, timelineX + minute(event.end_at, entry.night_start_date) / 1440 * width - x) + const shade = event.type === 'sleep' ? 0.75 : event.type === 'half_sleep' ? 0.87 : 0.93 + stream.push(`${shade} g ${x} ${y - 29} ${eventWidth} 18 re f 0 G ${x} ${y - 29} ${eventWidth} 18 re S\n`) + if (eventWidth > 24) stream.push(text(x + 2, y - 23, 5, labels[event.type])) + } else stream.push(text(x, y - 25, 8, labels[event.type])) + }) + stream.push(text(648, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(680, y - 16, 7, entry.wake_quality ?? '-')) + stream.push(text(712, y - 16, 7, entry.day_form ?? '-')); stream.push(text(744, y - 13, 5, entry.treatment_and_notes.slice(0, 24))) + stream.push(text(744, y - 22, 5, entry.treatment_and_notes.slice(24, 48))) +} + +function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, count: number, language: 'fr' | 'en') { + const stream = ['0 G 0 g\n', text(30, 565, 14, language === 'fr' ? 'AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL' : 'TRAINLOG SLEEP AND ALERTNESS DIARY'), text(740, 565, 7, `${index + 1}/${count}`), text(30, 548, 7, 'DATE')] + for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 530, 548, 5, String((18 + hour) % 24))) + stream.push(text(646, 548, 5, 'SLEEP'), text(678, 548, 5, 'WAKE'), text(710, 548, 5, 'DAY'), text(744, 548, 5, 'NOTES')) + entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40)) + const y = 520 - entries.length * 40 + stream.push(text(30, y, 9, 'OBSERVATIONS'), text(30, y - 14, 6, `${snapshot.summary.nights} nights; sleep ${Math.round(snapshot.summary.sleep_duration_seconds / 60)} min; long awake ${snapshot.summary.long_awake_count}; naps ${snapshot.summary.nap_count}; sleepiness ${snapshot.summary.sleepiness_count}`)) + entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 27 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`))) + stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.')) + return stream.join('') +} + +/** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */ +export function buildSleepDiaryPdf(snapshot: SleepSnapshot, language: 'fr' | 'en'): Blob { + const groups = Array.from({ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9)) + const objects = ['<< /Type /Catalog /Pages 2 0 R >>', `<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(' ')}] /Count ${groups.length} >>`, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'] + groups.forEach((entries, index) => { const content = page(snapshot, entries, index, groups.length, language); objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`, `<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`) }) + let output = '%PDF-1.4\n%Trainlog\n'; const offsets: number[] = [] + objects.forEach((object, index) => { offsets.push(new TextEncoder().encode(output).length); output += `${index + 1} 0 obj\n${object}\nendobj\n` }) + const xref = new TextEncoder().encode(output).length + output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` + return new Blob([new TextEncoder().encode(output)], { type: 'application/pdf' }) +} + +export function presentSleepDiaryPdf(blob: Blob, download: boolean) { + const url = URL.createObjectURL(blob) + if (download) { const link = document.createElement('a'); link.href = url; link.download = 'trainlog-sleep-diary.pdf'; link.click() } + else window.open(url, '_blank', 'noopener,noreferrer') + window.setTimeout(() => URL.revokeObjectURL(url), 60_000) +} diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx index 4a93045..ada9020 100644 --- a/web/src/routes/AnalysisPage.tsx +++ b/web/src/routes/AnalysisPage.tsx @@ -12,8 +12,9 @@ import { import { analysisMessages, type AnalysisLanguage } from "../analysis/messages"; import { BodyZoneFigure } from "../dashboard/BodyZoneFigure"; import { formatDuration } from "../dashboard/dashboardFormat"; +import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace"; -type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements"; +type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep"; type ExerciseMetric = "duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max"; interface ChartPoint { @@ -33,6 +34,7 @@ const sections: readonly AnalysisSection[] = [ "exercise", "body-zones", "measurements", + "sleep", ]; const exerciseIdPattern = /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -605,7 +607,9 @@ export function AnalysisPage() { ? t.exercises : id === "body-zones" ? t.distribution - : t.measurements} + : id === "measurements" + ? t.measurements + : t.sleep} ))} @@ -870,6 +874,9 @@ export function AnalysisPage() { )} )} + {section === "sleep" && ( + + )} )} diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx new file mode 100644 index 0000000..c64e20d --- /dev/null +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useState } from 'react' +import { deleteSleepEntry, fetchSleepDiary, saveSleepEntry, type SleepEntry, type SleepEntryInput, + type SleepEvent, type SleepEventType, type SleepQuality } from '../api/sleepDiary' +import type { AnalysisPeriod } from '../api/analysis' +import { buildSleepDiaryPdf, presentSleepDiaryPdf } from '../report/sleepDiaryPdf' + +type Language = 'fr' | 'en' +const q: readonly SleepQuality[] = ['TB', 'B', 'Moy', 'M', 'TM'] +const pointTypes: readonly SleepEventType[] = ['bed_time', 'final_get_up', 'night_get_up', 'daytime_sleepiness'] +const copy = { + fr: { agenda: 'Agenda', edit: 'Saisie et édition', morning: 'Matin', day: 'Journée / Soir', night: 'Nuit du', + bed_time: 'Mise au lit', final_get_up: 'Lever', night_get_up: 'Lever nocturne', sleep: 'Sommeil', nap: 'Sieste', + long_awake: 'Long réveil', half_sleep: 'Demi-sommeil', daytime_sleepiness: 'Somnolence', sleepQuality: 'Qualité du sommeil', + wakeQuality: 'Qualité du réveil', dayForm: 'Forme de la journée', notes: 'Traitement et remarques', add: 'Ajouter', + save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.', + preview: 'Prévisualiser', export: 'Exporter PDF', qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] }, + en: { agenda: 'Diary', edit: 'Entry editor', morning: 'Morning', day: 'Day / Evening', night: 'Night from', + bed_time: 'Bedtime', final_get_up: 'Final get-up', night_get_up: 'Night get-up', sleep: 'Sleep', nap: 'Nap', + long_awake: 'Long awakening', half_sleep: 'Half-sleep', daytime_sleepiness: 'Sleepiness', sleepQuality: 'Sleep quality', + wakeQuality: 'Wake quality', dayForm: 'Day form', notes: 'Treatment and notes', add: 'Add', save: 'Save', remove: 'Delete', + observations: 'Observations', summary: 'Factual summary', empty: 'No recorded nights.', preview: 'Preview', export: 'Export PDF', + qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] }, +} as const + +const localInput = (iso: string) => iso ? iso.slice(0, 16) : '' +const absolute = (value: string) => value ? new Date(value).toISOString() : '' +const offset = (iso: string, startDate: string) => { + const start = new Date(`${startDate}T18:00:00`) + return Math.max(0, Math.min(100, ((new Date(iso).getTime() - start.getTime()) / 86_400_000) * 100)) +} +const blank = (): SleepEntryInput => { + const now = new Date(); const start = new Date(now); if (now.getHours() < 18) start.setDate(start.getDate() - 1) + const end = new Date(start); end.setDate(end.getDate() + 1) + const date = (value: Date) => value.toLocaleDateString('en-CA') + return { entry_id: '', expected_revision: null, night_start_date: date(start), night_end_date: date(end), + created_at: now.toISOString(), updated_at: now.toISOString(), sleep_quality: null, wake_quality: null, + day_form: null, treatment_and_notes: '', events: [] } +} + +function QualityPicker({ label, value, onChange, language }: { label: string; value: SleepQuality | null; + onChange: (value: SleepQuality | null) => void; language: Language }) { + return
{label}{q.map((item, index) => + )}
+} + +export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeriod; language: Language }) { + const t = copy[language]; const [snapshot, setSnapshot] = useState> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState(blank) + const [eventType, setEventType] = useState('sleep'); const [pending, setPending] = useState(false); const [error, setError] = useState('') + const range = useMemo(() => { if (period === 'all') return {}; const days = Number(period.slice(0, -1)); const end = new Date(); const start = new Date(); start.setDate(end.getDate() - days + 1) + return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period]) + const reload = () => fetchSleepDiary(range.startDate, range.endDate).then(setSnapshot).catch(() => setError('sleep_diary_unavailable')) + useEffect(() => { void reload() }, [period]) + const edit = (entry: SleepEntry) => setDraft({ ...entry, expected_revision: entry.revision_id, events: entry.events.map((event) => ({ ...event })) }) + const change = (key: K, value: SleepEntryInput[K]) => setDraft((current) => ({ ...current, [key]: value })) + const addEvent = () => setDraft((current) => ({ ...current, events: [...current.events, { event_id: '', type: eventType, + start_at: new Date().toISOString(), end_at: pointTypes.includes(eventType) ? null : new Date(Date.now() + 3_600_000).toISOString() }] })) + const changeEvent = (index: number, patch: Partial) => setDraft((current) => ({ ...current, + events: current.events.map((event, currentIndex) => currentIndex === index ? { ...event, ...patch } : event) })) + const save = async () => { setPending(true); setError(''); try { await saveSleepEntry({ ...draft, updated_at: new Date().toISOString() }); setDraft(blank()); await reload() } catch (reason) { setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed') } finally { setPending(false) } } + return
+

{t.agenda}

+ {entries.length === 0 ?

{t.empty}

:
+ + {entries.map((entry) => )}
} +
+

{t.edit}

+

{t.morning}

change('sleep_quality', value)} language={language}/> change('wake_quality', value)} language={language}/>
+

{t.day}

change('day_form', value)} language={language}/>
+
+
    {draft.events.map((event, index) =>
  • {t[event.type]}{event.end_at !== null && }
  • )}
+