Manual per-calendar color choice for calendar mode
Build and push server image / build-and-push (push) Successful in 49s

Each linked person can pin one of the panel's four non-black/white
colors (Yellow/Red/Blue/Green) to their own calendar instead of relying
on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only,
like adding a calendar in the first place. Colors resolve against
whichever palette a frame actually renders with (including a custom
Advanced configuration override), so a pinned "Blue" stays this frame's
actual blue. Event color bars/dots are also bigger and rounded now
across agenda/week/month views, easier to tell apart at a glance.
This commit is contained in:
2026-07-22 22:30:55 -04:00
parent ffce798754
commit 27cd6b3703
10 changed files with 190 additions and 41 deletions
+6 -1
View File
@@ -94,13 +94,17 @@ class CalendarSource:
"caldav", url is the calendar's own URL, username/password its
account credentials) -- see caldav_client.py. owner_display_name
tags every event pulled from this source so a merged agenda can show
whose event is whose."""
whose event is whose. color_index (2-5, into
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
pinned color, or None to fall back on calendar_render.py's old
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
owner_display_name: str
kind: str
url: str
username: str = ""
password: str = ""
color_index: int | None = None
def merge_events(
@@ -127,6 +131,7 @@ def merge_events(
continue
for event in events:
event["owner_display_name"] = source.owner_display_name
event["color_index"] = source.color_index
merged.append(event)
merged.sort(key=lambda e: e["start"])
+58 -33
View File
@@ -40,14 +40,28 @@ MUTED = (110, 110, 110)
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
RULE = (0, 0, 0)
# Cycled per distinct owner_display_name so a merged multi-person calendar
# can visually tell whose event is whose -- the panel's own non-black/
# white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB)
# since those are already the page's text/background.
# Fallback for any event whose calendar has no manually pinned color
# (event["color_index"] is None): cycled per distinct owner_display_name
# so a merged multi-person calendar can still visually tell whose event
# is whose -- the panel's own non-black/white ink colors, skipping
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
# the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]:
def _event_color(event: dict, owners_seen: list[str], palette_rgb: list | None) -> tuple[int, int, int]:
"""A specific calendar's manually pinned color (event["color_index"],
set from models.FrameCalendar.color_index -- see
routers/api_frames.py's api_calendar_color) resolved against
whichever palette this frame actually renders with, so a pinned
"Blue" still looks like this frame's blue even if its Advanced
configuration has retuned the panel's RGB values. Falls back to the
old auto-cycle-by-owner-name when a calendar has no color pinned."""
color_index = event.get("color_index")
if color_index is not None:
palette = palette_rgb or DEFAULT_PALETTE_RGB
return tuple(palette[color_index])
owner_display_name = event["owner_display_name"]
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
@@ -262,7 +276,7 @@ def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0:
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str],
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
weather_units: str = "fahrenheit") -> None:
"""Draws one day's header + weather strip (if any) + event rows
@@ -296,16 +310,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
color = _event_color(event, owners_seen, palette_rgb)
draw.rounded_rectangle([text_x0, y + 2, text_x0 + 10, y + row_h - 7], radius=3, fill=color)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {event['summary']}"
draw_text(img, (text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), body_font)
draw_text(img, (text_x0 + 18, y), _truncate_to_width(draw, line, body_font, text_w - 18), body_font)
y += row_h
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, weather_cities: list[dict] | None = None,
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
@@ -325,13 +340,14 @@ def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz:
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
weather_cities, weather_font, weather_units)
palette_rgb, weather_cities, weather_font, weather_units)
return img
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, weather_cities: list[dict] | None = None,
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically within the content
region (below each other rather than side-by-side -- narrower than
@@ -359,13 +375,14 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
weather_cities, weather_font, weather_units)
palette_rgb, weather_cities, weather_font, weather_units)
return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int, weather_cities: list[dict] | None = None,
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
@@ -409,17 +426,17 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
color = _event_color(event, owners_seen, palette_rgb)
draw.rounded_rectangle([x0 + 4, y + 1, x0 + 11, y + row_h - 5], radius=2, fill=color)
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
draw_text(img, (x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), chip_font)
draw_text(img, (x0 + 16, y), _truncate_to_width(draw, text, chip_font, col_w - 20), chip_font)
y += row_h
return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int) -> Image.Image:
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
@@ -447,27 +464,27 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
owners_seen: list[str] = []
dot_r = 4
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
color = FG if in_month else MUTED
text_color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, color)
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
dot_y = y0 + row_h - 14
dot_y = y0 + row_h - dot_r * 2 - 6
for i, event in enumerate(day_events[:4]):
color = _owner_color(event["owner_display_name"], owners_seen)
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
dot_x += dot_r * 2 + 4
event_color = _event_color(event, owners_seen, palette_rgb)
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
draw_text(img, (dot_x, dot_y - 4), f"+{len(day_events) - 4}", header_font, MUTED)
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
return img
@@ -478,21 +495,29 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
if view == "agenda":
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
weather_cities, weather_units)
elif view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
weather_cities, weather_units)
elif view == "week":
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, weather_cities, weather_units)
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
weather_cities, weather_units)
elif view == "month":
# Never given weather -- no room for it at typical month-cell size,
# same reasoning that already keeps this view to density dots
# instead of literal event text (see _build_month's own docstring).
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
# Colors are still passed through, though -- that's a different
# concern (legibility of individual events) than weather's
# (space for a whole extra strip of content).
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
else:
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
weather_cities, weather_units)
if fetch_summary:
font = ImageFont.load_default(size=14)
@@ -512,7 +537,7 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
get_or_refresh_weather() cache, or None/[] to omit the weather strip
entirely (also always omitted for view == "month")."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
weather_cities, weather_units)
palette_rgb, weather_cities, weather_units)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
@@ -526,7 +551,7 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
weather_cities, weather_units)
palette_rgb, weather_cities, weather_units)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+13
View File
@@ -160,6 +160,18 @@ def _migration_10(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
def _migration_11(conn) -> None:
"""Manual per-calendar color choice (frame_calendars.color_index,
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
Green). calendar_render.py's event color bar/dot used to auto-cycle
through those same four colors in whatever order calendars happened
to appear; this lets a household pin a specific one instead so it
stays stable and recognizable. NULL (the default) keeps the old
auto-cycle behavior -- no existing frame's render changes until
someone actually picks a color."""
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -171,6 +183,7 @@ MIGRATIONS = [
(8, _migration_8),
(9, _migration_9),
(10, _migration_10),
(11, _migration_11),
]
+7
View File
@@ -291,6 +291,13 @@ class FrameCalendar(Base):
# if the owner's CalDAV account later stops offering this calendar.
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
# text/background) pinning this calendar's events to a specific
# panel color rather than calendar_render.py's old owner-name
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
# owner may set this -- see routers/api_frames.py's api_calendar_color.
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
+39
View File
@@ -469,6 +469,45 @@ def api_calendar_select(
return {"status": "saved", "included": row.included}
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
class CalendarColorRequest(BaseModel):
calendar_key: str
color_index: int | None # None clears the pin, reverting to auto-cycle
@router.post("/api/frames/{frame_id}/calendar-color")
def api_calendar_color(
body: CalendarColorRequest,
request: Request,
frame: Frame = Depends(require_frame_view),
db: Session = Depends(get_db),
):
"""Pins a specific panel color to one of your own included calendars
(models.FrameCalendar.color_index) -- always owner-only, unlike
calendar-select's included=False, since recoloring someone else's
calendar isn't the same kind of "I'd rather not see this" veto as
muting it. None clears the pin, reverting calendar_render.py to its
old auto-cycle-by-owner-name behavior for this calendar."""
user = require_user_api(request, db)
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.frame_id == frame.id,
FrameCalendar.user_id == user.id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Not included on this frame")
row.color_index = body.color_index
frame.calendar_checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
def _calendar_photo_inlay(frame: Frame, db: Session):
"""The photo-inlay's source image (any view now, not just agenda), or
None if inlay is off or the frame's photos-mode album isn't
+5 -2
View File
@@ -389,11 +389,14 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
name = u.display_name or u.username
if fc.calendar_key == "ics":
if u.calendar_ics_url:
sources.append(calendar_feed.CalendarSource(name, "ics", u.calendar_ics_url))
sources.append(calendar_feed.CalendarSource(
name, "ics", u.calendar_ics_url, color_index=fc.color_index
))
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
href = fc.calendar_key[len("caldav:"):]
sources.append(calendar_feed.CalendarSource(
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
color_index=fc.color_index,
))
return sources
+6 -2
View File
@@ -78,9 +78,10 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
for u in users:
is_self = u.id == viewer_id
if is_self:
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": included.get(c["key"], False)}
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in _user_available_calendars(u)
]
else:
@@ -119,6 +120,9 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
week_start_labels=WEEK_START_LABELS,
calendar_color_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
)
+27
View File
@@ -52,6 +52,33 @@ document.querySelectorAll('.calendar-toggle').forEach((el) => {
});
});
// Per-calendar color pin -- owner-only (the server enforces it; these
// buttons only ever render for the viewer's own calendars anyway, see
// frame_calendar.html). Clicking the currently-selected swatch again has
// no special "toggle off" behavior -- use the explicit Auto button.
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
+13
View File
@@ -265,6 +265,19 @@ input:focus, select:focus {
.calendar-user-list > li { margin-top: 14px; }
.calendar-user-list > li:first-child { margin-top: 0; }
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.calendar-row { flex-wrap: wrap; }
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
.color-swatch {
width: 20px; height: 20px; padding: 0; margin: 0;
border: 2px solid var(--border); border-radius: 5px;
box-shadow: none; cursor: pointer;
}
.color-swatch.selected { border-color: var(--text); box-shadow: 0 0 0 1.5px var(--text); }
.color-swatch-auto {
width: auto; height: 20px; padding: 0 6px; font-size: 10px; font-weight: 600;
color: var(--text-muted); background: var(--surface-alt);
}
.color-swatch-auto.selected { color: var(--text); }
button {
margin-top: 20px;
+16 -3
View File
@@ -60,13 +60,26 @@
<li>
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.calendars %}
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for c in u.calendars %}
<label class="checkbox-row" style="margin-top: 6px;">
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-toggle"
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
{{ c.label }}
</label>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
{% if u.is_self %}
<span class="calendar-color-picker" data-key="{{ c.key }}">
{% for idx in range(2, 6) %}
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
data-index="{{ idx }}" title="{{ calendar_color_labels[idx] }}"
style="background-color: {{ current_hex[idx] }};"></button>
{% endfor %}
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
data-index="" title="Auto (assigned automatically)">Auto</button>
</span>
{% endif %}
</div>
{% endfor %}
{% elif u.is_self %}
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>