feat(web): add sleep diary workspace and pdf

This commit is contained in:
fy59 2026-09-21 06:24:10 +02:00
parent 30df3030e6
commit 36a7e651ac
13 changed files with 890 additions and 8 deletions

View file

@ -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

View file

@ -0,0 +1,40 @@
#ifndef TRAINLOG_WEB_SLEEP_H
#define TRAINLOG_WEB_SLEEP_H
#include <stddef.h>
#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

View file

@ -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) ||

View file

@ -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;

488
tui/src/web_sleep.c Normal file
View file

@ -0,0 +1,488 @@
#include "trainlog/web_sleep.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <yyjson.h>
#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;
}

View file

@ -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.',

View file

@ -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',

69
web/src/api/sleepDiary.ts Normal file
View file

@ -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<SleepEntry, 'revision_id'> & { expected_revision: string | null }
const object = (value: unknown): value is Record<string, unknown> =>
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<SleepSnapshot> {
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(),
})

View file

@ -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')
}
})
})

View file

@ -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<SleepEventType, string> = { 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)
}

View file

@ -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}
</button>
))}
</div>
@ -870,6 +874,9 @@ export function AnalysisPage() {
)}
</article>
)}
{section === "sleep" && (
<SleepDiaryWorkspace period={period} language={language} />
)}
</>
)}
</section>

View file

@ -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 <fieldset className="sleep-quality"><legend>{label}</legend>{q.map((item, index) =>
<button type="button" key={item} aria-pressed={value === item} title={copy[language].qualities[index]}
onClick={() => onChange(value === item ? null : item)}>{item}</button>)}</fieldset>
}
export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeriod; language: Language }) {
const t = copy[language]; const [snapshot, setSnapshot] = useState<Awaited<ReturnType<typeof fetchSleepDiary>> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState<SleepEntryInput>(blank)
const [eventType, setEventType] = useState<SleepEventType>('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 = <K extends keyof SleepEntryInput>(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<SleepEvent>) => 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 <div className="sleep-workspace">
<article className="analysis-panel analysis-wide"><div className="sleep-heading"><h2>{t.agenda}</h2><div><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), false)}>{t.preview}</button><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), true)}>{t.export}</button></div></div>
{entries.length === 0 ? <p className="analysis-empty">{t.empty}</p> : <div className="sleep-agenda-scroll"><div className="sleep-agenda" role="table">
<div className="sleep-hours" aria-hidden="true">{Array.from({ length: 25 }, (_, index) => <span key={index}>{(18 + index) % 24}</span>)}</div>
{entries.map((entry) => <button type="button" className="sleep-row" key={entry.entry_id} onClick={() => edit(entry)}>
<strong>{t.night} {entry.night_start_date}</strong><span className="sleep-track">{entry.events.map((event) => {
const left = offset(event.start_at, entry.night_start_date); const width = event.end_at ? Math.max(1, offset(event.end_at, entry.night_start_date) - left) : 1
return <i key={event.event_id} className={`sleep-event sleep-${event.type}`} style={{ left: `${left}%`, width: `${width}%` }}
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}</i>})}</span>
<span>{entry.sleep_quality ?? '—'} / {entry.wake_quality ?? '—'} / {entry.day_form ?? '—'}</span></button>)}</div></div>}
</article>
<article className="analysis-panel analysis-wide"><h2>{t.edit}</h2><div className="sleep-dates"><label>{t.night}<input type="date" value={draft.night_start_date} onChange={(e) => change('night_start_date', e.target.value)} /></label><label>→<input type="date" value={draft.night_end_date} onChange={(e) => change('night_end_date', e.target.value)} /></label></div>
<div className="sleep-editor-columns"><section><h3>{t.morning}</h3><QualityPicker label={t.sleepQuality} value={draft.sleep_quality} onChange={(value) => change('sleep_quality', value)} language={language}/><QualityPicker label={t.wakeQuality} value={draft.wake_quality} onChange={(value) => change('wake_quality', value)} language={language}/></section>
<section><h3>{t.day}</h3><QualityPicker label={t.dayForm} value={draft.day_form} onChange={(value) => change('day_form', value)} language={language}/></section></div>
<div className="sleep-event-add"><label>{t.add}<select value={eventType} onChange={(e) => setEventType(e.target.value as SleepEventType)}>{(['bed_time','sleep','long_awake','night_get_up','final_get_up','half_sleep','nap','daytime_sleepiness'] as const).map((type) => <option key={type} value={type}>{t[type]}</option>)}</select></label><button type="button" onClick={addEvent}>{t.add}</button></div>
<ul className="sleep-event-list">{draft.events.map((event, index) => <li key={`${event.event_id}-${index}`}><strong>{t[event.type]}</strong><label>{t[event.type]}<input type="datetime-local" value={localInput(event.start_at)} onChange={(e) => changeEvent(index, { start_at: absolute(e.target.value) })}/></label>{event.end_at !== null && <label>→<input type="datetime-local" value={localInput(event.end_at)} onChange={(e) => changeEvent(index, { end_at: absolute(e.target.value) })}/></label>}<button type="button" aria-label={`${t.remove} ${t[event.type]}`} onClick={() => change('events', draft.events.filter((_, item) => item !== index))}>×</button></li>)}</ul>
<label className="sleep-notes">{t.notes}<textarea value={draft.treatment_and_notes} maxLength={16384} onChange={(e) => change('treatment_and_notes', e.target.value)}/></label>
{error && <p role="alert" className="error-panel">{error}</p>}<div className="sleep-actions"><button type="button" disabled={pending} onClick={() => void save()}>{t.save}</button>{draft.expected_revision && <button type="button" disabled={pending} onClick={async () => { const entry = entries.find((item) => item.entry_id === draft.entry_id); if (entry) { await deleteSleepEntry(entry); setDraft(blank()); await reload() } }}>{t.remove}</button>}</div>
</article>
<div className="analysis-section-grid"><article className="analysis-panel"><h2>{t.summary}</h2><dl className="analysis-facts"><div><dt>{language === 'fr' ? 'Nuits enregistrées' : 'Recorded nights'}</dt><dd>{entries.length}</dd></div><div><dt>{language === 'fr' ? 'Sommeil déclaré' : 'Declared sleep'}</dt><dd>{Math.round((snapshot?.summary.sleep_duration_seconds ?? 0) / 60)} min</dd></div><div><dt>{language === 'fr' ? 'Longs réveils' : 'Long awakenings'}</dt><dd>{snapshot?.summary.long_awake_count ?? 0}</dd></div><div><dt>{language === 'fr' ? 'Siestes' : 'Naps'}</dt><dd>{snapshot?.summary.nap_count ?? 0}</dd></div></dl></article><article className="analysis-panel"><h2>{t.observations}</h2>{entries.filter((entry) => entry.treatment_and_notes).map((entry) => <p key={entry.entry_id}><strong>{entry.night_start_date}</strong> — {entry.treatment_and_notes}</p>)}</article></div>
</div>
}

View file

@ -600,6 +600,32 @@ h2 {
text-underline-offset: 0.2rem;
cursor: pointer;
}
.sleep-workspace { display: grid; gap: 1rem; }
.sleep-heading, .sleep-actions, .sleep-event-add, .sleep-dates { display: flex; gap: .75rem; align-items: center; justify-content: space-between; }
.sleep-heading > div { display: flex; gap: .5rem; }
.sleep-agenda-scroll { overflow-x: auto; max-width: 100%; }
.sleep-agenda { min-width: 900px; display: grid; gap: .25rem; }
.sleep-hours { display: grid; grid-template-columns: 13rem repeat(25, 1fr) 9rem; color: var(--text-muted); font-size: .72rem; }
.sleep-hours::before { content: ''; }
.sleep-row { display: grid; grid-template-columns: 13rem 1fr 9rem; align-items: center; gap: .5rem; min-height: 2.6rem; text-align: left; border-radius: var(--radius-md); }
.sleep-track { position: relative; height: 1.65rem; background: color-mix(in srgb, var(--surface-raised) 80%, transparent); border: 1px solid var(--border-subtle); }
.sleep-event { position: absolute; top: .15rem; bottom: .15rem; min-width: .3rem; overflow: visible; font-style: normal; text-align: center; color: var(--text-primary); }
.sleep-sleep, .sleep-nap { background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px); }
.sleep-long_awake { border: 2px solid var(--warning); background: transparent; }
.sleep-half_sleep { background: repeating-linear-gradient(90deg, var(--lavender) 0 2px, transparent 2px 5px); }
.sleep-editor-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.sleep-quality { border: 0; padding: .25rem 0; display: flex; flex-wrap: wrap; gap: .35rem; }
.sleep-quality button[aria-pressed="true"] { border-color: var(--lavender); color: var(--lavender); background: var(--surface-raised); }
.sleep-event-list { display: grid; gap: .5rem; padding: 0; list-style: none; }
.sleep-event-list li { display: grid; grid-template-columns: 10rem 1fr 1fr auto; gap: .5rem; align-items: end; padding: .65rem; background: var(--surface-raised); border-radius: var(--radius-md); }
.sleep-event-list label, .sleep-notes { display: grid; gap: .25rem; }
.sleep-notes textarea { min-height: 6rem; resize: vertical; }
@media (max-width: 760px) {
.sleep-editor-columns { grid-template-columns: 1fr; }
.sleep-event-list li { grid-template-columns: 1fr; }
.sleep-heading { align-items: flex-start; flex-direction: column; }
}
.analysis-delta dd {
color: var(--lavender);
}