From 10588498ced0e235bdb5bbbaf7699bdea748a31b Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 15 Aug 2026 10:41:03 -0600 Subject: [PATCH] ui: show quota timing by minute Show each meter's reset time beneath its value and report successful fetch age in the group footer. Advance relative times only on minute ticks so temporal context stays useful without waking the watch every second. --- src/c/main.c | 111 ++++++++++++++++++------------------- src/c/quota_snapshot.c | 97 +++++++++++++++++++++++++++----- src/c/quota_snapshot.h | 6 +- test/quota_snapshot.test.c | 42 +++++++++++--- 4 files changed, 176 insertions(+), 80 deletions(-) diff --git a/src/c/main.c b/src/c/main.c index 56a84f8..2c98f63 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -94,6 +94,7 @@ static QuotaRequestSource s_active_quota_request_source = QUOTA_REQUEST_STARTUP; static QuotaUnloadedState s_unloaded_state = QUOTA_UNLOADED_LOADING; static int64_t s_last_success_epoch_seconds = 0; static int64_t s_last_automatic_attempt_epoch_seconds = 0; +static int64_t s_display_epoch_seconds = 0; static QuotaSnapshot s_quota_snapshot; static uint8_t s_snapshot_transfer[QUOTA_SNAPSHOT_SERIALIZED_MAX_SIZE]; static int32_t s_snapshot_transfer_id = 0; @@ -105,9 +106,10 @@ static int s_snapshot_transfer_next_chunk = 0; static bool s_snapshot_transfer_active = false; static char s_title_text[QUOTA_TITLE_LENGTH] = "Pebblexus"; -static char s_reset_text[QUOTA_FOOTER_LENGTH] = "Connecting to phone"; +static char s_unloaded_detail_text[QUOTA_FOOTER_LENGTH] = "Connecting to phone"; +static char s_footer_text[QUOTA_FOOTER_LENGTH] = ""; +static char s_footer_failure[QUOTA_FOOTER_LENGTH] = ""; static char s_status_text[QUOTA_STATUS_LENGTH] = "Waiting"; -static char s_updated_text[QUOTA_UPDATED_LENGTH] = ""; static QuotaMeter s_rows[MAX_ROWS]; static int clamp_percent(int percent) { @@ -361,52 +363,38 @@ static bool quota_snapshot_storage_is_available(void) { return storage_size > 0 && snapshot_store_storage_fits("A_SNAPSHOT_STORE, PERSIST_DATA_MAX_LENGTH, storage_size); } +static void update_footer_text(void) { + if (s_row_count < 1) { + s_footer_text[0] = '\0'; + return; + } + if (!quota_snapshot_format_footer(s_quota_snapshot.fetched_epoch_seconds, s_display_epoch_seconds, + s_showing_cached_data, s_footer_failure, s_footer_text, sizeof(s_footer_text))) + snprintf(s_footer_text, sizeof(s_footer_text), "%s", s_showing_cached_data ? "Cached" : "Updated"); +} + static bool show_cached_footer(const char *reason) { if (s_row_count < 1) return false; s_showing_cached_data = true; - if (!quota_snapshot_format_cached_footer(s_updated_text, reason, s_reset_text, sizeof(s_reset_text)) && - !quota_snapshot_format_cached_footer("", reason, s_reset_text, sizeof(s_reset_text))) - snprintf(s_reset_text, sizeof(s_reset_text), "Cached"); + snprintf(s_footer_failure, sizeof(s_footer_failure), "%s", reason != NULL ? reason : ""); + update_footer_text(); return true; } -static void format_updated_time(uint32_t epoch_seconds) { - time_t fetched = (time_t)epoch_seconds; - struct tm *local = localtime(&fetched); - if (local == NULL) { - s_updated_text[0] = '\0'; - return; - } - snprintf(s_updated_text, sizeof(s_updated_text), "at %d:%02d", local->tm_hour, local->tm_min); +static void update_display_time(void) { + time_t now = time(NULL); + if (now > 0) + s_display_epoch_seconds = (int64_t)now; + update_footer_text(); } -static void format_short_reset(const QuotaGroup *group) { - uint32_t reset_epoch_seconds = 0; - for (int meter_index = 0; meter_index < group->meter_count; meter_index += 1) { - const QuotaMeter *meter = &s_quota_snapshot.meters[group->meter_start + meter_index]; - if (meter->reset_epoch_seconds > 0 && - (reset_epoch_seconds == 0 || meter->reset_epoch_seconds < reset_epoch_seconds)) - reset_epoch_seconds = meter->reset_epoch_seconds; - } - if (reset_epoch_seconds == 0) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s", s_updated_text); +static void minute_tick_handler(struct tm *tick_time, TimeUnits units_changed) { + if (tick_time == NULL || (units_changed & MINUTE_UNIT) == 0) return; - } - - time_t now = time(NULL); - int64_t difference_seconds = (int64_t)reset_epoch_seconds - (int64_t)now; - bool future = difference_seconds >= 0; - uint64_t absolute_minutes = (uint64_t)(future ? difference_seconds : -difference_seconds) / 60; - const char *prefix = future ? "Next reset " : "Reset "; - if (absolute_minutes < 90) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%lum", prefix, (unsigned long)absolute_minutes); - } else if (absolute_minutes < 48 * 60) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%luh", prefix, (unsigned long)(absolute_minutes / 60)); - } else { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%lud", prefix, (unsigned long)(absolute_minutes / (24 * 60))); - } + update_display_time(); + mark_card_dirty(); } static void present_current_group(void) { @@ -443,10 +431,7 @@ static void present_current_group(void) { else snprintf(s_status_text, sizeof(s_status_text), "OK"); - format_updated_time(s_quota_snapshot.fetched_epoch_seconds); - format_short_reset(group); - if (s_showing_cached_data) - show_cached_footer(NULL); + update_footer_text(); } static int find_group(const char *id) { @@ -509,9 +494,8 @@ static void request_quota(QuotaRequestSource source) { show_cached_footer(NULL); } else { s_unloaded_state = QUOTA_UNLOADED_LOADING; - snprintf(s_reset_text, sizeof(s_reset_text), "Connecting to phone"); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "Connecting to phone"); snprintf(s_status_text, sizeof(s_status_text), "Refreshing"); - s_updated_text[0] = '\0'; } s_is_refreshing = true; s_refresh_frame = 0; @@ -733,13 +717,14 @@ static void card_update_proc(Layer *layer, GContext *ctx) { heading = "Loading quotas"; draw_text(ctx, heading, GRect(content_x, y + 48, content_w, 34), fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter); - draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24), - color_text_secondary(), GTextAlignmentCenter); + draw_text(ctx, s_unloaded_detail_text, GRect(content_x, y + 86, content_w, 46), + fonts_get_system_font(FONT_KEY_GOTHIC_24), color_text_secondary(), GTextAlignmentCenter); } else { - // Row layout heights (42/28/50 + 3px separators) are mirrored by the - // phone-side page packing in src/pkjs/index.js; keep them in sync. + // Base row heights are 42/28/50px. A meter reset line adds 18px, and + // separators add 3px between rows. GFont row_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); - int row_bottom_limit = bounds.size.h - 34 + offset; + GFont reset_font = fonts_get_system_font(FONT_KEY_GOTHIC_14); + int row_bottom_limit = bounds.size.h - 20 + offset; // One detail column for all bar rows keeps the bar tracks aligned. int detail_column_w = 28; @@ -757,8 +742,12 @@ static void card_update_proc(Layer *layer, GContext *ctx) { for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) { QuotaMeter *row = &s_rows[i]; GColor accent = color_for_status(row->status_rank); + char reset_text[QUOTA_FOOTER_LENGTH]; + bool has_reset = quota_snapshot_format_reset(row->reset_epoch_seconds, s_display_epoch_seconds, reset_text, + sizeof(reset_text)); + int more_height = i + 1 < s_row_count ? 25 : 0; if (row->has_bar) { - if (y + 42 > row_bottom_limit) + if (y + 42 + (has_reset ? 18 : 0) + more_height > row_bottom_limit) break; draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), GTextAlignmentLeft); @@ -774,7 +763,7 @@ static void card_update_proc(Layer *layer, GContext *ctx) { detail_w = 100; int label_w = content_w - detail_w - 8; bool two_line = text_width(row->label, row_font) > label_w; - if (y + (two_line ? 50 : 28) > row_bottom_limit) + if (y + (two_line ? 50 : 28) + (has_reset ? 18 : 0) + more_height > row_bottom_limit) break; if (two_line) { draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), @@ -791,6 +780,11 @@ static void card_update_proc(Layer *layer, GContext *ctx) { y += 28; } } + if (has_reset) { + draw_text(ctx, reset_text, GRect(content_x, y - 3, content_w, 18), reset_font, color_text_muted(), + GTextAlignmentLeft); + y += 18; + } rows_drawn += 1; if (i + 1 < s_row_count && i + 1 < MAX_ROWS) { graphics_context_set_stroke_color(ctx, color_card_outline()); @@ -810,8 +804,8 @@ static void card_update_proc(Layer *layer, GContext *ctx) { if (s_row_count > 0) { GColor footer_color = s_showing_cached_data ? color_for_status(3) : color_text_muted(); - draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 32 + offset, content_w, 24), - fonts_get_system_font(FONT_KEY_GOTHIC_18), footer_color, GTextAlignmentCenter); + draw_text(ctx, s_footer_text, GRect(content_x, bounds.size.h - 20 + offset, content_w, 18), + fonts_get_system_font(FONT_KEY_GOTHIC_14), footer_color, GTextAlignmentCenter); } } @@ -833,9 +827,8 @@ static void fail_snapshot_transfer(const char *reason) { s_page_count = 0; s_unloaded_state = QUOTA_UNLOADED_RETRYABLE; snprintf(s_title_text, sizeof(s_title_text), "Pebblexus"); - snprintf(s_reset_text, sizeof(s_reset_text), "%s", reason); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "%s", reason); snprintf(s_status_text, sizeof(s_status_text), "Select retries"); - s_updated_text[0] = '\0'; } snap_motion_idle(); if (was_refreshing && s_active_quota_request_source == QUOTA_REQUEST_MANUAL) @@ -914,7 +907,9 @@ static void finish_snapshot_transfer(void) { } s_is_refreshing = false; s_showing_cached_data = false; + s_footer_failure[0] = '\0'; s_last_success_epoch_seconds = s_quota_snapshot.fetched_epoch_seconds; + update_display_time(); int preserved_group_index = find_group(selected_group_id); s_page_index = preserved_group_index >= 0 ? preserved_group_index : 0; present_current_group(); @@ -972,14 +967,13 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) error_kind = QUOTA_ERROR_SETUP_REQUIRED; if (error_kind == QUOTA_ERROR_SETUP_REQUIRED) { s_unloaded_state = QUOTA_UNLOADED_SETUP_REQUIRED; - snprintf(s_reset_text, sizeof(s_reset_text), "Open settings in Pebble app"); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "Open settings in Pebble app"); s_status_text[0] = '\0'; } else { s_unloaded_state = QUOTA_UNLOADED_RETRYABLE; - snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "%s", error_tuple->value->cstring); snprintf(s_status_text, sizeof(s_status_text), "Select retries"); } - s_updated_text[0] = '\0'; snap_motion_idle(); mark_card_dirty(); return; @@ -1032,6 +1026,7 @@ static void main_window_unload(Window *window) { } static void init(void) { + update_display_time(); load_quota_snapshot(); s_main_window = window_create(); @@ -1046,10 +1041,12 @@ static void init(void) { window_stack_push(s_main_window, true); request_quota(QUOTA_REQUEST_STARTUP); + tick_timer_service_subscribe(MINUTE_UNIT, minute_tick_handler); backlight_service_subscribe(backlight_handler); } static void deinit(void) { + tick_timer_service_unsubscribe(); backlight_service_unsubscribe(); window_destroy(s_main_window); } diff --git a/src/c/quota_snapshot.c b/src/c/quota_snapshot.c index 295264b..f5b225e 100644 --- a/src/c/quota_snapshot.c +++ b/src/c/quota_snapshot.c @@ -145,24 +145,93 @@ bool quota_snapshot_deserialize(const uint8_t *serialization, size_t serializati return true; } -bool quota_snapshot_format_cached_footer(const char *updated, const char *reason, char *buffer, size_t buffer_size) { - if (updated == NULL || buffer == NULL || buffer_size == 0) +static bool write_format_result(int length, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) return false; - - const char *display_updated = strncmp(updated, "at ", 3) == 0 ? updated + 3 : updated; - int length; - if (display_updated[0] != '\0' && reason != NULL && reason[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s · %s", display_updated, reason); - else if (display_updated[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s", display_updated); - else if (reason != NULL && reason[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s", reason); - else - length = snprintf(buffer, buffer_size, "Cached"); - if (length < 0 || (size_t)length >= buffer_size) { buffer[0] = '\0'; return false; } return true; } + +static bool format_elapsed(uint64_t seconds, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + + uint32_t bounded_seconds = seconds > UINT32_MAX ? UINT32_MAX : (uint32_t)seconds; + if (bounded_seconds < 60) + return write_format_result(snprintf(buffer, buffer_size, "<1m"), buffer, buffer_size); + + uint32_t minutes = bounded_seconds / 60; + if (minutes < 60) + return write_format_result(snprintf(buffer, buffer_size, "%lum", (unsigned long)minutes), buffer, buffer_size); + + uint32_t hours = minutes / 60; + if (hours < 24) { + uint32_t remaining_minutes = minutes % 60; + int length = remaining_minutes > 0 ? snprintf(buffer, buffer_size, "%luh %lum", (unsigned long)hours, + (unsigned long)remaining_minutes) + : snprintf(buffer, buffer_size, "%luh", (unsigned long)hours); + return write_format_result(length, buffer, buffer_size); + } + + uint32_t days = hours / 24; + uint32_t remaining_hours = hours % 24; + int length = remaining_hours > 0 + ? snprintf(buffer, buffer_size, "%lud %luh", (unsigned long)days, (unsigned long)remaining_hours) + : snprintf(buffer, buffer_size, "%lud", (unsigned long)days); + return write_format_result(length, buffer, buffer_size); +} + +bool quota_snapshot_format_reset(uint32_t reset_epoch_seconds, int64_t now_epoch_seconds, char *buffer, + size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + buffer[0] = '\0'; + if (reset_epoch_seconds == 0 || now_epoch_seconds <= 0) + return false; + + int64_t remaining_seconds = (int64_t)reset_epoch_seconds - now_epoch_seconds; + if (remaining_seconds <= 0) + return write_format_result(snprintf(buffer, buffer_size, "Reset due"), buffer, buffer_size); + + char elapsed[24]; + if (!format_elapsed((uint64_t)remaining_seconds, elapsed, sizeof(elapsed))) + return false; + const char *format = remaining_seconds < 60 ? "Resets %s" : "Resets in %s"; + return write_format_result(snprintf(buffer, buffer_size, format, elapsed), buffer, buffer_size); +} + +bool quota_snapshot_format_footer(uint32_t fetched_epoch_seconds, int64_t now_epoch_seconds, bool cached, + const char *failure, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + buffer[0] = '\0'; + if (fetched_epoch_seconds == 0 || now_epoch_seconds <= 0) + return false; + + uint64_t elapsed_seconds = + now_epoch_seconds > (int64_t)fetched_epoch_seconds ? (uint64_t)(now_epoch_seconds - fetched_epoch_seconds) : 0; + char elapsed[24]; + if (!format_elapsed(elapsed_seconds, elapsed, sizeof(elapsed))) + return false; + + char updated[32]; + if (!write_format_result(snprintf(updated, sizeof(updated), "Updated %s ago", elapsed), updated, sizeof(updated))) + return false; + + const char *suffix = failure != NULL && failure[0] != '\0' ? failure : cached ? "Cached" : NULL; + if (suffix != NULL) { + int length = snprintf(buffer, buffer_size, "%s · %s", updated, suffix); + if (length >= 0 && (size_t)length < buffer_size) + return true; + } + if (cached && suffix != NULL && strcmp(suffix, "Cached") != 0) { + int length = snprintf(buffer, buffer_size, "%s · Cached", updated); + if (length >= 0 && (size_t)length < buffer_size) + return true; + } + + return write_format_result(snprintf(buffer, buffer_size, "%s", updated), buffer, buffer_size); +} diff --git a/src/c/quota_snapshot.h b/src/c/quota_snapshot.h index 6855250..c4fc57a 100644 --- a/src/c/quota_snapshot.h +++ b/src/c/quota_snapshot.h @@ -14,7 +14,6 @@ #define QUOTA_METER_LABEL_LENGTH 24 #define QUOTA_METER_DETAIL_LENGTH 28 #define QUOTA_STATUS_LENGTH 16 -#define QUOTA_UPDATED_LENGTH 16 #define QUOTA_FOOTER_LENGTH 40 // Keep these wire sizes synchronized with src/pkjs/index.js. Their maxima fit // two complete checksummed snapshots in the minimum 4 KiB persist budget. @@ -55,6 +54,9 @@ typedef struct { uint32_t quota_snapshot_checksum(const uint8_t *bytes, size_t size); bool quota_snapshot_is_valid(const uint8_t *serialization, size_t serialization_size); bool quota_snapshot_deserialize(const uint8_t *serialization, size_t serialization_size, QuotaSnapshot *snapshot); -bool quota_snapshot_format_cached_footer(const char *updated, const char *reason, char *buffer, size_t buffer_size); +bool quota_snapshot_format_reset(uint32_t reset_epoch_seconds, int64_t now_epoch_seconds, char *buffer, + size_t buffer_size); +bool quota_snapshot_format_footer(uint32_t fetched_epoch_seconds, int64_t now_epoch_seconds, bool cached, + const char *failure, char *buffer, size_t buffer_size); #endif diff --git a/test/quota_snapshot.test.c b/test/quota_snapshot.test.c index b923384..304f7bc 100644 --- a/test/quota_snapshot.test.c +++ b/test/quota_snapshot.test.c @@ -1,6 +1,7 @@ #include "quota_snapshot.h" #include +#include #include #define HEADER_SIZE 20 @@ -159,16 +160,42 @@ static void test_checksum(void) { assert(quota_snapshot_checksum(NULL, 0) == 0); } -static void test_cached_footer(void) { +static void test_reset_time(void) { char footer[QUOTA_FOOTER_LENGTH]; + int64_t now = INT64_C(1784826720); + + assert(quota_snapshot_format_reset(UINT32_C(1784841840), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets in 4h 12m") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1785355920), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets in 6d 3h") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1784826750), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets <1m") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1784826720), now, footer, sizeof(footer))); + assert(strcmp(footer, "Reset due") == 0); + + snprintf(footer, sizeof(footer), "invalid"); + assert(!quota_snapshot_format_reset(0, now, footer, sizeof(footer))); + assert(footer[0] == '\0'); +} - assert(quota_snapshot_format_cached_footer("at 14:32", NULL, footer, sizeof(footer))); - assert(strcmp(footer, "Cached · 14:32") == 0); - assert(quota_snapshot_format_cached_footer("at 14:32", "Disconnected", footer, sizeof(footer))); - assert(strcmp(footer, "Cached · 14:32 · Disconnected") == 0); +static void test_updated_footer(void) { + char footer[QUOTA_FOOTER_LENGTH]; + int64_t now = INT64_C(1784826720); + + assert(quota_snapshot_format_footer(UINT32_C(1784826720), now, false, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated <1m ago") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784826480), now, true, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated 4m ago · Cached") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784819340), now, true, "Dropped", footer, sizeof(footer))); + assert(strcmp(footer, "Updated 2h 3m ago · Dropped") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784301120), now, false, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated 6d 2h ago") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784826720), now, true, "An exceptionally verbose transfer failure", + footer, sizeof(footer))); + assert(strcmp(footer, "Updated <1m ago · Cached") == 0); char short_footer[8] = "invalid"; - assert(!quota_snapshot_format_cached_footer("at 14:32", "Disconnected", short_footer, sizeof(short_footer))); + assert(!quota_snapshot_format_footer(UINT32_C(1784826720), now, false, NULL, short_footer, sizeof(short_footer))); assert(short_footer[0] == '\0'); } @@ -176,6 +203,7 @@ int main(void) { test_valid_snapshot(); test_invalid_snapshots(); test_checksum(); - test_cached_footer(); + test_reset_time(); + test_updated_footer(); return 0; } -- knotmirror 0.0.1