diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-12 22:20:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-12 22:20:10 +0300 |
| commit | 3b03de3e1bac378bbbdf122110fea94f45981b9d (patch) | |
| tree | 43d980956902c7e24458e1632cad7021597a4032 /src/c/fastforge_history.c | |
| parent | 9d28ec3d0e03b32949b0392d22ad9489cb423f29 (diff) | |
i2: split FastForge core and history modules
Diffstat (limited to 'src/c/fastforge_history.c')
| -rw-r--r-- | src/c/fastforge_history.c | 502 |
1 files changed, 502 insertions, 0 deletions
diff --git a/src/c/fastforge_history.c b/src/c/fastforge_history.c new file mode 100644 index 0000000..8631162 --- /dev/null +++ b/src/c/fastforge_history.c @@ -0,0 +1,502 @@ +#include "fastforge_internal.h" + +#include <stdlib.h> +#include <string.h> + +#include "message_keys.auto.h" + +static const char *const s_note_tags[] = { + "", + "felt amazing", + "workout day", + "travel day", + "social day", + "busy day", + "reset day", + "late meal", + "high energy", + "rough day" +}; + +#ifndef PBL_PLATFORM_APLITE +typedef struct { + bool active; + int next_row; + int snapshot_count; + FastEntry snapshot[MAX_FASTS]; +} HistoryExportState; + +static HistoryExportState s_history_export = {0}; +#endif + +static char s_stats_body_text[160]; + +static int compare_history_entries_by_end_time(const void *a, const void *b) { + const FastEntry *entry_a = a; + const FastEntry *entry_b = b; + if (entry_a->end_time < entry_b->end_time) { + return -1; + } + if (entry_a->end_time > entry_b->end_time) { + return 1; + } + if (entry_a->start_time < entry_b->start_time) { + return -1; + } + if (entry_a->start_time > entry_b->start_time) { + return 1; + } + return 0; +} + +void sort_history_by_end_time(void) { + if (history_count <= 1) { + return; + } + qsort(history, (size_t)history_count, sizeof(FastEntry), compare_history_entries_by_end_time); +} + +const char *milestone_badge_label_for_level(uint8_t stage_level) { + if (stage_level >= 3) { + return "Deep Ketosis"; + } + if (stage_level == 2) { + return "Ketosis"; + } + if (stage_level == 1) { + return "Fat Burn"; + } + return NULL; +} + +void format_optional_tag_text(const char *prefix, const char *value, char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + + if (!value || value[0] == '\0') { + snprintf(buffer, size, "%s--", prefix ? prefix : ""); + return; + } + + snprintf(buffer, size, "%s%s", prefix ? prefix : "", value); +} + +const char *history_entry_badge_label(const FastEntry *entry) { + if (!entry) { + return NULL; + } + return milestone_badge_label_for_level(entry->max_stage_reached); +} + +int note_tag_index_for_entry(const FastEntry *entry) { + if (!entry || entry->note[0] == '\0') { + return 0; + } + + for (int i = 1; i < (int)ARRAY_LENGTH(s_note_tags); i++) { + if (strncmp(entry->note, s_note_tags[i], sizeof(entry->note)) == 0) { + return i; + } + } + + return 0; +} + +int history_note_tag_count(void) { + return (int)ARRAY_LENGTH(s_note_tags); +} + +void set_entry_note_from_tag_index(FastEntry *entry, int tag_index) { + if (!entry) { + return; + } + + if (tag_index <= 0 || tag_index >= (int)ARRAY_LENGTH(s_note_tags)) { + memset(entry->note, 0, sizeof(entry->note)); + return; + } + + snprintf(entry->note, sizeof(entry->note), "%s", s_note_tags[tag_index]); +} + +int history_index_for_row(int row) { + if (row < 0 || row >= history_count) { + return -1; + } + return history_count - 1 - row; +} + +static void format_history_row_impl(int row, char *title, size_t title_size, char *subtitle, size_t subtitle_size) { + if (!title || !subtitle || title_size == 0 || subtitle_size == 0) { + return; + } + if (history_count == 0) { + snprintf(title, title_size, "No completed fasts"); + snprintf(subtitle, subtitle_size, "Start + stop a fast first"); + return; + } + + int history_index = history_index_for_row(row); + if (history_index < 0) { + snprintf(title, title_size, "Unavailable"); + subtitle[0] = '\0'; + return; + } + + const FastEntry *entry = &history[history_index]; + char date_text[24]; + char duration_text[20]; + char badge_text[24]; + char note_text[32]; + const char *badge_label = history_entry_badge_label(entry); + format_entry_datetime(entry->end_time, date_text, sizeof(date_text)); + format_duration_hours_minutes(entry_duration_seconds(entry), duration_text, sizeof(duration_text)); + snprintf(title, title_size, "%s", date_text); + note_text[0] = '\0'; + if (entry->note[0] != '\0') { + snprintf(note_text, sizeof(note_text), " | %s", entry->note); + } + badge_text[0] = '\0'; + if (badge_label) { + snprintf(badge_text, sizeof(badge_text), " | %s", badge_label); + } + snprintf(subtitle, subtitle_size, "%s%s%s", duration_text, badge_text, note_text); +} + +void format_history_row(int row, char *title, size_t title_size, char *subtitle, size_t subtitle_size) { + format_history_row_impl(row, title, title_size, subtitle, subtitle_size); +} + +static void collect_stats_summary(time_t *total_seconds, time_t *longest_seconds, + int *completed_count, int *successful_count) { + time_t total_seconds_value = 0; + time_t longest_seconds_value = 0; + int completed_count_value = 0; + int successful_count_value = 0; + + for (int i = 0; i < history_count; i++) { + const FastEntry *entry = &history[i]; + time_t duration = entry_duration_seconds(entry); + if (duration <= 0) { + continue; + } + + completed_count_value++; + total_seconds_value += duration; + if (duration > longest_seconds_value) { + longest_seconds_value = duration; + } + if (entry->target_minutes > 0 && duration >= (time_t)entry->target_minutes * 60) { + successful_count_value++; + } + } + + if (total_seconds) { + *total_seconds = total_seconds_value; + } + if (longest_seconds) { + *longest_seconds = longest_seconds_value; + } + if (completed_count) { + *completed_count = completed_count_value; + } + if (successful_count) { + *successful_count = successful_count_value; + } +} + +static void format_stats_window_body(time_t total_seconds, time_t longest_seconds, + int completed_count, int successful_count) { + if (completed_count == 0) { + snprintf(s_stats_body_text, sizeof(s_stats_body_text), + "No completed fasts yet.\n" + "Start and stop your first\n" + "fast to populate stats.\n" + "Streak: %u current / %u best", + streak_data.current_streak, + streak_data.longest_streak); + } else { + char avg_text[20]; + char total_text[20]; + char longest_text[20]; + time_t average_seconds = total_seconds / completed_count; + int success_rate = (successful_count * 100 + completed_count / 2) / completed_count; + + format_duration_hours_minutes(average_seconds, avg_text, sizeof(avg_text)); + format_duration_hours_minutes(total_seconds, total_text, sizeof(total_text)); + format_duration_hours_minutes(longest_seconds, longest_text, sizeof(longest_text)); + snprintf(s_stats_body_text, sizeof(s_stats_body_text), + "Avg fast: %s\n" + "Total fasted: %s\n" + "Success: %d%% (%d/%d)\n" + "Longest: %s\n" + "Streak: %u / %u", + avg_text, + total_text, + success_rate, + successful_count, + completed_count, + longest_text, + streak_data.current_streak, + streak_data.longest_streak); + } +} + +void refresh_stats_window_content(void) { + if (!s_stats_body_layer) { + return; + } + + time_t total_seconds = 0; + time_t longest_seconds = 0; + int completed_count = 0; + int successful_count = 0; + collect_stats_summary(&total_seconds, &longest_seconds, &completed_count, &successful_count); + format_stats_window_body(total_seconds, longest_seconds, completed_count, successful_count); + text_layer_set_text(s_stats_body_layer, s_stats_body_text); +} + +void history_menu_reload(void) { + if (s_history_menu_layer) { + menu_layer_reload_data(s_history_menu_layer); + } +} + +#ifndef PBL_PLATFORM_APLITE +static void stop_history_export(void) { + s_history_export.active = false; + s_history_export.next_row = 0; + s_history_export.snapshot_count = 0; + memset(s_history_export.snapshot, 0, sizeof(s_history_export.snapshot)); +} + +static size_t csv_append_text(char *buffer, size_t size, size_t offset, const char *value) { + if (!buffer || size == 0 || offset >= size) { + return offset; + } + + bool needs_quotes = false; + if (value) { + for (const char *cursor = value; *cursor; cursor++) { + if (*cursor == ',' || *cursor == '"' || *cursor == '\n' || *cursor == '\r') { + needs_quotes = true; + break; + } + } + } + + if (needs_quotes) { + if (offset + 1 < size) { + buffer[offset++] = '"'; + } + if (value) { + for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { + if (*cursor == '"') { + if (offset + 2 >= size) { + break; + } + buffer[offset++] = '"'; + buffer[offset++] = '"'; + } else { + buffer[offset++] = *cursor; + } + } + } + if (offset + 1 < size) { + buffer[offset++] = '"'; + } + } else { + if (value) { + for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { + buffer[offset++] = *cursor; + } + } + } + + if (offset < size) { + buffer[offset] = '\0'; + } + return offset; +} + +static size_t csv_append_int(char *buffer, size_t size, size_t offset, long value) { + char number_text[16]; + snprintf(number_text, sizeof(number_text), "%ld", value); + return csv_append_text(buffer, size, offset, number_text); +} + +static void format_history_csv_row(const FastEntry *entry, char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + + buffer[0] = '\0'; + if (!entry) { + return; + } + + size_t offset = 0; + offset = csv_append_int(buffer, size, offset, (long)entry->start_time); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_int(buffer, size, offset, (long)entry->end_time); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_int(buffer, size, offset, (long)entry->target_minutes); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_text(buffer, size, offset, entry->note); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + (void)csv_append_int(buffer, size, offset, (long)entry->max_stage_reached); +} + +static void format_history_csv_header(char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + snprintf(buffer, size, "start_time,end_time,target_minutes,note,max_stage_reached"); +} + +static const char *history_export_status_for_row(void) { + if (s_history_export.next_row == 0) { + return s_history_export.snapshot_count == 0 ? "EXPORT_COMPLETE" : "EXPORT_STARTED"; + } + if (s_history_export.next_row >= s_history_export.snapshot_count) { + return "EXPORT_COMPLETE"; + } + return "EXPORT_ROW"; +} + +static void history_export_send_message(const char *row_text) { + DictionaryIterator *outbox = NULL; + AppMessageResult result = app_message_outbox_begin(&outbox); + if (result != APP_MSG_OK || !outbox) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to start history export outbox (%d)", result); + stop_history_export(); + return; + } + + dict_write_int32(outbox, MESSAGE_KEY_EXPORT_SEQUENCE, s_history_export.next_row); + dict_write_int32(outbox, MESSAGE_KEY_EXPORT_TOTAL, s_history_export.snapshot_count + 1); + dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_ROW, row_text ? row_text : ""); + dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_STATUS, history_export_status_for_row()); + result = app_message_outbox_send(); + if (result != APP_MSG_OK) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to queue history export message (%d)", result); + stop_history_export(); + } +} + +static void history_export_send_next(void) { + if (!s_history_export.active) { + return; + } + + char row_text[128]; + if (s_history_export.next_row == 0) { + format_history_csv_header(row_text, sizeof(row_text)); + } else { + int history_index = s_history_export.next_row - 1; + if (history_index < 0 || history_index >= s_history_export.snapshot_count) { + stop_history_export(); + return; + } + format_history_csv_row(&s_history_export.snapshot[history_index], row_text, sizeof(row_text)); + } + + history_export_send_message(row_text); +} + +static void history_export_on_sent(DictionaryIterator *iterator, void *context) { + (void)iterator; + (void)context; + if (!s_history_export.active) { + return; + } + + s_history_export.next_row++; + if (s_history_export.next_row >= s_history_export.snapshot_count + 1) { + APP_LOG(APP_LOG_LEVEL_INFO, "History export finished with %d rows", s_history_export.snapshot_count + 1); + stop_history_export(); + return; + } + + history_export_send_next(); +} + +static void history_export_on_failed(DictionaryIterator *iterator, AppMessageResult reason, void *context) { + (void)iterator; + (void)context; + APP_LOG(APP_LOG_LEVEL_ERROR, "History export failed at row %d (%d)", s_history_export.next_row, reason); + stop_history_export(); +} + +static bool history_export_begin(void) { + if (s_history_export.active) { + return false; + } + + s_history_export.active = true; + s_history_export.next_row = 0; + s_history_export.snapshot_count = history_count; + if (s_history_export.snapshot_count > 0) { + memcpy(s_history_export.snapshot, history, sizeof(FastEntry) * (size_t)s_history_export.snapshot_count); + } + history_export_send_next(); + return true; +} + +static void history_export_inbox_received(DictionaryIterator *iterator, void *context) { + (void)context; + Tuple *command_tuple = dict_find(iterator, MESSAGE_KEY_EXPORT_COMMAND); + if (!command_tuple || command_tuple->type != TUPLE_CSTRING) { + return; + } + + if (strcmp(command_tuple->value->cstring, "EXPORT_HISTORY") == 0) { + APP_LOG(APP_LOG_LEVEL_INFO, "Received export request from companion"); + history_export_begin(); + } +} +#endif + +void request_history_export(void) { +#ifndef PBL_PLATFORM_APLITE + if (!history_export_begin()) { + show_placeholder_window("BACKUP BUSY", + "A history export is already in progress.", + "BACK Menu"); + return; + } + + show_placeholder_window("BACKUP TO PHONE", + "CSV export started.\nCompanion stub stores the file.", + "BACK Menu"); +#else + show_placeholder_window("BACKUP UNAVAILABLE", + "AppMessage backup is disabled on Aplite.", + "BACK Menu"); +#endif +} + +#ifndef PBL_PLATFORM_APLITE +void fastforge_history_register_app_message_handlers(void) { + app_message_register_inbox_received(history_export_inbox_received); + app_message_register_outbox_sent(history_export_on_sent); + app_message_register_outbox_failed(history_export_on_failed); +} + +void fastforge_history_stop_export(void) { + stop_history_export(); +} +#endif |
