Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 4 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/app/routers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]:
Expand Down
65 changes: 65 additions & 0 deletions backend/tests/test_auth_profile_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/database-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/using-librislog/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 24 additions & 3 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/

## 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.
::: 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.
:::

### All releases

| Version | Date | Type |
|---|---|---|
| [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 |
Expand All @@ -38,6 +39,26 @@ LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on man

---

## v1.12.1: DST Chart & Timezone Fixes

<Badge type="info" text="Maintenance" /> <Badge type="info" text="2026-11-03" />

**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.
- 🌍 **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. `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...v1.12.1)

---

## v1.12.0: Mass Tag Editor, Profile Pictures & Authentication Methods

<Badge type="tip" text="Feature release" /> <Badge type="info" text="2026-11-02" />
Expand Down Expand Up @@ -103,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)

---

Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export const api = {
return request<UserSettings>('/profile/settings');
},

updateSettings(data: Partial<UserSettings>): Promise<UserSettings> {
updateSettings(data: Partial<Omit<UserSettings, 'user_id'>>): Promise<UserSettings> {
return request<UserSettings>('/profile/settings', {
method: 'PATCH',
body: JSON.stringify(data)
Expand Down
Loading
Loading