From d1a69498a3ac89002914ce4cadd27e85e1425da8 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 09:04:36 +0100 Subject: [PATCH 1/8] Fix the progress chart freeze across a DST fall-back --- frontend/src/lib/chartjs/register.test.ts | 123 ++++++++++++++++++++++ frontend/src/lib/chartjs/register.ts | 57 +++++++++- 2 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/chartjs/register.test.ts diff --git a/frontend/src/lib/chartjs/register.test.ts b/frontend/src/lib/chartjs/register.test.ts new file mode 100644 index 00000000..59a57d0a --- /dev/null +++ b/frontend/src/lib/chartjs/register.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { _adapters } from 'chart.js'; +import '#lib/chartjs/register.js'; +import { getTimezone, setTimezone } from '#lib/stores/timezone.js'; + +// Minimal shape of the dayjs-based date adapter installed by register.ts. +type DateAdapter = { + add(time: number, amount: number, unit: string): number; + startOf(time: number, unit: string, weekday?: number): number; + endOf(time: number, unit: string): number; +}; + +// `_adapters._date` is the DateAdapterBase class, and the static override() in +// register.ts patches its prototype, so a plain instance exposes the adapter. +const adapter = new (_adapters._date as unknown as new () => DateAdapter)(); + +const TICK_LIMIT = 1000; +const originalProfileTz = getTimezone(); + +// The freeze only reproduced when the device time zone and the profile time +// zone were the same fall-back zone, so pin both for this file. Otherwise a +// CI machine in, say, Europe/Berlin never hits the broken dayjs path. +beforeAll(() => { + vi.stubEnv('TZ', 'America/New_York'); + setTimezone('America/New_York'); +}); + +afterAll(() => { + vi.unstubAllEnvs(); + setTimezone(originalProfileTz); +}); + +function hourlyTicks(min: number, max: number): { ticks: number[]; next: number } { + const ticks: number[] = []; + let time = adapter.startOf(min, 'hour'); + while (time < max && ticks.length < TICK_LIMIT) { + ticks.push(time); + time = adapter.add(time, 1, 'hour'); + } + return { ticks, next: time }; +} + +function expectStrictlyIncreasing(values: number[]) { + for (let i = 1; i < values.length; i++) { + expect(values[i]).toBeGreaterThan(values[i - 1]); + } +} + +describe('chartjs dayjs date adapter across DST transitions', () => { + // 2026-11-01 01:00-02:00 America/New_York happens twice because clocks + // fall back from 02:00 EDT to 01:00 EST. + const firstOneAm = Date.UTC(2026, 10, 1, 5, 0); // 01:00 EDT + const secondOneAm = Date.UTC(2026, 10, 1, 6, 0); // 01:00 EST + const firstHalfPastOne = Date.UTC(2026, 10, 1, 5, 30); // 01:30 EDT + const secondHalfPastOne = Date.UTC(2026, 10, 1, 6, 30); // 01:30 EST + + it('runs with the pinned New York device and profile time zones', () => { + // If the TZ pin silently stops working, the tick-loop tests below would + // pass vacuously on the old adapter, so assert the premise loudly. + expect(new Date(firstOneAm).getTimezoneOffset()).toBe(240); // EDT + expect(new Date(secondOneAm).getTimezoneOffset()).toBe(300); // EST + expect(getTimezone()).toBe('America/New_York'); + }); + + it('add() always moves forward inside the repeated hour', () => { + expect(adapter.add(firstOneAm, 1, 'hour')).toBe(secondOneAm); + expect(adapter.add(secondOneAm, 1, 'hour')).toBe(Date.UTC(2026, 10, 1, 7, 0)); + // A minute step from 01:59 EDT must land on 01:00 EST, not go backwards. + expect(adapter.add(Date.UTC(2026, 10, 1, 5, 59), 1, 'minute')).toBe(secondOneAm); + }); + + it('startOf() rounds down to the local hour boundary of the instant', () => { + expect(adapter.startOf(firstHalfPastOne, 'hour')).toBe(firstOneAm); + expect(adapter.startOf(secondHalfPastOne, 'hour')).toBe(secondOneAm); + // Already on a boundary: must not jump to the previous occurrence. + expect(adapter.startOf(secondOneAm, 'hour')).toBe(secondOneAm); + }); + + it('endOf() ends the hour containing the instant', () => { + expect(adapter.endOf(secondHalfPastOne, 'hour')).toBe(Date.UTC(2026, 10, 1, 6, 59, 59, 999)); + }); + + it('startOf() and endOf() keep local boundaries in a non-whole-hour zone', () => { + setTimezone('Asia/Kolkata'); + try { + // 12:15 IST floors to 12:00 IST and ends at 12:59:59.999 IST. + expect(adapter.startOf(Date.UTC(2026, 10, 1, 6, 45), 'hour')).toBe(Date.UTC(2026, 10, 1, 6, 30)); + expect(adapter.endOf(Date.UTC(2026, 10, 1, 6, 45), 'hour')).toBe(Date.UTC(2026, 10, 1, 7, 29, 59, 999)); + } finally { + setTimezone('America/New_York'); + } + }); + + it('generates a finite, strictly increasing sequence across the fall-back', () => { + // The reproduction data from the bug report: a book started 2026-10-31 + // 18:59 UTC and read until 2026-11-01 17:04 UTC with a New York profile. + const min = Date.UTC(2026, 9, 31, 18, 59); + const max = Date.UTC(2026, 10, 1, 17, 4); + const { ticks, next } = hourlyTicks(min, max); + + // Before the fix the loop got stuck at 05:00Z and never reached max. + expect(ticks.length).toBeLessThan(TICK_LIMIT); + expect(next).toBeGreaterThanOrEqual(max); + expectStrictlyIncreasing(ticks); + // Both real 01:00 instants must be present, the repeated hour is not skipped. + expect(ticks).toContain(firstOneAm); + expect(ticks).toContain(secondOneAm); + }); + + it('generates a finite, strictly increasing sequence across spring-forward', () => { + // 2026-03-08 02:00 America/New_York does not exist, so the step from + // 01:00 EST goes straight to 03:00 EDT. + const min = Date.UTC(2026, 2, 8, 4, 0); // 23:00 EST on Mar 7 + const max = Date.UTC(2026, 2, 8, 12, 0); // 08:00 EDT on Mar 8 + const { ticks, next } = hourlyTicks(min, max); + + expect(ticks.length).toBeLessThan(TICK_LIMIT); + expect(next).toBeGreaterThanOrEqual(max); + expectStrictlyIncreasing(ticks); + expect(ticks).toContain(Date.UTC(2026, 2, 8, 6, 0)); // 01:00 EST + expect(ticks).toContain(Date.UTC(2026, 2, 8, 7, 0)); // 03:00 EDT + }); +}); diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index f6394533..587a0226 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -47,6 +47,38 @@ dayjs.extend(localizedFormat); dayjs.extend(quarterOfYear); dayjs.extend(weekday); +// Sub-day units are fixed-length durations. Chart.js advances its tick loop +// with add(), so these must be computed on the epoch value: dayjs's zoned +// arithmetic can stall or step backwards inside a DST fall-back, which makes +// the tick loop run forever and freezes the browser. +const SUB_DAY_UNIT_MS: Record = { + millisecond: 1, + ms: 1, + second: 1_000, + s: 1_000, + minute: 60_000, + m: 60_000, + hour: 3_600_000, + h: 3_600_000 +}; + +function subDayUnitMs(unit: string): number | undefined { + const key = unit.toLowerCase(); + return Object.hasOwn(SUB_DAY_UNIT_MS, key) ? SUB_DAY_UNIT_MS[key] : undefined; +} + +function zoneOffsetMs(time: number): number { + return dayjs.utc(time).tz(getTimezone()).utcOffset() * 60_000; +} + +function positiveModulo(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} + +function startOfLocalUnit(time: number, unitMs: number): number { + return time - positiveModulo(time + zoneOffsetMs(time), unitMs); +} + const FORMATS: Record = { datetime: 'MMM D, YYYY, h:mm:ss a', millisecond: 'h:mm:ss.SSS a', @@ -73,16 +105,35 @@ _adapters._date.override({ return d.isValid() ? d.valueOf() : null; }, format: (time: unknown, format: string) => dayjs.utc(time as number).tz(getTimezone()).format(format), - add: (time: unknown, amount: number, unit: string) => dayjs.utc(time as number).tz(getTimezone()).add(amount, unit as dayjs.ManipulateType).valueOf(), + add: (time: unknown, amount: number, unit: string) => { + const t = time as number; + const size = subDayUnitMs(unit); + if (size !== undefined) { + return t + amount * size; + } + return dayjs.utc(t).tz(getTimezone()).add(amount, unit as dayjs.ManipulateType).valueOf(); + }, diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).tz(getTimezone()).diff(dayjs.utc(min as number).tz(getTimezone()), unit as dayjs.OpUnitType), startOf: (time: unknown, unit: string, weekday?: number) => { - const date = dayjs.utc(time as number).tz(getTimezone()); + const t = time as number; + const size = subDayUnitMs(unit); + if (size !== undefined) { + return startOfLocalUnit(t, size); + } + const date = dayjs.utc(t).tz(getTimezone()); if (unit === 'isoWeek') { return (date as unknown as { weekday: (w: number) => { valueOf: () => number } }).weekday(weekday ?? 1).valueOf(); } return date.startOf(unit as dayjs.OpUnitType).valueOf(); }, - endOf: (time: unknown, unit: string) => dayjs.utc(time as number).tz(getTimezone()).endOf(unit as dayjs.OpUnitType).valueOf(), + endOf: (time: unknown, unit: string) => { + const t = time as number; + const size = subDayUnitMs(unit); + if (size !== undefined) { + return startOfLocalUnit(t, size) + size - 1; + } + return dayjs.utc(t).tz(getTimezone()).endOf(unit as dayjs.OpUnitType).valueOf(); + }, } as unknown as Parameters[0]); export { Chart as ChartJS }; From 103c4b89ae9b2e5d40c7fc97e1255aa50af44b62 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 09:04:43 +0100 Subject: [PATCH 2/8] Keep day and month chart ticks aligned across DST changes --- frontend/src/lib/chartjs/register.test.ts | 10 ++++++++++ frontend/src/lib/chartjs/register.ts | 13 ++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/chartjs/register.test.ts b/frontend/src/lib/chartjs/register.test.ts index 59a57d0a..53be7033 100644 --- a/frontend/src/lib/chartjs/register.test.ts +++ b/frontend/src/lib/chartjs/register.test.ts @@ -91,6 +91,16 @@ describe('chartjs dayjs date adapter across DST transitions', () => { } }); + it('add() keeps local wall-clock time for calendar units', () => { + // After the fall-back, a fixed 24h step would land at 23:00 instead of + // local midnight, which drifts day and month ticks. + expect(adapter.add(Date.UTC(2026, 9, 31, 4, 0), 1, 'day')).toBe(Date.UTC(2026, 10, 1, 4, 0)); + expect(adapter.add(Date.UTC(2026, 10, 1, 4, 0), 1, 'day')).toBe(Date.UTC(2026, 10, 2, 5, 0)); + expect(adapter.add(Date.UTC(2026, 9, 1, 4, 0), 1, 'month')).toBe(Date.UTC(2026, 10, 1, 4, 0)); + expect(adapter.add(Date.UTC(2026, 10, 1, 4, 0), 1, 'month')).toBe(Date.UTC(2026, 11, 1, 5, 0)); + expect(adapter.add(Date.UTC(2026, 10, 2, 5, 0), -1, 'day')).toBe(Date.UTC(2026, 10, 1, 4, 0)); + }); + it('generates a finite, strictly increasing sequence across the fall-back', () => { // The reproduction data from the bug report: a book started 2026-10-31 // 18:59 UTC and read until 2026-11-01 17:04 UTC with a New York profile. diff --git a/frontend/src/lib/chartjs/register.ts b/frontend/src/lib/chartjs/register.ts index 587a0226..14772d09 100644 --- a/frontend/src/lib/chartjs/register.ts +++ b/frontend/src/lib/chartjs/register.ts @@ -79,6 +79,17 @@ function startOfLocalUnit(time: number, unitMs: number): number { return time - positiveModulo(time + zoneOffsetMs(time), unitMs); } +// Calendar units must keep their local wall-clock time. Zoned dayjs.add() is +// a fixed-duration step for day and larger units, so it drifts by an hour +// after a DST transition. Do the arithmetic on the wall clock, then resolve +// the result back in the profile zone. +function addCalendarUnit(time: number, amount: number, unit: dayjs.ManipulateType): number { + if (amount === 0) return time; + const tz = getTimezone(); + const wall = dayjs.utc(time + zoneOffsetMs(time)).add(amount, unit); + return dayjs.tz(wall.format('YYYY-MM-DDTHH:mm:ss.SSS'), tz).valueOf(); +} + const FORMATS: Record = { datetime: 'MMM D, YYYY, h:mm:ss a', millisecond: 'h:mm:ss.SSS a', @@ -111,7 +122,7 @@ _adapters._date.override({ if (size !== undefined) { return t + amount * size; } - return dayjs.utc(t).tz(getTimezone()).add(amount, unit as dayjs.ManipulateType).valueOf(); + return addCalendarUnit(t, amount, unit as dayjs.ManipulateType); }, diff: (max: unknown, min: unknown, unit: string) => dayjs.utc(max as number).tz(getTimezone()).diff(dayjs.utc(min as number).tz(getTimezone()), unit as dayjs.OpUnitType), startOf: (time: unknown, unit: string, weekday?: number) => { From d74af3f1f1e036d6d4264c9e293593401b323b69 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 09:04:45 +0100 Subject: [PATCH 3/8] Document the DST chart fixes in the release notes --- docs/releases.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 2f47ca41..69cb7420 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,6 +6,12 @@ All LibrisLog releases, newest first — what's new, what was fixed, and anythin You can also browse the [GitHub Releases](https://github.com/codebude/librislog/releases) page and the [full changelog](https://github.com/codebude/librislog/commits/main). +## Unreleased + +::: tip ⭐ v1.12.1 - DST Chart Freeze Fix +LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's reading progress chart crossed a daylight-saving fall-back in the user's profile time zone, the chart's time axis could loop forever and lock up the page. Time steps now keep advancing across the transition, and day, week, and month ticks stay on their local calendar boundaries. No data changes or manual steps are needed. +::: + ## Latest Release ::: tip ⭐ v1.12.0 - Mass Tag Editor, Profile Pictures & Authentication Methods @@ -16,6 +22,7 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man | Version | Date | Type | |---|---|---| +| [v1.12.1](#v1-12-1-dst-chart-freeze-fix) | Unreleased | Maintenance | | [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | 2026-11-02 | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | @@ -38,6 +45,23 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man --- +## v1.12.1: DST Chart Freeze Fix + + + +**Summary:** LibrisLog v1.12.1 fixes a browser freeze in the book detail view when a book's reading progress chart spans a daylight-saving fall-back in the profile time zone. Sub-day time steps now run on the UTC timeline, so tick generation always completes, and day, week, and month steps keep their local wall-clock time, so calendar ticks no longer drift after a transition. + +**Bug fixes** +- 🕒 **No more freeze across a DST fall-back.** When the device and profile time zones were the same zone that observes daylight saving, a time step inside the repeated hour could stall or move backwards. Chart.js then kept generating ticks forever, which froze the book detail dialog in Firefox and Chromium-based browsers. Millisecond, second, minute, and hour steps now use the UTC epoch value, so they always move forward. Hour ticks still start on local hour boundaries and show the repeated hour correctly when ticks are hourly, for example 1AM twice. +- 📅 **Calendar ticks stay aligned.** Day, week, month, quarter, and year steps now keep the local wall-clock time instead of adding a fixed number of milliseconds, so day and month ticks no longer land at 23:00 or drift into the wrong month after a DST change. +- 🧪 **Regression coverage.** A new test pins the device and profile time zones to `America/New_York` and covers fall-back and spring-forward tick sequences, monotonic time steps, local `startOf` and `endOf` boundaries, non-whole-hour zones, and calendar-unit steps. + +**Breaking changes:** None. + +[Compare with v1.12.0](https://github.com/codebude/librislog/compare/v1.12.0...HEAD) + +--- + ## v1.12.0: Mass Tag Editor, Profile Pictures & Authentication Methods From da1fab4c0e74075e132edcb88efb22c97d7387e8 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 11:01:03 +0100 Subject: [PATCH 4/8] Add an explicit timezone choice flag to user settings --- ...add_timezone_configured_to_usersettings.py | 39 +++++++++++ backend/app/models.py | 4 ++ backend/app/routers/profile.py | 4 ++ backend/app/schemas.py | 21 ++++++ backend/tests/test_auth_profile_users.py | 65 +++++++++++++++++++ backend/tests/test_profile.py | 2 + 6 files changed, 135 insertions(+) create mode 100644 backend/alembic/versions/c8d1e2f3a4b5_add_timezone_configured_to_usersettings.py diff --git a/backend/alembic/versions/c8d1e2f3a4b5_add_timezone_configured_to_usersettings.py b/backend/alembic/versions/c8d1e2f3a4b5_add_timezone_configured_to_usersettings.py new file mode 100644 index 00000000..1f55a7b6 --- /dev/null +++ b/backend/alembic/versions/c8d1e2f3a4b5_add_timezone_configured_to_usersettings.py @@ -0,0 +1,39 @@ +"""add timezone_configured to usersettings + +Revision ID: c8d1e2f3a4b5 +Revises: b4c5d6e7f8a9 +Create Date: 2026-11-03 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c8d1e2f3a4b5" +down_revision: Union[str, Sequence[str], None] = "b4c5d6e7f8a9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "usersettings", + sa.Column( + "timezone_configured", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + # Preserve the old behaviour for existing accounts: a non-UTC value was + # either picked by the user or already auto-detected, while the UTC default + # still needs the one-time detection on the next login. + op.execute("UPDATE usersettings SET timezone_configured = true WHERE timezone != 'UTC'") + + +def downgrade() -> None: + # The explicit-choice bit cannot be reconstructed by a later upgrade: + # UTC rows are treated as unconfigured again and re-detected once. + op.drop_column("usersettings", "timezone_configured") diff --git a/backend/app/models.py b/backend/app/models.py index 21dc4eb4..affbd1f4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -219,6 +219,10 @@ class UserSettings(SQLModel, table=True): user_id: int = Field(foreign_key="user.id", unique=True, index=True) language: str = Field(default="en", max_length=10) timezone: str = Field(default="UTC", max_length=64) + # False means the account still uses the default and the login/OIDC flows + # may replace it with the detected zone. True means the stored zone is + # authoritative, even when it is UTC. + timezone_configured: bool = Field(default=False) theme: str = Field(default="light", max_length=20) custom_theme: Optional[str] = Field(default=None, max_length=30) goal_pages_per_day_enabled: bool = Field(default=False) diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 21ad9afb..e136dd7b 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -312,6 +312,7 @@ def get_settings( user_id=settings.user_id, language=settings.language, timezone=settings.timezone, + timezone_configured=settings.timezone_configured, theme=settings.theme, custom_theme=settings.custom_theme, goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, @@ -357,6 +358,8 @@ def update_settings( if custom_from is not None and custom_to is not None and (custom_to - custom_from).days > MAX_CUSTOM_RANGE_DAYS: raise HTTPException(status_code=422, detail="Statistics custom range cannot exceed 25 years") settings.sqlmodel_update(update_data) + if update_data.get("timezone") is not None: + settings.timezone_configured = update_data.get("timezone_configured", True) if settings.theme != 'custom': settings.custom_theme = None session.add(settings) @@ -367,6 +370,7 @@ def update_settings( user_id=settings.user_id, language=settings.language, timezone=settings.timezone, + timezone_configured=settings.timezone_configured, theme=settings.theme, custom_theme=settings.custom_theme, goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 29025674..3888150e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -4,6 +4,7 @@ from datetime import date, datetime from enum import Enum from typing import Literal +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel, field_validator, model_validator import pydantic @@ -548,6 +549,7 @@ class UserSettingsRead(SQLModel): user_id: int language: str timezone: str + timezone_configured: bool theme: str custom_theme: Optional[str] = None goal_pages_per_day_enabled: bool @@ -570,6 +572,7 @@ class UserSettingsUpdate(SQLModel): """User settings update request.""" language: Optional[str] = None timezone: Optional[str] = None + timezone_configured: Optional[bool] = None theme: Optional[str] = None custom_theme: Optional[str] = None goal_pages_per_day_enabled: Optional[bool] = None @@ -594,6 +597,24 @@ def validate_date_automation_setting(cls, value: Optional[bool]) -> Optional[boo raise ValueError("Reading date automation settings cannot be null") return value + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: Optional[str]) -> Optional[str]: + if value is None: + raise ValueError("timezone cannot be null") + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError): + raise ValueError("timezone must be a valid IANA zone name") + return value + + @field_validator("timezone_configured") + @classmethod + def validate_timezone_configured(cls, value: Optional[bool]) -> Optional[bool]: + if value is None: + raise ValueError("timezone_configured cannot be null") + return value + @field_validator('theme') @classmethod def validate_theme(cls, v: Optional[str]) -> Optional[str]: diff --git a/backend/tests/test_auth_profile_users.py b/backend/tests/test_auth_profile_users.py index 4fb8244e..238d23d4 100644 --- a/backend/tests/test_auth_profile_users.py +++ b/backend/tests/test_auth_profile_users.py @@ -220,6 +220,7 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert current.status_code == 200 assert current.json()["language"] == "en" assert current.json()["timezone"] == "UTC" + assert current.json()["timezone_configured"] is False assert current.json()["theme"] == "light" assert current.json()["custom_theme"] is None @@ -231,6 +232,7 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: tz_updated = client.patch("/api/profile/settings", json={"timezone": "Europe/Berlin"}) assert tz_updated.status_code == 200 assert tz_updated.json()["timezone"] == "Europe/Berlin" + assert tz_updated.json()["timezone_configured"] is True theme_updated = client.patch("/api/profile/settings", json={"theme": "dark"}) assert theme_updated.status_code == 200 @@ -243,6 +245,69 @@ def test_profile_settings_get_and_update(client: TestClient) -> None: assert custom_updated.json()["custom_theme"] == "dracula" +def test_profile_settings_explicit_utc_marks_timezone_configured(client: TestClient) -> None: + current = client.get("/api/profile/settings") + assert current.status_code == 200 + assert current.json()["timezone"] == "UTC" + assert current.json()["timezone_configured"] is False + + updated = client.patch("/api/profile/settings", json={"timezone": "UTC"}) + assert updated.status_code == 200 + assert updated.json()["timezone"] == "UTC" + assert updated.json()["timezone_configured"] is True + + reloaded = client.get("/api/profile/settings") + assert reloaded.status_code == 200 + assert reloaded.json()["timezone_configured"] is True + + +def test_profile_settings_rejects_invalid_timezone(client: TestClient) -> None: + for value in ("", "Not/AZone", None): + response = client.patch("/api/profile/settings", json={"timezone": value}) + assert response.status_code == 422, value + + +def test_profile_settings_keep_configured_flag_without_timezone(client: TestClient) -> None: + client.patch("/api/profile/settings", json={"timezone": "America/New_York"}) + + updated = client.patch("/api/profile/settings", json={"language": "de"}) + assert updated.status_code == 200 + assert updated.json()["timezone"] == "America/New_York" + assert updated.json()["timezone_configured"] is True + + +def test_profile_settings_reject_null_timezone_configured(client: TestClient) -> None: + response = client.patch("/api/profile/settings", json={"timezone_configured": None}) + assert response.status_code == 422 + + +def test_profile_settings_can_return_to_automatic_timezone(client: TestClient) -> None: + client.patch("/api/profile/settings", json={"timezone": "UTC"}) + pinned = client.get("/api/profile/settings").json() + assert pinned["timezone_configured"] is True + + reset = client.patch( + "/api/profile/settings", + json={"timezone": "America/New_York", "timezone_configured": False}, + ) + assert reset.status_code == 200 + assert reset.json()["timezone"] == "America/New_York" + assert reset.json()["timezone_configured"] is False + + reloaded = client.get("/api/profile/settings").json() + assert reloaded["timezone"] == "America/New_York" + assert reloaded["timezone_configured"] is False + + +def test_profile_settings_flag_resets_without_changing_timezone(client: TestClient) -> None: + client.patch("/api/profile/settings", json={"timezone": "America/New_York"}) + + reset = client.patch("/api/profile/settings", json={"timezone_configured": False}) + assert reset.status_code == 200 + assert reset.json()["timezone"] == "America/New_York" + assert reset.json()["timezone_configured"] is False + + def test_profile_api_key_lifecycle(client: TestClient) -> None: listed = client.get("/api/profile/api-keys") assert listed.status_code == 200 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 02cc06f8..43833331 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -42,6 +42,7 @@ def test_get_settings_creates_default_when_missing(client: TestClient, session: data = resp.json() assert data["language"] == "en" assert data["user_id"] == user.id + assert data["timezone_configured"] is False assert data["auto_set_date_started"] is True assert data["auto_set_date_finished"] is True @@ -70,6 +71,7 @@ def test_update_settings_creates_default_when_missing(client: TestClient, sessio data = resp.json() assert data["language"] == "de" assert data["user_id"] == user.id + assert data["timezone_configured"] is False def test_statistics_range_settings_are_persisted(client: TestClient) -> None: From 2384e969d47e51f2c240e665b4cac2c2528cdf1b Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 11:01:08 +0100 Subject: [PATCH 5/8] Use the explicit timezone flag during login and OIDC sign-in --- frontend/src/lib/api.test.ts | 1 + frontend/src/lib/api.ts | 2 +- frontend/src/lib/types.ts | 1 + .../routes/auth/oidc/callback/+page.svelte | 6 +- .../routes/auth/oidc/callback/page.test.ts | 84 ++++++++++ frontend/src/routes/login/+page.svelte | 13 +- frontend/src/routes/login/timezone.test.ts | 150 ++++++++++++++++++ 7 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 frontend/src/routes/auth/oidc/callback/page.test.ts create mode 100644 frontend/src/routes/login/timezone.test.ts diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 7c2eafd6..96ebab92 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -263,6 +263,7 @@ describe('api.statistics.gamification', () => { user_id: 1, language: 'en', timezone: 'UTC', + timezone_configured: true, theme: 'light', custom_theme: null, goal_pages_per_day_enabled: true, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a15dce5..01c3799f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -211,7 +211,7 @@ export const api = { return request('/profile/settings'); }, - updateSettings(data: Partial): Promise { + updateSettings(data: Partial>): Promise { return request('/profile/settings', { method: 'PATCH', body: JSON.stringify(data) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1201e614..da509900 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -258,6 +258,7 @@ export interface UserSettings { user_id: number; language: string; timezone: string; + timezone_configured: boolean; theme: string; custom_theme: string | null; goal_pages_per_day_enabled: boolean; diff --git a/frontend/src/routes/auth/oidc/callback/+page.svelte b/frontend/src/routes/auth/oidc/callback/+page.svelte index 7a068ac3..7456140f 100644 --- a/frontend/src/routes/auth/oidc/callback/+page.svelte +++ b/frontend/src/routes/auth/oidc/callback/+page.svelte @@ -18,8 +18,10 @@ const settings = await api.profile.getSettings(); setLocale((settings.language as 'en' | 'de') ?? 'en'); const detected = detectTimezone(); - if (settings.timezone === 'UTC') { - await api.profile.updateSettings({ timezone: detected }); + // Same fallback as the login page for an older backend. + const timezoneConfigured = settings.timezone_configured ?? settings.timezone !== 'UTC'; + if (!timezoneConfigured) { + await api.profile.updateSettings({ timezone: detected, timezone_configured: false }); setTimezone(detected); } else { setTimezone(settings.timezone); diff --git a/frontend/src/routes/auth/oidc/callback/page.test.ts b/frontend/src/routes/auth/oidc/callback/page.test.ts new file mode 100644 index 00000000..ddd97798 --- /dev/null +++ b/frontend/src/routes/auth/oidc/callback/page.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/svelte'; +import CallbackPage from './+page.svelte'; + +const mockMe = vi.fn(); +const mockCsrf = vi.fn(); +const mockGetSettings = vi.fn(); +const mockUpdateSettings = vi.fn(); +const mockSetTimezone = vi.fn(); + +vi.mock('#lib/api.js', () => ({ + api: { + auth: { + me: (...args: unknown[]) => mockMe(...args), + csrf: (...args: unknown[]) => mockCsrf(...args) + }, + profile: { + getSettings: (...args: unknown[]) => mockGetSettings(...args), + updateSettings: (...args: unknown[]) => mockUpdateSettings(...args) + } + } +})); + +vi.mock('#lib/stores/timezone.js', () => ({ + setTimezone: (...args: unknown[]) => mockSetTimezone(...args), + getTimezone: () => 'Europe/Berlin', + detectTimezone: () => 'Europe/Berlin', + setQuoteServiceEnabled: () => {}, + isQuoteServiceEnabled: () => false +})); + +function mockSettings(overrides: Record = {}) { + mockGetSettings.mockResolvedValue({ + user_id: 1, + language: 'en', + timezone: 'UTC', + timezone_configured: false, + theme: 'light', + custom_theme: null, + ...overrides + }); +} + +describe('OIDC callback timezone handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMe.mockResolvedValue({ id: 1, email: 'test@example.com' }); + mockCsrf.mockResolvedValue({ csrf_token: 'token' }); + mockUpdateSettings.mockResolvedValue({}); + }); + + it('auto-detects the timezone when the user has not configured one', async () => { + mockSettings({ timezone: 'UTC', timezone_configured: false }); + render(CallbackPage); + + await waitFor(() => expect(mockSetTimezone).toHaveBeenCalled()); + + expect(mockUpdateSettings).toHaveBeenCalledWith({ + timezone: 'Europe/Berlin', + timezone_configured: false + }); + expect(mockSetTimezone).toHaveBeenCalledWith('Europe/Berlin'); + }); + + it('keeps an explicitly configured UTC timezone', async () => { + mockSettings({ timezone: 'UTC', timezone_configured: true }); + render(CallbackPage); + + await waitFor(() => expect(mockSetTimezone).toHaveBeenCalled()); + + expect(mockUpdateSettings).not.toHaveBeenCalled(); + expect(mockSetTimezone).toHaveBeenCalledWith('UTC'); + }); + + it('keeps a custom timezone the user has configured', async () => { + mockSettings({ timezone: 'America/New_York', timezone_configured: true }); + render(CallbackPage); + + await waitFor(() => expect(mockSetTimezone).toHaveBeenCalled()); + + expect(mockUpdateSettings).not.toHaveBeenCalled(); + expect(mockSetTimezone).toHaveBeenCalledWith('America/New_York'); + }); +}); diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3552b4e3..74d1908f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -81,11 +81,18 @@ csrfToken.set(csrf.csrf_token); const settings = await api.profile.getSettings(); const detected = detectTimezone(); - const update: { language?: string; timezone?: string } = {}; + const update: { language?: string; timezone?: string; timezone_configured?: boolean } = {}; if (languageChanged) update.language = selectedLanguage; - if (settings.timezone === 'UTC') update.timezone = detected; + // Fall back to the old UTC check when talking to a backend that does + // not send the flag yet, so a temporary version mismatch never + // overwrites an explicit choice. + const timezoneConfigured = settings.timezone_configured ?? settings.timezone !== 'UTC'; + if (!timezoneConfigured) { + update.timezone = detected; + update.timezone_configured = false; + } await api.profile.updateSettings(update); - setTimezone(settings.timezone === 'UTC' ? detected : settings.timezone); + setTimezone(timezoneConfigured ? settings.timezone : detected); if (settings.theme) { const dbMode = sanitizeThemeMode(settings.theme); diff --git a/frontend/src/routes/login/timezone.test.ts b/frontend/src/routes/login/timezone.test.ts new file mode 100644 index 00000000..2f481b6c --- /dev/null +++ b/frontend/src/routes/login/timezone.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import LoginPage from './+page.svelte'; + +const mockOidcConfig = vi.fn(); +const mockAppConfig = vi.fn(); +const mockLogin = vi.fn(); +const mockCsrf = vi.fn(); +const mockGetSettings = vi.fn(); +const mockUpdateSettings = vi.fn(); +const mockSetTimezone = vi.fn(); + +vi.mock('#lib/api.js', () => ({ + api: { + oidc: { + config: (...args: unknown[]) => mockOidcConfig(...args), + loginUrl: () => '/api/oidc/login' + }, + app: { + config: (...args: unknown[]) => mockAppConfig(...args) + }, + auth: { + login: (...args: unknown[]) => mockLogin(...args), + csrf: (...args: unknown[]) => mockCsrf(...args), + forgotPassword: vi.fn() + }, + profile: { + getSettings: (...args: unknown[]) => mockGetSettings(...args), + updateSettings: (...args: unknown[]) => mockUpdateSettings(...args) + } + } +})); + +vi.mock('#lib/stores/timezone.js', () => ({ + setTimezone: (...args: unknown[]) => mockSetTimezone(...args), + getTimezone: () => 'Europe/Berlin', + detectTimezone: () => 'Europe/Berlin', + setQuoteServiceEnabled: () => {}, + isQuoteServiceEnabled: () => false +})); + +function mockConfig() { + mockOidcConfig.mockResolvedValue({ + enabled: true, + provider_id: 'oidc', + provider_name: 'Pocket ID', + auto_redirect: false + }); + mockAppConfig.mockResolvedValue({ + embed_enabled: true, + dashboard_quote_enabled: true, + thalia_cover_search_enabled: false, + password_login_enabled: true, + trusted_proxy_auth_enabled: false + }); +} + +function mockSettings(overrides: Record = {}) { + mockGetSettings.mockResolvedValue({ + user_id: 1, + language: 'en', + timezone: 'UTC', + timezone_configured: false, + theme: 'light', + custom_theme: null, + ...overrides + }); +} + +async function prepareLogin(settings: Record = {}) { + mockConfig(); + mockLogin.mockResolvedValue({ user: { id: 1 } }); + mockCsrf.mockResolvedValue({ csrf_token: 'token' }); + mockSettings(settings); + mockUpdateSettings.mockResolvedValue({}); + render(LoginPage); +} + +async function submitLogin() { + await waitFor(() => { + expect(screen.getAllByLabelText('Email').length).toBeGreaterThan(0); + }); + await fireEvent.input(screen.getAllByLabelText('Email')[0], { + target: { value: 'test@example.com' } + }); + await fireEvent.input(screen.getByLabelText('Password'), { + target: { value: 'secret' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Login' })); +} + +describe('Login page timezone handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('auto-detects the timezone when the user has not configured one', async () => { + await prepareLogin({ timezone: 'UTC', timezone_configured: false }); + await submitLogin(); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + + expect(mockUpdateSettings).toHaveBeenCalledWith({ + timezone: 'Europe/Berlin', + timezone_configured: false + }); + expect(mockSetTimezone).toHaveBeenCalledWith('Europe/Berlin'); + }); + + it('keeps a custom timezone the user has configured', async () => { + await prepareLogin({ timezone: 'America/New_York', timezone_configured: true }); + await submitLogin(); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone'); + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone_configured'); + expect(mockSetTimezone).toHaveBeenCalledWith('America/New_York'); + }); + + it('keeps an explicitly configured UTC timezone', async () => { + await prepareLogin({ timezone: 'UTC', timezone_configured: true }); + await submitLogin(); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone'); + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone_configured'); + expect(mockSetTimezone).toHaveBeenCalledWith('UTC'); + }); + + it('falls back to the UTC check when the backend omits the flag', async () => { + await prepareLogin({ timezone: 'UTC', timezone_configured: undefined }); + await submitLogin(); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + + expect(mockUpdateSettings).toHaveBeenCalledWith({ + timezone: 'Europe/Berlin', + timezone_configured: false + }); + expect(mockSetTimezone).toHaveBeenCalledWith('Europe/Berlin'); + }); + + it('keeps a custom timezone when the backend omits the flag', async () => { + await prepareLogin({ timezone: 'America/New_York', timezone_configured: undefined }); + await submitLogin(); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone'); + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty('timezone_configured'); + expect(mockSetTimezone).toHaveBeenCalledWith('America/New_York'); + }); +}); From e71d5d03193bef1b21960518517c956331e6234f Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 11:01:11 +0100 Subject: [PATCH 6/8] Add a reset to automatic timezone detection on the profile page --- frontend/src/lib/i18n/locales/de.json | 2 + frontend/src/lib/i18n/locales/en.json | 2 + frontend/src/lib/i18n/locales/es.json | 2 + frontend/src/lib/i18n/locales/fr.json | 2 + frontend/src/lib/i18n/locales/zh.json | 2 + frontend/src/routes/profile/+page.svelte | 54 +++++++++++++++++++----- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 3f95809b..8fd660fa 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -320,6 +320,8 @@ "timezoneHelp": "Daten und Zeiten in deiner lokalen Zeitzone anzeigen.", "timezoneDetected": "Erkannt: {tz}", "timezoneSelected": "Ausgewählt: {tz}", + "timezoneAuto": "Automatisch: folgt {tz}", + "timezoneUseDetected": "Erkannte Zeitzone verwenden", "timezoneInvalid": "Bitte wähle eine gültige Zeitzone aus der Liste.", "themeTitle": "Design", "themeLight": "Hell", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index dbf4d614..6714ca1f 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -320,6 +320,8 @@ "timezoneHelp": "Display dates and times in your local timezone.", "timezoneDetected": "Detected: {tz}", "timezoneSelected": "Selected: {tz}", + "timezoneAuto": "Automatic: following {tz}", + "timezoneUseDetected": "Use detected timezone", "timezoneInvalid": "Please select a valid timezone from the list.", "themeTitle": "Theme", "themeLight": "Light", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 0975d712..350bf415 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -320,6 +320,8 @@ "timezoneHelp": "Mostrar fechas y horas en tu zona horaria local.", "timezoneDetected": "Detectada: {tz}", "timezoneSelected": "Seleccionada: {tz}", + "timezoneAuto": "Automática: siguiendo {tz}", + "timezoneUseDetected": "Usar la zona horaria detectada", "timezoneInvalid": "Selecciona una zona horaria válida de la lista.", "themeTitle": "Tema", "themeLight": "Claro", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index b322f7f6..2a49111a 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -320,6 +320,8 @@ "timezoneHelp": "Afficher les dates et heures dans ton fuseau horaire local.", "timezoneDetected": "Détecté : {tz}", "timezoneSelected": "Sélectionné : {tz}", + "timezoneAuto": "Automatique : suit {tz}", + "timezoneUseDetected": "Utiliser le fuseau horaire détecté", "timezoneInvalid": "Sélectionne un fuseau horaire valide dans la liste.", "themeTitle": "Thème", "themeLight": "Clair", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index ab4f84b5..79f55be1 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -320,6 +320,8 @@ "timezoneHelp": "以本地时区显示日期和时间。", "timezoneDetected": "检测到:{tz}", "timezoneSelected": "已选择:{tz}", + "timezoneAuto": "自动:跟随 {tz}", + "timezoneUseDetected": "使用检测到的时区", "timezoneInvalid": "请从列表中选择有效的时区。", "themeTitle": "主题", "themeLight": "浅色", diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 4edce794..0091f52e 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -39,6 +39,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; let language = $state('en'); let languageMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let timezone = $state(getTimezone()); + let timezoneConfigured = $state(true); let timezoneMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let description = $state(''); let createdKey = $state(null); @@ -155,6 +156,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; const settings = await api.profile.getSettings(); language = settings.language; timezone = settings.timezone; + timezoneConfigured = settings.timezone_configured; setTimezone(settings.timezone); themeMode = sanitizeThemeMode(settings.theme); setThemeMode(themeMode); @@ -354,9 +356,13 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; languageMessage = { type: 'success', text: $_('common.saved') }; } - const allTimezones: string[] = typeof Intl.supportedValuesOf === 'function' + const supportedTimezones: string[] = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []; + // V8 omits UTC from supportedValuesOf, but it is a valid profile choice. + const allTimezones: string[] = supportedTimezones.includes('UTC') + ? supportedTimezones + : ['UTC', ...supportedTimezones]; const browserTz = detectTimezone(); @@ -367,10 +373,28 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; } timezoneMessage = null; await api.profile.updateSettings({ timezone }); + timezoneConfigured = true; setTimezone(timezone); timezoneMessage = { type: 'success', text: $_('common.saved') }; } + async function useDetectedTimezone() { + const detected = detectTimezone(); + timezoneMessage = null; + try { + await api.profile.updateSettings({ timezone: detected, timezone_configured: false }); + timezone = detected; + timezoneConfigured = false; + setTimezone(detected); + timezoneMessage = { type: 'success', text: $_('common.saved') }; + } catch (e) { + timezoneMessage = { + type: 'error', + text: e instanceof Error ? e.message : $_('common.saveFailed') + }; + } + } + $effect(() => { if (typeof window !== 'undefined') { setCustomTheme(customTheme); @@ -1005,16 +1029,26 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; onClose={() => timezoneMessage = null} >{timezoneMessage.text} {/if} - +
+ + +

{$_('settings.timezoneDetected', { values: { tz: browserTz } })}

-

{$_('settings.timezoneSelected', { values: { tz: timezone } })}

+

+ {timezoneConfigured + ? $_('settings.timezoneSelected', { values: { tz: timezone } }) + : $_('settings.timezoneAuto', { values: { tz: timezone } })} +

From dbcb154083b6dfe561f0448abbcaec91f874c5bd Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 11:01:15 +0100 Subject: [PATCH 7/8] Document the timezone fixes in the release notes --- docs/guide/database-layout.md | 4 ++++ docs/guide/using-librislog/profile.md | 2 +- docs/releases.md | 16 +++++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md index 7f450a9c..9a559b07 100644 --- a/docs/guide/database-layout.md +++ b/docs/guide/database-layout.md @@ -164,6 +164,7 @@ erDiagram integer user_id UK varchar(10) language varchar(64) timezone + boolean timezone_configured varchar(20) theme varchar(30) custom_theme boolean goal_pages_per_day_enabled @@ -417,6 +418,7 @@ Per-user settings such as language, timezone, and theme. | `user_id` | `INTEGER` | FK → user.id, UNIQUE, NOT NULL | | | `language` | `VARCHAR(10)` | NOT NULL | default `en` | | `timezone` | `VARCHAR(64)` | NOT NULL | default `UTC` | +| `timezone_configured` | `BOOLEAN` | NOT NULL | default False; true once a timezone was saved explicitly, false means automatic detection on login | | `theme` | `VARCHAR(20)` | NOT NULL | default `light` | | `custom_theme` | `VARCHAR(30)` | | | | `goal_pages_per_day_enabled` | `BOOLEAN` | NOT NULL | default False | @@ -434,6 +436,8 @@ Per-user settings such as language, timezone, and theme. | `statistics_custom_from` | `DATE` | | | | `statistics_custom_to` | `DATE` | | | +On upgrade, existing non-UTC values are marked as configured, including values that were auto-detected before this flag existed. Rows still on the `UTC` default remain in automatic detection. + ### `book_author` Many-to-many association between books and authors. diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 912942b8..1c2fcb0b 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -55,7 +55,7 @@ Switch the UI language between available locales. The change applies immediately ## Timezone -Set your preferred timezone for date/time displays (e.g., for the calendar heatmap and progress log timestamps). Your browser's detected timezone is shown as a reference. +Set your preferred timezone for date/time displays (e.g., for the calendar heatmap and progress log timestamps). Your browser's detected timezone is shown as a reference. Saving a timezone pins that choice, including UTC, so the app will not replace it with the detected zone on the next login. Use **Use detected timezone** to switch back to automatic detection, which follows the browser's zone on each login. Accounts that never saved a timezone follow the detected zone automatically. ## Theme diff --git a/docs/releases.md b/docs/releases.md index 69cb7420..98260dff 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,8 +8,8 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Unreleased -::: tip ⭐ v1.12.1 - DST Chart Freeze Fix -LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's reading progress chart crossed a daylight-saving fall-back in the user's profile time zone, the chart's time axis could loop forever and lock up the page. Time steps now keep advancing across the transition, and day, week, and month ticks stay on their local calendar boundaries. No data changes or manual steps are needed. +::: tip ⭐ v1.12.1 - DST Chart & Timezone Fixes +LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's reading progress chart crossed a daylight-saving fall-back in the user's profile time zone, the chart's time axis could loop forever and lock up the page. Time steps now keep advancing across the transition, day, week, and month ticks stay on their local calendar boundaries, and an explicitly chosen UTC timezone is no longer replaced with the detected zone on the next login. The timezone card also has a **Use detected timezone** button to switch back to automatic detection. The new timezone flag is added by an automatic database migration. ::: ## Latest Release @@ -22,7 +22,7 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man | Version | Date | Type | |---|---|---| -| [v1.12.1](#v1-12-1-dst-chart-freeze-fix) | Unreleased | Maintenance | +| [v1.12.1](#v1-12-1-dst-chart-timezone-fixes) | Unreleased | Maintenance | | [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | 2026-11-02 | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | @@ -45,18 +45,20 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man --- -## v1.12.1: DST Chart Freeze Fix +## v1.12.1: DST Chart & Timezone Fixes -**Summary:** LibrisLog v1.12.1 fixes a browser freeze in the book detail view when a book's reading progress chart spans a daylight-saving fall-back in the profile time zone. Sub-day time steps now run on the UTC timeline, so tick generation always completes, and day, week, and month steps keep their local wall-clock time, so calendar ticks no longer drift after a transition. +**Summary:** LibrisLog v1.12.1 fixes a browser freeze in the book detail view when a book's reading progress chart spans a daylight-saving fall-back in the profile time zone. Sub-day time steps now run on the UTC timeline, so tick generation always completes, and day, week, and month steps keep their local wall-clock time, so calendar ticks no longer drift after a transition. It also keeps an explicitly chosen UTC profile timezone instead of replacing it with the detected zone on the next login, and adds a **Use detected timezone** button that switches an account back to automatic detection. **Bug fixes** - 🕒 **No more freeze across a DST fall-back.** When the device and profile time zones were the same zone that observes daylight saving, a time step inside the repeated hour could stall or move backwards. Chart.js then kept generating ticks forever, which froze the book detail dialog in Firefox and Chromium-based browsers. Millisecond, second, minute, and hour steps now use the UTC epoch value, so they always move forward. Hour ticks still start on local hour boundaries and show the repeated hour correctly when ticks are hourly, for example 1AM twice. - 📅 **Calendar ticks stay aligned.** Day, week, month, quarter, and year steps now keep the local wall-clock time instead of adding a fixed number of milliseconds, so day and month ticks no longer land at 23:00 or drift into the wrong month after a DST change. -- 🧪 **Regression coverage.** A new test pins the device and profile time zones to `America/New_York` and covers fall-back and spring-forward tick sequences, monotonic time steps, local `startOf` and `endOf` boundaries, non-whole-hour zones, and calendar-unit steps. +- 🌍 **Explicit UTC choice is kept.** The timezone picker now offers UTC in every browser, including Chromium-based ones that omit it from `Intl.supportedValuesOf`. The login and OIDC callback used to treat a stored UTC value as "not configured" and replaced it with the detected zone. Saving any timezone now marks it as explicitly chosen, so UTC stays UTC. Existing accounts keep their current behaviour: non-UTC values are untouched, and a legacy UTC default follows the detected zone on login. +- 🔄 **Back to automatic detection.** The timezone card has a **Use detected timezone** button that saves the browser's zone and switches the account back to automatic detection, which follows the browser on each login. Saving a zone still pins it, including UTC. +- 🧪 **Regression coverage.** A new test pins the device and profile time zones to `America/New_York` and covers fall-back and spring-forward tick sequences, monotonic time steps, local `startOf` and `endOf` boundaries, non-whole-hour zones, and calendar-unit steps. Login and OIDC callback tests cover unconfigured, explicitly chosen, and version-skew cases, and backend tests cover invalid input and switching back to automatic detection. -**Breaking changes:** None. +**Breaking changes:** None. The new `timezone_configured` flag is added by an automatic database migration. `GET /api/profile/settings` gains one additive field, and `PATCH` accepts an optional `timezone_configured` flag to pin or reset automatic detection. Downgrading drops the explicit-choice bit, so a later upgrade treats UTC as unconfigured again. [Compare with v1.12.0](https://github.com/codebude/librislog/compare/v1.12.0...HEAD) From f85ff59dcd183bd80f47c1d91736d2b80b62d664 Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 3 Nov 2026 15:06:03 +0100 Subject: [PATCH 8/8] Finalize the v1.12.1 release notes --- docs/releases.md | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 98260dff..038bf54e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,23 +6,17 @@ All LibrisLog releases, newest first — what's new, what was fixed, and anythin You can also browse the [GitHub Releases](https://github.com/codebude/librislog/releases) page and the [full changelog](https://github.com/codebude/librislog/commits/main). -## Unreleased +## Latest Release ::: tip ⭐ v1.12.1 - DST Chart & Timezone Fixes LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's reading progress chart crossed a daylight-saving fall-back in the user's profile time zone, the chart's time axis could loop forever and lock up the page. Time steps now keep advancing across the transition, day, week, and month ticks stay on their local calendar boundaries, and an explicitly chosen UTC timezone is no longer replaced with the detected zone on the next login. The timezone card also has a **Use detected timezone** button to switch back to automatic detection. The new timezone flag is added by an automatic database migration. ::: -## Latest Release - -::: tip ⭐ v1.12.0 - Mass Tag Editor, Profile Pictures & Authentication Methods -LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, and profile pictures with three selectable sources: the existing animal avatar, a locally cached OIDC picture, and a manually uploaded image with a built-in cropper. Public profiles gain a separate profile picture toggle. It also adds two opt-in authentication options: password login can be disabled once single sign-on is in use, and a trusted reverse proxy can sign users in from a forwarded header. Existing accounts keep their animal avatar and existing share links stay unchanged after upgrading, and the new options need no manual data migration. -::: - ### All releases | Version | Date | Type | |---|---|---| -| [v1.12.1](#v1-12-1-dst-chart-timezone-fixes) | Unreleased | Maintenance | +| [v1.12.1](#v1-12-1-dst-chart-timezone-fixes) | 2026-11-03 | Maintenance | | [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | 2026-11-02 | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | @@ -47,7 +41,7 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man ## v1.12.1: DST Chart & Timezone Fixes - + **Summary:** LibrisLog v1.12.1 fixes a browser freeze in the book detail view when a book's reading progress chart spans a daylight-saving fall-back in the profile time zone. Sub-day time steps now run on the UTC timeline, so tick generation always completes, and day, week, and month steps keep their local wall-clock time, so calendar ticks no longer drift after a transition. It also keeps an explicitly chosen UTC profile timezone instead of replacing it with the detected zone on the next login, and adds a **Use detected timezone** button that switches an account back to automatic detection. @@ -56,11 +50,12 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man - 📅 **Calendar ticks stay aligned.** Day, week, month, quarter, and year steps now keep the local wall-clock time instead of adding a fixed number of milliseconds, so day and month ticks no longer land at 23:00 or drift into the wrong month after a DST change. - 🌍 **Explicit UTC choice is kept.** The timezone picker now offers UTC in every browser, including Chromium-based ones that omit it from `Intl.supportedValuesOf`. The login and OIDC callback used to treat a stored UTC value as "not configured" and replaced it with the detected zone. Saving any timezone now marks it as explicitly chosen, so UTC stays UTC. Existing accounts keep their current behaviour: non-UTC values are untouched, and a legacy UTC default follows the detected zone on login. - 🔄 **Back to automatic detection.** The timezone card has a **Use detected timezone** button that saves the browser's zone and switches the account back to automatic detection, which follows the browser on each login. Saving a zone still pins it, including UTC. +- 🛡️ **Invalid timezones are rejected.** Saving an empty or unknown timezone name now returns a validation error instead of storing a value that breaks date rendering. - 🧪 **Regression coverage.** A new test pins the device and profile time zones to `America/New_York` and covers fall-back and spring-forward tick sequences, monotonic time steps, local `startOf` and `endOf` boundaries, non-whole-hour zones, and calendar-unit steps. Login and OIDC callback tests cover unconfigured, explicitly chosen, and version-skew cases, and backend tests cover invalid input and switching back to automatic detection. -**Breaking changes:** None. The new `timezone_configured` flag is added by an automatic database migration. `GET /api/profile/settings` gains one additive field, and `PATCH` accepts an optional `timezone_configured` flag to pin or reset automatic detection. Downgrading drops the explicit-choice bit, so a later upgrade treats UTC as unconfigured again. +**Breaking changes:** None. The new `timezone_configured` flag is added by an automatic database migration. `GET /api/profile/settings` gains one additive field, and `PATCH` accepts an optional `timezone_configured` flag to pin or reset automatic detection. `PATCH` now rejects empty or unknown timezone names with a validation error. Downgrading drops the explicit-choice bit, so a later upgrade treats UTC as unconfigured again. -[Compare with v1.12.0](https://github.com/codebude/librislog/compare/v1.12.0...HEAD) +[Compare with v1.12.0](https://github.com/codebude/librislog/compare/v1.12.0...v1.12.1) --- @@ -129,7 +124,7 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man **Breaking changes:** None. Existing installs keep working untouched: every book with a single session behaves and scores identically to before, and data migrates automatically on upgrade. Downgrading to a previous release drops re-read history (the `reading_session` table is removed). -[Compare with v1.10.0](https://github.com/codebude/librislog/compare/v1.10.0...HEAD) +[Compare with v1.10.0](https://github.com/codebude/librislog/compare/v1.10.0...v1.11.0) ---