Metadata-Version: 2.5
Name: ticktick-focus-client
Version: 0.2.0
Summary: Read live TickTick focus/pomodoro state
Author-email: Jon Wood <jon@blankpad.net>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: websockets>=13.0
Description-Content-Type: text/markdown

# ticktick-focus-client

A small async Python library that reads **live** TickTick focus state from
TickTick's private API and websocket server.

## Why this exists

TickTick's documented Open API (`/open/v1/focus`) returns **completed** focus
records only. It has no endpoint for the session you are in right now, so it
cannot answer "is Jon focusing?".

The desktop and web clients get live state from a separate, undocumented
channel, which this library speaks:

| Piece | What it does |
|---|---|
| `wss://wssp.ticktick.com/web?x-device=…&hl=…` | Push channel. Server sends `{"type":"focusSync"}` when anything changes — a doorbell, carrying no state. Client sends `{"type":"ping"}` on open and every 540 s. |
| `POST https://ms.ticktick.com/focus/batch/focusOp` | The state. Body `{"lastPoint": <n>, "opList": []}` returns `{"point", "current", "updates"}`. `current` is the live session. |

Sending an empty `opList` argument makes no changes to focus state, this
client is purely read only. (Although I wouldn't be against adding some
write support in the future.)

## Usage

```python
from ticktick_focus_client import FocusClient, FocusStatus

async with FocusClient(cookie) as tt:
    # One-shot. Raises AuthError if the cookie is rejected.
    print(await tt.current())

    # Live, driven by the push socket. Never raises for auth or network
    # trouble — that arrives as state instead.
    async for state in tt.watch():
        if state.state is FocusStatus.FOCUSING:
            print(state.task_title, state.remaining_seconds)
```

`watch()` yields the first time it has a state, and thereafter whenever a read
produces something different from the last value yielded — a real change, or a
reconcile tick refreshing the elapsed/remaining clocks mid-session. Steady idle
is silent.

### `FocusState`

A frozen dataclass. `state` is a `FocusStatus`, `focus_type` a `FocusKind`,
`started_at` and `scheduled_end` are `datetime`s, and the rest are
`task_title`, `task_id`, `elapsed_seconds`, `remaining_seconds`, `pomo_count`
and `session_id`. `as_dict()` gives a JSON-safe view: times as ISO 8601, unset
fields dropped.

| Enum | Members |
|---|---|
| `FocusStatus` | `IDLE`, `FOCUSING`, `PAUSED`, `BREAK`, `UNAVAILABLE`, plus `.in_session` |
| `FocusKind` | `POMODORO`, `STOPWATCH`, each carrying the API's int as `.api_value` |
| `SessionStatus` | `RUNNING`, `COMPLETED`, `ABANDONED` — the API's `status` |
| `PauseLogType` | `PAUSED`, `RESUMED` — entries in the API's `pauseLogs` |

`FocusStatus` and `FocusKind` are `StrEnum`s, so `state.state == "focusing"`
holds and `json.dumps` needs no help. `UNAVAILABLE` means "cannot currently
tell" — before the first successful read, or while the cookie is not working.
It is never conflated with `IDLE`.

### `client.health`

Health of the client, kept separate from the focus state it carries:
`auth_ok`, `websocket_connected`, `healthy`, `can_report_focus`,
`last_sync_at`, `last_error`, `last_error_at`, `consecutive_failures`, a
`failure_reason` of `AUTH_EXPIRED` or `NETWORK`, plus Premium details once
`await client.refresh_account()` has run. `as_dict()` behaves as it does on
`FocusState`.

### `client.point`

The sync checkpoint, which only ever moves forward. Nothing is written to disk;
persist it yourself and hand it back if you want a new process to resume rather
than re-read from scratch:

```python
FocusClient(cookie, point=saved_point)
```

### Options

| Argument | Default | Notes |
|---|---|---|
| `cookie` | — | The `t` session cookie value. Required. |
| `domain` | `Domain.TICKTICK` | Or `Domain.DIDA` for the Chinese service. Plain strings are accepted and validated. |
| `device_id` | a fixed placeholder | Any 24-char hex-ish id. |
| `language` | `en_US` | |
| `reconcile_seconds` | `300` | Safety-net re-read, in case a poke is missed. |
| `point` | `0` | Sync checkpoint to resume from. |
| `http` | — | An `httpx.AsyncClient` to borrow; the caller closes it. |

### Typing

The package ships a `py.typed` marker and checks clean under
[ty](https://github.com/astral-sh/ty), so the enums and datetimes above reach
anything built on top of it.

## Requirements

- Python 3.11+
- **TickTick Premium.** Cross-device focus sync is gated behind it
  (`focusConf.keepInSync` plus a Premium check). Without it the server does not
  push and `current` will not track your sessions. (Untested)
- **"Keep in Sync" enabled** in TickTick's focus settings.
- A session cookie (see below).

## Getting the session cookie

This endpoint is not covered by the Open API, and an Open API personal token
(`Authorization: Bearer …`) **will not work** — it belongs to a different auth
realm. You need the `t` cookie from a logged-in session:

1. Sign in at <https://ticktick.com> in a browser.
2. DevTools → Application → Cookies → `https://ticktick.com`.
3. Copy the **Value** of the `t` cookie.

The library takes it as a plain string and never touches the filesystem. Where
it comes from — a file, a keychain, an environment variable — is yours to
decide. A cookie that has been rotated means a new client.

## Design notes

- **Push, not poll.** The socket is the trigger; the reconcile timer only
  covers missed pokes. Sync requests collapse — a burst of pokes causes one read.
- **`endTime` does not mean "finished".** For a pomodoro it is the *projected*
  end (start + configured duration) and is present throughout the session.
  Liveness comes from `status == 0` and `exited == false`; `endTime` is only a
  fallback when `status` is absent. Getting this wrong reports `idle` mid-session.
- **Nothing is fatal while watching.** Network errors and expired cookies are
  reported as state and retried; only a genuine bug escapes the iterator.
- **Auth failures are distinguished from network failures.** A connection reset
  does not clear `auth_ok`, so a blip never gets reported as an expired cookie.
- Reconnects with exponential backoff (2 s → 5 min).

## Caveats

- This uses TickTick's **private API**, which is against their Terms of Service.
  It can change without notice.
- Sessions shorter than TickTick's minimum valid duration are discarded by the
  app and never appear anywhere.

## Tests

```bash
uv run pytest
uv run ty check
```

The state machine is a pure function of the API payload, so the interesting
logic is covered without network or credentials.
