From 1fa1c6847854623322c859cb26183039011f17d4 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Wed, 22 Jul 2026 19:05:02 -0400 Subject: [PATCH] Add calendar frame mode data model (migration 7) New columns, all ADD COLUMN with inert defaults -- no existing frame's behavior changes until mode is explicitly switched to "calendar": - users.calendar_ics_url: one personal iCal/CalDAV subscription per user, same shape as the existing per-user immich_url/immich_api_key. - user_frames.calendar_included: explicit per-(user,frame) opt-in, default off. Being linked to a frame does not by itself contribute your calendar to it -- each person's calendar is their own data to share, not something a frame's controller decides on their behalf. - frames.calendar_view/calendar_photo_inlay/calendar_browse_offset: per-frame display settings and NEXT/BACK navigation state. - frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary: the throttled merge-fetch cache, same shape as the existing firmware_update_checked_at/firmware_gitea_latest_version pattern. --- firmware/main/manage_qr_overlay.c | 356 ------------------------------ firmware/main/manage_qr_overlay.h | 61 ----- server/app/migration.py | 21 ++ server/app/models.py | 39 ++++ 4 files changed, 60 insertions(+), 417 deletions(-) delete mode 100644 firmware/main/manage_qr_overlay.c delete mode 100644 firmware/main/manage_qr_overlay.h diff --git a/firmware/main/manage_qr_overlay.c b/firmware/main/manage_qr_overlay.c deleted file mode 100644 index b63dc16..0000000 --- a/firmware/main/manage_qr_overlay.c +++ /dev/null @@ -1,356 +0,0 @@ -#include -#include - -#include "esp_check.h" - -#include "epd7in3e.h" -#include "epd_draw.h" -#include "fonts.h" -#include "qrcodegen.h" - -#include "manage_qr_overlay.h" - -static const char *TAG = "manage_qr_overlay"; - -#define QR_MAX_VERSION 10 -#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION) -/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact - * corner popups, not a full-screen setup step. */ -#define QR_MODULE_PX 4 -#define PADDING 16 -#define QR_TEXT_GAP 8 -#define LINE_GAP 4 -/* Distance from the panel's edges to each overlay box. Combined with - * EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this - * guarantees x0 is always even -- required so a region's columns land on - * frame byte boundaries (2px/byte) when spliced into the fetch stream. */ -#define PANEL_MARGIN 20 - -typedef enum { - CORNER_TOP_LEFT, - CORNER_TOP_RIGHT, - CORNER_BOTTOM_LEFT, - CORNER_BOTTOM_RIGHT, -} overlay_corner_t; - -static void position_region(manage_overlay_region_t *region, overlay_corner_t corner) -{ - switch (corner) { - case CORNER_TOP_LEFT: - region->x0 = PANEL_MARGIN; - region->y0 = PANEL_MARGIN; - break; - case CORNER_TOP_RIGHT: - region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w; - region->y0 = PANEL_MARGIN; - break; - case CORNER_BOTTOM_LEFT: - region->x0 = PANEL_MARGIN; - region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h; - break; - case CORNER_BOTTOM_RIGHT: - region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w; - region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h; - break; - } -} - -static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x, - int origin_y) -{ - int size = qrcodegen_getSize(qrcode); - for (int y = 0; y < size; y++) { - for (int x = 0; x < size; x++) { - epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE; - for (int dy = 0; dy < QR_MODULE_PX; dy++) { - for (int dx = 0; dx < QR_MODULE_PX; dx++) { - epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx, - origin_y + y * QR_MODULE_PX + dy, color); - } - } - } - } -} - -/* White-padded box with a QR code encoding payload, plus zero, one, or - * two centered caption lines beneath it (either may be NULL). Used for - * both the top-right "scan to manage" box (two lines) and the - * bottom-left share-link box (no lines). */ -static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner, - manage_overlay_region_t *out) -{ - uint8_t temp_buffer[QR_BUFFER_LEN]; - uint8_t qrcode[QR_BUFFER_LEN]; - bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, - QR_MAX_VERSION, qrcodegen_Mask_AUTO, true); - ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload); - - int qr_size = qrcodegen_getSize(qrcode); - int qr_px = qr_size * QR_MODULE_PX; - - /* Font24 (32x41px uppercase glyphs) is the only font vendored into - * this project -- see components/epaper_fonts. "SCAN TO MANAGE" on - * one line would be 448px wide, too wide for a compact corner box, - * so it's passed in pre-wrapped across two lines instead. */ - int text_w = 0; - int text_h = 0; - if (line1 != NULL) { - int w1 = (int)strlen(line1) * Font24.Width; - int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0; - text_w = w1 > w2 ? w1 : w2; - text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0); - } - - int content_w = qr_px > text_w ? qr_px : text_w; - int content_h = qr_px + text_h; - - int w = content_w + PADDING * 2; - int h = content_h + PADDING * 2; - w += w % 2; /* keep byte-aligned (2px/byte) */ - - int stride = w / 2; - uint8_t *buf = malloc((size_t)stride * h); - ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region"); - memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h); - - int center_x = w / 2; - int y = PADDING; - draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y); - y += qr_px; - if (line1 != NULL) { - y += QR_TEXT_GAP; - epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y); - y += Font24.Height; - } - if (line2 != NULL) { - y += LINE_GAP; - epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y); - } - - out->buf = buf; - out->w = w; - out->h = h; - position_region(out, corner); - return ESP_OK; -} - -/* White-padded box with one or two centered lines of text (line2 may be - * NULL) -- used for the top-left location (city + state/country, two - * lines rather than cramming both onto one to keep the box from - * threatening to overlap the top-right QR box) and the bottom-right - * date-taken label (one line). */ -static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner, - manage_overlay_region_t *out) -{ - int w1 = (int)strlen(line1) * Font24.Width; - int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0; - int text_w = w1 > w2 ? w1 : w2; - int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0); - - int w = text_w + PADDING * 2; - int h = text_h + PADDING * 2; - w += w % 2; - - int stride = w / 2; - uint8_t *buf = malloc((size_t)stride * h); - ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region"); - memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h); - - int center_x = w / 2; - int y = PADDING; - epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y); - if (line2 != NULL) { - y += Font24.Height + LINE_GAP; - epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y); - } - - out->buf = buf; - out->w = w; - out->h = h; - position_region(out, corner); - return ESP_OK; -} - -/* Deliberately tighter than PADDING (used for the fixed QR/text corner - * boxes) -- these labels sit right next to a face rather than needing - * generous QR-scanning margin, and there can be several of them - * simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in - * the header). */ -#define FACE_LABEL_PADDING 8 -#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */ - -/* White-padded single-line name label positioned near an arbitrary - * (anchor_x, anchor_y) face position, rather than a fixed corner -- - * unlike the four corner regions (always in-bounds by construction), - * this needs real clamping since a face can be anywhere, including near - * an edge. Centered horizontally on the face, placed just below it by - * default, flipped above if there's no room below. */ -static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out) -{ - int text_w = (int)strlen(name) * Font24.Width; - int w = text_w + FACE_LABEL_PADDING * 2; - int h = Font24.Height + FACE_LABEL_PADDING * 2; - w += w % 2; - - int stride = w / 2; - uint8_t *buf = malloc((size_t)stride * h); - ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region"); - memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h); - epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING); - - int x0 = anchor_x - w / 2; - int y0 = anchor_y + FACE_LABEL_GAP; - if (y0 + h > EPD_HEIGHT) { - y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */ - } - if (x0 < 0) { - x0 = 0; - } else if (x0 + w > EPD_WIDTH) { - x0 = EPD_WIDTH - w; - } - if (y0 < 0) { - y0 = 0; - } else if (y0 + h > EPD_HEIGHT) { - y0 = EPD_HEIGHT - h; - } - x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */ - - out->buf = buf; - out->w = w; - out->h = h; - out->x0 = x0; - out->y0 = y0; - return ESP_OK; -} - -/* Battery glyph dimensions -- a static outline (body rectangle + small - * terminal nub on the right), deliberately NOT a fill-level graphic. */ -#define BATTERY_ICON_W 44 -#define BATTERY_ICON_H 24 -#define BATTERY_ICON_STROKE 2 -#define BATTERY_NUB_W 6 -#define BATTERY_NUB_H 12 -#define BATTERY_ICON_TEXT_GAP 8 -#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */ - -static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0) -{ - for (int y = 0; y < BATTERY_ICON_H; y++) { - for (int x = 0; x < BATTERY_ICON_W; x++) { - bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE || - y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE; - if (edge) { - epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK); - } - } - } - int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2; - for (int y = 0; y < BATTERY_NUB_H; y++) { - for (int x = 0; x < BATTERY_NUB_W; x++) { - epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK); - } - } -} - -/* White-padded box with the battery glyph and "NN%" beside it, placed - * directly below an already-positioned anchor region (the top-right - * manage QR box), right-aligned to the anchor's right edge. */ -static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor, - manage_overlay_region_t *out) -{ - char text[8]; - snprintf(text, sizeof(text), "%d%%", percent); - - int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W; - int text_w = (int)strlen(text) * Font24.Width; - int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w; - int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H; - - int w = content_w + PADDING * 2; - int h = content_h + PADDING * 2; - w += w % 2; - - int stride = w / 2; - uint8_t *buf = malloc((size_t)stride * h); - ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region"); - memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h); - - draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2); - epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP, - PADDING + (content_h - Font24.Height) / 2); - - out->buf = buf; - out->w = w; - out->h = h; - out->x0 = anchor->x0 + anchor->w - w; - out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */ - out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP; - return ESP_OK; -} - -esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out) -{ - out->count = 0; - - esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT, - &out->regions[out->count]); - if (err != ESP_OK) { - return err; - } - out->count++; - - if (content->battery_percent >= 0 && content->battery_percent <= 100) { - /* Anchored below the manage QR box just rendered (regions[0]). */ - if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) == - ESP_OK) { - out->count++; - } - } - - if (content->location_line1 != NULL && content->location_line1[0] != '\0') { - const char *line2 = - (content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL; - if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) == - ESP_OK) { - out->count++; - } - } - - if (content->taken_at != NULL && content->taken_at[0] != '\0') { - if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) { - out->count++; - } - } - - if (content->share_url != NULL && content->share_url[0] != '\0') { - if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT, - &out->regions[out->count]) == ESP_OK) { - out->count++; - } - } - - int face_count = content->face_label_count; - if (face_count > MANAGE_FACE_LABELS_MAX) { - face_count = MANAGE_FACE_LABELS_MAX; - } - for (int i = 0; content->face_labels != NULL && i < face_count; i++) { - const manage_face_label_t *label = &content->face_labels[i]; - if (label->name[0] == '\0') { - continue; - } - if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) { - out->count++; - } - } - - return ESP_OK; -} - -void manage_overlay_free(manage_overlay_set_t *overlay) -{ - for (int i = 0; i < overlay->count; i++) { - free(overlay->regions[i].buf); - overlay->regions[i].buf = NULL; - } - overlay->count = 0; -} diff --git a/firmware/main/manage_qr_overlay.h b/firmware/main/manage_qr_overlay.h deleted file mode 100644 index 033135e..0000000 --- a/firmware/main/manage_qr_overlay.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once - -#include - -#include "esp_err.h" - -/* 5 fixed regions (manage QR, battery indicator, location, date, share - * QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face - * labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is - * capped small deliberately, not arbitrarily -- each label is its own - * malloc'd buffer, and the fixed regions alone already use a meaningful - * chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay - * memory well clear of what the WiFi/HTTP stack needs alongside it. */ -#define MANAGE_FACE_LABELS_MAX 4 -#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX) - -typedef struct { - uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */ - int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */ - int w, h; /* pixel dimensions (w is always even) */ -} manage_overlay_region_t; - -typedef struct { - manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS]; - int count; -} manage_overlay_set_t; - -typedef struct { - char name[16]; - int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */ -} manage_face_label_t; - -typedef struct { - const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */ - const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */ - const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */ - const char *taken_at; /* bottom-right text; NULL/empty skips this region */ - const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */ - const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */ - int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */ - int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */ -} manage_overlay_content_t; - -/** - * Renders the manage-button overlay: always a "scan to manage" QR in the - * top-right corner, plus whichever of location_line1/taken_at/share_url - * are non-NULL/non-empty in their own corners (top-left, bottom-right, - * bottom-left respectively), plus one region per entry in face_labels - * (positioned near that face rather than a fixed corner -- see - * render_face_label_region() in the .c file for the clamping logic). - * Each region is its own separately malloc'd small buffer (not a full - * EPD_FRAME_BYTES frame). A failure rendering the top-right region fails - * the whole call; a failure rendering any other region just skips that - * region and keeps going. Caller must call manage_overlay_free() on out - * regardless of the return value (out->count reflects however many - * regions were actually populated). - */ -esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out); - -/** Frees every populated region's buffer in overlay. */ -void manage_overlay_free(manage_overlay_set_t *overlay); diff --git a/server/app/migration.py b/server/app/migration.py index 267c732..f0edc78 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -80,6 +80,26 @@ def _migration_6(conn) -> None: conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0")) +def _migration_7(conn) -> None: + """Calendar frame mode: a personal ICS subscription per user + (users.calendar_ics_url), an explicit per-(user,frame) opt-in into a + frame's merged calendar (user_frames.calendar_included, default off + -- linking to a frame does not auto-include your calendar there), + and the frame-level view/inlay/browse-offset/cache settings calendar + mode needs (see calendar_feed.py, calendar_render.py, + routers/device.py's RENDERERS["calendar"]). Every new column has a + behavior-preserving default -- no existing frame's behavior changes + until its mode is actually switched to "calendar".""" + conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''")) + conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT")) + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -87,6 +107,7 @@ MIGRATIONS = [ (4, _migration_4), (5, _migration_5), (6, _migration_6), + (7, _migration_7), ] diff --git a/server/app/models.py b/server/app/models.py index a60bc16..fadabe4 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -48,6 +48,12 @@ class User(Base): # email -- see routers/device.py's frame_battery) go here; blank = no # email configured, both features silently no-op for this user. email: Mapped[str] = mapped_column(String, default="") + # Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar + # frame mode -- see calendar_feed.py. Setting this alone shows up + # nowhere: a linked frame only pulls this user's events in once + # they've also opted in on that frame's own Configuration -> Calendar + # card (UserFrame.calendar_included below). + calendar_ics_url: Mapped[str] = mapped_column(String, default="") created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = ( @@ -139,6 +145,31 @@ class Frame(Base): # original always-on full-strength Floyd-Steinberg dithering. dither_strength: Mapped[float] = mapped_column(Float, default=1.0) + # -- calendar mode (see calendar_feed.py, calendar_render.py, + # routers/device.py's RENDERERS["calendar"]) -- + calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month" + # Agenda view only; reuses this frame's existing photos-mode album/ + # queue, not a separate photo setup. + calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False) + # How many periods (unit depends on calendar_view: days/weeks/months) + # NEXT/BACK have browsed from "today". Reset to 0 by the next normal + # (non-button) /frame/image request, and whenever calendar_view + # itself changes -- a stale offset means something different in a + # different view's units. + calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0) + # Throttled merge-fetch cache (see routers/common.py's + # get_or_refresh_calendar_events) -- same shape as the + # firmware_update_checked_at/firmware_gitea_latest_version pattern + # below. One shared cache for every included user's merged events, + # not per-user. + calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0) + calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + # "" when the last merge-fetch fully succeeded, else e.g. "1 of 2 + # calendars unavailable" -- never names which user's feed failed, a + # shared household display shouldn't call out a specific person's + # outage to everyone who looks at it. + calendar_fetch_summary: Mapped[str] = mapped_column(String, default="") + # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) @@ -200,6 +231,14 @@ class UserFrame(Base): ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True ) created_at: Mapped[float] = mapped_column(Float, default=time.time) + # Explicit per-(user,frame) opt-in for calendar frame mode -- being + # linked to a frame does NOT by itself contribute this user's + # calendar to it (deliberate choice, not an oversight: each person's + # calendar is their own data to share or not, not something a + # frame's controller decides on their behalf). Meaningless if the + # user has no calendar_ics_url set. See routers/api_frames.py's + # api_calendar_included. + calendar_included: Mapped[bool] = mapped_column(Boolean, default=False) class PendingClaim(Base):