From db9a6f1875707647a4737f315b6bb1b1e752b297 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 23 Jul 2026 08:17:25 -0400 Subject: [PATCH] Week view: relative start-day offset for non-7-day counts; hide week-only settings elsewhere calendar_week_start's fixed-weekday anchor ("start on the most recent Monday") stops making sense once the view isn't a literal calendar week, so a non-7-day week view now starts calendar_week_start_offset days from today instead (0 = starts today, negative/positive = past/ future) -- calendar_week_start still governs at the default 7 days, unchanged. Also hides the Calendar tab's week-only fields (days to show, layout, start offset) unless View is actually set to Week, and further hides the new start-offset field specifically when Days to show is 7 (where it has no effect). "Week starts on" stays visible for Month too, since it actually still applies there. --- server/app/calendar_render.py | 31 +++++++++++----- server/app/migration.py | 11 ++++++ server/app/models.py | 9 +++++ server/app/routers/api_frames.py | 7 ++++ server/app/routers/device.py | 3 +- server/app/static/frame_calendar.js | 21 +++++++++++ server/app/templates/frame_calendar.html | 47 +++++++++++++++--------- 7 files changed, 101 insertions(+), 28 deletions(-) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index dbe53f5..3e6c8a1 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -642,7 +642,8 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", - days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None) -> Image.Image: + days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None, + start_offset: int = 0) -> Image.Image: """`days` (2-10, see routers/api_frames.py's clamp) side-by-side columns (layout="horizontal", the original fixed-at-7 behavior generalized) or stacked bands (layout="vertical", reusing @@ -650,7 +651,13 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo an arbitrary day count instead of a hardcoded 2). `tasks` (see routers/common.py's get_or_refresh_tasks), if not None, takes the LAST slot instead of adding an extra one -- "N days" always means N - slots total, whether they're all days or N-1 days plus a task list.""" + slots total, whether they're all days or N-1 days plus a task list. + + At the default 7 days, the view anchors to week_start (a fixed + weekday, "start on the most recent Monday") exactly like before -- + otherwise "start of the week" doesn't mean much for an arbitrary day + count, so it instead starts `start_offset` days from today (0 = + today, see routers/api_frames.py's api_config_save).""" logical_w, logical_h = logical_render_size(orientation) img = Image.new("RGB", (logical_w, logical_h), BG) if photo_inlay is not None: @@ -659,8 +666,11 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo draw = ImageDraw.Draw(img) today = datetime.now(tz).date() - days_since_start = (today.weekday() - week_start) % 7 - week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) + if days == 7: + days_since_start = (today.weekday() - week_start) % 7 + week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) + else: + week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset) day_count = days - 1 if tasks is not None else days owners_seen: list[str] = [] @@ -802,7 +812,8 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: 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", - week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None) -> Image.Image: + week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None, + week_start_offset: int = 0) -> Image.Image: tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") if view == "agenda": img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb, @@ -812,7 +823,7 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str, weather_cities, weather_units) elif view == "week": img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb, - weather_cities, weather_units, week_days, week_layout, tasks) + weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) elif view == "month": # Never given weather or tasks -- no room for either at typical # month-cell size, same reasoning that already keeps this view @@ -839,7 +850,7 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal", - tasks: list[dict] | None = None) -> bytes: + tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes: """Renders one of CALENDAR_VIEWS to the panel's packed format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every other renderer honors. weather_cities is routers/common.py's @@ -848,7 +859,7 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati get_or_refresh_tasks()'s cache, or None to omit the task list entirely -- only ever drawn for view == "week", see _build_week.""" img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start, - palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks) + palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) @@ -859,12 +870,12 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal", - tasks: list[dict] | None = None) -> bytes: + tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes: """Same pipeline as render_calendar, but a normal browser-viewable 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, - palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks) + palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) 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 fbade0f..ca4e75a 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -189,6 +189,16 @@ def _migration_12(conn) -> None: conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT")) +def _migration_13(conn) -> None: + """A day-count-relative start offset for the week view + (calendar_week_start_offset), used instead of calendar_week_start's + fixed-weekday anchor once the view isn't a literal 7-day week -- + "start on the most recent Monday" stops meaning much for e.g. a + 5-day view. Default 0 (starts today) is a behavior-preserving no-op + until someone changes the day count away from 7.""" + conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -202,6 +212,7 @@ MIGRATIONS = [ (10, _migration_10), (11, _migration_11), (12, _migration_12), + (13, _migration_13), ] diff --git a/server/app/models.py b/server/app/models.py index fc597bd..dd77673 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -207,6 +207,15 @@ class Frame(Base): # columns or stacked bands (see calendar_render.py's _build_week). calendar_week_days: Mapped[int] = mapped_column(Integer, default=7) calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical" + # Only used when calendar_week_days != 7 -- calendar_week_start's + # fixed-weekday anchor ("start on the most recent Monday") stops + # making sense once the view isn't a literal calendar week, so a + # non-7-day view instead starts this many days from today (0 = + # starts today, negative = starts in the past, positive = starts in + # the future). Ignored (calendar_week_start governs instead) at the + # default 7 days, so this has no effect until someone actually + # changes the day count. + calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0) # Optional task list, week view only -- takes the space of one day # slot rather than adding an extra one (see calendar_render.py's diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index b687f9f..f5ab3d2 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -104,6 +104,7 @@ def api_config_save( calendar_week_start: int | None = Form(None), calendar_week_days: int | None = Form(None), calendar_week_layout: str | None = Form(None), + calendar_week_start_offset: int | None = Form(None), calendar_weather_enabled: bool | None = Form(None), calendar_weather_units: str | None = Form(None), calendar_tasks_enabled: bool | None = Form(None), @@ -194,6 +195,11 @@ def api_config_save( cfg.calendar_week_days = new_days if calendar_week_layout is not None: cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" + if calendar_week_start_offset is not None: + new_offset = max(-30, min(30, calendar_week_start_offset)) + if new_offset != cfg.calendar_week_start_offset: + cfg.calendar_browse_offset = 0 + cfg.calendar_week_start_offset = new_offset if calendar_weather_enabled is not None: cfg.calendar_weather_enabled = calendar_weather_enabled if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): @@ -571,6 +577,7 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session week_start=frame.calendar_week_start, weather_cities=weather_cities, weather_units=frame.calendar_weather_units, week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks, + week_start_offset=frame.calendar_week_start_offset, ) return Response(content=png, media_type="image/png") diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 73a503c..e27a3af 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -149,6 +149,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d week_start = locked.calendar_week_start week_days = locked.calendar_week_days week_layout = locked.calendar_week_layout + week_start_offset = locked.calendar_week_start_offset inlay_wanted = locked.calendar_photo_inlay events, summary = get_or_refresh_calendar_events(db, frame) @@ -183,7 +184,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start, weather_cities=weather_cities, weather_units=frame.calendar_weather_units, - week_days=week_days, week_layout=week_layout, tasks=tasks, + week_days=week_days, week_layout=week_layout, tasks=tasks, week_start_offset=week_start_offset, ) diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js index f4811c3..6c61704 100644 --- a/server/app/static/frame_calendar.js +++ b/server/app/static/frame_calendar.js @@ -3,6 +3,26 @@ // Calendar card became its own tab (window.FRAME_API is set by the // template; checkboxes are always sent explicitly as "true"/"false"). +// Week-view-only settings (days/layout/start-offset) only matter when +// View is actually "Week"; "Week starts on" also matters for Month, so +// it gets its own, slightly looser condition. The start-offset row is +// further gated on the day count -- it's meaningless at the default 7 +// days, where "Week starts on" governs instead (see +// calendar_render.py's _build_week). +function updateCalendarFieldVisibility() { + const view = document.getElementById('calendar_view').value; + const days = Number(document.getElementById('calendar_week_days').value); + const isWeek = view === 'week'; + document.getElementById('calendar-week-start-row').style.display = + (view === 'week' || view === 'month') ? '' : 'none'; + document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none'; + document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none'; + document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none'; +} +document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility); +document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility); +updateCalendarFieldVisibility(); + document.getElementById('calendar-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = new URLSearchParams({ @@ -10,6 +30,7 @@ document.getElementById('calendar-config-form').addEventListener('submit', async calendar_week_start: document.getElementById('calendar_week_start').value, calendar_week_days: document.getElementById('calendar_week_days').value, calendar_week_layout: document.getElementById('calendar_week_layout').value, + calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked), }); try { diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html index ffcbcc0..3329ba6 100644 --- a/server/app/templates/frame_calendar.html +++ b/server/app/templates/frame_calendar.html @@ -31,23 +31,36 @@ {% endfor %} - -

Only affects the Week and Month views.

- - +
+ +

Only affects the Week and Month views, and (for Week) only at 7 days.

+
+
+ +
+
+ +
+
+ +

0 = starts today, negative = starts in the + past, positive = starts in the future. Only used when Days to show isn't 7.

+