// Desk Command Slab — ESP32-C6-LCD-1.47 (172x320, LCD-only, no touch/IMU)
// Short BOOT (GPIO9) cycles: clock → weather → message
// Hold BOOT ~0.7s = portrait (172×320) / landscape (320×172)
// RGB GPIO8: blue clock, cyan weather, amber message
// BL GPIO22 ≤ 50%. Web: GET /  POST /msg  (STA or Soft-AP "DeskCommand")
//
// Flash:  cd board-sketches/desk-command-slab/firmware && pio run -t upload
// Serial: 115200 — help | status | page N | rotate
// Sim:    python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/desk-command-slab

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <time.h>
#include <Adafruit_NeoPixel.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include "../../../shared/c6_slab_pins.h"

#define BOOT_LONG_MS 700UL

// Fill these in to join your 2.4 GHz network. Leave empty to stay on Soft-AP
// "DeskCommand" (no password) so you can POST /msg from a browser.
#define WIFI_SSID ""   // YOUR_WIFI_SSID_HERE
#define WIFI_PASSWORD ""  // YOUR_WIFI_PASSWORD_HERE
#define AP_SSID "DeskCommand"

#define WEATHER_LAT 41.8781
#define WEATHER_LON -87.6298
#define WEATHER_FAHRENHEIT 1

#define TZ_OFFSET_HOURS -6
#define DST_OFFSET_HOURS 1

#define MSG_HOLD_MS 60000UL
#define MSG_MAX_LEN 140
#define WEATHER_TTL_MS 900000UL

enum Page : uint8_t { PAGE_CLOCK = 0, PAGE_WEATHER = 1, PAGE_MSG = 2, PAGE_COUNT = 3 };

static const uint16_t COL_BG = 0x0884;
static const uint16_t COL_TEXT = 0xEF7F;
static const uint16_t COL_MUTED = 0x7C10;
static const uint16_t COL_BLUE = 0x3BFF;
static const uint16_t COL_CYAN = 0x269D;
static const uint16_t COL_AMBER = 0xF524;

Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
WebServer server(80);
Preferences prefs;

static Page page = PAGE_CLOCK;
static Page pageBeforeMsg = PAGE_CLOCK;
static bool dirty = true;
static bool g_landscape = false;
static bool g_bootDown = false;
static bool g_bootLong = false;
static unsigned long g_bootDownAt = 0;
static unsigned long lastClockTick = 0;
static char lastTimeKey[8] = "";
static char lastDateKey[12] = "";
static unsigned long lastHoldShown = 0;

static int sw() { return gfx->width(); }
static int sh() { return gfx->height(); }

static void applyOrient() { gfx->setRotation(g_landscape ? 1 : 0); }

static void persistOrient() { prefs.putUChar("land", g_landscape ? 1 : 0); }

static String customMsg;
static unsigned long msgHoldUntil = 0;

static bool weatherOk = true;
static float weatherTemp = 52;
static int weatherCode = 2;
static int weatherWind = 12;
static int weatherRh = 55;
static unsigned long weatherFetchedAt = 0;
static bool weatherFetchDue = true;

static time_t fallbackEpoch = 0;
static unsigned long fallbackMillis = 0;
static bool staOk = false;
static String ipLabel = "offline";

static uint16_t accentFor(Page p) {
  if (p == PAGE_WEATHER) return COL_CYAN;
  if (p == PAGE_MSG) return COL_AMBER;
  return COL_BLUE;
}

static void setRgbFor(Page p) {
  uint8_t r = 0, g = 0, b = 0;
  if (p == PAGE_CLOCK) {
    b = 220;
  } else if (p == PAGE_WEATHER) {
    g = 200;
    b = 220;
  } else {
    r = 240;
    g = 150;
  }
  rgb.setPixelColor(0, rgb.Color(r, g, b));
  rgb.show();
}

static const char *pageName(Page p) {
  if (p == PAGE_WEATHER) return "WEATHER";
  if (p == PAGE_MSG) return "MESSAGE";
  return "CLOCK";
}

static const char *wmoText(int code) {
  if (code == 0) return "Clear";
  if (code <= 3) return "Partly cloudy";
  if (code <= 48) return "Fog";
  if (code <= 57) return "Drizzle";
  if (code <= 67) return "Rain";
  if (code <= 77) return "Snow";
  if (code <= 82) return "Showers";
  if (code <= 86) return "Snow showers";
  if (code <= 99) return "Thunder";
  return "Unknown";
}

static void initFallbackClock() {
  static const char kMonths[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
  char mon[4] = {0};
  int day = 1, year = 2026, h = 0, mi = 0, s = 0;
  sscanf(__DATE__, "%3s %d %d", mon, &day, &year);
  sscanf(__TIME__, "%d:%d:%d", &h, &mi, &s);
  struct tm t = {};
  const char *found = strstr(kMonths, mon);
  t.tm_mon = found ? (int)(found - kMonths) / 3 : 0;
  t.tm_mday = day;
  t.tm_year = year - 1900;
  t.tm_hour = h;
  t.tm_min = mi;
  t.tm_sec = s;
  t.tm_isdst = -1;
  fallbackEpoch = mktime(&t);
  fallbackMillis = millis();
}

static time_t nowEpoch() {
  time_t n = time(nullptr);
  if (n > 1700000000UL) return n;
  if (!fallbackEpoch) return 0;
  return fallbackEpoch + (time_t)((millis() - fallbackMillis) / 1000UL);
}

static bool nowTm(struct tm *out) {
  time_t n = nowEpoch();
  if (!n) return false;
  localtime_r(&n, out);
  return true;
}

static int glyphW(int size) { return 6 * size; }

static void printLeft(int x, int y, int size, uint16_t col, const char *s) {
  gfx->setTextSize(size);
  gfx->setTextColor(col);
  gfx->setCursor(x, y);
  gfx->print(s);
}

static void printCenter(int y, int size, uint16_t col, const char *s) {
  int x = (sw() - (int)strlen(s) * glyphW(size)) / 2;
  if (x < 4) {
    x = 4;
  }
  printLeft(x, y, size, col, s);
}

static void drawWrapped(const String &text, int x, int y, int maxW, int lineH, int size) {
  gfx->setTextSize(size);
  String line;
  int start = 0;
  const int n = (int)text.length();
  while (start <= n) {
    int sp = text.indexOf(' ', start);
    if (sp < 0) sp = n;
    String word = text.substring(start, sp);
    String trial = line.length() ? line + " " + word : word;
    int16_t x1, y1;
    uint16_t w, h;
    gfx->getTextBounds(trial.c_str(), 0, 0, &x1, &y1, &w, &h);
    if (w > (uint16_t)maxW && line.length()) {
      gfx->setCursor(x, y);
      gfx->print(line);
      y += lineH;
      line = word;
      if (y + lineH > sh()) {
        return;
      }
    } else {
      line = trial;
    }
    if (sp >= n) {
      break;
    }
    start = sp + 1;
  }
  if (line.length() && y + 4 < sh()) {
    gfx->setCursor(x, y);
    gfx->print(line);
  }
}

static void drawChrome(Page p, bool title) {
  gfx->fillScreen(COL_BG);
  gfx->fillRect(0, 0, sw(), 8, accentFor(p));
  if (!title) {
    return;
  }
  gfx->setTextSize(2);
  gfx->setTextColor(accentFor(p));
  gfx->setCursor(8, 16);
  gfx->print(pageName(p));
}

static bool clockKeys(char *timeKey, int tn, char *dateKey, int dn) {
  struct tm t;
  if (!nowTm(&t)) {
    strncpy(timeKey, "wait", tn);
    strncpy(dateKey, "wait", dn);
    timeKey[tn - 1] = 0;
    dateKey[dn - 1] = 0;
    return false;
  }
  strftime(timeKey, tn, "%I%M%p", &t);
  strftime(dateKey, dn, "%Y%m%d", &t);
  return true;
}

static void rememberClockKeys() {
  clockKeys(lastTimeKey, sizeof(lastTimeKey), lastDateKey, sizeof(lastDateKey));
}

static void paintClockTimeOnly() {
  struct tm t;
  if (!nowTm(&t)) {
    return;
  }
  char hm[8];
  char ap[4];
  strftime(hm, sizeof(hm), "%I:%M", &t);
  const char *hms = (hm[0] == '0' || hm[0] == ' ') ? hm + 1 : hm;
  strftime(ap, sizeof(ap), "%p", &t);
  if (g_landscape) {
    gfx->fillRect(0, 36, sw(), 56, COL_BG);
    printLeft(8, 36, 6, COL_TEXT, hms);
    printLeft(12 + (int)strlen(hms) * glyphW(6), 44, 3, COL_BLUE, ap);
  } else {
    gfx->fillRect(0, 52, sw(), 40, COL_BG);
    printCenter(52, 5, COL_TEXT, hms);
    gfx->fillRect(0, 108, sw(), 24, COL_BG);
    printCenter(108, 3, COL_BLUE, ap);
  }
  rememberClockKeys();
}

static void paintHoldCount() {
  if (!msgHoldUntil || millis() >= msgHoldUntil) {
    return;
  }
  unsigned long left = (msgHoldUntil - millis() + 999UL) / 1000UL;
  if (left == lastHoldShown) {
    return;
  }
  lastHoldShown = left;
  char hold[8];
  snprintf(hold, sizeof(hold), "%lus", left);
  const int size = 2;
  const int boxW = 4 * glyphW(size);
  const int x = sw() - 8 - boxW;
  gfx->fillRect(x, 16, boxW, 8 * size, COL_BG);
  printLeft(sw() - 8 - (int)strlen(hold) * glyphW(size), 16, 2, COL_AMBER, hold);
}

static void drawClock() {
  drawChrome(PAGE_CLOCK, true);
  struct tm t;
  if (!nowTm(&t)) {
    printCenter(g_landscape ? 56 : 120, 3, COL_TEXT, "Waiting");
    printCenter(g_landscape ? 88 : 156, 3, COL_TEXT, "for time");
    rememberClockKeys();
    return;
  }

  char hm[8];
  char ap[4];
  char day[8];
  char md[12];
  char year[8];
  strftime(hm, sizeof(hm), "%I:%M", &t);
  const char *hms = (hm[0] == '0' || hm[0] == ' ') ? hm + 1 : hm;
  strftime(ap, sizeof(ap), "%p", &t);
  strftime(day, sizeof(day), "%a", &t);
  strftime(md, sizeof(md), "%d %b", &t);
  strftime(year, sizeof(year), "%Y", &t);

  if (g_landscape) {
    printLeft(8, 36, 6, COL_TEXT, hms);
    const int timeW = (int)strlen(hms) * glyphW(6);
    printLeft(12 + timeW, 44, 3, COL_BLUE, ap);
    printLeft(8, 100, 3, COL_TEXT, day);
    printLeft(8 + (int)strlen(day) * glyphW(3) + 12, 100, 3, COL_TEXT, md);
    printLeft(8 + (int)strlen(day) * glyphW(3) + 12 + (int)strlen(md) * glyphW(3) + 12,
              108, 2, COL_MUTED, year);
    rememberClockKeys();
    return;
  }

  printCenter(52, 5, COL_TEXT, hms);
  printCenter(108, 3, COL_BLUE, ap);
  printCenter(168, 3, COL_TEXT, day);
  printCenter(204, 3, COL_TEXT, md);
  printCenter(248, 2, COL_MUTED, year);
  rememberClockKeys();
}

static void drawWeather() {
  drawChrome(PAGE_WEATHER, false);
  char tempBuf[12];
  snprintf(tempBuf, sizeof(tempBuf), "%d%c", (int)lroundf(weatherTemp),
           WEATHER_FAHRENHEIT ? 'F' : 'C');
  char windBuf[16];
  snprintf(windBuf, sizeof(windBuf), "WIND %d", weatherWind);
  char rhBuf[16];
  snprintf(rhBuf, sizeof(rhBuf), "RH %d%%", weatherRh);
  const char *cond = wmoText(weatherCode);

  if (g_landscape) {
    printLeft(8, 16, 6, COL_TEXT, tempBuf);
    gfx->setTextColor(COL_CYAN);
    drawWrapped(String(cond), 8, 76, sw() - 16, 28, 3);
    printLeft(8, 140, 2, COL_TEXT, windBuf);
    printLeft(8 + (int)strlen(windBuf) * glyphW(2) + 16, 140, 2, COL_TEXT, rhBuf);
    return;
  }

  printCenter(28, 7, COL_TEXT, tempBuf);
  gfx->setTextColor(COL_CYAN);
  {
    const int size = 3;
    const int maxW = sw() - 12;
    String line;
    int y = 108;
    const String text = String(cond);
    int start = 0;
    const int n = (int)text.length();
    gfx->setTextSize(size);
    while (start <= n) {
      int sp = text.indexOf(' ', start);
      if (sp < 0) {
        sp = n;
      }
      String word = text.substring(start, sp);
      String trial = line.length() ? line + " " + word : word;
      int tw = (int)trial.length() * glyphW(size);
      if (tw > maxW && line.length()) {
        printCenter(y, size, COL_CYAN, line.c_str());
        y += 32;
        line = word;
      } else {
        line = trial;
      }
      if (sp >= n) {
        break;
      }
      start = sp + 1;
    }
    if (line.length()) {
      printCenter(y, size, COL_CYAN, line.c_str());
    }
  }
  printCenter(228, 3, COL_TEXT, windBuf);
  printCenter(268, 3, COL_TEXT, rhBuf);
}

static void drawMessage() {
  drawChrome(PAGE_MSG, true);
  const int wrapY = g_landscape ? 44 : 52;
  const int lineH = 28;
  const int size = 3;
  if (!customMsg.length()) {
    gfx->setTextColor(COL_TEXT);
    drawWrapped("Send a message from the web UI", 8, wrapY, sw() - 16, lineH, size);
    return;
  }
  if (msgHoldUntil && millis() < msgHoldUntil) {
    unsigned long left = (msgHoldUntil - millis() + 999UL) / 1000UL;
    char hold[8];
    snprintf(hold, sizeof(hold), "%lus", left);
    lastHoldShown = left;
    printLeft(sw() - 8 - (int)strlen(hold) * glyphW(2), 16, 2, COL_AMBER, hold);
  }
  gfx->setTextColor(COL_TEXT);
  drawWrapped(customMsg, 8, wrapY, sw() - 16, lineH, size);
}

static void render() {
  setRgbFor(page);
  if (page == PAGE_CLOCK) {
    drawClock();
  } else if (page == PAGE_WEATHER) {
    drawWeather();
  } else {
    drawMessage();
  }
  dirty = false;
}

static void showMessage(const String &raw) {
  String next = raw;
  next.trim();
  if (next.length() > MSG_MAX_LEN) {
    next = next.substring(0, MSG_MAX_LEN);
  }
  if (!next.length()) return;
  if (page != PAGE_MSG) {
    pageBeforeMsg = page;
  }
  customMsg = next;
  msgHoldUntil = millis() + MSG_HOLD_MS;
  page = PAGE_MSG;
  dirty = true;
}

static const char *kFormHtml =
    "<!DOCTYPE html><html><head><meta name=viewport content='width=device-width,initial-scale=1'>"
    "<title>Desk Command</title><style>"
    "body{font-family:sans-serif;background:#0B1020;color:#E8EEFC;padding:16px;max-width:28rem}"
    "h1{font-size:1.2rem;color:#3D7CFF}input,button{font-size:18px;padding:10px;width:100%;"
    "box-sizing:border-box;margin:8px 0;border-radius:8px;border:0}"
    "input{background:#151b30;color:#E8EEFC}button{background:#F5A623;color:#111;font-weight:700}"
    "</style></head><body><h1>Desk Command</h1>"
    "<form method=POST action=/msg>"
    "<input name=msg maxlength=140 placeholder='Message for the slab' autofocus>"
    "<button type=submit>Show on slab</button></form>"
    "<p style=color:#8aa>Shown 60s or until BOOT.</p></body></html>";

static const char *kOkHtml =
    "<!DOCTYPE html><html><head><meta name=viewport content='width=device-width,initial-scale=1'>"
    "<title>Desk Command</title></head><body style='font-family:sans-serif;background:#0B1020;color:#E8EEFC;padding:16px'>"
    "<p>On the slab for 60s.</p><p><a href=/ style=color:#F5A623>Send another</a></p></body></html>";

static void handleRoot() { server.send(200, "text/html", kFormHtml); }

static void handleMsg() {
  showMessage(server.arg("msg"));
  if (!customMsg.length()) {
    server.send(400, "text/plain", "empty msg");
    return;
  }
  server.send(200, "text/html", kOkHtml);
}

static void setupWifi() {
  if (strlen(WIFI_SSID) > 0) {
    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    unsigned long start = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - start < 8000UL) {
      delay(200);
    }
  }
  staOk = WiFi.status() == WL_CONNECTED;
  if (staOk) {
    ipLabel = WiFi.localIP().toString();
    configTime((long)TZ_OFFSET_HOURS * 3600L, (long)DST_OFFSET_HOURS * 3600L,
               "pool.ntp.org", "time.nist.gov");
    Serial.print("STA ");
    Serial.println(ipLabel);
  } else {
    WiFi.mode(WIFI_AP);
    WiFi.softAP(AP_SSID);
    ipLabel = String("AP ") + WiFi.softAPIP().toString();
    Serial.print("Soft-AP ");
    Serial.print(AP_SSID);
    Serial.print(" ");
    Serial.println(WiFi.softAPIP());
  }
}

static void fetchWeather() {
  if (!staOk) {
    weatherFetchDue = false;
    return;
  }
  char url[256];
  snprintf(url, sizeof(url),
           "https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f"
           "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
           "&temperature_unit=%s&timezone=auto",
           WEATHER_LAT, WEATHER_LON, WEATHER_FAHRENHEIT ? "fahrenheit" : "celsius");

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(8000);
  if (!http.begin(client, url)) {
    Serial.println("weather begin fail");
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  int code = http.GET();
  if (code != 200) {
    Serial.printf("weather HTTP %d\n", code);
    http.end();
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) {
    Serial.println(err.c_str());
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  JsonObject cur = doc["current"];
  if (cur.isNull()) return;
  weatherTemp = cur["temperature_2m"] | 0.0f;
  weatherCode = cur["weather_code"] | 0;
  weatherWind = (int)lroundf(cur["wind_speed_10m"] | 0.0f);
  weatherRh = cur["relative_humidity_2m"] | 0;
  weatherOk = true;
  weatherFetchedAt = millis();
  weatherFetchDue = false;
  if (page == PAGE_WEATHER) dirty = true;
  Serial.printf("weather %d %s\n", (int)lroundf(weatherTemp), wmoText(weatherCode));
}

static void toggleOrient() {
  g_landscape = !g_landscape;
  applyOrient();
  persistOrient();
  dirty = true;
  Serial.printf("orient %s\n", g_landscape ? "landscape" : "portrait");
}

static void printHelp() {
  Serial.println(F("Desk Command Slab"));
  Serial.println(F("  help    this list"));
  Serial.println(F("  status  wifi / page / message"));
  Serial.println(F("  page N  0 clock | 1 weather | 2 message"));
  Serial.println(F("  rotate  portrait <-> landscape"));
  Serial.println(F("BOOT short = next page. Hold BOOT = rotate."));
}

static void printStatus() {
  Serial.print(F("page: "));
  Serial.println(pageName(page));
  Serial.printf("orient: %s  %dx%d\n", g_landscape ? "landscape" : "portrait", sw(), sh());
  Serial.print(F("wifi: "));
  Serial.println(staOk ? String("STA ") + ipLabel : ipLabel);
  Serial.print(F("rgb: "));
  Serial.println(page == PAGE_WEATHER ? "cyan" : page == PAGE_MSG ? "amber" : "blue");
  struct tm t;
  if (nowTm(&t)) {
    char buf[24];
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &t);
    Serial.print(F("time: "));
    Serial.println(buf);
  } else {
    Serial.println(F("time: unset"));
  }
  Serial.print(F("weather: "));
  if (weatherOk) {
    Serial.print((int)lroundf(weatherTemp));
    Serial.print(WEATHER_FAHRENHEIT ? "F " : "C ");
    Serial.println(wmoText(weatherCode));
  } else {
    Serial.println(F("none"));
  }
  Serial.print(F("message: "));
  Serial.println(customMsg.length() ? customMsg : "(none)");
  if (msgHoldUntil && millis() < msgHoldUntil) {
    Serial.print(F("hold: "));
    Serial.print((msgHoldUntil - millis()) / 1000UL);
    Serial.println("s");
  }
}

static void pollSerial() {
  if (!Serial.available()) return;
  String line = Serial.readStringUntil('\n');
  line.trim();
  line.toLowerCase();
  if (line == "help" || line == "?") {
    printHelp();
  } else if (line == "status") {
    printStatus();
  } else if (line.startsWith("page")) {
    int n = line.substring(4).toInt();
    if (n >= 0 && n < PAGE_COUNT) {
      page = (Page)n;
      dirty = true;
    }
  } else if (line == "rotate" || line == "orient") {
    toggleOrient();
  } else if (line.length()) {
    printHelp();
  }
}

static void nextPage() {
  msgHoldUntil = 0;
  page = (Page)((page + 1) % PAGE_COUNT);
  dirty = true;
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const unsigned long now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    toggleOrient();
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) {
      nextPage();
    }
    g_bootDown = false;
  }
}

static void pollMsgHold() {
  if (page != PAGE_MSG || !msgHoldUntil) return;
  if (millis() < msgHoldUntil) return;
  msgHoldUntil = 0;
  page = pageBeforeMsg;
  dirty = true;
}

void setup() {
  Serial.begin(115200);
  delay(200);
  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  rgb.begin();
  rgb.setBrightness(40);
  rgb.clear();
  rgb.show();

  prefs.begin("deskcmd", false);
  g_landscape = prefs.getUChar("land", 0) != 0;

  if (!gfx->begin()) {
    Serial.println(F("GFX begin failed"));
  }
  gfx->setTextWrap(false);
  applyOrient();

  initFallbackClock();
  setupWifi();
  server.on("/", HTTP_GET, handleRoot);
  server.on("/msg", HTTP_POST, handleMsg);
  server.begin();

  setRgbFor(page);
  dirty = true;
  printHelp();
}

void loop() {
  server.handleClient();
  pollBoot();
  pollSerial();
  pollMsgHold();

  if (weatherFetchDue || (staOk && weatherFetchedAt && millis() - weatherFetchedAt > WEATHER_TTL_MS)) {
    fetchWeather();
  }

  unsigned long now = millis();
  if (page == PAGE_CLOCK && now - lastClockTick >= 1000UL) {
    lastClockTick = now;
    char timeKey[8];
    char dateKey[12];
    clockKeys(timeKey, sizeof(timeKey), dateKey, sizeof(dateKey));
    if (strcmp(dateKey, lastDateKey) != 0) {
      dirty = true;
    } else if (strcmp(timeKey, lastTimeKey) != 0) {
      paintClockTimeOnly();
    }
  }
  if (page == PAGE_MSG && msgHoldUntil && now - lastClockTick >= 250UL) {
    lastClockTick = now;
    paintHoldCount();
  }
  if (dirty) render();
}
