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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ export CRONOMETER_USERNAME="your@email.com"
export CRONOMETER_PASSWORD="your-password"
```

#### Optional: override the account timezone

Diary entries are stamped in your Cronometer account's timezone, which the
server reports at login. If that zone is wrong (for example, an older build
had reset it) you can force a specific IANA zone without changing your account
settings:

```bash
export CRONOMETER_ACCOUNT_TZ="America/Los_Angeles"
```

When set, this takes precedence over both the value reported at login and any
cached session, so it also overrides a stale cached timezone.

### 3. Configure your MCP client

`uvx` downloads and runs the server on demand -- no separate install step.
Expand Down
66 changes: 60 additions & 6 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
# the login response. Matches the value historically assumed by this client.
_DEFAULT_TIMEZONE = "America/New_York"

# Optional deploy-time override for the account timezone. When set to a valid
# IANA zone name it is authoritative over both the login response and any
# cached value. This is the escape hatch for accounts whose server-side zone
# was clobbered by older builds (see issue #29) or when the resolved zone is
# otherwise wrong.
_ACCOUNT_TZ_ENV = "CRONOMETER_ACCOUNT_TZ"

# Cache the auth token across processes to avoid /api/v2/login rate limits.
# Cronometer throttles repeated logins per account; reusing a sessionKey lets
# short-lived CLI invocations behave like a long-running app.
Expand Down Expand Up @@ -145,11 +152,14 @@ def _load_cached_session(self) -> None:
if isinstance(token, str) and isinstance(user_id, int):
self._user_id = user_id
self._token = token
self._timezone = timezone
# A CRONOMETER_ACCOUNT_TZ override wins over the cached value so a
# session.json poisoned by an older build (issue #29) can't defeat
# an explicit deploy-time setting without invalidating the cache.
self._timezone = self._resolve_timezone(timezone)
logger.debug(
"Restored Cronometer session for user_id=%d (tz=%s) from %s",
user_id,
timezone,
self._timezone,
self._session_path,
)

Expand Down Expand Up @@ -205,7 +215,15 @@ def login(self) -> None:
payload = {
"email": username,
"password": password,
"timezone": "America/New_York",
# Must stay null: the login endpoint treats a non-null timezone as
# a *write* that overwrites the account's server-side zone (verified
# against the live API — sending "Asia/Tokyo" changed the account
# setting and it persisted across subsequent logins). Older builds
# hardcoded "America/New_York" here, silently resetting every user's
# account zone to Eastern on each login (issue #29). Sending null
# leaves the account setting untouched and the response echoes the
# account's real zone.
"timezone": None,
"userCode": None,
"build": "4.48.2 b2807-a",
"device": "Android 14 (SDK 34), Google Pixel 6 Pro",
Expand Down Expand Up @@ -235,9 +253,9 @@ def login(self) -> None:
self._token = data["sessionKey"]
# The login response embeds the account profile, including the user's
# configured IANA timezone. Prefer it over the host clock so diary
# timestamps are correct regardless of where the server runs.
tz = data.get("timezone")
self._timezone = tz if isinstance(tz, str) and tz else _DEFAULT_TIMEZONE
# timestamps are correct regardless of where the server runs. A
# CRONOMETER_ACCOUNT_TZ override, if set, wins over the response.
self._timezone = self._resolve_timezone(data.get("timezone"))
self._save_cached_session()
logger.info(
"Cronometer login successful (userId=%d, tz=%s, token=%s...)",
Expand Down Expand Up @@ -348,6 +366,42 @@ def _request_v3(
# Date helpers
# ------------------------------------------------------------------

@staticmethod
def _env_timezone() -> str | None:
"""Return a valid IANA zone from CRONOMETER_ACCOUNT_TZ, or None.

An invalid name is logged and ignored so a typo can't hard-fail
startup; resolution then falls through to the response/cache value.
"""
name = os.getenv(_ACCOUNT_TZ_ENV)
if not name:
return None
try:
ZoneInfo(name)
except ZoneInfoNotFoundError, ValueError:
logger.warning(
"Ignoring invalid %s=%r (not a known IANA timezone)",
_ACCOUNT_TZ_ENV,
name,
)
return None
return name

def _resolve_timezone(self, response_tz: str | None) -> str:
"""Resolve the account timezone by priority.

1. CRONOMETER_ACCOUNT_TZ env override (authoritative escape hatch).
2. The value from the login response (trustworthy now that login()
no longer overwrites the account's server-side zone; see #29).
3. The historical default.
"""
env = self._env_timezone()
if env:
return env
if isinstance(response_tz, str) and response_tz:
return response_tz
return _DEFAULT_TIMEZONE

def _tzinfo(self) -> ZoneInfo:
"""Return the account's timezone, falling back to the default.

Expand Down
121 changes: 121 additions & 0 deletions tests/test_timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,127 @@ def fake_post(endpoint, json=None):
assert saved["timezone"] == "America/Los_Angeles"


def test_login_sends_null_request_timezone(tmp_path, monkeypatch):
"""login() must send timezone:null in the request. A non-null value is a
*write* that overwrites the account's server-side zone (issue #29), so the
field must never be hardcoded again -- this is a regression guard."""
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
client = CronometerClient(session_path=tmp_path / "session.json")

captured: dict = {}

def fake_post(endpoint, json=None):
captured["payload"] = json
return FakeResp(
{
"result": "SUCCESS",
"id": 1,
"sessionKey": "K",
"timezone": "America/Los_Angeles",
}
)

client._http.post = fake_post # type: ignore[method-assign]
client.login()

assert "timezone" in captured["payload"]
assert captured["payload"]["timezone"] is None
# The response zone is trusted now that we no longer overwrite it.
assert client._timezone == "America/Los_Angeles"


def test_env_override_wins_over_login_response(tmp_path, monkeypatch):
"""CRONOMETER_ACCOUNT_TZ is authoritative over the login response zone."""
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles")
client = CronometerClient(session_path=tmp_path / "session.json")

def fake_post(endpoint, json=None):
return FakeResp(
{
"result": "SUCCESS",
"id": 1,
"sessionKey": "K",
"timezone": "America/New_York",
}
)

client._http.post = fake_post # type: ignore[method-assign]
client.login()

assert client._timezone == "America/Los_Angeles"
import json

saved = json.loads((tmp_path / "session.json").read_text())
assert saved["timezone"] == "America/Los_Angeles"


def test_env_override_wins_over_poisoned_cache(tmp_path, monkeypatch):
"""A session.json poisoned by an older build must not defeat an explicit
CRONOMETER_ACCOUNT_TZ override on warm start (issue #29)."""
monkeypatch.setenv("CRONOMETER_USERNAME", "")
monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "Europe/Berlin")
import json

(tmp_path / "session.json").write_text(
json.dumps(
{
"username": "",
"user_id": 123,
"token": "TOKEN",
"timezone": "America/New_York",
}
)
)
client = CronometerClient(session_path=tmp_path / "session.json")
assert client._token == "TOKEN"
assert client._timezone == "Europe/Berlin"


def test_invalid_env_override_is_ignored(tmp_path, monkeypatch):
"""A malformed CRONOMETER_ACCOUNT_TZ is ignored, falling through to the
login response rather than hard-failing."""
monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "Not/AZone")
client = CronometerClient(session_path=tmp_path / "session.json")

def fake_post(endpoint, json=None):
return FakeResp(
{
"result": "SUCCESS",
"id": 1,
"sessionKey": "K",
"timezone": "America/Chicago",
}
)

client._http.post = fake_post # type: ignore[method-assign]
client.login()

assert client._timezone == "America/Chicago"


def test_env_override_stamps_entries(tmp_path, monkeypatch, frozen_utc):
"""End-to-end: with the override set, diary stamping uses the override zone
even when the client's stored zone would otherwise be Eastern."""
monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles")
client = _client(tmp_path, "America/New_York")
# Re-resolve as warm start would, honoring the override.
client._timezone = client._resolve_timezone("America/New_York")
captured = _capture_serving(client)

client.add_serving(food_id=1, measure_id=0, grams=100.0)

serving = captured["payload"]["serving"]
# 18:01 UTC -> 11:01 PDT (Los Angeles), not 14:01 EDT.
assert serving["time"] == "11:1:30"
assert serving["day"] == "2026-7-27"


def test_warm_start_restores_timezone(tmp_path, monkeypatch):
"""A cache file with a timezone is restored without re-login."""
monkeypatch.setenv("CRONOMETER_USERNAME", "")
Expand Down
Loading