diff --git a/server/app/calendar_feed.py b/server/app/calendar_feed.py index 7cb7266..822e9d8 100644 --- a/server/app/calendar_feed.py +++ b/server/app/calendar_feed.py @@ -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"]) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index 4e722a6..876e7e0 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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() diff --git a/server/app/migration.py b/server/app/migration.py index 589e936..be2ebc5 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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), ] diff --git a/server/app/models.py b/server/app/models.py index 4d721b1..14e6819 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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), diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 8b09590..f37637d 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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 diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 8e0b0ae..af1c44e 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index c8b1078..cf4bdb6 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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, ) diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js index e9ba077..b773b90 100644 --- a/server/app/static/frame_calendar.js +++ b/server/app/static/frame_calendar.js @@ -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({ diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 21c2b7d..d57d199 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -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; diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html index 16cdec3..9b4e1d5 100644 --- a/server/app/templates/frame_calendar.html +++ b/server/app/templates/frame_calendar.html @@ -60,13 +60,26 @@
{{ u.display_name }}{% if u.is_self %} (you){% endif %}
{% 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 %} -