diff --git a/openapi.yaml b/openapi.yaml index dc88351..3afea31 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -31,6 +31,8 @@ tags: description: Proclamation content, optionally scoped by license entitlement. - name: License description: License key validation. + - name: Schedule + description: A user's daily listening schedule, consumed by the kiosk. - name: Automations description: Signed machine-to-machine webhooks. paths: @@ -230,6 +232,83 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorMessage' + /api/v2/schedule: + get: + tags: [Schedule] + summary: Get a user's listening schedule (query-param auth) + description: | + Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via + the paired `email` + `licenseKey` **query parameters** and validates only + that the key belongs to the account for that email. License + active/entitlement is deliberately not checked here: an invalid license + would already lock the kiosk out, so this endpoint only checks the + pairing. + + Returns every Block with its start/end times and the ordered list of + selected Proclamation IDs. Overnight blocks (an end clock time before + the start) are supported and flagged with `overnight: true` and a + wrapping `durationMinutes`. Each block also carries `dynamicAudio`; + when true, the kiosk enables dynamic audio for the block's duration, + overriding any audio selections made on the device. Proclamation + content is not included; the kiosk fetches it separately via + `/api/v2/proclamations`. + operationId: getSchedule + parameters: + - in: query + name: email + required: true + schema: + type: string + format: email + description: Account email. + - in: query + name: licenseKey + required: true + schema: + type: string + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + description: License key. The legacy alias `key` is also accepted. + responses: + '200': + description: The user's schedule (possibly empty). + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + example: + email: jane@example.com + blocks: + - id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f + startTime: '21:00' + startMinutes: 1260 + endTime: '09:00' + endMinutes: 540 + overnight: true + durationMinutes: 720 + dynamicAudio: true + proclamationIds: + - b40fdcb9-703c-420e-af32-a081964c6f08 + - 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b + '400': + description: | + Missing/invalid parameters, unknown key or user, or the key does not + belong to the email's account. Possible messages: `not-authorized`, + `this key doesn't appear to belong to you`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '429': + description: Rate limit exceeded (10 requests per minute per IP). + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' /api/v2/automations/create/account: post: tags: [Automations] @@ -436,6 +515,72 @@ components: email_address: type: string format: email + ScheduleBlock: + type: object + required: + [ + id, + startTime, + startMinutes, + endTime, + endMinutes, + overnight, + durationMinutes, + dynamicAudio, + proclamationIds + ] + properties: + id: + type: string + format: uuid + startTime: + type: string + example: '06:00' + description: Wall-clock start time as `HH:MM`. + startMinutes: + type: integer + minimum: 0 + maximum: 1440 + description: Minutes from midnight. + endTime: + type: string + example: '08:00' + description: Wall-clock end time as `HH:MM`. + endMinutes: + type: integer + minimum: 0 + maximum: 1440 + description: Minutes from midnight. + overnight: + type: boolean + description: True when the end clock time is at or before the start, i.e. the block crosses midnight. + durationMinutes: + type: integer + minimum: 1 + description: Block length in minutes. An overnight block counts the wrap past midnight as the next day. + dynamicAudio: + type: boolean + description: | + When true, dynamic audio is enabled for the block's duration, overriding any + audio selections made on the device while the block is active. + proclamationIds: + type: array + description: IDs of the selected Proclamations, in play order. + items: + type: string + format: uuid + ScheduleResponse: + type: object + required: [email, blocks] + properties: + email: + type: string + format: email + blocks: + type: array + maxItems: 4 + items: + $ref: '#/components/schemas/ScheduleBlock' OnboardingRequest: type: object required: [email] diff --git a/src/config/app_config.h b/src/config/app_config.h index 1a2169c..b8d04d5 100644 --- a/src/config/app_config.h +++ b/src/config/app_config.h @@ -16,6 +16,7 @@ struct ProclamationConfig { float scroll_speed = 1.0f; // scroll speed multiplier int font_size = 32; // font size in pt std::string current_id = ""; // currently active proclamation ID + bool schedule_active = false; // follow the portal's listening schedule }; struct AppConfig { diff --git a/src/config/config_manager.cpp b/src/config/config_manager.cpp index 1150777..77cbcbf 100644 --- a/src/config/config_manager.cpp +++ b/src/config/config_manager.cpp @@ -250,6 +250,9 @@ void ConfigManager::loadAndMergeDefaults() { if (proc_node.hasChild("current_id")) { merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.current_id); } + if (proc_node.hasChild("schedule_active")) { + merged.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(defaults.proclamation.schedule_active); + } } { diff --git a/src/config/yaml_parser.cpp b/src/config/yaml_parser.cpp index cc8b650..2ec71fc 100644 --- a/src/config/yaml_parser.cpp +++ b/src/config/yaml_parser.cpp @@ -231,6 +231,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) { if (proc_node.hasChild("current_id")) { config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.current_id); } + if (proc_node.hasChild("schedule_active")) { + config.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(config.proclamation.schedule_active); + } } return config; @@ -256,6 +259,7 @@ std::string serializeConfigToYaml(const AppConfig& config) { ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n"; ss << " font_size: " << config.proclamation.font_size << "\n"; ss << " current_id: \"" << config.proclamation.current_id << "\"\n"; + ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n"; return ss.str(); } diff --git a/src/db/database_manager.cpp b/src/db/database_manager.cpp index 0b6b923..058c0da 100644 --- a/src/db/database_manager.cpp +++ b/src/db/database_manager.cpp @@ -181,6 +181,23 @@ bool DatabaseManager::runMigrations() { hex TEXT NOT NULL, PRIMARY KEY (solfeggio_frequency_id, color_index) ); + + CREATE TABLE IF NOT EXISTS schedule_blocks ( + id TEXT PRIMARY KEY, + start_minutes INTEGER NOT NULL DEFAULT 0, + end_minutes INTEGER NOT NULL DEFAULT 0, + overnight INTEGER NOT NULL DEFAULT 0, + duration_minutes INTEGER NOT NULL DEFAULT 0, + dynamic_audio INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS schedule_block_proclamations ( + block_id TEXT NOT NULL, + position INTEGER NOT NULL, + proclamation_id TEXT NOT NULL, + PRIMARY KEY (block_id, position) + ); )"; bool ok = executeSql(schema); @@ -1083,4 +1100,115 @@ std::vector DatabaseManager::getAudioFiles() { return result; } +// ─── Schedule Blocks Table ───────────────────────────────────────────── + +bool DatabaseManager::replaceScheduleBlocks(const std::vector& records) { + std::lock_guard lock(mutex_); + if (!db_) return false; + + if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) { + return false; + } + + bool ok = true; + if (sqlite3_exec(db_, "DELETE FROM schedule_block_proclamations;", nullptr, nullptr, nullptr) != SQLITE_OK || + sqlite3_exec(db_, "DELETE FROM schedule_blocks;", nullptr, nullptr, nullptr) != SQLITE_OK) { + sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr); + return false; + } + + const char* blockSql = "INSERT INTO schedule_blocks " + "(id, start_minutes, end_minutes, overnight, duration_minutes, " + " dynamic_audio, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?, ?);"; + const char* procSql = "INSERT INTO schedule_block_proclamations " + "(block_id, position, proclamation_id) VALUES (?, ?, ?);"; + + for (size_t i = 0; i < records.size() && ok; ++i) { + const ScheduleBlockRecord& rec = records[i]; + + sqlite3_stmt* blockStmt = nullptr; + if (sqlite3_prepare_v2(db_, blockSql, -1, &blockStmt, nullptr) != SQLITE_OK) { + ok = false; + break; + } + sqlite3_bind_text(blockStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(blockStmt, 2, rec.start_minutes); + sqlite3_bind_int(blockStmt, 3, rec.end_minutes); + sqlite3_bind_int(blockStmt, 4, rec.overnight ? 1 : 0); + sqlite3_bind_int(blockStmt, 5, rec.duration_minutes); + sqlite3_bind_int(blockStmt, 6, rec.dynamic_audio ? 1 : 0); + sqlite3_bind_int(blockStmt, 7, static_cast(i)); + if (sqlite3_step(blockStmt) != SQLITE_DONE) ok = false; + sqlite3_finalize(blockStmt); + if (!ok) break; + + for (size_t p = 0; p < rec.proclamation_ids.size(); ++p) { + sqlite3_stmt* procStmt = nullptr; + if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) { + ok = false; + break; + } + sqlite3_bind_text(procStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(procStmt, 2, static_cast(p)); + sqlite3_bind_text(procStmt, 3, rec.proclamation_ids[p].c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(procStmt) != SQLITE_DONE) ok = false; + sqlite3_finalize(procStmt); + if (!ok) break; + } + } + + if (ok) { + sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr); + } else { + sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr); + } + return ok; +} + +std::vector DatabaseManager::getScheduleBlocks() { + std::lock_guard lock(mutex_); + std::vector result; + if (!db_) return result; + + const char* sql = "SELECT id, start_minutes, end_minutes, overnight, duration_minutes, " + "dynamic_audio FROM schedule_blocks ORDER BY sort_order ASC;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) { + return result; + } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + ScheduleBlockRecord rec; + rec.id = reinterpret_cast(sqlite3_column_text(stmt, 0)); + rec.start_minutes = sqlite3_column_int(stmt, 1); + rec.end_minutes = sqlite3_column_int(stmt, 2); + rec.overnight = (sqlite3_column_int(stmt, 3) != 0); + rec.duration_minutes = sqlite3_column_int(stmt, 4); + rec.dynamic_audio = (sqlite3_column_int(stmt, 5) != 0); + result.push_back(std::move(rec)); + } + sqlite3_finalize(stmt); + + // Attach each block's proclamation ids in play order. + const char* procSql = "SELECT proclamation_id FROM schedule_block_proclamations " + "WHERE block_id = ? ORDER BY position ASC;"; + for (auto& block : result) { + sqlite3_stmt* procStmt = nullptr; + if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) { + break; + } + sqlite3_bind_text(procStmt, 1, block.id.c_str(), -1, SQLITE_TRANSIENT); + while (sqlite3_step(procStmt) == SQLITE_ROW) { + const unsigned char* text = sqlite3_column_text(procStmt, 0); + if (text) { + block.proclamation_ids.push_back(reinterpret_cast(text)); + } + } + sqlite3_finalize(procStmt); + } + + return result; +} + } // namespace radiantwave::db diff --git a/src/db/database_manager.h b/src/db/database_manager.h index 21c5a03..71d07fe 100644 --- a/src/db/database_manager.h +++ b/src/db/database_manager.h @@ -54,6 +54,18 @@ struct AudioFileRecord { int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency }; +// One scheduled listening block, mirrored from GET /schedule. `proclamation_ids` +// is the ordered play list for the block. +struct ScheduleBlockRecord { + std::string id; + int start_minutes = 0; + int end_minutes = 0; + bool overnight = false; + int duration_minutes = 0; + bool dynamic_audio = true; // override the device audio for this block + std::vector proclamation_ids; +}; + // A single color in a solfeggio frequency's color palette (RGBA). struct PaletteColor { uint8_t r = 0; @@ -154,6 +166,16 @@ public: // palettes can be pushed from the portal once the API route exists. bool replaceColorPalettes(const std::vector& records); + // ── Schedule Blocks Table ───────────────────────────────────────── + // Full sync: replaces the stored schedule with `records` (the portal is the + // source of truth). Pass an empty vector to clear the schedule. Block order + // and per-block proclamation order are preserved exactly as given. + bool replaceScheduleBlocks(const std::vector& records); + + // Fetch all schedule blocks, ordered as stored, with their proclamation ids + // in play order. + std::vector getScheduleBlocks(); + // ── Event Logging Table ─────────────────────────────────────────── bool logEvent(const std::string& level, const std::string& component, const std::string& message); diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index 6a7f621..de8bd06 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -221,6 +221,86 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) { return true; } +// ─── GET /schedule ───────────────────────────────────────────────────── + +bool parseScheduleJson(const std::string& body, ScheduleResponse& out) { + radiantwave::json::JsonValue root; + if (!radiantwave::json::parseDocument(body, root) || + root.type != radiantwave::json::JsonValue::Type::Object) { + return false; + } + + ScheduleResponse parsed; + parsed.email = root.stringField("email"); + + const radiantwave::json::JsonValue* blocks = root.find("blocks"); + if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& entry : blocks->array) { + if (entry.type != radiantwave::json::JsonValue::Type::Object) continue; + + ScheduleBlock block; + block.id = entry.stringField("id"); + if (block.id.empty()) continue; + + block.start_minutes = static_cast(entry.numberField("startMinutes")); + block.end_minutes = static_cast(entry.numberField("endMinutes")); + block.overnight = entry.boolField("overnight"); + block.duration_minutes = static_cast(entry.numberField("durationMinutes")); + + // `dynamicAudio` is optional on the wire and defaults to true. + const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio"); + block.dynamic_audio = + (dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true; + + const radiantwave::json::JsonValue* ids = entry.find("proclamationIds"); + if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& id : ids->array) { + if (id.type == radiantwave::json::JsonValue::Type::String && + !id.str.empty()) { + block.proclamation_ids.push_back(id.str); + } + } + } + + parsed.blocks.push_back(std::move(block)); + } + } + + out = std::move(parsed); + return true; +} + +namespace { + +// Percent-encodes a query-parameter value (RFC 3986 unreserved set). +std::string urlEncode(const std::string& value) { + CURL* curl = curl_easy_init(); + if (!curl) return value; + char* escaped = curl_easy_escape(curl, value.c_str(), static_cast(value.size())); + std::string result = escaped ? escaped : value; + if (escaped) curl_free(escaped); + curl_easy_cleanup(curl); + return result; +} + +} // anonymous namespace + +bool fetchSchedule(const std::string& email, const std::string& license_key, + ScheduleResponse& out) { + if (email.empty() || license_key.empty()) { + return false; + } + + const std::string path = std::string(PATH_SCHEDULE) + + "?email=" + urlEncode(email) + + "&licenseKey=" + urlEncode(license_key); + HttpResponse response = httpGet(path); + if (!response.transportOk || response.status < 200 || response.status >= 300) { + return false; + } + return parseScheduleJson(response.body, out); +} + AccountResult createAccount(const std::string& email, const std::string& name) { AccountResult result; diff --git a/src/network/api_client.h b/src/network/api_client.h index 0f71c9c..cc9005b 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -17,6 +17,7 @@ constexpr const char* PATH_FREQUENCIES = "/frequencies"; constexpr const char* PATH_LICENSE = "/license"; constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account"; constexpr const char* PATH_COLOR_PALETTES = "/color-palettes"; +constexpr const char* PATH_SCHEDULE = "/schedule"; // Result of a single HTTP request. struct HttpResponse { @@ -119,4 +120,37 @@ struct AccountResult { // to the user and is NOT part of the response. `name` may be empty. AccountResult createAccount(const std::string& email, const std::string& name = ""); +// ─── GET /schedule ───────────────────────────────────────────────────── + +// One scheduled listening block. Wall-clock times are carried as minutes from +// midnight; overnight blocks wrap past midnight (end clock time <= start). +struct ScheduleBlock { + std::string id; + int start_minutes = 0; + int end_minutes = 0; + bool overnight = false; + int duration_minutes = 0; + // When true the kiosk enables dynamic audio for the block's duration, + // overriding the device's audio selection. Defaults to true: schedules are + // dynamic unless the portal explicitly says otherwise. + bool dynamic_audio = true; + // Selected proclamation ids, in play order. + std::vector proclamation_ids; +}; + +struct ScheduleResponse { + std::string email; + std::vector blocks; +}; + +// Parse the JSON payload returned by the schedule endpoint. `dynamicAudio` is +// optional and defaults to true when absent. Returns false on malformed input. +bool parseScheduleJson(const std::string& body, ScheduleResponse& out); + +// Fetch a user's schedule. Authenticates via the paired `email` + `license_key` +// query parameters. Returns true only on HTTP 2xx + successful parse; failures +// (including a missing/blank pair) leave `out` untouched. +bool fetchSchedule(const std::string& email, const std::string& license_key, + ScheduleResponse& out); + } // namespace radiantwave::api diff --git a/src/network/proclamation_sync.cpp b/src/network/proclamation_sync.cpp index 1eeac31..0f62c30 100644 --- a/src/network/proclamation_sync.cpp +++ b/src/network/proclamation_sync.cpp @@ -1,4 +1,5 @@ #include "network/proclamation_sync.h" +#include "db/db_keys.h" #include "network/api_client.h" #include "network/json.h" @@ -161,9 +162,41 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db, if (!db.syncProclamations(proclamations, defaultProclamationId)) { return false; } + + // Best-effort: a schedule fetch/store failure must never fail the + // proclamation sync or roll back the data just written. + syncScheduleFromApi(db); return true; } +bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) { + const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS); + const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY); + if (email.empty() || license_key.empty()) { + return false; + } + + api::ScheduleResponse schedule; + if (!api::fetchSchedule(email, license_key, schedule)) { + return false; + } + + std::vector records; + records.reserve(schedule.blocks.size()); + for (const auto& block : schedule.blocks) { + radiantwave::db::ScheduleBlockRecord rec; + rec.id = block.id; + rec.start_minutes = block.start_minutes; + rec.end_minutes = block.end_minutes; + rec.overnight = block.overnight; + rec.duration_minutes = block.duration_minutes; + rec.dynamic_audio = block.dynamic_audio; + rec.proclamation_ids = block.proclamation_ids; + records.push_back(std::move(rec)); + } + return db.replaceScheduleBlocks(records); +} + // ─── Color palettes (readiness code; not wired into the app flow) ───── bool parseProclamationPalettesJson(const std::string& body, diff --git a/src/network/proclamation_sync.h b/src/network/proclamation_sync.h index 706a6a0..9079bae 100644 --- a/src/network/proclamation_sync.h +++ b/src/network/proclamation_sync.h @@ -23,9 +23,18 @@ bool parseGroupsJson(const std::string& body, // the DB, preserving any existing selection and forcing `defaultProclamationId` // to stay selected. Returns true only if the API sync succeeded; on failure the // database is left untouched so the app can fall back to cached data. +// +// Also requests the user's schedule and replaces the stored schedule when the +// device has an email + license key. Schedule sync is best-effort: a schedule +// failure never fails (or rolls back) the proclamation sync. bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db, const std::string& defaultProclamationId); +// Fetch GET /schedule for the device's paired email + license key and replace +// the stored schedule blocks. Skips (returns false) when either credential is +// missing. Returns true only on HTTP 2xx + successful parse + successful store. +bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db); + // Parse color palettes embedded in the proclamations JSON payload. Each // proclamation object may carry a "colorPalette" array of "#RRGGBB" strings; // the palette is keyed by the proclamation's solfeggioFrequencyId. When diff --git a/src/proclamation_options_page.cpp b/src/proclamation_options_page.cpp index f6e497a..fd3c334 100644 --- a/src/proclamation_options_page.cpp +++ b/src/proclamation_options_page.cpp @@ -174,6 +174,16 @@ void ProclamationOptionsPage::rebuildItems() { items_.clear(); + // Schedule master toggle: enables/disables following the portal schedule. + // It is a plain on/off row (selected stores the on state) and sits above + // the always-on proclamation so it is always the first list entry. + items_.push_back({ + FlatItem::Type::ScheduleToggle, + "schedule", + "Schedule Active", + 0, false, state_->config.get().proclamation.schedule_active, false, + }); + // Always-on top-level proclamation, never inside a category. items_.push_back({ FlatItem::Type::Proclamation, @@ -258,6 +268,11 @@ void ProclamationOptionsPage::rebuildVisible() for (size_t i = 0; i < items_.size(); ++i) { const FlatItem& item = items_[i]; + if (item.type == FlatItem::Type::ScheduleToggle) { + visible_.push_back(i); + continue; + } + if (item.type == FlatItem::Type::Category) { currentCategory = static_cast(i); currentGroup = -1; @@ -304,6 +319,18 @@ void ProclamationOptionsPage::toggleItem() return; } + if (item.type == FlatItem::Type::ScheduleToggle) { + item.selected = !item.selected; + const bool enabled = item.selected; + state_->config.update([enabled](radiantwave::config::AppConfig& cfg) { + cfg.proclamation.schedule_active = enabled; + }); + statusMessage_ = enabled + ? "Schedule active - playback follows the portal schedule" + : "Schedule inactive - playback uses your selections"; + return; + } + if (item.isDefault) { statusMessage_ = "The top-level proclamation is always on"; return; @@ -715,14 +742,25 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer) } } else { // --- Browse tree --- + // The schedule toggle renders its on/off state inline; category + // and group rows get an expand/collapse marker. + auto labelFor = [](const FlatItem& item) -> std::string { + if (item.type == FlatItem::Type::ScheduleToggle) { + return "Schedule Active: " + + std::string(item.selected ? "[ON]" : "[OFF]"); + } + if (item.type == FlatItem::Type::Category || + item.type == FlatItem::Type::Group) { + return (item.expanded ? "[-] " : "[+] ") + item.label; + } + return item.label; + }; + // Stable width: compute from every item so the list doesn't // shift left/right when groups expand or collapse. float maxTotalWidth = 0.0f; for (const auto& item : items_) { - std::string label = item.label; - if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) { - label = (item.expanded ? "[-] " : "[+] ") + label; - } + std::string label = labelFor(item); int w = 0, h = 0; TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); float total = item.depth * indent + static_cast(w); @@ -732,10 +770,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer) std::vector textWidths(visible_.size()); for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast(visible_.size()); ++i) { const FlatItem& item = items_[visible_[i]]; - std::string label = item.label; - if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) { - label = (item.expanded ? "[-] " : "[+] ") + label; - } + std::string label = labelFor(item); int w = 0, h = 0; TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); textWidths[i] = static_cast(w); @@ -750,10 +785,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer) const FlatItem& item = items_[visible_[i]]; float y = listTop + rowIndex * rowPitch; - std::string label = item.label; - if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) { - label = (item.expanded ? "[-] " : "[+] ") + label; - } + std::string label = labelFor(item); SDL_Color color = white; if (item.type == FlatItem::Type::Category) { diff --git a/src/proclamation_options_page.h b/src/proclamation_options_page.h index f69e257..5cf5649 100644 --- a/src/proclamation_options_page.h +++ b/src/proclamation_options_page.h @@ -28,14 +28,14 @@ public: private: struct FlatItem { - enum class Type { Category, Group, Proclamation }; + enum class Type { ScheduleToggle, Category, Group, Proclamation }; Type type = Type::Proclamation; - std::string id; // category id, group id, or proclamation id + std::string id; // category id, group id, proclamation id, or "schedule" std::string label; // display text int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation bool expanded = false; // categories and groups only - bool selected = false; // proclamations only + bool selected = false; // proclamations and the schedule toggle bool isDefault = false; // the always-on top-level proclamation }; diff --git a/src/scroller_page.cpp b/src/scroller_page.cpp index d40ca9a..a40c52a 100644 --- a/src/scroller_page.cpp +++ b/src/scroller_page.cpp @@ -5,6 +5,7 @@ #include "logical_size.h" #include "proclamation_options_page.h" #include "scramble/fibonacci_scrambler.h" +#include "timezone_helper.h" #include "utf8_utils.h" #include @@ -72,6 +73,7 @@ void ScrollerPage::onPush(AppState &state) { createFont(state); createPatterns(); + lines_.clear(); prefilling_ = true; prefillLines(); prefilling_ = false; @@ -79,9 +81,10 @@ void ScrollerPage::onPush(AppState &state) { // Start audio. In dynamic mode the current stream's solfeggio-frequency // track wins (when its file exists); otherwise the configured manual track // plays. A frequency file that is not on disk yet falls back to the manual - // track rather than playing nothing. + // track rather than playing nothing. A schedule block with dynamicAudio=true + // forces dynamic audio for its duration; false leaves the device setting be. std::string track = state.config.get().audio.current_track; - if (state.config.get().audio.dynamic_audio && !streams_.empty()) { + if (effectiveDynamicAudio() && !streams_.empty()) { const ProclamationStream &stream = streams_[streamIndex_]; if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() && @@ -137,6 +140,14 @@ void ScrollerPage::update(float dt) { state_->audio.update(dt); } + // Block boundaries are minute-granular, so a one-second poll is plenty and + // avoids mutating the process timezone every frame. + schedulePollTimer_ -= dt; + if (schedulePollTimer_ <= 0.0f) { + schedulePollTimer_ = 1.0f; + pollScheduleChange(); + } + updateAutoVelocity(dt); scrollOffset_ += velocity_ * dt; @@ -200,8 +211,18 @@ void ScrollerPage::render(SDL_Renderer *renderer) { void ScrollerPage::buildStreams(AppState &state) { streams_.clear(); fallbackPalette_.clear(); - lines_.clear(); streamIndex_ = 0; + scheduleChangePending_ = false; + + // Schedule state: only an active schedule with a currently-matching block + // changes the play list. The block boundary is handled in advanceStream(), + // so rebuilding mid-playback never truncates the current proclamation. + const radiantwave::config::AppConfig cfg = state.config.get(); + scheduleActive_ = cfg.proclamation.schedule_active; + timezone_ = cfg.timezone; + scheduleBlocks_ = scheduleActive_ ? state.db.getScheduleBlocks() + : std::vector{}; + activeBlockIndex_ = currentScheduleBlockIndex(); auto proclamations = state.db.getActiveProclamations(); if (proclamations.empty()) { @@ -241,9 +262,53 @@ void ScrollerPage::buildStreams(AppState &state) { } } + const std::vector* scheduledIds = nullptr; + std::vector scheduledOrder; + if (activeBlockIndex_ >= 0 && + activeBlockIndex_ < static_cast(scheduleBlocks_.size())) { + scheduledOrder = scheduleBlocks_[activeBlockIndex_].proclamation_ids; + scheduledIds = &scheduledOrder; + } + streams_ = radiantwave::scroller::buildStreams( proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId, - DEFAULT_PROPLEAMATION_ID); + DEFAULT_PROPLEAMATION_ID, scheduledIds); +} + +int ScrollerPage::currentScheduleBlockIndex() const { + if (!scheduleActive_ || scheduleBlocks_.empty()) { + return -1; + } + const int now = radiantwave::timezone::currentLocalMinutes(timezone_); + for (size_t i = 0; i < scheduleBlocks_.size(); ++i) { + const radiantwave::db::ScheduleBlockRecord &block = scheduleBlocks_[i]; + if (radiantwave::scroller::minuteInScheduleBlock( + now, block.start_minutes, block.end_minutes, block.overnight)) { + return static_cast(i); + } + } + return -1; +} + +void ScrollerPage::pollScheduleChange() { + if (!scheduleActive_) { + return; + } + if (currentScheduleBlockIndex() != activeBlockIndex_) { + scheduleChangePending_ = true; + } +} + +bool ScrollerPage::effectiveDynamicAudio() const { + // dynamicAudio=true on the active block forces dynamic audio, overriding + // whatever the device selected. false means "don't override": the device's + // own selection (dynamic or a fixed track) applies as usual. + if (scheduleActive_ && activeBlockIndex_ >= 0 && + activeBlockIndex_ < static_cast(scheduleBlocks_.size()) && + scheduleBlocks_[activeBlockIndex_].dynamic_audio) { + return true; + } + return state_ ? state_->config.get().audio.dynamic_audio : false; } void ScrollerPage::createFont(AppState &state) { @@ -451,7 +516,19 @@ void ScrollerPage::advanceStream() { if (streams_.empty()) { return; } - streamIndex_ = (streamIndex_ + 1) % static_cast(streams_.size()); + if (scheduleChangePending_ && state_) { + // A schedule block changed while the previous proclamation was playing. + // Apply it only now, at a stream boundary, so the current proclamation + // finishes instead of being cut off. + buildStreams(*state_); + createPatterns(); + streamIndex_ = 0; + } else { + streamIndex_ = (streamIndex_ + 1) % static_cast(streams_.size()); + } + if (streams_.empty()) { + return; + } // Diagnostic: report the palette the incoming stream will render so the // console shows whether colors should change when this stream starts. const ProclamationStream &stream = streams_[streamIndex_]; @@ -473,8 +550,9 @@ void ScrollerPage::switchAudioForCurrentStream() { } // Dynamic audio only: with a fixed manual track selected the audio must - // never switch as proclamations change. - if (!state_->config.get().audio.dynamic_audio) { + // never switch as proclamations change. An active schedule block's + // dynamicAudio flag overrides the device-wide setting. + if (!effectiveDynamicAudio()) { return; } diff --git a/src/scroller_page.h b/src/scroller_page.h index 0ccd429..5c68dec 100644 --- a/src/scroller_page.h +++ b/src/scroller_page.h @@ -1,5 +1,6 @@ #pragma once +#include "db/database_manager.h" #include "fibonacci_line_pattern.h" #include "page.h" #include "scroller_streams.h" @@ -82,6 +83,16 @@ private: bool addLineAt(float baseY); void advanceStream(); void switchAudioForCurrentStream(); + // Re-reads the schedule from the DB/config and records which block (if any) + // matches the clock right now. Returns the matching index, or -1. + int currentScheduleBlockIndex() const; + // If the active block changed, latch a pending switch that is applied at the + // next proclamation boundary so the current proclamation is never cut off. + void pollScheduleChange(); + // True when the current stream should use dynamic (frequency-following) + // audio. A schedule block with dynamicAudio=true forces it on; otherwise the + // configured audio.dynamic_audio setting applies. + bool effectiveDynamicAudio() const; void updateAutoVelocity(float dt); void ensureWordTexture(const std::string& word); void ensureCharTexture(const std::string& glyph); @@ -97,6 +108,17 @@ private: int streamIndex_ = 0; bool prefilling_ = false; + // Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is + // the cached schedule; `activeBlockIndex_` is the block currently playing + // (-1 = normal mode / no matching block). A change is latched in + // `scheduleChangePending_` and applied when the current proclamation ends. + std::string timezone_; + std::vector scheduleBlocks_; + bool scheduleActive_ = false; + int activeBlockIndex_ = -1; + bool scheduleChangePending_ = false; + float schedulePollTimer_ = 0.0f; + TTF_Font* font_ = nullptr; int fontPxSize_ = 32; float lineHeight_ = 0; diff --git a/src/scroller_streams.cpp b/src/scroller_streams.cpp index 8b2f214..ae27127 100644 --- a/src/scroller_streams.cpp +++ b/src/scroller_streams.cpp @@ -53,21 +53,45 @@ std::vector buildStreams( const std::unordered_map>& palettesByFreqId, const std::vector& fallbackPalette, const std::unordered_map& audioByFreqId, - const std::string& defaultProclamationId) + const std::string& defaultProclamationId, + const std::vector* scheduledIds) { const db::ProclamationRecord* defaultRecord = nullptr; std::vector selected; - selected.reserve(proclamations.size()); - for (const auto& rec : proclamations) { - if (rec.id == defaultProclamationId) { - defaultRecord = &rec; - continue; + if (scheduledIds) { + // A schedule supplies an explicit, ordered play list. Look each id up + // in the proclamation pool, skipping unknown ids and the default (which + // is interleaved as usual). + std::unordered_map byId; + byId.reserve(proclamations.size()); + for (const auto& rec : proclamations) { + byId[rec.id] = &rec; } - if (!rec.selected) { - continue; + defaultRecord = byId.count(defaultProclamationId) ? byId[defaultProclamationId] : nullptr; + + selected.reserve(scheduledIds->size()); + for (const auto& id : *scheduledIds) { + if (id == defaultProclamationId) { + continue; + } + auto it = byId.find(id); + if (it != byId.end()) { + selected.push_back(it->second); + } + } + } else { + selected.reserve(proclamations.size()); + for (const auto& rec : proclamations) { + if (rec.id == defaultProclamationId) { + defaultRecord = &rec; + continue; + } + if (!rec.selected) { + continue; + } + selected.push_back(&rec); } - selected.push_back(&rec); } auto makeStream = [&](const db::ProclamationRecord& rec, int repeat) { diff --git a/src/scroller_streams.h b/src/scroller_streams.h index c1572cc..d20e335 100644 --- a/src/scroller_streams.h +++ b/src/scroller_streams.h @@ -25,6 +25,16 @@ struct StreamColor { inline constexpr int kSelectedRepeatCount = 20; inline constexpr int kDefaultRepeatCount = 10; +// True when `minutes` (minutes since midnight) falls inside a schedule block. +// Non-overnight blocks are [start, end); overnight blocks wrap past midnight +// and match [start, 1440) or [0, end). +inline bool minuteInScheduleBlock(int minutes, int start_minutes, int end_minutes, + bool overnight) +{ + return overnight ? (minutes >= start_minutes || minutes < end_minutes) + : (minutes >= start_minutes && minutes < end_minutes); +} + struct ProclamationStream { std::string name; std::vector words; @@ -45,11 +55,18 @@ struct ProclamationStream { // the default plays. `palettesByFreqId` and `audioByFreqId` are // database-derived lookups; `fallbackPalette` is used for the default stream // and unknown frequencies. +// +// When `scheduledIds` is non-null it replaces the `selected` flag as the source +// of the play order: the listed proclamation ids are played in the given order +// (unknown ids and the default id are skipped) and interleaved with the default +// exactly like a normal selection. An empty list falls back to the default +// only. Passing nullptr keeps the normal database-selection behavior. std::vector buildStreams( const std::vector& proclamations, const std::unordered_map>& palettesByFreqId, const std::vector& fallbackPalette, const std::unordered_map& audioByFreqId, - const std::string& defaultProclamationId); + const std::string& defaultProclamationId, + const std::vector* scheduledIds = nullptr); } // namespace radiantwave::scroller diff --git a/src/timezone_helper.cpp b/src/timezone_helper.cpp index d33d01d..33cc9a0 100644 --- a/src/timezone_helper.cpp +++ b/src/timezone_helper.cpp @@ -133,6 +133,37 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c return std::string(buf); } +int currentLocalMinutes(const std::string& iana_code) { + std::lock_guard lock(g_tz_mutex); + + const char* old_tz = std::getenv("TZ"); + std::string old_tz_str = old_tz ? old_tz : ""; + + if (!iana_code.empty()) { + setenv("TZ", iana_code.c_str(), 1); + } else { + setenv("TZ", "UTC", 1); + } + tzset(); + + time_t now = std::time(nullptr); + struct tm tm_buf; +#if defined(_WIN32) + localtime_s(&tm_buf, &now); +#else + localtime_r(&now, &tm_buf); +#endif + + if (!old_tz_str.empty()) { + setenv("TZ", old_tz_str.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); + + return tm_buf.tm_hour * 60 + tm_buf.tm_min; +} + std::string getUtcTimestampIso() { time_t now = std::time(nullptr); struct tm gmt_buf; diff --git a/src/timezone_helper.h b/src/timezone_helper.h index bb1f8e7..6a7ef31 100644 --- a/src/timezone_helper.h +++ b/src/timezone_helper.h @@ -22,6 +22,11 @@ const std::vector& getStandardTimeZones(); // Converts a UTC timestamp to the user's configured IANA timezone std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, const std::string& format = "%Y-%m-%d %H:%M:%S %Z"); +// Wall-clock minutes since midnight (0-1439) right now in `iana_code`. Used by +// the schedule to pick the active listening block. Falls back to UTC when the +// timezone is empty. +int currentLocalMinutes(const std::string& iana_code); + // Gets current UTC timestamp as ISO string std::string getUtcTimestampIso(); diff --git a/tests/test_api_client.cpp b/tests/test_api_client.cpp index 39e2bd0..8644e55 100644 --- a/tests/test_api_client.cpp +++ b/tests/test_api_client.cpp @@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) { EXPECT_FALSE(parseCreateAccountJson("[]", account)); } +TEST(ApiClientTest, ParseScheduleJson) { + const std::string body = + R"({"email":"jane@example.com","blocks":[ + {"id":"b1","startTime":"06:00","startMinutes":360,"endTime":"08:00", + "endMinutes":480,"overnight":false,"durationMinutes":120, + "dynamicAudio":false,"proclamationIds":["p1","p2"]}, + {"id":"b2","startTime":"21:00","startMinutes":1260,"endTime":"09:00", + "endMinutes":540,"overnight":true,"durationMinutes":720, + "dynamicAudio":true,"proclamationIds":[]}]})"; + + ScheduleResponse out; + EXPECT_TRUE(parseScheduleJson(body, out)); + EXPECT_EQ(out.email, "jane@example.com"); + ASSERT_EQ(out.blocks.size(), 2u); + + EXPECT_EQ(out.blocks[0].id, "b1"); + EXPECT_EQ(out.blocks[0].start_minutes, 360); + EXPECT_EQ(out.blocks[0].end_minutes, 480); + EXPECT_FALSE(out.blocks[0].overnight); + EXPECT_EQ(out.blocks[0].duration_minutes, 120); + EXPECT_FALSE(out.blocks[0].dynamic_audio); + ASSERT_EQ(out.blocks[0].proclamation_ids.size(), 2u); + EXPECT_EQ(out.blocks[0].proclamation_ids[0], "p1"); + EXPECT_EQ(out.blocks[0].proclamation_ids[1], "p2"); + + EXPECT_EQ(out.blocks[1].id, "b2"); + EXPECT_TRUE(out.blocks[1].overnight); + EXPECT_TRUE(out.blocks[1].dynamic_audio); + EXPECT_TRUE(out.blocks[1].proclamation_ids.empty()); +} + +TEST(ApiClientTest, ParseScheduleDefaultsDynamicAudioTrue) { + const std::string body = + R"({"email":"a@b.com","blocks":[ + {"id":"b1","startMinutes":0,"endMinutes":60,"overnight":false, + "durationMinutes":60,"proclamationIds":["p1"]}]})"; + + ScheduleResponse out; + EXPECT_TRUE(parseScheduleJson(body, out)); + ASSERT_EQ(out.blocks.size(), 1u); + EXPECT_TRUE(out.blocks[0].dynamic_audio); +} + +TEST(ApiClientTest, ParseScheduleRejectsMalformed) { + ScheduleResponse out; + EXPECT_FALSE(parseScheduleJson("not json", out)); + EXPECT_FALSE(parseScheduleJson("[1,2,3]", out)); // array, not object + // Missing id blocks are skipped, not fatal. + EXPECT_TRUE(parseScheduleJson(R"({"blocks":[{"startMinutes":0}]})", out)); + EXPECT_TRUE(out.blocks.empty()); +} + // ─── Offline: error message extraction ──────────────────────────────── TEST(ApiClientTest, ExtractErrorMessage) { diff --git a/tests/test_config.cpp b/tests/test_config.cpp index c8c1f00..2e8aee6 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) { original.audio.volume = 60; original.audio.dynamic_audio = false; original.proclamation.scroll_speed = 1.8f; + original.proclamation.schedule_active = true; std::string serialized = serializeConfigToYaml(original); AppConfig parsed = parseConfigFromYaml(serialized); @@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) { EXPECT_EQ(parsed.audio.volume, original.audio.volume); EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio); EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed); + EXPECT_TRUE(parsed.proclamation.schedule_active); } TEST(ConfigManagerTest, DefaultGeneration) { @@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) { EXPECT_EQ(cfg.audio.volume, 80); EXPECT_EQ(cfg.audio.dynamic_audio, true); EXPECT_EQ(cfg.proclamation.font_size, 32); + EXPECT_FALSE(cfg.proclamation.schedule_active); EXPECT_TRUE(std::filesystem::exists(temp_path)); std::filesystem::remove(temp_path); diff --git a/tests/test_db.cpp b/tests/test_db.cpp index f58595b..4ee3382 100644 --- a/tests/test_db.cpp +++ b/tests/test_db.cpp @@ -347,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) { EXPECT_EQ(fx.db.getAudioFiles().size(), 0u); } +TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) { + DbFixture fx("test_rw_db_schedule.db"); + + ScheduleBlockRecord morning; + morning.id = "b1"; + morning.start_minutes = 360; + morning.end_minutes = 480; + morning.overnight = false; + morning.duration_minutes = 120; + morning.dynamic_audio = false; + morning.proclamation_ids = {"p2", "p1"}; + + ScheduleBlockRecord night; + night.id = "b2"; + night.start_minutes = 1260; + night.end_minutes = 540; + night.overnight = true; + night.duration_minutes = 720; + night.dynamic_audio = true; + night.proclamation_ids = {"p3"}; + + ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night})); + + auto rows = fx.db.getScheduleBlocks(); + ASSERT_EQ(rows.size(), 2u); + + EXPECT_EQ(rows[0].id, "b1"); + EXPECT_EQ(rows[0].start_minutes, 360); + EXPECT_EQ(rows[0].end_minutes, 480); + EXPECT_FALSE(rows[0].overnight); + EXPECT_EQ(rows[0].duration_minutes, 120); + EXPECT_FALSE(rows[0].dynamic_audio); + // Play order is preserved, not sorted. + ASSERT_EQ(rows[0].proclamation_ids.size(), 2u); + EXPECT_EQ(rows[0].proclamation_ids[0], "p2"); + EXPECT_EQ(rows[0].proclamation_ids[1], "p1"); + + EXPECT_EQ(rows[1].id, "b2"); + EXPECT_TRUE(rows[1].overnight); + EXPECT_TRUE(rows[1].dynamic_audio); + ASSERT_EQ(rows[1].proclamation_ids.size(), 1u); + EXPECT_EQ(rows[1].proclamation_ids[0], "p3"); + + // A re-sync replaces the whole schedule. + ASSERT_TRUE(fx.db.replaceScheduleBlocks({})); + EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u); +} + TEST(DatabaseManagerTest, PalettesSeededAndRead) { DbFixture fx("test_rw_db_palette.db"); diff --git a/tests/test_scroller_streams.cpp b/tests/test_scroller_streams.cpp index f0a9736..c30d829 100644 --- a/tests/test_scroller_streams.cpp +++ b/tests/test_scroller_streams.cpp @@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor; using radiantwave::scroller::buildStreams; using radiantwave::scroller::kDefaultRepeatCount; using radiantwave::scroller::kSelectedRepeatCount; +using radiantwave::scroller::minuteInScheduleBlock; const std::string kDefaultId = "default"; @@ -185,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected) EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount); } +TEST(ScrollerStreamsTest, MinuteInScheduleBlockDaytime) +{ + // 06:00 (360) to 08:00 (480), half-open. + EXPECT_FALSE(minuteInScheduleBlock(359, 360, 480, false)); + EXPECT_TRUE(minuteInScheduleBlock(360, 360, 480, false)); + EXPECT_TRUE(minuteInScheduleBlock(479, 360, 480, false)); + EXPECT_FALSE(minuteInScheduleBlock(480, 360, 480, false)); +} + +TEST(ScrollerStreamsTest, MinuteInScheduleBlockOvernight) +{ + // 21:00 (1260) to 09:00 (540) wraps past midnight. + EXPECT_TRUE(minuteInScheduleBlock(1260, 1260, 540, true)); + EXPECT_TRUE(minuteInScheduleBlock(1439, 1260, 540, true)); + EXPECT_TRUE(minuteInScheduleBlock(0, 1260, 540, true)); + EXPECT_TRUE(minuteInScheduleBlock(539, 1260, 540, true)); + EXPECT_FALSE(minuteInScheduleBlock(540, 1260, 540, true)); + EXPECT_FALSE(minuteInScheduleBlock(1259, 1260, 540, true)); +} + +TEST(ScrollerStreamsTest, ScheduledIdsDriveOrderRegardlessOfSelection) +{ + ProclamationRecord p1; + p1.id = "one"; + p1.title = "One"; + p1.content = "x"; + p1.selected = false; + + ProclamationRecord p2; + p2.id = "two"; + p2.title = "Two"; + p2.content = "y"; + p2.selected = false; + + ProclamationRecord def; + def.id = kDefaultId; + def.title = "Default"; + def.content = "z"; + + // Explicit order two -> one, interleaved with the default. Unknown ids are + // skipped; the `selected` flag is ignored. + std::vector scheduled = {"two", "missing", "one"}; + auto streams = buildStreams({p1, p2, def}, {}, palette(), {}, kDefaultId, &scheduled); + + ASSERT_EQ(streams.size(), 4u); + EXPECT_EQ(streams[0].name, "Two"); + EXPECT_EQ(streams[1].name, "Default"); + EXPECT_EQ(streams[2].name, "One"); + EXPECT_EQ(streams[3].name, "Default"); +} + +TEST(ScrollerStreamsTest, EmptyScheduleFallsBackToDefault) +{ + ProclamationRecord p1; + p1.id = "one"; + p1.title = "One"; + p1.content = "x"; + p1.selected = true; + + ProclamationRecord def; + def.id = kDefaultId; + def.title = "Default"; + def.content = "y"; + + std::vector scheduled; // empty block + auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId, &scheduled); + + ASSERT_EQ(streams.size(), 1u); + EXPECT_EQ(streams[0].name, "Default"); +} + TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation) { ProclamationRecord p1; diff --git a/tests/test_timezone.cpp b/tests/test_timezone.cpp index fa01782..944637b 100644 --- a/tests/test_timezone.cpp +++ b/tests/test_timezone.cpp @@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) { EXPECT_NE(iso.find("UTC"), std::string::npos); } +TEST(TimeZoneTest, CurrentLocalMinutesInRangeAndMatchesUtc) { + int minutes = currentLocalMinutes("UTC"); + EXPECT_GE(minutes, 0); + EXPECT_LT(minutes, 1440); + + // Cross-check against the UTC wall clock. The two calls happen within the + // same second, so a minute-boundary rollover is vanishingly unlikely. + time_t now = std::time(nullptr); + struct tm gmt_buf; + gmtime_r(&now, &gmt_buf); + EXPECT_EQ(minutes, gmt_buf.tm_hour * 60 + gmt_buf.tm_min); + + // A timezone that is known to differ from UTC must still be in range. + int ny = currentLocalMinutes("America/New_York"); + EXPECT_GE(ny, 0); + EXPECT_LT(ny, 1440); +} + TEST(TimeZoneTest, FormatUtcToTimezone) { // 2026-07-24 12:00:00 UTC // Fixed epoch timestamp: 1784808000