diff --git a/changelog/+ifc-2721-date-rendering.added.md b/changelog/+ifc-2721-date-rendering.added.md
new file mode 100644
index 0000000000..d34e207439
--- /dev/null
+++ b/changelog/+ifc-2721-date-rendering.added.md
@@ -0,0 +1 @@
+The web interface now renders dates and times using your effective preferences — your personal `date_format` and `timezone`, falling back to the organisation default and then to your browser's locale and timezone when neither is set. Preferences affect display only; stored values are unchanged.
diff --git a/docs/docs/deploy-manage/user-management/managing-preferences.mdx b/docs/docs/deploy-manage/user-management/managing-preferences.mdx
new file mode 100644
index 0000000000..282f9e4f30
--- /dev/null
+++ b/docs/docs/deploy-manage/user-management/managing-preferences.mdx
@@ -0,0 +1,140 @@
+---
+title: Managing preferences
+---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+# Managing preferences
+
+Preferences control how Infrahub renders dates and times in the web interface. Each user sets their own preferences, and administrators set organisation-wide defaults that apply to everyone who has not chosen their own.
+
+Two preferences are available today: **date format** and **timezone**.
+
+## How preferences resolve
+
+Infrahub resolves each preference field on its own, in this order:
+
+1. **Your value** — the value you set for yourself.
+2. **The organisation default** — the value an administrator set for everyone.
+3. **The browser default** — your browser's locale for date formatting, and your browser's timezone.
+
+Resolution is per field, so you can take the date format from your own value and the timezone from the organisation default at the same time. A field you never set falls through to the next layer; it is not stored until you set it.
+
+## Available preferences
+
+### Date format
+
+The date format is a semantic key, not a rendering pattern. Each preset renders both a date and a time. The web interface renders the following presets:
+
+| Key | Format | Example |
+|---|---|---|
+| `ISO_DATETIME` (default) | `yyyy-MM-dd HH:mm` | `2026-07-01 14:30` |
+| `ISO_DATETIME_SECONDS` | `yyyy-MM-dd HH:mm:ss` | `2026-07-01 14:30:00` |
+| `ISO_8601` | `yyyy-MM-dd'T'HH:mm:ssXXX` | `2026-07-01T14:30:00+02:00` |
+| `EU_DATETIME` | `dd/MM/yyyy HH:mm` | `01/07/2026 14:30` |
+| `US_12H` | `MM/dd/yyyy hh:mm a` | `07/01/2026 02:30 PM` |
+
+When you select a format, the interface shows a live example next to the field.
+
+### Timezone
+
+The timezone is an IANA name, such as `Europe/Paris` or `UTC`. The picker lists the timezones your browser supports. A field left unset uses your browser's own timezone.
+
+## Set your preferences
+
+
+
+
+1. Open **Account settings** from the user menu in the left sidebar.
+2. On the **Profile** tab, find the **Preferences** card.
+3. Select a **Date format** and a **Timezone**.
+4. Click **Save**.
+
+A field you leave on **Automatic (inherited)** takes the organisation default, or your browser default when no organisation default is set. The information icon next to each field shows where the current value comes from.
+
+To clear an override and go back to the inherited value, re-select the value that is currently applied.
+
+
+
+
+Write your own preferences with `InfrahubSetPreferences` using `scope: USER`. The mutation only ever writes your own row — there is no account argument.
+
+```graphql
+mutation {
+ InfrahubSetPreferences(scope: USER, date_format: EU_DATETIME, timezone: "Europe/Paris") {
+ ok
+ date_format
+ timezone
+ }
+}
+```
+
+An omitted argument leaves that field unchanged. An explicit `null` clears the field, so it falls back to the organisation default or your browser default.
+
+Read your resolved values — the ones the interface renders with — with `InfrahubEffectivePreferences`. Each field returns its `value` and the `source` it resolved from (`USER`, `GLOBAL`, or `DEFAULT`).
+
+```graphql
+query {
+ InfrahubEffectivePreferences {
+ date_format {
+ value
+ source
+ }
+ timezone {
+ value
+ source
+ }
+ }
+}
+```
+
+
+
+
+## Set organisation defaults
+
+Organisation defaults apply to every user who has not set their own value. Setting them requires the `manage_global_preferences` permission (super administrators have it implicitly). See the [permissions reference](../../reference/permissions.mdx) for the full list of permissions.
+
+
+
+
+1. Open **Global preferences** from the user menu in the left sidebar. The menu item is visible only to users with the `manage_global_preferences` permission.
+2. Select a **Date format** and a **Timezone**.
+3. Click **Save**.
+
+A field left on **Automatic (browser default)** sets no organisation default for that field, so users without their own value fall back to their browser default.
+
+
+
+
+Write organisation defaults with `InfrahubSetPreferences` using `scope: GLOBAL`. The mutation enforces the `manage_global_preferences` permission before it writes.
+
+```graphql
+mutation {
+ InfrahubSetPreferences(scope: GLOBAL, date_format: ISO_DATETIME, timezone: "UTC") {
+ ok
+ date_format
+ timezone
+ }
+}
+```
+
+Read the raw organisation defaults with `InfrahubGlobalPreferences`. This query also requires the `manage_global_preferences` permission.
+
+```graphql
+query {
+ InfrahubGlobalPreferences {
+ date_format
+ timezone
+ }
+}
+```
+
+
+
+
+## Best practices
+
+- **Set an organisation default for the timezone when your teams work in one region.** Everyone then reads timestamps in the same zone until they choose their own.
+- **Leave the date format on the default (`ISO_DATETIME`) unless your organisation has a house style.** The ISO presets sort and compare the same way in every locale.
+- **Preferences affect display only.** They change how the web interface renders timestamps; they never change the stored data. A timezone preference renders each timestamp in that zone without altering the underlying value.
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 5bbe08c329..7b071c0494 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -482,6 +482,7 @@ const sidebars: SidebarsConfig = {
},
// Managing API Tokens (PR 14)
{ type: 'doc', id: 'deploy-manage/user-management/managing-api-tokens', label: 'Managing API tokens' },
+ { type: 'doc', id: 'deploy-manage/user-management/managing-preferences', label: 'Managing preferences' },
],
},
],
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index a9cfd4143f..9296f8034d 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -95,6 +95,23 @@ async def login(page: Page, username: str, password: str) -> None:
await expect(page.get_by_test_id(AUTHENTICATED_MENU_TRIGGER)).to_be_visible()
+async def select_combobox_option(page: Page, label: str, option: str) -> None:
+ """Open a Combobox by its accessible name and pick an option by its exact label.
+
+ The preference forms use a searchable Combobox — a button trigger (its
+ accessible name is ``label``) that opens a dialog holding a searchbox and a
+ listbox. Typing into the search narrows the list, which the virtualized
+ timezone picker needs (only rendered options are in the DOM) and the short
+ date-format list tolerates. The option is then clicked by its exact label, so
+ a value that is a prefix of another (``yyyy-MM-dd HH:mm`` vs
+ ``yyyy-MM-dd HH:mm:ss``) still resolves unambiguously.
+ """
+ await page.get_by_role("button", name=label, exact=True).click()
+ dialog = page.get_by_role("dialog", name=label)
+ await dialog.get_by_role("searchbox").fill(option)
+ await dialog.get_by_role("option", name=option, exact=True).click()
+
+
class BranchAPI:
"""Port of tests/e2e/utils/graphql.ts branch helpers.
diff --git a/tests/e2e/preferences/test_global_preferences.py b/tests/e2e/preferences/test_global_preferences.py
new file mode 100644
index 0000000000..9682620be2
--- /dev/null
+++ b/tests/e2e/preferences/test_global_preferences.py
@@ -0,0 +1,66 @@
+"""E2E coverage for organisation-wide default preferences.
+
+A user holding ``manage_global_preferences`` sets organisation defaults on the
+Global preferences page. Every user without a personal override then inherits
+those defaults — surfaced in the Preferences card as the "organisation default"
+source.
+
+Uses the read-only account (from the RBAC slice) as the inheriting user, so the
+test declares the ``data_rbac`` dependency transitively via ``read_only_page``.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from helpers import select_combobox_option
+from playwright.async_api import expect
+
+pytestmark = pytest.mark.shard_foundation
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+ from infrahub_sdk import InfrahubClient
+ from playwright.async_api import Page
+
+# US 12-hour preset — distinct from the ISO default so an inherited value is
+# unambiguous.
+GLOBAL_DATE_FORMAT = "MM/dd/yyyy hh:mm a"
+
+
+class TestGlobalPreferences:
+ @pytest.fixture(autouse=True)
+ async def reset_global_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]:
+ """Clear the organisation-wide defaults after the test.
+
+ The global row is shared by every account on the session instance, so it
+ must be reset or later tests inherit a stale default.
+ """
+ yield
+ await infrahub_client.execute_graphql(
+ query=("mutation { InfrahubSetPreferences(scope: GLOBAL, date_format: null, timezone: null) { ok } }"),
+ )
+
+ async def test_global_default_is_inherited_by_users_without_an_override(
+ self, admin_page: Page, read_only_page: Page
+ ) -> None:
+ # An administrator sets the organisation default.
+ await admin_page.goto("/global-preferences")
+ await select_combobox_option(admin_page, "Date format", GLOBAL_DATE_FORMAT)
+ await admin_page.get_by_role("button", name="Save", exact=True).click()
+ await expect(admin_page.get_by_text("Global preferences updated")).to_be_visible()
+
+ # A user with no personal override sees the field inherit, and the source
+ # hint attributes it to the organisation default.
+ await read_only_page.goto("/profile")
+ await expect(read_only_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
+ "Automatic (inherited)"
+ )
+
+ # Focus (not hover): React Aria opens the tooltip immediately on keyboard
+ # focus, while hover goes through the warm-up delay — the deterministic
+ # option for CI.
+ await read_only_page.get_by_role("button", name="Where this value comes from").first.focus()
+ await expect(read_only_page.get_by_text("From the organisation default")).to_be_visible()
diff --git a/tests/e2e/preferences/test_preferences_permissions.py b/tests/e2e/preferences/test_preferences_permissions.py
new file mode 100644
index 0000000000..42e89898ce
--- /dev/null
+++ b/tests/e2e/preferences/test_preferences_permissions.py
@@ -0,0 +1,50 @@
+"""E2E coverage for the manage_global_preferences permission gate.
+
+The Global preferences page is gated by the ``manage_global_preferences`` global
+permission. An administrator (super admin, who holds it implicitly) sees the
+account-menu entry and the editor; a user without the permission never sees the
+menu entry, and a direct visit to the page is refused.
+
+Uses the read-only account (from the RBAC slice) as the unprivileged user, so the
+test declares the ``data_rbac`` dependency transitively via ``read_only_page``.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+
+import pytest
+from playwright.async_api import expect
+
+pytestmark = pytest.mark.shard_foundation
+
+if TYPE_CHECKING:
+ from playwright.async_api import Page
+
+
+class TestPreferencesPermissions:
+ async def test_admin_sees_the_global_preferences_menu_entry_and_editor(self, admin_page: Page) -> None:
+ await admin_page.goto("/")
+ await admin_page.get_by_test_id("authenticated-menu-trigger").click()
+ await expect(admin_page.get_by_role("menuitem", name="Global preferences")).to_be_visible()
+
+ await admin_page.get_by_role("menuitem", name="Global preferences").click()
+ await expect(admin_page).to_have_url(re.compile(r"/global-preferences$"))
+ await expect(admin_page.get_by_role("button", name="Save", exact=True)).to_be_visible()
+
+ async def test_non_admin_cannot_see_or_open_global_preferences(self, read_only_page: Page) -> None:
+ await read_only_page.goto("/")
+ # The account menu opens, but the gated entry is absent.
+ await read_only_page.get_by_test_id("authenticated-menu-trigger").click()
+ await expect(read_only_page.get_by_role("menuitem", name="Account settings")).to_be_visible()
+ await expect(read_only_page.get_by_role("menuitem", name="Global preferences")).to_have_count(0)
+ await read_only_page.keyboard.press("Escape")
+
+ # A direct visit to the gated page is refused. The detailed message sits
+ # inside a collapsed accordion, so expand it before asserting on it.
+ await read_only_page.goto("/global-preferences")
+ await expect(read_only_page.get_by_text("You can't access this view")).to_be_visible()
+ await read_only_page.get_by_text("You can't access this view").click()
+ await expect(read_only_page.get_by_text("You don't have permission to edit global preferences")).to_be_visible()
+ await expect(read_only_page.get_by_role("button", name="Save", exact=True)).to_have_count(0)
diff --git a/tests/e2e/preferences/test_user_preferences.py b/tests/e2e/preferences/test_user_preferences.py
new file mode 100644
index 0000000000..e06b3011f0
--- /dev/null
+++ b/tests/e2e/preferences/test_user_preferences.py
@@ -0,0 +1,85 @@
+"""E2E coverage for personal user preferences.
+
+Each user sets their own date format and timezone from the Preferences card on
+the account Profile tab. A saved value persists across a reload; re-selecting the
+currently-applied value clears the override so the field inherits again.
+
+Runs as admin only (the bootstrap account), so no demo data is required.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from helpers import select_combobox_option
+from playwright.async_api import expect
+
+pytestmark = pytest.mark.shard_foundation
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+ from infrahub_sdk import InfrahubClient
+ from playwright.async_api import Page
+
+# A preset that differs from every layer's default, so a persisted value is
+# unambiguous. The option's accessible name is its date-fns pattern (the label
+# the Combobox shows).
+EU_DATE_FORMAT = "dd/MM/yyyy HH:mm"
+TIMEZONE = "Asia/Tokyo"
+
+
+class TestUserPreferences:
+ @pytest.fixture(autouse=True)
+ async def reset_admin_preferences(self, infrahub_client: InfrahubClient) -> AsyncGenerator[None, None]:
+ """Clear the admin account's own preference row after each test.
+
+ Preferences persist per account on the shared session instance, so an
+ override left behind would leak into later tests. An explicit ``null``
+ resets each field (an omitted field would be left unchanged).
+ """
+ yield
+ await infrahub_client.execute_graphql(
+ query=("mutation { InfrahubSetPreferences(scope: USER, date_format: null, timezone: null) { ok } }"),
+ )
+
+ async def test_set_personal_preferences_persist_across_reload(self, admin_page: Page) -> None:
+ await admin_page.goto("/profile")
+ await expect(admin_page.get_by_text("Preferences", exact=True)).to_be_visible()
+
+ await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
+ await select_combobox_option(admin_page, "Timezone", TIMEZONE)
+ await admin_page.get_by_role("button", name="Save", exact=True).click()
+ await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()
+
+ # The saved override survives a full reload (it is read back from the server).
+ await admin_page.reload()
+ await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(EU_DATE_FORMAT)
+ await expect(admin_page.get_by_role("button", name="Timezone", exact=True)).to_contain_text(TIMEZONE)
+
+ async def test_reselecting_the_current_value_clears_the_override(self, admin_page: Page) -> None:
+ await admin_page.goto("/profile")
+
+ # Set an override first.
+ await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
+ await admin_page.get_by_role("button", name="Save", exact=True).click()
+ await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()
+ # Let the toast auto-dismiss so the second save's toast is unambiguous;
+ # otherwise the next visibility check can pass against this stale toast
+ # and the reload below races the in-flight mutation.
+ await expect(admin_page.get_by_text("Preferences updated")).not_to_be_visible()
+
+ # Re-selecting the currently-applied value clears the override; the field
+ # falls back to the inherited value and shows its placeholder again.
+ await select_combobox_option(admin_page, "Date format", EU_DATE_FORMAT)
+ await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
+ "Automatic (inherited)"
+ )
+ await admin_page.get_by_role("button", name="Save", exact=True).click()
+ await expect(admin_page.get_by_text("Preferences updated")).to_be_visible()
+
+ await admin_page.reload()
+ await expect(admin_page.get_by_role("button", name="Date format", exact=True)).to_contain_text(
+ "Automatic (inherited)"
+ )