Skip to content

Commit 47df311

Browse files
aveaoclaude
andcommitted
health: split typical pill into bin-time + daily-total on color
Redesign the activity-summary "typical" pill so it surfaces both the bin-aware "by now" step count and the full-day weekday historical average, side by side, on color displays. The header stays "TYPICAL <day>"; below it a two-column row shows each number on top with its label beneath (bin time | TOTAL) in black, separated by a 1px black vertical divider. BW boards stay two-line: TYPICAL <day> over the daily total alone, since they lack the room for the split. Renderer (health_ui_render_typical_text_box): - Add daily_total and bin_minute arguments. The sleep card passes -1/-1 to keep its single-value behavior; the activity card passes the bin-aware count as value_text plus the new numbers. The empty state collapses to the em-dash via -1/-1. - On color with both numbers present, the pill grows to 53px to fit the three rows; otherwise it keeps its original 35/36px height. - Push the pill down by a third of HEALTH_Y_OFFSET (and the current step count by a sixth) so the spacing stays balanced on taller displays; zero on legacy-sized displays where the offset is 0. - On round, narrow the column band so the two halves sit closer to center instead of marooned at the display edges. Data layer: re-add health_data_steps_get_current_average_minute, returning the minute-of-day of the latest completed step-average bin, factoring the lazy refresh into prv_refresh_current_step_average shared with health_data_steps_get_current_average. The activity card caches it each layer update and passes it to the renderer. Test fixture: pin RTC to Saturday 16:19:35 UTC with 24h clock so the rendered "16:15" bin time is deterministic; seed step_average_last_updated_time per case. Regenerate activity, sleep, and card-view goldens on obelix/gabbro. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Ave Özkal <git@ave.zone>
1 parent d2944fc commit 47df311

51 files changed

Lines changed: 155 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/fw/apps/system/health/activity_summary_card.c

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ typedef struct HealthActivitySummaryCardData {
3636
KinoReel *icon;
3737
int32_t current_steps;
3838
int32_t typical_steps;
39+
int32_t typical_steps_bin_minute;
3940
int32_t daily_average_steps;
4041
} HealthActivitySummaryCardData;
4142

@@ -109,7 +110,11 @@ static void prv_render_current_steps(GContext *ctx, Layer *base_layer) {
109110
snprintf(buffer, sizeof(buffer), EM_DASH);
110111
}
111112

112-
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET;
113+
// Mirror the pill's downshift at half the offset so the step count and
114+
// pill stay visually balanced. Zero on legacy-sized displays where
115+
// HEALTH_Y_OFFSET itself is 0.
116+
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET
117+
+ HEALTH_Y_OFFSET / 6;
113118
graphics_context_set_text_color(ctx, CURRENT_TEXT_COLOR);
114119
graphics_draw_text(ctx, buffer, font,
115120
GRect(0, y, base_layer->bounds.size.w, 40),
@@ -126,14 +131,19 @@ static void prv_render_typical_steps(GContext *ctx, Layer *base_layer) {
126131
snprintf(steps_buffer, sizeof(steps_buffer), EM_DASH);
127132
}
128133

129-
health_ui_render_typical_text_box(ctx, base_layer, steps_buffer);
134+
// Only enable the split layout when the bin-aware count is known.
135+
const int32_t daily_total = (data->typical_steps > 0) ? data->daily_average_steps : -1;
136+
const int32_t bin_minute = (data->typical_steps > 0) ? data->typical_steps_bin_minute : -1;
137+
health_ui_render_typical_text_box(ctx, base_layer, steps_buffer, daily_total, bin_minute);
130138
}
131139

132140
static void prv_base_layer_update_proc(Layer *base_layer, GContext *ctx) {
133141
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
134142

135143
data->current_steps = health_data_current_steps_get(data->health_data);
136144
data->typical_steps = health_data_steps_get_current_average(data->health_data);
145+
data->typical_steps_bin_minute =
146+
health_data_steps_get_current_average_minute(data->health_data);
137147
data->daily_average_steps = health_data_steps_get_cur_wday_average(data->health_data);
138148

139149
prv_render_icon(ctx, base_layer);

src/fw/apps/system/health/data.c

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ static int32_t prv_health_data_get_n_average_chunks(HealthData *health_data, int
213213
return total_steps_avg;
214214
}
215215

216-
int32_t health_data_steps_get_current_average(HealthData *health_data) {
216+
static void prv_refresh_current_step_average(HealthData *health_data) {
217217
// get the current minutes into today
218218
time_t utc_sec = rtc_get_time();
219219
struct tm local_tm;
@@ -230,9 +230,18 @@ int32_t health_data_steps_get_current_average(HealthData *health_data) {
230230
health_data->step_average_last_updated_time =
231231
(today_min / k_minutes_per_step_avg) * k_minutes_per_step_avg;
232232
}
233+
}
234+
235+
int32_t health_data_steps_get_current_average(HealthData *health_data) {
236+
prv_refresh_current_step_average(health_data);
233237
return health_data->current_step_average;
234238
}
235239

240+
int32_t health_data_steps_get_current_average_minute(HealthData *health_data) {
241+
prv_refresh_current_step_average(health_data);
242+
return health_data->step_average_last_updated_time;
243+
}
244+
236245
int32_t health_data_steps_get_cur_wday_average(HealthData *health_data) {
237246
return prv_health_data_get_n_average_chunks(health_data, ACTIVITY_NUM_METRIC_AVERAGES);
238247
}

src/fw/apps/system/health/data.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ int32_t health_data_current_calories_get(HealthData *health_data);
9090
//! @return The integer number of steps that should be taken by this time today
9191
int32_t health_data_steps_get_current_average(HealthData *health_data);
9292

93+
//! Get the minute-of-day timestamp of the latest completed step-average bin.
94+
//! Returns a value in [0, MINUTES_PER_DAY) representing the start minute of the
95+
//! current incomplete bin (i.e., the end of the latest completed bin).
96+
//! @param health_data A pointer to the health data to use
97+
//! @return The minute-of-day of the latest completed bin boundary
98+
int32_t health_data_steps_get_current_average_minute(HealthData *health_data);
99+
93100
//! Get the step average for the current day of the week
94101
//! @param health_data A pointer to the health data to use
95102
//! @return An integer value for the number of steps that are typically taken on this week day

src/fw/apps/system/health/sleep_summary_card.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ static void prv_render_typical_sleep_text(GContext *ctx, Layer *base_layer) {
201201
snprintf(sleep_text, sizeof(sleep_text), EM_DASH);
202202
}
203203

204-
health_ui_render_typical_text_box(ctx, base_layer, sleep_text);
204+
health_ui_render_typical_text_box(ctx, base_layer, sleep_text, -1, -1);
205205
}
206206

207207
static void prv_render_no_sleep_data_text(GContext *ctx, Layer *base_layer) {

src/fw/apps/system/health/ui.c

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
#include "ui.h"
55

6+
#include <inttypes.h>
7+
68
#include "applib/pbl_std/pbl_std.h"
79
#include "board/display.h"
10+
#include "pbl/services/clock.h"
811
#include "pbl/services/i18n/i18n.h"
912
#include "util/string.h"
13+
#include "util/time/time.h"
1014

1115
// Compile-time display offset calculations
1216
#define HEALTH_X_OFFSET ((DISP_COLS - LEGACY_2X_DISP_COLS) / 2)
@@ -47,41 +51,117 @@ void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect dra
4751
}
4852
}
4953

50-
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text) {
54+
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text,
55+
int32_t daily_total, int32_t bin_minute) {
5156
time_t now = rtc_get_time();
5257
struct tm time_tm;
5358
localtime_r(&now, &time_tm);
5459
char weekday[8];
5560
strftime(weekday, sizeof(weekday), "%a", &time_tm);
5661
toupper_str(weekday);
5762

58-
char typical_text[32];
63+
char typical_text[16];
5964
snprintf(typical_text, sizeof(typical_text), i18n_get("TYPICAL %s", layer), weekday);
6065

61-
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(122, 120), 125) + HEALTH_Y_OFFSET;
62-
GRect rect = GRect(0, y, layer->bounds.size.w, PBL_IF_RECT_ELSE(35, 36));
66+
// Color displays with both bin and daily-total data get the 3-row split
67+
// layout (time + value on the left, TOTAL + daily on the right). Everything
68+
// else (BW, sleep, em-dash fallback) renders a single value below the
69+
// TYPICAL <day> header.
70+
const bool is_split = PBL_IF_COLOR_ELSE(daily_total > 0 && bin_minute >= 0, false);
71+
const int pill_height = is_split ? 53 : PBL_IF_RECT_ELSE(35, 36);
72+
73+
// Push the pill lower by a third of the display's vertical health-offset.
74+
// Scales to the slack available — zero on legacy-sized displays (asterix,
75+
// flint), a few px on taller displays (obelix, getafix). Applies to both
76+
// activity and sleep cards on those taller displays; the extra breathing
77+
// room reads better on both.
78+
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(122, 120), 125) + HEALTH_Y_OFFSET
79+
+ HEALTH_Y_OFFSET / 3;
80+
GRect rect = GRect(0, y, layer->bounds.size.w, pill_height);
6381
#if PBL_RECT
6482
rect = grect_inset(rect, GEdgeInsets(0, HEALTH_INSET));
6583
#endif
6684

6785
const GColor bg_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack);
6886
const GColor text_color = PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite);
6987
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
88+
const GFont small_font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD);
7089

7190
graphics_context_set_fill_color(ctx, bg_color);
7291
graphics_fill_round_rect(ctx, &rect, 3, GCornersAll);
7392

93+
// Compensate for the text renderer's top padding so the header glyphs sit
94+
// just inside the pill's visual top edge.
7495
rect.origin.y -= PBL_IF_RECT_ELSE(3, 2);
75-
// Restrict the rect to draw one line at a time to prevent them from wrapping into each other
76-
rect.size.h = 16;
7796

7897
graphics_context_set_text_color(ctx, text_color);
7998

80-
graphics_draw_text(ctx, typical_text, font, rect,
99+
// Row 1: TYPICAL <day>, full width.
100+
GRect header_rect = rect;
101+
header_rect.size.h = 16;
102+
graphics_draw_text(ctx, typical_text, font, header_rect,
81103
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
82104

83-
rect.origin.y += 16;
105+
if (!is_split) {
106+
GRect value_rect = header_rect;
107+
value_rect.origin.y += 16;
108+
109+
const char *bottom_text = value_text;
110+
char bottom_buf[12];
111+
if (daily_total > 0) {
112+
snprintf(bottom_buf, sizeof(bottom_buf), "%"PRId32, daily_total);
113+
bottom_text = bottom_buf;
114+
}
115+
graphics_draw_text(ctx, bottom_text, font, value_rect,
116+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
117+
return;
118+
}
84119

85-
graphics_draw_text(ctx, value_text, font, rect,
120+
// 3-row split layout (color activity only). Numbers sit on top, their
121+
// labels (bin time | TOTAL) below in black, split by a vertical divider.
122+
char time_text[12];
123+
clock_format_time(time_text, sizeof(time_text),
124+
bin_minute / MINUTES_PER_HOUR,
125+
bin_minute % MINUTES_PER_HOUR,
126+
false /* add_space */);
127+
128+
char total_value[12];
129+
snprintf(total_value, sizeof(total_value), "%"PRId32, daily_total);
130+
131+
const char *total_label = i18n_get("TOTAL", layer);
132+
133+
// On round we narrow the column band so the two halves sit closer to the
134+
// pill's center instead of marooned at the round display's edges.
135+
const int16_t col_band_inset = PBL_IF_RECT_ELSE(0, 40);
136+
const int16_t col_w = (rect.size.w - 2 * col_band_inset) / 2;
137+
138+
// Row 2: values (value_text | daily total), numbers on top.
139+
GRect val_left = GRect(rect.origin.x + col_band_inset, rect.origin.y + 19,
140+
col_w, 16);
141+
GRect val_right = val_left;
142+
val_right.origin.x += val_left.size.w;
143+
graphics_draw_text(ctx, value_text, font, val_left,
86144
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
145+
graphics_draw_text(ctx, total_value, font, val_right,
146+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
147+
148+
// Row 3: labels (bin time | TOTAL) below the numbers, in black.
149+
const GColor label_color = GColorBlack;
150+
GRect sub_left = val_left;
151+
sub_left.origin.y += 17;
152+
sub_left.size.h = 14;
153+
GRect sub_right = sub_left;
154+
sub_right.origin.x += sub_left.size.w;
155+
graphics_context_set_text_color(ctx, label_color);
156+
graphics_draw_text(ctx, time_text, small_font, sub_left,
157+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
158+
graphics_draw_text(ctx, total_label, small_font, sub_right,
159+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
160+
161+
// Vertical divider between the two columns, 1px wide black.
162+
graphics_context_set_stroke_color(ctx, label_color);
163+
graphics_context_set_stroke_width(ctx, 1);
164+
const int16_t divider_x = rect.origin.x + rect.size.w / 2;
165+
graphics_draw_line(ctx, GPoint(divider_x, val_left.origin.y + 6),
166+
GPoint(divider_x, sub_left.origin.y + sub_left.size.h));
87167
}

src/fw/apps/system/health/ui.h

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,17 @@ void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect dra
99
const int16_t y_offset, const GFont small_font, GColor box_color,
1010
GColor text_color);
1111

12-
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text);
12+
//! Render the "typical" pill used by the health summary cards.
13+
//!
14+
//! @param value_text Pre-formatted value text for the lower line. On color
15+
//! activity the bin-aware step count; on sleep the typical sleep duration.
16+
//! @param daily_total Full-day weekday-average step count, or <= 0 if not
17+
//! applicable. Triggers the split layout on color and replaces value_text
18+
//! on BW.
19+
//! @param bin_minute Minute-of-day of the latest completed step-average bin,
20+
//! or < 0 if not applicable. Together with a positive daily_total enables
21+
//! the split layout on color.
22+
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer,
23+
const char *value_text,
24+
int32_t daily_total,
25+
int32_t bin_minute);

tests/fw/apps/system_apps/health/test_health_activity_summary_card.c

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ GContext *graphics_context_get_current_context(void) {
1919
return &s_ctx;
2020
}
2121

22+
// Override fake_clock's WEAK default so the split layout's bin time renders
23+
// in a stable 24h format.
24+
bool clock_is_24h_style(void) {
25+
return true;
26+
}
27+
2228
void test_health_activity_summary_card__initialize(void) {
29+
// Pin RTC to 2024-01-06 16:19:35 UTC (Saturday 16:19 -> bin minute 975 = 16:15).
30+
rtc_set_time(1704557975);
31+
2332
// Setup graphics context
2433
framebuffer_init(&s_fb, &(GSize) {DISP_COLS, DISP_ROWS});
2534
framebuffer_clear(&s_fb);
@@ -79,6 +88,7 @@ void test_health_activity_summary_card__no_current_steps(void) {
7988
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
8089
10, 10, 10, 10, 10, 50}, // 1000
8190
.current_step_average = 750,
91+
.step_average_last_updated_time = 975,
8292
};
8393

8494
prv_create_card_and_render(&health_data);
@@ -99,6 +109,7 @@ void test_health_activity_summary_card__render_current_behind_typical1(void) {
99109
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
100110
10, 10, 10, 10, 10, 50}, // 1000
101111
.current_step_average = 340,
112+
.step_average_last_updated_time = 975,
102113
};
103114

104115
prv_create_card_and_render(&health_data);
@@ -119,6 +130,7 @@ void test_health_activity_summary_card__render_current_behind_typical2(void) {
119130
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
120131
10, 10, 10, 10, 10, 50}, // 1000
121132
.current_step_average = 340,
133+
.step_average_last_updated_time = 975,
122134
};
123135

124136
prv_create_card_and_render(&health_data);
@@ -139,6 +151,7 @@ void test_health_activity_summary_card__render_current_behind_typical3(void) {
139151
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
140152
10, 10, 10, 10, 10, 50}, // 1000
141153
.current_step_average = 555,
154+
.step_average_last_updated_time = 975,
142155
};
143156

144157
prv_create_card_and_render(&health_data);
@@ -159,6 +172,7 @@ void test_health_activity_summary_card__render_current_behind_typical4(void) {
159172
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
160173
10, 10, 10, 10, 10, 50}, // 1000
161174
.current_step_average = 840,
175+
.step_average_last_updated_time = 975,
162176
};
163177

164178
prv_create_card_and_render(&health_data);
@@ -179,6 +193,7 @@ void test_health_activity_summary_card__render_current_behind_typical5(void) {
179193
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
180194
10, 10, 10, 10, 10, 50}, // 1000
181195
.current_step_average = 914,
196+
.step_average_last_updated_time = 975,
182197
};
183198

184199
prv_create_card_and_render(&health_data);
@@ -199,6 +214,7 @@ void test_health_activity_summary_card__render_current_equals_typical(void) {
199214
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
200215
10, 10, 10, 10, 10, 50}, // 1000
201216
.current_step_average = 837,
217+
.step_average_last_updated_time = 975,
202218
};
203219

204220
prv_create_card_and_render(&health_data);
@@ -219,6 +235,7 @@ void test_health_activity_summary_card__render_current_above_typical1(void) {
219235
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
220236
10, 10, 10, 10, 10, 50}, // 1000
221237
.current_step_average = 170,
238+
.step_average_last_updated_time = 975,
222239
};
223240

224241
prv_create_card_and_render(&health_data);
@@ -239,6 +256,7 @@ void test_health_activity_summary_card__render_current_above_typical2(void) {
239256
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
240257
10, 10, 10, 10, 10, 50}, // 1000
241258
.current_step_average = 379,
259+
.step_average_last_updated_time = 975,
242260
};
243261

244262
prv_create_card_and_render(&health_data);
@@ -259,6 +277,7 @@ void test_health_activity_summary_card__render_current_above_typical3(void) {
259277
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
260278
10, 10, 10, 10, 10, 50}, // 1000
261279
.current_step_average = 480,
280+
.step_average_last_updated_time = 975,
262281
};
263282

264283
prv_create_card_and_render(&health_data);
@@ -279,6 +298,7 @@ void test_health_activity_summary_card__render_current_above_typical4(void) {
279298
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
280299
10, 10, 10, 10, 10, 50}, // 1000
281300
.current_step_average = 700,
301+
.step_average_last_updated_time = 975,
282302
};
283303

284304
prv_create_card_and_render(&health_data);
@@ -299,6 +319,7 @@ void test_health_activity_summary_card__render_current_above_typical5(void) {
299319
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
300320
10, 10, 10, 10, 10, 50}, // 1000
301321
.current_step_average = 900,
322+
.step_average_last_updated_time = 975,
302323
};
303324

304325
prv_create_card_and_render(&health_data);
@@ -319,6 +340,7 @@ void test_health_activity_summary_card__render_current_above_expected(void) {
319340
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
320341
10, 10, 10, 10, 10, 50}, // 1000
321342
.current_step_average = 800,
343+
.step_average_last_updated_time = 975,
322344
};
323345

324346
prv_create_card_and_render(&health_data);
240 Bytes
246 Bytes
234 Bytes

0 commit comments

Comments
 (0)